From 3104aac6f321b29c74bf72a2a7d6f97a30450483 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 11:12:22 +0000 Subject: [PATCH 1/8] test: specify release preflight contract --- test/preflight.test.sh | 119 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100755 test/preflight.test.sh diff --git a/test/preflight.test.sh b/test/preflight.test.sh new file mode 100755 index 0000000..6cf2bb4 --- /dev/null +++ b/test/preflight.test.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Contract tests for lib/preflight.sh (issue #273) — every row of the +# merge-door resume table, offline. set -u, not -e: refusals 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" + +PREFLIGHT="$ROOT/lib/preflight.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +VER=1.2.3 +MERGE_SHA=1111111111111111111111111111111111111111 +FOREIGN_SHA=2222222222222222222222222222222222222222 + +# preflight — run the pure decision +# with exactly the four gathered facts in its environment. +preflight() { + VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" bash "$PREFLIGHT" +} + +preflight_stdout() { + preflight "$@" 2>/dev/null +} + +preflight_stderr() { + preflight "$@" 2>&1 >/dev/null +} + +refuses_without_output() { + local out rc + out="$(preflight "$@" 2>/dev/null)" + rc=$? + [ "$rc" -eq 1 ] && [ -z "$out" ] +} + +# --- the four table rows ---------------------------------------------------- + +check "row 1: a published release refuses even with no tag" 1 \ + "release '$VER' already exists — this release already happened" \ + preflight_stderr "$VER" "$MERGE_SHA" "" yes +check "row 1: a published release refuses with the matching tag" 1 \ + "release '$VER' already exists — this release already happened" \ + preflight_stderr "$VER" "$MERGE_SHA" "$MERGE_SHA" yes +check "row 1: a published release refuses with a foreign tag" 1 \ + "release '$VER' already exists — this release already happened" \ + preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" yes +check "row 1: refusal emits no workflow output" 0 "" \ + refuses_without_output "$VER" "$MERGE_SHA" "$MERGE_SHA" yes + +check "row 2: an ordinary first run proceeds" 0 "resume=no" \ + preflight_stdout "$VER" "$MERGE_SHA" "" no + +check "row 3: the matching tag resumes" 0 "resume=yes" \ + preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no +check "row 3: resume notice names the previous failed publish" 0 \ + "a previous run of this door tagged and then failed to publish" \ + preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no +check "row 3: an annotated tag resumes when the peeled ref matches" 0 \ + "resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \ + "$FOREIGN_SHA"$'\n'"$MERGE_SHA" no +check "row 3: an annotated tag resumes when the direct ref matches" 0 \ + "resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \ + "$MERGE_SHA"$'\n'"$FOREIGN_SHA" no + +check "row 4: a foreign tag refuses" 1 "tag '$VER' already exists" \ + preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no +check "row 4: the refusal names the foreign tag SHA" 1 "$FOREIGN_SHA" \ + preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no +check "row 4: the refusal names the merge SHA" 1 "$MERGE_SHA" \ + preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no +check "row 4: refusal emits no workflow output" 0 "" \ + refuses_without_output "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no + +# A ref object that merely contains MERGE_SHA is not the merge commit. Each +# ls-remote object name is compared as a whole line. +PREFIX_SHA="${MERGE_SHA%?}" +check "a prefix of MERGE_SHA does not resume" 1 "already exists at $PREFIX_SHA" \ + preflight_stderr "$VER" "$MERGE_SHA" "$PREFIX_SHA" no +check "a line containing MERGE_SHA does not resume" 1 \ + "already exists at x${MERGE_SHA}y" \ + preflight_stderr "$VER" "$MERGE_SHA" "x${MERGE_SHA}y" no + +# --- fact validation -------------------------------------------------------- + +check "empty VER refuses" 1 "VER is empty" \ + preflight_stderr "" "$MERGE_SHA" "" no +check "empty MERGE_SHA refuses" 1 "MERGE_SHA is empty" \ + preflight_stderr "$VER" "" "" no +check "empty RELEASED refuses" 1 "RELEASED is empty" \ + preflight_stderr "$VER" "$MERGE_SHA" "" "" +check "malformed RELEASED refuses" 1 "RELEASED='maybe' — expected yes or no" \ + preflight_stderr "$VER" "$MERGE_SHA" "" maybe + +# --- stream discipline and purity ------------------------------------------ + +notice_stays_on_stdout() { + local stdout stderr + stdout="$(preflight "$VER" "$MERGE_SHA" "$MERGE_SHA" no 2>"$TMP/preflight.err")" + stderr="$(cat "$TMP/preflight.err")" + [ -n "$stdout" ] && [ -z "$stderr" ] +} +refusal_stays_on_stderr() { + local stdout stderr rc + stdout="$(preflight "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no 2>"$TMP/preflight.err")" + rc=$? + stderr="$(cat "$TMP/preflight.err")" + [ "$rc" -eq 1 ] && [ -z "$stdout" ] && [ -n "$stderr" ] +} +no_tool_calls() { + ! grep -v '^[[:space:]]*#' "$PREFLIGHT" | grep -Ewq 'git|gh|curl|wget' +} +check "resume notice and output stay on stdout" 0 "" notice_stays_on_stdout +check "refusal stays on stderr" 0 "" refusal_stays_on_stderr +check "preflight calls no git/gh/network tools" 0 "" no_tool_calls + +summary -- 2.45.2 From 153a408e107fdd6a71bd4ac2e0dc47689dbf7239 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 11:15:01 +0000 Subject: [PATCH 2/8] feat: decide when merge-door releases resume --- lib/preflight.sh | 81 ++++++++++++++++++++++++++++++++++++++++++ test/preflight.test.sh | 8 ++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100755 lib/preflight.sh diff --git a/lib/preflight.sh b/lib/preflight.sh new file mode 100755 index 0000000..f34b01c --- /dev/null +++ b/lib/preflight.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# lib/preflight.sh — the merge door's resume decision, pure and exhaustively +# tested (issue #273). +# +# A merge-door run creates the tag before the artifact hook and release. A +# failed hook or publish therefore leaves a tag but no release. Re-running the +# same merge commit must resume after that irreversible step; a published +# release or a tag naming another commit must still refuse. +# +# Pure: no repository or forge reads. The workflow establishes four facts: +# +# VER the version being released +# MERGE_SHA the commit this door would tag +# TAG_SHAS object names returned for the direct and peeled tag refs, +# newline-separated; empty means the tag is absent +# RELEASED yes|no — whether a published release for VER exists +# +# Output: resume=yes or resume=no on stdout, notices to stdout, refusals to +# stderr, return 1 on refusal. +# +# The decision table (this IS the spec — issue #273): +# +# | # | RELEASED | TAG_SHAS contains MERGE_SHA | result | +# |---|----------|------------------------------|---------------------------| +# | 1 | yes | either | REFUSE: already released | +# | 2 | no | empty | resume=no: ordinary run | +# | 3 | no | yes | resume=yes + resume NOTICE | +# | 4 | no | non-empty, no | REFUSE: tag is elsewhere | + +release_preflight() { + local tag_sha sha + + if [ -z "${VER:-}" ]; then + printf '%s\n' "VER is empty — the caller failed to establish the release version. Refusing to decide — creating nothing." >&2 + return 1 + fi + if [ -z "${MERGE_SHA:-}" ]; then + printf '%s\n' "MERGE_SHA is empty — the caller failed to establish the merge commit. Refusing to decide — creating nothing." >&2 + return 1 + fi + if [ -z "${RELEASED:-}" ]; then + printf '%s\n' "RELEASED is empty — the caller failed to establish whether release '$VER' exists. Refusing to decide — creating nothing." >&2 + return 1 + fi + case "$RELEASED" in + yes | no) ;; + *) + printf '%s\n' "RELEASED='$RELEASED' — expected yes or no. Refusing to decide — creating nothing." >&2 + return 1 + ;; + esac + + # Row 1 comes first: deleting a tag under a standing release never makes + # that release safe to recreate. + if [ "$RELEASED" = yes ]; then + printf '%s\n' "release '$VER' already exists — this release already happened; refusing to re-release, creating nothing." >&2 + return 1 + fi + + # Row 2: an absent tag is the ordinary first run. + if [ -z "${TAG_SHAS:-}" ]; then + printf '%s\n' 'resume=no' + return 0 + fi + + # Row 3: compare each object name as a whole line. For an annotated tag the + # direct ref names the tag object and the peeled ref names MERGE_SHA. + while IFS= read -r sha; do + if [ "$sha" = "$MERGE_SHA" ]; then + printf '%s\n' "NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run." + printf '%s\n' 'resume=yes' + return 0 + fi + done <<<"$TAG_SHAS" + + # Row 4: the first object name is enough to diagnose the conflicting tag; + # MERGE_SHA is printed beside it so the operator sees both sides. + tag_sha="${TAG_SHAS%%$'\n'*}" + printf '%s\n' "tag '$VER' already exists at $tag_sha but this run would tag $MERGE_SHA — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit." >&2 + return 1 +} diff --git a/test/preflight.test.sh b/test/preflight.test.sh index 6cf2bb4..dc07abe 100755 --- a/test/preflight.test.sh +++ b/test/preflight.test.sh @@ -18,7 +18,13 @@ FOREIGN_SHA=2222222222222222222222222222222222222222 # preflight — run the pure decision # with exactly the four gathered facts in its environment. preflight() { - VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" bash "$PREFLIGHT" + ( + VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" + export VER MERGE_SHA TAG_SHAS RELEASED + # shellcheck source=lib/preflight.sh + . "$PREFLIGHT" + release_preflight + ) } preflight_stdout() { -- 2.45.2 From 7bd331a44d7f0aca073511a205b1a3f3c98ef96a Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 11:17:49 +0000 Subject: [PATCH 3/8] fix: resume stranded merge-door publishes --- .github/workflows/release.yml | 35 ++++++++++++-------- test/forge-backends.test.sh | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fb9bbf..7bc81cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,31 +205,40 @@ jobs: fi changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - - name: nothing may exist yet — no tag, no release (re-runs refuse loudly) + - name: preflight — resume this merge, refuse every other collision + id: preflight if: steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} VER: ${{ steps.facts.outputs.ver }} - # What makes a re-run of a completed ceremony refuse instead of - # clobber, and what catches a manual tag racing the merge. + MERGE_SHA: ${{ github.sha }} + # The pure table in lib/preflight.sh distinguishes a stranded run of + # this door from a completed release or a tag at another commit (#273). run: | - if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then - echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2 - exit 1 - fi + tag_read_rc=0 + tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$? + case "$tag_read_rc" in + 0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;; + 2) tag_shas="" ;; + *) + echo "could not read tag '$VER' from origin (git ls-remote exit $tag_read_rc) — refusing rather than assuming it does not exist." >&2 + exit 1 + ;; + esac # shellcheck source=/dev/null . "$CEREMONY_DIR/lib/forge.sh" forge_select "" - if ! exists="$(forge_release_exists "$VER")"; then + if ! released="$(forge_release_exists "$VER")"; then echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2 exit 1 fi - if [ "$exists" = yes ]; then - echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2 - exit 1 - fi + # shellcheck source=/dev/null + . "$CEREMONY_DIR/lib/preflight.sh" + out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)" + printf '%s\n' "$out" + printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT" - name: tag the merge commit — same job as the publish, on purpose - if: steps.decide.outputs.ceremony == 'yes' + if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes' env: GH_TOKEN: ${{ github.token }} VER: ${{ steps.facts.outputs.ver }} diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index f515a19..ebd75a8 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -1185,6 +1185,66 @@ release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}' forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1 check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES" +# The merge door's preflight step is extracted and executed. Its network +# edges are stubbed at the boundary, while the real pure decision library +# consumes the gathered facts (#273). +MERGE_PREFLIGHT="$TMP/merge-preflight.sh" +{ + printf '%s\n' '#!/usr/bin/env bash' 'set -e' + yq -r '.jobs.release-on-merge.steps[] | select(.id == "preflight") | .run' \ + "$ROOT/.github/workflows/release.yml" +} >"$MERGE_PREFLIGHT" +chmod +x "$MERGE_PREFLIGHT" + +mkdir -p "$TMP/merge-preflight-lib/lib" "$TMP/merge-preflight-bin" +ln -s "$ROOT/lib/preflight.sh" "$TMP/merge-preflight-lib/lib/preflight.sh" +# shellcheck disable=SC2016 # expanded when the generated helper runs +printf '%s\n' \ + 'forge_select() { :; }' \ + 'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \ + >"$TMP/merge-preflight-lib/lib/forge.sh" +# shellcheck disable=SC2016 # expanded when the PATH stub is executed +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'case "$GIT_LS_REMOTE" in' \ + ' absent) exit 2 ;;' \ + ' error) exit 128 ;;' \ + ' *) printf "%s\n" "$GIT_LS_REMOTE" ;;' \ + 'esac' >"$TMP/merge-preflight-bin/git" +chmod +x "$TMP/merge-preflight-bin/git" + +merge_preflight_extracted() { [ "$(wc -l <"$MERGE_PREFLIGHT")" -ge 8 ]; } +run_merge_preflight() { + : >"$TMP/merge-preflight-output" + CEREMONY_DIR="$TMP/merge-preflight-lib" \ + GITHUB_OUTPUT="$TMP/merge-preflight-output" \ + PATH="$TMP/merge-preflight-bin:$PATH" \ + VER=1.2.3 MERGE_SHA=1111111111111111111111111111111111111111 \ + RELEASE_EXISTS="$1" GIT_LS_REMOTE="$2" "$MERGE_PREFLIGHT" +} +merge_preflight_output_is() { + run_merge_preflight "$1" "$2" >/dev/null && \ + [ "$(cat "$TMP/merge-preflight-output")" = "$3" ] +} + +check "the merge door's preflight is extracted" 0 "" merge_preflight_extracted +check "the merge door refuses an unreadable release state" 1 \ + "refusing rather than assuming" run_merge_preflight error absent +check "the merge door refuses an unreadable tag state" 1 \ + "could not read tag '1.2.3'" run_merge_preflight no error +check "ls-remote exit 2 is tag absence and proceeds" 0 "" \ + merge_preflight_output_is no absent resume=no +check "a matching peeled ref records a resume" 0 "" \ + merge_preflight_output_is no \ + $'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/1.2.3\n1111111111111111111111111111111111111111\trefs/tags/1.2.3^{}' \ + resume=yes +tag_step_uses_preflight() { + yq -r '.jobs.release-on-merge.steps[] | select(.name | test("tag the merge commit")) | .if' \ + "$ROOT/.github/workflows/release.yml" | grep -q 'steps.preflight.outputs.resume' +} +check "the merge-door tag step is conditioned on the preflight output" 0 "" \ + tag_step_uses_preflight + # The tag door's published-release guard is extracted and executed, like the # other workflow-shell contracts in this repository. The tag itself is this # door's premise, so only a published release refuses; adding the merge door's -- 2.45.2 From 79e747b163f33afb6220c3fe6cdce33174267925 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 11:21:09 +0000 Subject: [PATCH 4/8] docs: describe merge-door resume recovery --- .github/labeler.yml | 1 + .github/scripts/release-path.sh | 1 + README.md | 32 ++++++++++++++++---------------- changelog.d/273.md | 1 + 4 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 changelog.d/273.md diff --git a/.github/labeler.yml b/.github/labeler.yml index c7a60bc..9db1f61 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -46,6 +46,7 @@ scope:release-flow: - CHANGELOG.md - drills/** - test/decide.test.sh + - test/preflight.test.sh - test/facts.test.sh - test/release-chain.test.sh - test/version.test.sh diff --git a/.github/scripts/release-path.sh b/.github/scripts/release-path.sh index 3bbe3db..c2f0cd6 100755 --- a/.github/scripts/release-path.sh +++ b/.github/scripts/release-path.sh @@ -19,6 +19,7 @@ printf '%s\n' \ bin/ \ lib/version.sh \ lib/decide.sh \ + lib/preflight.sh \ lib/facts.sh \ lib/changelog.sh \ lib/forge.sh diff --git a/README.md b/README.md index d1bfc00..0b57551 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,13 @@ steps run past the tag, and what a failure at each leaves behind is what sorts them. Two fail before the release exists: the consumer's [artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and the publish, so its non-zero exit aborts, and the publish itself -([`forge_release_create`](.github/workflows/release.yml#L255-L268)) +([`forge_release_create`](.github/workflows/release.yml#L264-L277)) can fail on the API call or the assets. Either leaves the same state — a tag -standing and no release — which the -[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag -door recovers. The third is the re-arm, which runs after the publish, and its -refusal is the single failure in this file that leaves a real release behind. +standing and no release — which the merge-door preflight recognizes and a +re-run resumes. The tag door remains the fallback when the original run is no +longer reachable or the release must come from a fixed tree. The third is the +re-arm, which runs after the publish, and its refusal is the single failure in +this file that leaves a real release behind. ## The two doors @@ -482,18 +483,17 @@ without its stamp (a state the the PR — red main here means it was overridden). Stamp the section on main, then publish by the tag door. -> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing. -> release '$VER' already exists — refusing to re-release, creating nothing. +> release '$VER' already exists — this release already happened; refusing to re-release, creating nothing. +> tag '$VER' already exists at but this run would tag — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit. +> NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run. -[L208–L223](.github/workflows/release.yml#L208-L223), the nothing-exists -assert — what makes a re-run of a completed ceremony refuse instead of -clobber, and what catches a manual tag racing the merge. If the release -truly exists, there is nothing to do: this red is the system declining to do -the thing twice. If the tag exists but the release does not (a manual tag -won the race, or -[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish -step itself failing after the tag), recover by the tag door: delete and -re-push the tag, or run `forge_release_create` by hand from a fixed tree. +[L208–L239](.github/workflows/release.yml#L208-L239), the merge-door +preflight — the published-release refusal prevents clobbering, the +different-commit refusal diagnoses a racing or manual tag with both SHAs, and +the notice resumes this door after its tag succeeded but the artifact hook or +publish failed. Re-run the merge-door job first. If that run is no longer +reachable or the tree itself needs repair, use the tag-door fallback: delete +and re-push the tag from the fixed tree, or run `forge_release_create` by hand. > direct push refused (branch protection?) — opening the bump PR instead diff --git a/changelog.d/273.md b/changelog.d/273.md new file mode 100644 index 0000000..2d9d657 --- /dev/null +++ b/changelog.d/273.md @@ -0,0 +1 @@ +- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273). -- 2.45.2 From f19658ea82445ccb58b10dbb78ed079d8e939e73 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 11:24:53 +0000 Subject: [PATCH 5/8] test: isolate preflight fact environments --- test/preflight.test.sh | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/test/preflight.test.sh b/test/preflight.test.sh index dc07abe..ac88451 100755 --- a/test/preflight.test.sh +++ b/test/preflight.test.sh @@ -18,13 +18,9 @@ FOREIGN_SHA=2222222222222222222222222222222222222222 # preflight — run the pure decision # with exactly the four gathered facts in its environment. preflight() { - ( - VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" - export VER MERGE_SHA TAG_SHAS RELEASED - # shellcheck source=lib/preflight.sh - . "$PREFLIGHT" - release_preflight - ) + # shellcheck disable=SC2016 # PREFLIGHT expands inside the isolated child + env VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" \ + PREFLIGHT="$PREFLIGHT" bash -c '. "$PREFLIGHT"; release_preflight' } preflight_stdout() { @@ -32,7 +28,7 @@ preflight_stdout() { } preflight_stderr() { - preflight "$@" 2>&1 >/dev/null + { preflight "$@" >/dev/null; } 2>&1 } refuses_without_output() { -- 2.45.2 From e2aa8346011f9198961202c9074daff6d749d656 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 13:55:25 +0000 Subject: [PATCH 6/8] test: derive preflight in release path fixtures --- test/release-path.test.sh | 52 +++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/test/release-path.test.sh b/test/release-path.test.sh index 2ba304a..9f5a30e 100755 --- a/test/release-path.test.sh +++ b/test/release-path.test.sh @@ -97,6 +97,17 @@ path_check() { [ -z "$missing" ] && [ -z "$extra" ] } +path_check_reports_only() { + local tree="$1" expected="$2" output rc + output="$(path_check "$tree" 2>&1)" + rc=$? + if [ "$rc" -ne 1 ] || [ "$output" != "$expected" ]; then + printf 'expected only: %s\ngot (exit %s): %s\n' \ + "$expected" "$rc" "$output" >&2 + return 1 + fi +} + readme_has_no_path_enumeration() { local token found=no for token in \ @@ -119,6 +130,7 @@ fixture() { "\$ROOT/lib/changelog.sh" >"$tree/bin/assemble" printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" + printf '#!/usr/bin/env bash\n' >"$tree/lib/preflight.sh" # facts.sh sources BOTH on this tree: version.sh, and the forge shim #191 # put on the doors' path so a Forgejo consumer can publish (#198). The # synthetic tree mirrors the real one, or every fixture below reports @@ -134,7 +146,7 @@ fixture() { # Exact output is the record author's copy-paste source. check "manifest prints the specified ordered release path" 0 \ - $'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \ + $'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/preflight.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \ bash "$PATH_SCRIPT" check "real workflow and transitive dependencies match the manifest" 0 "" \ path_check "$ROOT" @@ -143,54 +155,56 @@ check "drill doctrine does not duplicate the executable release path" 0 "" \ # A door growing a dependency must name the missing path (#237 D7). tree="$(fixture missing)" -printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ - "\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \ - "\$CEREMONY_DIR/lib/ruling.sh" \ + "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \ + "\$CEREMONY_DIR/lib/version.sh" "\$CEREMONY_DIR/lib/ruling.sh" \ >"$tree/.github/workflows/release.yml" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" -check "a new workflow library fails with its missing path" 1 \ - "missing dependency: lib/ruling.sh" path_check "$tree" +check "a new workflow library fails with only its missing path" 0 "" \ + path_check_reports_only "$tree" \ + "release-path: missing dependency: lib/ruling.sh" # A library growing a sibling dependency in the production idiom must also # name the missing path; a literal lib/ marker in a comment is not evidence. tree="$(fixture missing-transitive)" -printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ - "\$CEREMONY_DIR/lib/changelog.sh" \ + "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \ >"$tree/.github/workflows/release.yml" printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \ "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \ >>"$tree/lib/facts.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" -check "a new sibling library fails with its missing path" 1 \ - "missing dependency: lib/ruling.sh" path_check "$tree" +check "a new sibling library fails with only its missing path" 0 "" \ + path_check_reports_only "$tree" \ + "release-path: missing dependency: lib/ruling.sh" # A manifest may not rot into a safe-looking superset. tree="$(fixture extra)" -printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ - "\$CEREMONY_DIR/lib/changelog.sh" \ + "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \ >"$tree/.github/workflows/release.yml" -sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \ +sed -i '$ s|$| \\|' \ "$tree/.github/scripts/release-path.sh" printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" -check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \ - path_check "$tree" +check "a path no door reads fails with only its stale path" 0 "" \ + path_check_reports_only "$tree" "release-path: stale path: lib/ruling.sh" # Transitive sourcing is part of the derivation, not decoration. tree="$(fixture transitive)" -printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ - "\$CEREMONY_DIR/lib/changelog.sh" \ + "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \ >"$tree/.github/workflows/release.yml" # Only the version source is dropped; the forge source #191 added stays, or # the fixture reports two stale paths and proves neither of them (#198). printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \ "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \ >"$tree/lib/facts.sh" -check "removing facts' version source fails as a stale path" 1 \ - "stale path: lib/version.sh" path_check "$tree" +check "removing facts' version source fails with only its stale path" 0 "" \ + path_check_reports_only "$tree" "release-path: stale path: lib/version.sh" summary -- 2.45.2 From 715663cf53472f48d907cc9609eaca967b79b52f Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 14:02:11 +0000 Subject: [PATCH 7/8] docs: group release recovery changelog entry --- changelog.d/273.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.d/273.md b/changelog.d/273.md index 2d9d657..fd7d534 100644 --- a/changelog.d/273.md +++ b/changelog.d/273.md @@ -1 +1,3 @@ +### Fixed + - Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273). -- 2.45.2 From d944bddeccba60a0978e8565c1237a68bcaf10e6 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 15:55:21 +0000 Subject: [PATCH 8/8] docs: repair release workflow anchors after preflight shift --- README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0b57551..945bbb7 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ workflow that carries it. consumer's release, when it is stale.) **The merge is the ship decision; the tag is transcription.** After the -merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its +merge, [release.yml](.github/workflows/release.yml#L136-L310) asserts its way to certainty, tags the merge commit, publishes the forge release with the version's own changelog section as the body — the curated prose, never the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one @@ -142,20 +142,20 @@ this file that leaves a real release behind. `release`-labeled PR whose version transitioned to bare is the ceremony, everything legitimate that isn't one is a green no-op, and every half-ceremony dies loudly - ([release.yml](.github/workflows/release.yml#L136-L301)). Use it for every + ([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every normal release. - **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push — **no `v` prefix**, box's 0.6.0 set the scheme - ([release.yml](.github/workflows/release.yml#L316-L401)) — publishes the + ([release.yml](.github/workflows/release.yml#L325-L410)) — publishes the same way. The tag is the operator's explicit act, so there is no decide and no label check — what is left is three asserts: **the tag names the tree's own version** - ([L341–L352](.github/workflows/release.yml#L341-L352)), **the tagged + ([L350–L361](.github/workflows/release.yml#L350-L361)), **the tagged tree carries a publishable `## X.Y.Z` section** - ([L353–L365](.github/workflows/release.yml#L353-L365)), and **no published + ([L362–L374](.github/workflows/release.yml#L362-L374)), and **no published release already exists for the tag** - ([L366–L381](.github/workflows/release.yml#L366-L381)); any failure + ([L375–L390](.github/workflows/release.yml#L375-L390)); any failure refuses, creating nothing. No `-dev` bump either — the fallback does not rewrite main (cast's precedent). Use it when the merge path is red, for backfills, and for the @@ -473,7 +473,7 @@ so this line can only appear when some *other* caller invokes `version_read` directly with a backend that is neither `file` nor `package-json`. Fix that caller. -### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301)) +### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L310)) > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release @@ -497,7 +497,7 @@ and re-push the tag from the fixed tree, or run `forge_release_create` by hand. > direct push refused (branch protection?) — opening the bump PR instead -[L293–L301](.github/workflows/release.yml#L293-L301) — loud, but not a +[L302–L310](.github/workflows/release.yml#L302-L310) — loud, but not a refusal: the post-release `-dev` bump could not push directly, so the run opened a `release`-labeled bump PR itself. Your move: merge it promptly — until it lands, main is sitting bare, where a dev install impersonates the @@ -505,35 +505,35 @@ release and the [armed guard's window](#changelog-armed--main-never-sits-disarmed) stays open. -### The tag door refused ([release.yml](.github/workflows/release.yml#L316-L401)) +### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410)) > tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing. > A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit. -[L347–L350](.github/workflows/release.yml#L347-L350). The message is the +[L356–L359](.github/workflows/release.yml#L356-L359). The message is the remedy. > CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release -[L359–L365](.github/workflows/release.yml#L359-L365). The tagged tree was +[L368–L374](.github/workflows/release.yml#L368-L374). The tagged tree was never stamped. Assemble the section ([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then delete and re-push the tag. > release '$VER' already exists — refusing to re-release, creating nothing. -[L366–L381](.github/workflows/release.yml#L366-L381). A published release is +[L375–L390](.github/workflows/release.yml#L375-L390). A published release is never replaced by the fallback. If it is correct, there is nothing to do; if it is wrong, correct that published artifact deliberately before retrying. -### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) +### The re-arm refused ([release.yml](.github/workflows/release.yml#L276-L310)) The bump belongs to the merge door alone — the tag door deliberately does not -rewrite main ([L316–L320](.github/workflows/release.yml#L316-L320)) — and it +rewrite main ([L325–L329](.github/workflows/release.yml#L325-L329)) — and it runs *after* the tag, the notes and the publish. So a refusal here leaves a real release standing behind a main that never re-armed — the release exists, and main is left *armed to impersonate* it, still reading the version it just -shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure +shipped ([L275](.github/workflows/release.yml#L275)). That is the one failure in this catalog whose remedy is a manual bump, not a re-run. > version_next_dev: refusing '$ver' — expected bare X.Y.Z @@ -581,7 +581,7 @@ In every case the remedy has the same shape — bump `VERSION` (or the shipped version was bare, and where it was an rc, whatever you have decided comes next. Note that a *push* refusal is not one of these — branch protection is expected, and the step opens the bump PR itself rather than -failing ([L293–L301](.github/workflows/release.yml#L293-L301)). +failing ([L302–L310](.github/workflows/release.yml#L302-L310)). ### Red main that is not the release workflow -- 2.45.2