From a2b9b30930be0946e46caa2de77b6c3258502474 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 24 Aug 2026 20:14:09 +0000 Subject: [PATCH 1/3] test: expose target-head stranded fragments --- test/changelog-assembled.test.sh | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh index 79a69f1..e721d73 100644 --- a/test/changelog-assembled.test.sh +++ b/test/changelog-assembled.test.sh @@ -217,6 +217,49 @@ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \ check "the survivor refusal names the file" 1 "changelog.d/9.md" \ run survivor base +# A release PR can be faithful to its merge base while the target branch moves +# ahead and gains a fragment during review. That target-only fragment was not +# available to the ceremony, so merging the PR would strand it for the next +# release. The guard must read the target ref as well as their merge base. +seed_flat target-stranded +ceremony target-stranded 0.2.0 2026-07-24 +commit_head target-stranded +git -C "$TMP/target-stranded" switch -q base +printf -- '- Landed while the release was under review (#30).\n' \ + >"$TMP/target-stranded/changelog.d/30.md" +git -C "$TMP/target-stranded" add -A +git -C "$TMP/target-stranded" commit -qm target-fragment +git -C "$TMP/target-stranded" switch -q main +check "a target-head fragment the release did not consume fails" 1 \ + "changelog.d/30.md" run target-stranded base +check "the target-stranding refusal names the rebase remedy" 1 \ + "rebase onto the target head" run target-stranded base +check "the target-stranding refusal names the assembler re-run" 1 \ + "changelog-assemble '0.2.0'" run target-stranded base + +# Removing the target-only fragment makes the same diverged fixture green: +# target drift itself is not the failure, only a stranded fragment is. +git -C "$TMP/target-stranded" switch -q base +rm "$TMP/target-stranded/changelog.d/30.md" +git -C "$TMP/target-stranded" add -A +git -C "$TMP/target-stranded" commit -qm target-fragment-removed +git -C "$TMP/target-stranded" switch -q main +check "the same target fixture is green once no fragment is stranded" 0 \ + "byte-for-byte" run target-stranded base + +# Spell out the common harmless case independently: the target branch moved, +# but the advancing commit added no fragment. +seed_flat target-unrelated +ceremony target-unrelated 0.2.0 2026-07-24 +commit_head target-unrelated +git -C "$TMP/target-unrelated" switch -q base +printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt" +git -C "$TMP/target-unrelated" add -A +git -C "$TMP/target-unrelated" commit -qm target-unrelated +git -C "$TMP/target-unrelated" switch -q main +check "a target head advanced without a fragment stays green" 0 \ + "byte-for-byte" run target-unrelated base + # Fragments consumed, section never stamped: the prose went nowhere. seed_flat halfdone rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md" From 0f3d3b36eb2748845e82d6aaeac6eb80a940d831 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 24 Aug 2026 20:15:55 +0000 Subject: [PATCH 2/3] fix: refuse target-head stranded fragments --- .../changelog-assembled.sh | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/actions/changelog-assembled/changelog-assembled.sh b/actions/changelog-assembled/changelog-assembled.sh index 93f03b1..bccafe4 100644 --- a/actions/changelog-assembled/changelog-assembled.sh +++ b/actions/changelog-assembled/changelog-assembled.sh @@ -33,6 +33,12 @@ set -euo pipefail # no '## ' heading, and changelog_section extracts the body below HEAD's # heading — so the date HEAD stamped into its heading never enters the # comparison, and a date difference can never masquerade as a prose one. +# +# This guard narrows, but cannot close, the target-movement window: it sees a +# fragment present when CI reads the target ref, but one can still land after +# the final run and before merge. Requiring release PRs to be up to date with +# their target branch before merge is the repository setting that closes that +# residual window (#253). base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}" changelog="${2:-${CHANGELOG:-CHANGELOG.md}}" @@ -169,6 +175,37 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)" failures=0 +# Refusal: the target branch gained a fragment after this release PR's merge +# base, so the ceremony could not have consumed it. Merging this tree would +# strand that fragment for the next release and misattribute when it shipped. +stranded="" +while IFS= read -r -d '' entry; do + meta="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')" + name="${path##*/}" + case "$otype:$name" in + blob:README.md) ;; + blob:*.md) + if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then + stranded="${stranded} ${path}"$'\n' + fi + ;; + esac +done < <(git ls-tree -z "$base_ref" -- "$dir/") +if [ -n "$stranded" ]; then + { + echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:" + echo + printf '%s' "$stranded" + echo + echo " Merging now would strand these entries for the next release and" + echo " misattribute when they shipped." + echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'." + } >&2 + failures=$((failures + 1)) +fi + # Refusal: a fragment the ceremony consumed is still present on HEAD. The # ceremony deletes exactly what it assembles (#112) — a fragment that # survives its own release sits in the directory and is assembled AGAIN From 5823f3d7b7724c574364dd1c1d95ed969afa1fc8 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 24 Aug 2026 20:17:36 +0000 Subject: [PATCH 3/3] docs: record stranded-fragment refusal --- changelog.d/253.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/253.md diff --git a/changelog.d/253.md b/changelog.d/253.md new file mode 100644 index 0000000..a465493 --- /dev/null +++ b/changelog.d/253.md @@ -0,0 +1,3 @@ +### Fixed + +- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).