Merge pull request 'fix: refuse release PRs that strand target fragments' (#255) from build/253-stranded-fragments into main
All checks were successful
CI / test (push) Successful in 3m47s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 21s
All checks were successful
CI / test (push) Successful in 3m47s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 21s
Reviewed-on: #255 Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders> Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders> Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
This commit is contained in:
commit
e55e99663e
3 changed files with 83 additions and 0 deletions
|
|
@ -33,6 +33,12 @@ set -euo pipefail
|
||||||
# no '## ' heading, and changelog_section extracts the body below HEAD's
|
# no '## ' heading, and changelog_section extracts the body below HEAD's
|
||||||
# heading — so the date HEAD stamped into its heading never enters the
|
# heading — so the date HEAD stamped into its heading never enters the
|
||||||
# comparison, and a date difference can never masquerade as a prose one.
|
# 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}}"
|
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
|
||||||
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
|
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
|
||||||
|
|
@ -169,6 +175,37 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
|
||||||
|
|
||||||
failures=0
|
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
|
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
|
||||||
# ceremony deletes exactly what it assembles (#112) — a fragment that
|
# ceremony deletes exactly what it assembles (#112) — a fragment that
|
||||||
# survives its own release sits in the directory and is assembled AGAIN
|
# survives its own release sits in the directory and is assembled AGAIN
|
||||||
|
|
|
||||||
3
changelog.d/253.md
Normal file
3
changelog.d/253.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -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" \
|
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
|
||||||
run survivor base
|
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.
|
# Fragments consumed, section never stamped: the prose went nowhere.
|
||||||
seed_flat halfdone
|
seed_flat halfdone
|
||||||
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
|
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue