diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9c2a5d..ff863ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: fetch-depth: 0 - uses: ./actions/changelog-armed - uses: ./actions/changelog-monotonic + - uses: ./actions/changelog-assembled - uses: ./actions/drill-recorded - uses: ./actions/runner-isolated diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a4612..8243ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop. ## 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). - 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). diff --git a/actions/changelog-assembled/action.yml b/actions/changelog-assembled/action.yml new file mode 100644 index 0000000..9d3dd54 --- /dev/null +++ b/actions/changelog-assembled/action.yml @@ -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" diff --git a/actions/changelog-assembled/changelog-assembled.sh b/actions/changelog-assembled/changelog-assembled.sh new file mode 100644 index 0000000..93f03b1 --- /dev/null +++ b/actions/changelog-assembled/changelog-assembled.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-assembled.sh [] [] [] [] +# — 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@` 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' 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)" diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh new file mode 100644 index 0000000..73b0a64 --- /dev/null +++ b/test/changelog-assembled.test.sh @@ -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 — 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 — 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 — 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