fix: resume a stranded merge-door release #274

Merged
andres merged 8 commits from build/273-resume-merge-door into main 2026-08-31 16:57:10 +00:00
9 changed files with 354 additions and 64 deletions

1
.github/labeler.yml vendored
View file

@ -46,6 +46,7 @@ scope:release-flow:
- CHANGELOG.md - CHANGELOG.md
- drills/** - drills/**
- test/decide.test.sh - test/decide.test.sh
- test/preflight.test.sh
- test/facts.test.sh - test/facts.test.sh
- test/release-chain.test.sh - test/release-chain.test.sh
- test/version.test.sh - test/version.test.sh

View file

@ -19,6 +19,7 @@ printf '%s\n' \
bin/ \ bin/ \
lib/version.sh \ lib/version.sh \
lib/decide.sh \ lib/decide.sh \
lib/preflight.sh \
lib/facts.sh \ lib/facts.sh \
lib/changelog.sh \ lib/changelog.sh \
lib/forge.sh lib/forge.sh

View file

@ -205,31 +205,40 @@ jobs:
fi fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$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' if: steps.decide.outputs.ceremony == 'yes'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }} VER: ${{ steps.facts.outputs.ver }}
# What makes a re-run of a completed ceremony refuse instead of MERGE_SHA: ${{ github.sha }}
# clobber, and what catches a manual tag racing the merge. # 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: | run: |
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then tag_read_rc=0
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2 tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$?
exit 1 case "$tag_read_rc" in
fi 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 # shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh" . "$CEREMONY_DIR/lib/forge.sh"
forge_select "" 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 echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1 exit 1
fi fi
if [ "$exists" = yes ]; then # shellcheck source=/dev/null
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2 . "$CEREMONY_DIR/lib/preflight.sh"
exit 1 out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)"
fi printf '%s\n' "$out"
printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT"
- name: tag the merge commit — same job as the publish, on purpose - 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: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }} VER: ${{ steps.facts.outputs.ver }}

View file

@ -108,7 +108,7 @@ workflow that carries it.
consumer's release, when it is stale.) consumer's release, when it is stale.)
**The merge is the ship decision; the tag is transcription.** After the **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 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 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 the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
@ -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 sorts them. Two fail before the release exists: the consumer's
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and [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 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 can fail on the API call or the assets. Either leaves the same state — a tag
standing and no release — which the standing and no release — which the merge-door preflight recognizes and a
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag re-run resumes. The tag door remains the fallback when the original run is no
door recovers. The third is the re-arm, which runs after the publish, and its longer reachable or the release must come from a fixed tree. The third is the
refusal is the single failure in this file that leaves a real release behind. 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 ## The two doors
@ -141,20 +142,20 @@ refusal is the single failure in this file that leaves a real release behind.
`release`-labeled PR whose version transitioned to bare is the ceremony, `release`-labeled PR whose version transitioned to bare is the ceremony,
everything legitimate that isn't one is a green no-op, and every everything legitimate that isn't one is a green no-op, and every
half-ceremony dies loudly 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. normal release.
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push - **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 **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 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 and no label check — what is left is three asserts: **the tag names the
tree's own version** tree's own version**
([L341L352](.github/workflows/release.yml#L341-L352)), **the tagged ([L350L361](.github/workflows/release.yml#L350-L361)), **the tagged
tree carries a publishable `## X.Y.Z` section** tree carries a publishable `## X.Y.Z` section**
([L353L365](.github/workflows/release.yml#L353-L365)), and **no published ([L362L374](.github/workflows/release.yml#L362-L374)), and **no published
release already exists for the tag** release already exists for the tag**
([L366L381](.github/workflows/release.yml#L366-L381)); any failure ([L375L390](.github/workflows/release.yml#L375-L390)); any failure
refuses, creating nothing. No `-dev` bump either refuses, creating nothing. No `-dev` bump either
— the fallback does not rewrite main (cast's precedent). Use it when the — the fallback does not rewrite main (cast's precedent). Use it when the
merge path is red, for backfills, and for the merge path is red, for backfills, and for the
@ -472,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 directly with a backend that is neither `file` nor `package-json`. Fix that
caller. 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 > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
@ -482,22 +483,21 @@ without its stamp (a state the
the PR — red main here means it was overridden). Stamp the section on main, the PR — red main here means it was overridden). Stamp the section on main,
then publish by the tag door. 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 — this release already happened; refusing to re-release, creating nothing.
> release '$VER' already exists — refusing to re-release, creating nothing. > 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.
> 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.
[L208L223](.github/workflows/release.yml#L208-L223), the nothing-exists [L208L239](.github/workflows/release.yml#L208-L239), the merge-door
assert — what makes a re-run of a completed ceremony refuse instead of preflight — the published-release refusal prevents clobbering, the
clobber, and what catches a manual tag racing the merge. If the release different-commit refusal diagnoses a racing or manual tag with both SHAs, and
truly exists, there is nothing to do: this red is the system declining to do the notice resumes this door after its tag succeeded but the artifact hook or
the thing twice. If the tag exists but the release does not (a manual tag publish failed. Re-run the merge-door job first. If that run is no longer
won the race, or reachable or the tree itself needs repair, use the tag-door fallback: delete
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
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.
> direct push refused (branch protection?) — opening the bump PR instead > direct push refused (branch protection?) — opening the bump PR instead
[L293L301](.github/workflows/release.yml#L293-L301) — loud, but not a [L302L310](.github/workflows/release.yml#L302-L310) — loud, but not a
refusal: the post-release `-dev` bump could not push directly, so the run 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 — 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 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 [armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
open. 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. > 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. > 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.
[L347L350](.github/workflows/release.yml#L347-L350). The message is the [L356L359](.github/workflows/release.yml#L356-L359). The message is the
remedy. remedy.
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release > CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
[L359L365](.github/workflows/release.yml#L359-L365). The tagged tree was [L368L374](.github/workflows/release.yml#L368-L374). The tagged tree was
never stamped. Assemble the section never stamped. Assemble the section
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then ([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
delete and re-push the tag. delete and re-push the tag.
> release '$VER' already exists — refusing to re-release, creating nothing. > release '$VER' already exists — refusing to re-release, creating nothing.
[L366L381](.github/workflows/release.yml#L366-L381). A published release is [L375L390](.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 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. 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 The bump belongs to the merge door alone — the tag door deliberately does not
rewrite main ([L316L320](.github/workflows/release.yml#L316-L320)) — and it rewrite main ([L325L329](.github/workflows/release.yml#L325-L329)) — and it
runs *after* the tag, the notes and the publish. So a refusal here leaves a 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, 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 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. in this catalog whose remedy is a manual bump, not a re-run.
> version_next_dev: refusing '$ver' — expected bare X.Y.Z > 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 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 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 protection is expected, and the step opens the bump PR itself rather than
failing ([L293L301](.github/workflows/release.yml#L293-L301)). failing ([L302L310](.github/workflows/release.yml#L302-L310)).
### Red main that is not the release workflow ### Red main that is not the release workflow

3
changelog.d/273.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273).

81
lib/preflight.sh Executable file
View file

@ -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
}

View file

@ -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 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" 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 # 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 # 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 # door's premise, so only a published release refuses; adding the merge door's

121
test/preflight.test.sh Executable file
View file

@ -0,0 +1,121 @@
#!/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 <VER> <MERGE_SHA> <TAG_SHAS> <RELEASED> — run the pure decision
# with exactly the four gathered facts in its environment.
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() {
preflight "$@" 2>/dev/null
}
preflight_stderr() {
{ preflight "$@" >/dev/null; } 2>&1
}
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

View file

@ -97,6 +97,17 @@ path_check() {
[ -z "$missing" ] && [ -z "$extra" ] [ -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() { readme_has_no_path_enumeration() {
local token found=no local token found=no
for token in \ for token in \
@ -119,6 +130,7 @@ fixture() {
"\$ROOT/lib/changelog.sh" >"$tree/bin/assemble" "\$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/changelog.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.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 # 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 # put on the doors' path so a Forgejo consumer can publish (#198). The
# synthetic tree mirrors the real one, or every fixture below reports # 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. # Exact output is the record author's copy-paste source.
check "manifest prints the specified ordered release path" 0 \ 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" bash "$PATH_SCRIPT"
check "real workflow and transitive dependencies match the manifest" 0 "" \ check "real workflow and transitive dependencies match the manifest" 0 "" \
path_check "$ROOT" 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). # A door growing a dependency must name the missing path (#237 D7).
tree="$(fixture missing)" 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/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \ "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
"\$CEREMONY_DIR/lib/ruling.sh" \ "\$CEREMONY_DIR/lib/version.sh" "\$CEREMONY_DIR/lib/ruling.sh" \
>"$tree/.github/workflows/release.yml" >"$tree/.github/workflows/release.yml"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a new workflow library fails with its missing path" 1 \ check "a new workflow library fails with only its missing path" 0 "" \
"missing dependency: lib/ruling.sh" path_check "$tree" path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
# A library growing a sibling dependency in the production idiom must also # 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. # name the missing path; a literal lib/ marker in a comment is not evidence.
tree="$(fixture missing-transitive)" 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/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" >"$tree/.github/workflows/release.yml"
printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \ printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \ "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \
>>"$tree/lib/facts.sh" >>"$tree/lib/facts.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a new sibling library fails with its missing path" 1 \ check "a new sibling library fails with only its missing path" 0 "" \
"missing dependency: lib/ruling.sh" path_check "$tree" path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
# A manifest may not rot into a safe-looking superset. # A manifest may not rot into a safe-looking superset.
tree="$(fixture extra)" 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/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" >"$tree/.github/workflows/release.yml"
sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \ sed -i '$ s|$| \\|' \
"$tree/.github/scripts/release-path.sh" "$tree/.github/scripts/release-path.sh"
printf ' lib/ruling.sh\n' >>"$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" 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" \ check "a path no door reads fails with only its stale path" 0 "" \
path_check "$tree" path_check_reports_only "$tree" "release-path: stale path: lib/ruling.sh"
# Transitive sourcing is part of the derivation, not decoration. # Transitive sourcing is part of the derivation, not decoration.
tree="$(fixture transitive)" 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/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" >"$tree/.github/workflows/release.yml"
# Only the version source is dropped; the forge source #191 added stays, or # 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). # 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' \ printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \ "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
>"$tree/lib/facts.sh" >"$tree/lib/facts.sh"
check "removing facts' version source fails as a stale path" 1 \ check "removing facts' version source fails with only its stale path" 0 "" \
"stale path: lib/version.sh" path_check "$tree" path_check_reports_only "$tree" "release-path: stale path: lib/version.sh"
summary summary