Merge pull request #124 from claude-bot-andresmgsl/build/116-changelog-assembled

actions/changelog-assembled — the release PR's section must be exactly the fragments it consumed
This commit is contained in:
Daniel Marin 2026-07-24 11:07:43 +01:00 committed by GitHub
commit 531b8af54c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 631 additions and 0 deletions

View file

@ -73,6 +73,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- uses: ./actions/changelog-armed - uses: ./actions/changelog-armed
- uses: ./actions/changelog-monotonic - uses: ./actions/changelog-monotonic
- uses: ./actions/changelog-assembled
- uses: ./actions/drill-recorded - uses: ./actions/drill-recorded
- uses: ./actions/runner-isolated - uses: ./actions/runner-isolated

View file

@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop.
## Unreleased ## Unreleased
- `actions/changelog-assembled` — a release PR's stamped section must be byte-for-byte what the fragments it consumed assemble to, replayed from the merge base; inapplicable trees pass with a NOTICE (#116).
- `lib/changelog.sh` + `bin/changelog-assemble` — read the `changelog.d/` fragments, assemble one release section (canonical group order, one shape per repo), and consume exactly what was published (#114). - `lib/changelog.sh` + `bin/changelog-assemble` — read the `changelog.d/` fragments, assemble one release section (canonical group order, one shape per repo), and consume exactly what was published (#114).
- BUILDER.md — the handed-off PR is the parked claim's fourth shape, its handoff is its declaration, and shape 2 covers the round awaiting its first verdicts (#109). - BUILDER.md — the handed-off PR is the parked claim's fourth shape, its handoff is its declaration, and shape 2 covers the round awaiting its first verdicts (#109).
- `labels-reconcile` — a degraded mergeability/checks read now logs gh's actual stderr (collapsed, bounded) beside the byte-identical counted line, and the blind-sweep warning leads with the observed reason instead of asserting the permissions cause (#101). - `labels-reconcile` — a degraded mergeability/checks read now logs gh's actual stderr (collapsed, bounded) beside the byte-identical counted line, and the blind-sweep warning leads with the observed reason instead of asserting the permissions cause (#101).

View file

@ -0,0 +1,53 @@
name: Changelog assembled
description: >-
Assert a release PR's stamped section is exactly what the changelog.d/
fragments it consumed assemble to — the fragments as of the MERGE BASE
are replayed through the assembler's --check and compared byte-for-byte
against the section on HEAD (#116; the fragment flow is #112). Needs the
HISTORY: the caller's checkout must use fetch-depth: 0, or the base ref
will not resolve and strict mode (the default — CI must never skip)
fails red with a message naming that fix. Inapplicable trees — a -dev
tree, a legacy repo with no fragments directory at the merge base — pass
with a NOTICE, never a silent skip.
inputs:
base-ref:
description: >-
The ref the fragment set is read relative to. The default resolves
the event's base branch — the PR's target on pull_request, the
pushed branch itself on push (where the merge base IS HEAD and the
check is vacuous by construction, named honestly in the log).
required: false
default: origin/${{ github.base_ref || github.ref_name }}
changelog:
description: Path to the changelog, relative to the workspace
required: false
default: CHANGELOG.md
fragments-dir:
description: Path to the fragments directory, relative to the workspace
required: false
default: changelog.d
version-source:
description: Where the tree's version lives ("file" or "package-json")
required: false
default: file
strict:
description: >-
"1" (the default) makes an unresolvable base ref a hard failure
instead of a loud skip — a guard that can quietly stop guarding is
the failure shape this family of checks exists to refuse. The local
default in the script itself stays "0", so a plain working-copy run
degrades sensibly.
required: false
default: "1"
runs:
using: composite
steps:
- name: changelog assembled
shell: bash
env:
CHANGELOG_ASSEMBLED_BASE: ${{ inputs.base-ref }}
CHANGELOG: ${{ inputs.changelog }}
CHANGELOG_ASSEMBLED_DIR: ${{ inputs.fragments-dir }}
VERSION_SOURCE: ${{ inputs.version-source }}
CHANGELOG_ASSEMBLED_STRICT: ${{ inputs.strict }}
run: bash "$GITHUB_ACTION_PATH/changelog-assembled.sh"

View file

@ -0,0 +1,251 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-assembled.sh [<base-ref>] [<changelog>] [<fragments-dir>] [<version-source>]
# — assert that a release PR's stamped section is EXACTLY what the fragments
# it consumed assemble to: read the fragments as of the MERGE BASE (they are
# gone from HEAD's tree — that is the point of the ceremony), replay the
# assembler's --check over that set, and compare byte-for-byte against
# changelog_section on HEAD (#116; the fragment flow is #112).
#
# The failure it exists to catch leaves no trace — the shape every guard in
# this family was bought by. bin/changelog-assemble is run BY HAND in the
# release PR, deliberately: the assembled section must land in the PR diff
# where the panel reads it (#114). Drop one fragment from the deletion and
# its entry is simply absent from the release: the file is well-formed,
# changelog-armed is green (the section exists and has prose),
# changelog-monotonic is green (no heading was deleted), and the publisher
# happily publishes the shortened section. Hand-edit one word of the
# assembled prose and the published history quietly stops being what the
# authors wrote. The only way anyone finds out is by reading the release
# body against a directory that no longer exists.
#
# Why this cannot live in changelog-armed: "the section matches the
# fragments it consumed" is not a property of a TREE — no single tree holds
# both the fragments and the section they became. It is a property of a
# DIFF: what existed at the merge base versus what HEAD stamped. That is
# exactly the argument changelog-monotonic made for being its own git-aware
# action rather than a clause inside changelog-armed, and this is the third
# guard on the same reasoning. changelog-armed.sh stays drivable against
# constructed two-file trees that are not git repos at all.
#
# The date is never compared as prose: --check prints the section BODY with
# 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.
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
dir="${3:-${CHANGELOG_ASSEMBLED_DIR:-changelog.d}}"
version_source="${4:-${VERSION_SOURCE:-file}}"
# Fail-closed switch, changelog-monotonic's stance exactly: CI sets it (the
# action defaults strict to "1"), so a degradation that is sensible on a
# laptop becomes a red run there. A guard that can quietly stop guarding is
# the failure shape this whole family of checks exists to refuse.
strict="${CHANGELOG_ASSEMBLED_STRICT:-0}"
# The shared libs and the assembler travel with this action: a consumer's
# `uses: heavy-duty/ceremony/actions/changelog-assembled@<tag>` downloads
# this whole repository at that ref, so ../../lib and ../../bin are always
# present and always at the same ref — no checkout step, no version skew.
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/version.sh
. "$here/../../lib/version.sh"
# shellcheck source=lib/changelog.sh
. "$here/../../lib/changelog.sh"
assemble="$here/../../bin/changelog-assemble"
skip() {
if [ "$strict" = "1" ]; then
echo "changelog-assembled: $* — and CHANGELOG_ASSEMBLED_STRICT=1, so this is a FAILURE, not a skip." >&2
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
exit 1
fi
echo "changelog-assembled: SKIPPED — $*"
echo " (Everything this guard asserts compares HEAD against the merge base —"
echo " without the history there is nothing it can honestly say. In CI this"
echo " same condition is a hard failure.)"
exit 0
}
# A pass with a NOTICE, never a skip in silence: an inapplicable tree is a
# legitimate green, and the log says why instead of implying a check ran.
notice() {
echo "changelog-assembled: NOTICE — $*"
exit 0
}
[ -f "$changelog" ] || { echo "changelog-assembled: no such file: $changelog" >&2; exit 1; }
# --- everything here needs the HISTORY ---------------------------------------
# Even applicability does: "fragment mode" is a fact about the merge base,
# not about HEAD's tree, whose fragments are consumed by construction. So
# unlike changelog-monotonic there is no history-free half to run first —
# an unusable checkout degrades (or, under STRICT, refuses) before the
# guard claims anything.
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| skip "not inside a git work tree, so there is no merge base to read the fragments from"
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
[ -n "$merge_base" ] \
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
short_base="$(git rev-parse --short "$merge_base")"
# Push-to-main shape: the merge base IS HEAD, so the fragment set this guard
# would replay is HEAD's own — nothing was consumed between the two points,
# and comparing a tree against itself would assert nothing. Named honestly,
# the same discipline as changelog-monotonic's vacuous line.
if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then
echo "changelog-assembled: vacuous (the merge base IS HEAD, so no fragments were consumed between them — there is no diff for the section to answer to)."
exit 0
fi
# --- applicability: the ceremony PR, and nothing else ------------------------
if ! git cat-file -e "$merge_base:$dir" 2>/dev/null; then
notice "no '$dir/' at the merge base ($short_base) — legacy mode; the changelog is edited directly and there is no fragment set for a section to answer to"
fi
# version_read refuses loudly on a missing or empty source; the wrapper line
# names the guard so a workflow log shows which check refused.
ver="$(version_read "$version_source")" || {
echo "changelog-assembled: cannot read the version (version-source: $version_source)" >&2
exit 1
}
if version_is_dev "$ver"; then
notice "version '$ver' is a development tree — no release section is being stamped; whatever this PR does to '$dir/' is cargo for a future ceremony, not a consumption to verify"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
git show "$merge_base:$changelog" >"$tmp/base-changelog.md" 2>/dev/null || : >"$tmp/base-changelog.md"
# A branch that merely SITS on a release is not the ceremony that stamped
# it: right after a release merges, main's version is bare until the -dev
# bump lands, and a PR branched in that window would otherwise be asked to
# answer for a consumption that happened at its merge base, not on it. If
# the section already existed at the merge base, this branch did not stamp
# it. Whole-version match, as everywhere in this family.
if awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$tmp/base-changelog.md"; then
notice "the section for '$ver' already exists at the merge base ($short_base) — this branch is not the ceremony that stamped it, so there is no consumed set to answer to"
fi
# --- the replay: the merge base's fragment set, byte for byte ----------------
# Every blob in the directory is extracted — strays included, and subtrees
# recreated — so the replay refuses exactly what a real assembler run over
# that tree would have refused, instead of quietly narrowing the set.
mkdir -p "$tmp/$dir"
base_frags=""
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" in
blob)
git show "$merge_base:$path" >"$tmp/$dir/$name"
case "$name" in
README.md) ;;
*.md) base_frags="${base_frags}${path}"$'\n' ;;
esac
;;
tree)
mkdir -p "$tmp/$dir/$name"
;;
esac
done < <(git ls-tree -z "$merge_base" -- "$dir/")
frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
failures=0
# 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
# into the NEXT section, republishing its prose as if it were new.
survivors=""
while IFS= read -r p; do
[ -n "$p" ] || continue
if [ -e "$p" ]; then
survivors="${survivors} ${p}"$'\n'
fi
done <<<"$base_frags"
if [ -n "$survivors" ]; then
{
echo "changelog-assembled: fragment(s) present at the merge base ($short_base) are STILL PRESENT on HEAD:"
echo
printf '%s' "$survivors"
echo
echo " The ceremony deletes exactly what it assembles. A fragment that survives"
echo " its own release is assembled AGAIN into the next section, republishing"
echo " its prose as if it were new. Delete it in this PR — its entry is (or"
echo " should be) already in the stamped section."
} >&2
failures=$((failures + 1))
fi
expected=""
if expected="$( (cd "$tmp" && "$assemble" "$ver" --check --changelog "$tmp/base-changelog.md" --dir "$dir") 2>&1)"; then
found="$(changelog_section "$changelog" "$ver")"
if [ -z "$found" ]; then
{
echo "changelog-assembled: the version is '$ver' (a release tree) and '$dir/' at the"
echo " merge base ($short_base) holds $frag_count fragment(s), but $changelog has no"
echo " non-empty section for '$ver' on HEAD."
echo
echo " The fragments were consumed and their prose went nowhere: the release"
echo " this tree is about to publish would have a body the authors never got"
echo " to write. The ceremony's edit is one tool run, in this PR:"
echo
echo " bin/changelog-assemble '$ver'"
} >&2
failures=$((failures + 1))
else
if ! diff_out="$(diff -u \
--label "expected — assembled from the $frag_count fragment(s) at the merge base ($short_base)" \
--label "found — section '$ver' in $changelog on HEAD" \
<(printf '%s\n' "$expected") <(printf '%s\n' "$found"))"; then
{
echo "changelog-assembled: the section '$ver' in $changelog is NOT what the fragments it consumed assemble to:"
echo
printf '%s\n' "$diff_out" | sed 's/^/ /'
echo
echo " The release body is published verbatim from this section, so any"
echo " difference here ships: a missing line is an author's entry silently"
echo " dropped from history, an extra or edited line is prose nobody wrote,"
echo " and a re-ordering is not the canonical order the assembler produces."
echo " The fix is to redo the ceremony's edit with the tool — re-run"
echo " bin/changelog-assemble '$ver' <date> from the merge base's fragments —"
echo " never to hand-edit the section into agreement."
} >&2
failures=$((failures + 1))
fi
fi
else
{
echo "changelog-assembled: the merge base's fragment set does not assemble — the replay refuses:"
echo
printf '%s\n' "$expected" | sed 's/^/ /'
echo
echo " The set replayed is exactly '$dir/' as of the merge base ($short_base)."
echo " A section that bin/changelog-assemble would refuse to produce cannot"
echo " have been produced by it — whatever stamped this section did it by"
echo " hand, and the release body cannot be trusted to be what the fragment"
echo " authors wrote."
} >&2
failures=$((failures + 1))
fi
[ "$failures" -eq 0 ] || exit 1
echo "changelog-assembled: section '$ver' in $changelog is byte-for-byte the assembly of the $frag_count fragment(s) consumed at the merge base ($short_base)"

View file

@ -0,0 +1,325 @@
#!/usr/bin/env bash
# Contract tests for actions/changelog-assembled (issue #116). Like the
# monotonic guard's suite, every applicable case is a constructed git repo —
# "the section matches the fragments it consumed" is a property of a DIFF:
# the fragments are gone from HEAD's tree by construction, so the fixture is
# a history: a base commit holding the fragments and a -dev version, a HEAD
# commit holding the ceremony's edit, and a 'base' branch standing in for
# origin/main. set -u, not -e: failing commands are behavior for the
# harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
SCRIPT="$ROOT/actions/changelog-assembled/changelog-assembled.sh"
ARMED="$ROOT/actions/changelog-armed/changelog-armed.sh"
MONOTONIC="$ROOT/actions/changelog-monotonic/changelog-monotonic.sh"
ASSEMBLE="$ROOT/bin/changelog-assemble"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
init_repo() {
local dir="$TMP/$1"
mkdir -p "$dir"
git -C "$dir" init -q -b main
git -C "$dir" config user.email ci@example.invalid
git -C "$dir" config user.name ci
}
# commit_base <name> — commit the tree as the merge base and pin 'base' there.
commit_base() {
git -C "$TMP/$1" add -A
git -C "$TMP/$1" commit -qm base
git -C "$TMP/$1" branch base
}
commit_head() {
git -C "$TMP/$1" add -A
git -C "$TMP/$1" commit -qm head
}
# seed_flat <name> — the shared pre-ceremony tree: an armed changelog, two
# flat fragments (numeric order will put 12 before 9), a -dev version.
seed_flat() {
local name="$1" dir="$TMP/$1"
init_repo "$name"
mkdir -p "$dir/changelog.d"
printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$dir/changelog.d/README.md"
cat >"$dir/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.1.1-dev\n' >"$dir/VERSION"
printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md"
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md"
commit_base "$name"
}
# ceremony <name> <ver> <date> — the faithful release edit on the working
# tree, exactly as a human runs it: the real assembler in write mode, then
# the bare version stamp. Deliberately does NOT commit, so a case can break
# the tree before committing HEAD.
ceremony() {
local name="$1" ver="$2" stamp="$3"
(cd "$TMP/$name" && "$ASSEMBLE" "$ver" "$stamp" >/dev/null 2>&1) || return 1
printf '%s\n' "$ver" >"$TMP/$name/VERSION"
}
run() { local name="$1"; shift; (cd "$TMP/$name" && bash "$SCRIPT" "$@"); }
run_strict() { local name="$1"; shift; (cd "$TMP/$name" && CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT" "$@"); }
# --- the documented flow passes ----------------------------------------------
seed_flat faithful-flat
ceremony faithful-flat 0.2.0 2026-07-24
commit_head faithful-flat
check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
"byte-for-byte" run faithful-flat base
seed_flat faithful-grouped
printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md"
printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md"
rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"
git -C "$TMP/faithful-grouped" add -A
git -C "$TMP/faithful-grouped" commit -qm regroup
git -C "$TMP/faithful-grouped" branch -f base
ceremony faithful-grouped 0.2.0 2026-07-24
commit_head faithful-grouped
check "faithful grouped ceremony passes" 0 "byte-for-byte" run faithful-grouped base
# The date is HEAD's to choose: a stamp nowhere near today must not read as
# a prose difference — the comparison is body against body, headings (and
# so dates) never enter it.
seed_flat old-date
ceremony old-date 0.2.0 2020-01-01
commit_head old-date
check "the stamp's date never enters the comparison" 0 "byte-for-byte" \
run old-date base
# --- inapplicable trees: green NOTICE, never a silent skip -------------------
seed_flat ordinary-add
printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md"
commit_head ordinary-add
check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base
seed_flat ordinary-none
printf 'code\n' >"$TMP/ordinary-none/code.txt"
commit_head ordinary-none
check "-dev PR touching no fragment: green NOTICE" 0 "NOTICE" run ordinary-none base
seed_flat ordinary-del
rm "$TMP/ordinary-del/changelog.d/12.md"
commit_head ordinary-del
check "-dev PR even deleting a fragment: green NOTICE" 0 "NOTICE" run ordinary-del base
# Legacy mode: no changelog.d/ at the merge base — always a NOTICE, even on
# a release tree, because the mid-adoption ceremony edits the changelog by
# hand and there is no fragment set for its section to answer to.
init_repo legacy
printf '# Changelog\n\n## Unreleased\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md"
printf '0.1.1-dev\n' >"$TMP/legacy/VERSION"
commit_base legacy
printf '# Changelog\n\n## 0.2.0 — 2026-07-24\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md"
printf '0.2.0\n' >"$TMP/legacy/VERSION"
commit_head legacy
check "legacy repo (no changelog.d at base): green NOTICE, even on a release tree" 0 \
"NOTICE" run legacy base
# The un-rearmed window: a PR branched right after a release merges sits on
# a bare version whose section was stamped at its MERGE BASE — it is not
# the ceremony and must not be asked to answer for one.
seed_flat post-release
ceremony post-release 0.2.0 2026-07-24
git -C "$TMP/post-release" add -A
git -C "$TMP/post-release" commit -qm release
git -C "$TMP/post-release" branch -f base
printf 'code\n' >"$TMP/post-release/code.txt"
commit_head post-release
check "a PR atop the un-rearmed release: green NOTICE (this branch did not stamp)" 0 \
"NOTICE" run post-release base
# --- the refusals ------------------------------------------------------------
# The issue's headline failure: one fragment kept out of the ceremony — it
# survives at HEAD and its entry is absent from the section. Both refusals
# fire: the diff names the missing entry, the survivor list names the file.
seed_flat dropped
mv "$TMP/dropped/changelog.d/9.md" "$TMP/dropped/9.md.hold"
ceremony dropped 0.2.0 2026-07-24
mv "$TMP/dropped/9.md.hold" "$TMP/dropped/changelog.d/9.md"
commit_head dropped
check "a fragment kept out of the ceremony fails" 1 "" run dropped base
check "the dropped-entry diff names the missing entry" 1 "Nine landed" \
run dropped base
check "the surviving fragment is listed by path" 1 "changelog.d/9.md" \
run dropped base
# The same drop, but the fragment was deleted anyway: its prose vanished
# without ever being published. Only the diff can say so.
seed_flat vanished
mv "$TMP/vanished/changelog.d/9.md" "$TMP/vanished/9.md.hold"
ceremony vanished 0.2.0 2026-07-24
rm "$TMP/vanished/9.md.hold"
commit_head vanished
check "a deleted fragment whose entry never landed fails" 1 "Nine landed" \
run vanished base
seed_flat edited
ceremony edited 0.2.0 2026-07-24
sed -i 's/Twelve landed/Twelve allegedly landed/' "$TMP/edited/CHANGELOG.md"
commit_head edited
check "a hand-edited entry fails with a unified diff" 1 "+++" run edited base
check "the edit is visible in the diff" 1 "allegedly" run edited base
check "the diff failure teaches redo-with-the-tool, never hand-edit" 1 \
"never to hand-edit" run edited base
# Re-ordering away from the canonical order: the section is hand-built with
# the right entries in the wrong order (the assembler puts 12 before 9).
seed_flat reordered
rm "$TMP/reordered/changelog.d/12.md" "$TMP/reordered/changelog.d/9.md"
cat >"$TMP/reordered/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.2.0 — 2026-07-24
- Nine landed, and its prose wraps onto a
continuation line.
- Twelve landed.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.2.0\n' >"$TMP/reordered/VERSION"
commit_head reordered
check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base
# A faithful assembly that forgot one deletion: the section is right, the
# directory is not — only the survivor refusal fires.
seed_flat survivor
ceremony survivor 0.2.0 2026-07-24
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md"
commit_head survivor
check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
run survivor base
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
run survivor 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"
printf '0.2.0\n' >"$TMP/halfdone/VERSION"
commit_head halfdone
check "fragments consumed but no section stamped fails" 1 \
"non-empty section for '0.2.0'" run halfdone base
# A release stamped out of a fragment-free directory: the replay refuses the
# way the real assembler would have — an empty release.
seed_flat empty-release
rm "$TMP/empty-release/changelog.d/12.md" "$TMP/empty-release/changelog.d/9.md"
git -C "$TMP/empty-release" add -A
git -C "$TMP/empty-release" commit -qm consume-early
git -C "$TMP/empty-release" branch -f base
cat >"$TMP/empty-release/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.2.0 — 2026-07-24
- Prose from nowhere.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.2.0\n' >"$TMP/empty-release/VERSION"
commit_head empty-release
check "a section stamped from zero fragments fails on the replay's refusal" 1 \
"zero fragments" run empty-release base
check "missing changelog fails" 1 "no such file" run faithful-flat base NOPE.md
# --- the trio interaction row (the issue's whole argument) -------------------
# On the faithful tree all three guards are green; on the dropped-entry tree
# armed is green (the section exists and has prose), monotonic is green (no
# heading was deleted) — this guard is the ONLY one that goes red.
run_armed() { (cd "$TMP/$1" && bash "$ARMED"); }
run_monotonic() { local name="$1"; shift; (cd "$TMP/$name" && bash "$MONOTONIC" "$@"); }
check "trio, faithful tree: changelog-armed green" 0 "agrees" run_armed faithful-flat
check "trio, faithful tree: changelog-monotonic green" 0 "still present" \
run_monotonic faithful-flat base
check "trio, faithful tree: changelog-assembled green" 0 "byte-for-byte" \
run faithful-flat base
check "trio, dropped-entry tree: changelog-armed stays green" 0 "agrees" \
run_armed dropped
check "trio, dropped-entry tree: changelog-monotonic stays green" 0 "still present" \
run_monotonic dropped base
check "trio, dropped-entry tree: changelog-assembled is the only red" 1 "" \
run dropped base
# --- degradation: the loud skip and the STRICT refusal -----------------------
seed_flat no-base
ceremony no-base 0.2.0 2026-07-24
commit_head no-base
check "base ref missing, STRICT=0: loud skip, exit 0" 0 "SKIPPED" \
run no-base does-not-exist
check "base ref missing, STRICT=1: hard failure" 1 "FAILURE" \
run_strict no-base does-not-exist
check "the STRICT failure names the checkout fix, not the script" 1 "fetch-depth: 0" \
run_strict no-base does-not-exist
mkdir -p "$TMP/plain"
printf '# Changelog\n\n## Unreleased\n' >"$TMP/plain/CHANGELOG.md"
check "not a git repo, STRICT=0: loud skip" 0 "not inside a git work tree" \
run plain
check "not a git repo, STRICT=1: hard failure" 1 "FAILURE" run_strict plain
# --- the honest edges --------------------------------------------------------
# Push-to-main shape: the merge base IS HEAD, nothing was consumed between
# them, and the success line must say so instead of claiming a comparison.
seed_flat vacuous
ceremony vacuous 0.2.0 2026-07-24
commit_head vacuous
check "merge base IS HEAD: vacuous, named honestly" 0 "vacuous" run vacuous HEAD
# --- the action's wiring: inputs arrive as env vars --------------------------
# Non-default names for everything action.yml passes, STRICT included, prove
# the env vars are honored the way the composite sets them.
init_repo env-tree
mkdir -p "$TMP/env-tree/frags"
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md"
printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION"
printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md"
git -C "$TMP/env-tree" add -A
git -C "$TMP/env-tree" commit -qm base
git -C "$TMP/env-tree" branch fixture-base
(cd "$TMP/env-tree" && "$ASSEMBLE" 0.2.0 2026-07-24 --changelog NOTES.md --dir frags >/dev/null 2>&1)
printf '0.2.0\n' >"$TMP/env-tree/VERSION"
git -C "$TMP/env-tree" add -A
git -C "$TMP/env-tree" commit -qm head
env_tree() {
(cd "$TMP/env-tree" && \
CHANGELOG_ASSEMBLED_BASE=fixture-base CHANGELOG=NOTES.md \
CHANGELOG_ASSEMBLED_DIR=frags VERSION_SOURCE=file \
CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT")
}
check "env vars drive the script the way action.yml does" 0 "byte-for-byte" env_tree
summary