Compare commits

...

25 commits
0.6.3 ... main

Author SHA1 Message Date
91aee7f842 Merge pull request 'fix(labels): compare release shape to merge base' (#277) from build/275-merge-base-release-shape into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 9s
CI / action-exercise (push) Successful in 8s
CI / docs-sync-exercise (push) Successful in 8s
release / release (push) Successful in 9s
CI / test (push) Successful in 3m46s
Reviewed-on: #277
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-09-01 05:52:26 +00:00
codex-bot-andresmgsl
0b1fa70d60 fix(changelog): match grouped fragment shape
All checks were successful
CI / action-exercise (pull_request) Successful in 9s
CI / self-guards (pull_request) Successful in 11s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 7s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Successful in 4m15s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-31 21:24:50 +00:00
codex-bot-andresmgsl
4bce62e1fa test(labels): prove merge-base fallback
Some checks failed
CI / self-guards (pull_request) Failing after 9s
CI / action-exercise (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 7s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 8s
CI / test (pull_request) Successful in 4m18s
2026-08-31 21:20:35 +00:00
codex-bot-andresmgsl
4ce43c4a3b test(labels): document isolated fixture state
Some checks failed
CI / self-guards (pull_request) Failing after 10s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 13s
CI / docs-sync-exercise (pull_request) Successful in 7s
Refs guard / refs-not-closing (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 9s
CI / test (pull_request) Successful in 3m43s
2026-08-31 21:11:30 +00:00
codex-bot-andresmgsl
dc29fdc84c fix(labels): compare release shape to merge base
Some checks failed
CI / self-guards (pull_request) Failing after 10s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 15s
CI / docs-sync-exercise (pull_request) Successful in 7s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 7s
CI / test (pull_request) Failing after 42s
2026-08-31 21:06:50 +00:00
codex-bot-andresmgsl
328c8707df test(labels): reproduce moving base-tip warning
Some checks failed
CI / self-guards (pull_request) Successful in 12s
CI / release-exercise (pull_request) Successful in 15s
CI / action-exercise (pull_request) Successful in 8s
CI / docs-sync-exercise (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Failing after 45s
2026-08-31 21:04:41 +00:00
85290031b2 Merge pull request 'fix: resume a stranded merge-door release' (#274) from build/273-resume-merge-door into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 13s
CI / action-exercise (push) Successful in 11s
CI / docs-sync-exercise (push) Successful in 11s
release / release (push) Successful in 11s
CI / test (push) Successful in 5m51s
Reviewed-on: #274
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-31 16:57:09 +00:00
codex-bot-andresmgsl
d944bddecc docs: repair release workflow anchors after preflight shift
All checks were successful
CI / release-exercise (pull_request) Successful in 15s
CI / self-guards (pull_request) Successful in 16s
CI / action-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 12s
labels / labels (pull_request) Successful in 16s
CI / test (pull_request) Successful in 5m17s
Refs guard / refs-not-closing (pull_request) Successful in 7s
2026-08-31 15:55:21 +00:00
codex-bot-andresmgsl
715663cf53 docs: group release recovery changelog entry
All checks were successful
CI / action-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 13s
CI / release-exercise (pull_request) Successful in 16s
CI / docs-sync-exercise (pull_request) Successful in 9s
labels / labels (pull_request) Successful in 11s
CI / test (pull_request) Successful in 4m7s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-31 14:02:11 +00:00
codex-bot-andresmgsl
e2aa834601 test: derive preflight in release path fixtures
Some checks failed
CI / self-guards (pull_request) Failing after 9s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Successful in 3m47s
Refs guard / refs-not-closing (pull_request) Successful in 9s
2026-08-31 13:55:25 +00:00
codex-bot-andresmgsl
f19658ea82 test: isolate preflight fact environments
Some checks failed
CI / self-guards (pull_request) Failing after 17s
CI / action-exercise (pull_request) Successful in 16s
CI / release-exercise (pull_request) Successful in 24s
CI / docs-sync-exercise (pull_request) Successful in 13s
labels / labels (pull_request) Successful in 14s
CI / test (pull_request) Failing after 1m8s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-31 11:24:53 +00:00
codex-bot-andresmgsl
79e747b163 docs: describe merge-door resume recovery
Some checks failed
CI / action-exercise (pull_request) Successful in 29s
CI / self-guards (pull_request) Failing after 32s
CI / release-exercise (pull_request) Successful in 39s
CI / docs-sync-exercise (pull_request) Successful in 12s
labels / labels (pull_request) Successful in 14s
Refs guard / refs-not-closing (pull_request) Successful in 11s
CI / test (pull_request) Failing after 1m11s
2026-08-31 11:21:09 +00:00
codex-bot-andresmgsl
7bd331a44d fix: resume stranded merge-door publishes
Some checks failed
CI / action-exercise (pull_request) Successful in 12s
CI / self-guards (pull_request) Successful in 16s
CI / release-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 12s
labels / labels (pull_request) Successful in 13s
Refs guard / refs-not-closing (pull_request) Successful in 10s
CI / test (pull_request) Failing after 58s
2026-08-31 11:17:49 +00:00
codex-bot-andresmgsl
153a408e10 feat: decide when merge-door releases resume
Some checks failed
CI / self-guards (pull_request) Successful in 10s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Failing after 43s
2026-08-31 11:15:01 +00:00
codex-bot-andresmgsl
3104aac6f3 test: specify release preflight contract
Some checks failed
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 9s
CI / action-exercise (pull_request) Successful in 8s
CI / docs-sync-exercise (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 8s
CI / test (pull_request) Failing after 44s
2026-08-31 11:12:22 +00:00
58ab50361d Merge pull request 'fix: publish Forgejo releases atomically' (#272) from build/271-atomic-forgejo-release into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
CI / test (push) Successful in 3m42s
Reviewed-on: #272
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-30 10:47:55 +00:00
codex-bot-andresmgsl
1aa03cad77 docs: describe all tag-door assertions
All checks were successful
CI / self-guards (pull_request) Successful in 10s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 9s
CI / test (pull_request) Successful in 3m53s
Refs guard / refs-not-closing (pull_request) Successful in 7s
2026-08-30 09:33:12 +00:00
codex-bot-andresmgsl
fe4ec57ff2 test: document deferred fixture expansion
All checks were successful
CI / action-exercise (pull_request) Successful in 6s
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
CI / test (pull_request) Successful in 3m53s
2026-08-30 09:31:16 +00:00
codex-bot-andresmgsl
20cba4583d docs: describe atomic release publication
Some checks failed
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 13s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Failing after 42s
2026-08-30 09:27:56 +00:00
codex-bot-andresmgsl
734676ab7d fix: guard tag release publication
Some checks failed
CI / test (pull_request) Failing after 41s
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
2026-08-30 09:27:08 +00:00
codex-bot-andresmgsl
c4315c2cfa fix: publish Forgejo releases atomically
All checks were successful
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Successful in 3m58s
2026-08-30 09:25:43 +00:00
codex-bot-andresmgsl
2ab9902c38 test: expose non-atomic Forgejo releases
Some checks failed
CI / action-exercise (pull_request) Successful in 7s
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
CI / test (pull_request) Failing after 3m57s
2026-08-30 09:23:48 +00:00
f5c02fee8f Merge pull request 'docs: correct upstream sync campaign record' (#270) from build/269-upstream-sync-record into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 6s
CI / action-exercise (push) Successful in 4s
CI / docs-sync-exercise (push) Successful in 5s
release / release (push) Successful in 5s
CI / test (push) Successful in 3m36s
Reviewed-on: #270
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-08-27 12:45:00 +00:00
codex-bot-andresmgsl
7e96748193 docs: correct upstream sync campaign record
All checks were successful
CI / self-guards (pull_request) Successful in 8s
CI / action-exercise (pull_request) Successful in 6s
CI / release-exercise (pull_request) Successful in 11s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Successful in 7s
CI / test (pull_request) Successful in 3m40s
Refs guard / refs-not-closing (pull_request) Successful in 5s
2026-08-27 10:19:53 +00:00
github-actions[bot]
bcbcd90047 chore: bump main to 0.6.4-dev — a dev install must not impersonate 0.6.3 2026-08-26 20:20:20 +00:00
17 changed files with 758 additions and 106 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=$?
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 exit 1
fi ;;
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 }}
@ -363,6 +372,22 @@ 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: no published release may exist
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(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
- name: artifact hook — the consumer's own release-artifact action - name: artifact hook — the consumer's own release-artifact action
# After the tag exists (it fired this door), before the publish — # After the tag exists (it fired this door), before the publish —
# the same contract as the merge door's twin step. # the same contract as the merge door's twin step.

View file

@ -108,8 +108,8 @@ 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 GitHub 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
canonical extractor, and [bin/changelog-section](bin/changelog-section) is canonical extractor, and [bin/changelog-section](bin/changelog-section) is
@ -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
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258)) ([`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,18 +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#L303-L371)) — 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 two 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**
([L328L339](.github/workflows/release.yml#L328-L339)) and **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**
([L340L352](.github/workflows/release.yml#L340-L352)); either failing ([L362L374](.github/workflows/release.yml#L362-L374)), and **no published
release already exists for the tag**
([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
@ -470,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
@ -480,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 `gh 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
@ -503,29 +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#L303-L371)) ### 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.
[L333L337](.github/workflows/release.yml#L333-L337). 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
[L346L349](.github/workflows/release.yml#L346-L349). 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.
### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) > release '$VER' already exists — refusing to re-release, creating nothing.
[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
it is wrong, correct that published artifact deliberately before retrying.
### 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 ([L303L307](.github/workflows/release.yml#L303-L307)) — 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
@ -573,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

View file

@ -1 +1 @@
0.6.3 0.6.4-dev

View file

@ -270,7 +270,8 @@ set_required_bots() { # the PR author is recused by construction
# The state machine. Pure functions over these globals, set per PR: # The state machine. Pure functions over these globals, set per PR:
# DRAFT true|false # DRAFT true|false
# HEAD_SHA the PR's current head commit # HEAD_SHA the PR's current head commit
# BASE_SHA the PR's base branch head (the release-shape guard's ref) # BASE_SHA the PR's base branch head
# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref)
# REQUESTED newline-separated logins with a review currently requested # REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted, gradeable reviews # REVIEWS_JSON JSON array of submitted, gradeable reviews
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) # MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
@ -918,7 +919,8 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# Drafts are exempt (the build phase is the builder's); the version # Drafts are exempt (the build phase is the builder's); the version
# reads cost two API calls and only on PRs missing the label. # reads cost two API calls and only on PRs missing the label.
if [ "$DRAFT" != true ] && ! has_label release; then if [ "$DRAFT" != true ] && ! has_label release; then
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")" release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
fi fi
# ---- merge-next: cleared, never set ---------------------------------- # ---- merge-next: cleared, never set ----------------------------------
@ -1031,6 +1033,7 @@ main() {
set_required_bots "$AUTHOR" set_required_bots "$AUTHOR"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")" BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
MERGE_BASE_SHA="$(jq -r '.merge_base // empty' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
# This allow-list answers whether a row is a submitted, gradeable review; # This allow-list answers whether a row is a submitted, gradeable review;
# bot_verdict separately answers what that submitted verdict says (#235). # bot_verdict separately answers what that submitted verdict says (#235).

7
changelog.d/269.md Normal file
View file

@ -0,0 +1,7 @@
### Fixed
- The upstream deferral record now names its floor and a dated measurement instead of a frozen `0.7.4` ceiling that expired before it was written (#269).
### Changed
- `docs/UPSTREAM-SYNC.md` now records unconditionally that the next sync campaign merges rather than ports, and so advances the ancestry baseline (#269).

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

@ -0,0 +1,3 @@
### Fixed
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).

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).

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

@ -0,0 +1,3 @@
### Fixed
- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275).

View file

@ -30,10 +30,33 @@ The ancestry baseline therefore remains the full `.upstream-ref` value
`upstream-0.6.x` for upstream's line and bare `0.6.x` for releases cut on this `upstream-0.6.x` for upstream's line and bare `0.6.x` for releases cut on this
forge. forge.
Upstream's drill-record fixes and the upstream `0.7.0` through `0.7.4` line Upstream's drill-record fixes and the upstream `0.7.x` line remain deferred to
remain deferred to the next sync campaign. If that campaign merges rather the next sync campaign. That line has no ceiling this file can hold still —
than ports, it is the campaign that advances `.upstream-ref`; another port upstream tags roughly one release a week — so what is recorded here is its
leaves the ancestry baseline unchanged. floor and the date it was last measured: `0.7.0` onward, `0.7.6` newest as
measured 2026-08-27. Run `git ls-remote --tags` for today's ceiling. The
sentence this replaced froze `0.7.4` and was already a release behind on the
day it was written.
## The next campaign merges
The next sync campaign is a merge, not a port, and it therefore advances
`.upstream-ref` to the commit it merges. The operator ruled this on #268 on
2026-08-27, when release-init found no forge-local work to fill an `0.6.4` and
opened no window.
The reason is the ancestry baseline. `.upstream-ref` has been pinned at
`8c3a4d1` — upstream `0.6.0`, merged by #198 — because 0.6.2 came across as a
port and a port moves no ancestry. Another port would leave it pinned and
guarantee a third, larger campaign against a delta that is still growing.
Size it before starting. With upstream's tags fetched read-only per step 1,
`git diff --shortstat 8c3a4d1dee2bdb5ac06a632a285bb65ab2615214 0.7.6`
was 92 files and +25,121/-971 over 488 commits when measured 2026-08-27, and
the three action scripts the forge delta is heaviest in all move:
`runner-isolated` +1619, `issueflow-reconcile` +913, `labels-reconcile` +889.
Step 4 of the procedure below — the audit of what merged *without* conflicting
— is where that size is actually paid for.
## The standing resolutions ## The standing resolutions

View file

@ -25,12 +25,12 @@ forgejo_api_base() {
# THE TRAP, measured on this instance with REPO unset (#191, caught by # THE TRAP, measured on this instance with REPO unset (#191, caught by
# @kimi on !193 before it shipped): # @kimi on !193 before it shipped):
# #
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1 # forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
# 404s; a repo-less path read # 404s; a repo-less path read
# as "the release does not # as "the published release
# exist" — and the # does not exist" — and the
# nothing-exists assert would # nothing-exists assert would
# then proceed to CREATE) # then proceed to CREATE)
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged # forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
# PR behind it, read as none) # PR behind it, read as none)
# #
@ -672,22 +672,31 @@ forgejo_read_code() {
rm -f "$hdr" rm -f "$hdr"
} }
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means # forge_release_exists <tag> — prints `yes` for a published release and `no`
# the read did not complete and the answer is UNKNOWN; the caller must not # for a draft or a 404. A non-zero exit means the read did not complete and
# treat that as `no` (#191). # the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
forge_release_exists() { forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" body code local tag="${1:?forge_release_exists: tag required}" body code draft
body="$(mktemp)" body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; } code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
rm -f "$body"
case "$code" in case "$code" in
2*) echo yes ;; 2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$body"
echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2
return 1
fi
[ "$draft" = true ] && echo no || echo yes
;;
404) echo no ;; 404) echo no ;;
*) *)
rm -f "$body"
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2 echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
return 1 return 1
;; ;;
esac esac
rm -f "$body"
} }
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as # forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
@ -765,20 +774,74 @@ forgejo_urlencode() {
jq -rn --arg s "${1-}" '$s|@uri' jq -rn --arg s "${1-}" '$s|@uri'
} }
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then # forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
# uploads each asset to the created release. The release id comes back from # post-create failure. The caller has already printed the original failure;
# the create, so no second lookup is needed. # cleanup can add evidence but must never replace that diagnosis (#271).
forgejo_release_cleanup_draft() {
local id="${1:?forgejo_release_cleanup_draft: id required}"
local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup
if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then
[ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2
echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2
fi
return 0
}
# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft,
# uploads every asset, then publishes it. Any failure after create removes the
# draft, so the function leaves either a complete published release or nothing.
forge_release_create() { forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}" local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}" out id base token local notes="${3:?forge_release_create: notes file required}" out id base token
local existing code draft existing_id
shift 3 shift 3
# A previous rollback whose DELETE failed must not wedge the next attempt.
# Remove only a draft for this exact tag; a published release is never
# touched and its create will retain Forgejo's ordinary conflict refusal.
existing="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; }
case "$code" in
2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$existing"
echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2
return 1
fi
if [ "$draft" = true ]; then
existing_id="$(jq -r '.id // empty' "$existing")"
if [ -z "$existing_id" ]; then
rm -f "$existing"
echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2
return 1
fi
echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2
if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then
rm -f "$existing"
echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2
return 1
fi
fi
;;
404) ;;
*)
rm -f "$existing"
echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2
return 1
;;
esac
rm -f "$existing"
out="$(forgejo_write POST "repos/$REPO/releases" \ out="$(forgejo_write POST "repos/$REPO/releases" \
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \ "$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1 '{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
id="$(printf '%s' "$out" | jq -r '.id // empty')" id="$(printf '%s' "$out" | jq -r '.id // empty')"
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; } [ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
[ "$#" -gt 0 ] || return 0 if ! base="$(forgejo_api_base)"; then
base="$(forgejo_api_base)" || return 1 forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local f name local f name
for f in "$@"; do for f in "$@"; do
@ -792,8 +855,16 @@ forge_release_create() {
curl -sS -f -X POST -H "Authorization: token $token" \ curl -sS -f -X POST -H "Authorization: token $token" \
-F "attachment=@$f" \ -F "attachment=@$f" \
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \ "$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; } || {
echo "forge_release_create: asset upload failed for '$f'" >&2
forgejo_release_cleanup_draft "$id" "$tag"
return 1
}
done done
if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then
forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
} }
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes # forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes

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

@ -952,16 +952,25 @@ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
# tag creation GitHub POST /git/refs # tag creation GitHub POST /git/refs
# Forgejo POST /tags (/git/refs is GET-only) # Forgejo POST /tags (/git/refs is GET-only)
# release_stub <code> <body> — a curl stub answering one canned response and # release_stub <code> <body> [<code> <body>…] — a curl stub answering a
# recording the method+path it was asked for. # sequence of canned responses and recording the method+path it was asked for.
# The cursor lives in a file because forge_release_create captures
# forgejo_write in a command substitution; an in-memory counter would advance
# only in that subshell and every later request would replay response one.
release_stub() { release_stub() {
# Globals, not locals: the curl closure below runs long after this # Globals, not locals: the curl closure below runs long after this
# function returns, exactly as stub_writes does above. # function returns, exactly as stub_writes does above.
STUB_CODE="$1" STUB_BODY="$2" STUB_CODES=() STUB_BODIES=()
while [ "$#" -gt 0 ]; do
STUB_CODES+=("$1") STUB_BODIES+=("$2")
shift 2
done
STUB_READ_CODE="${STUB_CODES[0]}" STUB_READ_BODY="${STUB_BODIES[0]}"
printf '0\n' >"$TMP/release-stub-cursor"
: >"$WRITES" : >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs # shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() { curl() {
local hdr="" out="" method=GET url="" payload="" local hdr="" out="" method=GET url="" payload="" fail_on_http=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
-D) hdr="$2"; shift ;; -D) hdr="$2"; shift ;;
@ -969,18 +978,32 @@ release_stub() {
-X) method="$2"; shift ;; -X) method="$2"; shift ;;
-d) payload="$2"; shift ;; -d) payload="$2"; shift ;;
-F) payload="$payload -F $2"; shift ;; -F) payload="$payload -F $2"; shift ;;
-f) fail_on_http=true ;;
-H) shift ;; -H) shift ;;
-*) ;; -*) ;;
*) url="$1" ;; *) url="$1" ;;
esac esac
shift shift
done done
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr" local index code body
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out" if [ "$method" = GET ]; then
code="$STUB_READ_CODE" body="$STUB_READ_BODY"
else
index="$(cat "$TMP/release-stub-cursor")"
code="${STUB_CODES[$index]:-${STUB_CODES[${#STUB_CODES[@]} - 1]}}"
body="${STUB_BODIES[$index]:-${STUB_BODIES[${#STUB_BODIES[@]} - 1]}}"
printf '%s\n' "$((index + 1))" >"$TMP/release-stub-cursor"
fi
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$code" >"$hdr"
[ -n "$out" ] && printf '%s' "$body" >"$out"
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
if [ "$fail_on_http" = true ]; then
case "$code" in 2*) ;; *) return 22 ;; esac
fi
return 0 return 0
} }
} }
release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; }
GITHUB_API_URL=https://forge.example/api/v1 GITHUB_API_URL=https://forge.example/api/v1
export GITHUB_API_URL export GITHUB_API_URL
@ -1023,6 +1046,10 @@ check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknow
release_stub 200 '{"tag_name":"1.2.3"}' release_stub 200 '{"tag_name":"1.2.3"}'
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3 check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":true}'
check "forgejo: a draft is not a present release" 0 "no" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":false}'
check "forgejo: a published release is present" 0 "yes" forge_release_exists 1.2.3
release_stub 404 '{}' release_stub 404 '{}'
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3 check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
release_stub 503 '{}' release_stub 503 '{}'
@ -1046,9 +1073,22 @@ check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
writes_after forge_tag_create 1.2.3 cafebabe writes_after forge_tag_create 1.2.3 cafebabe
printf 'notes body\n' >"$TMP/notes.md" printf 'notes body\n' >"$TMP/notes.md"
release_stub 201 '{"id":42}' release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; }
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \ check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
check "the create stages a draft" 0 '"draft":true' cat "$WRITES"
zero_asset_writes() {
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
local got
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'POST repos/o/r/releases\nPATCH repos/o/r/releases/42' ] || { printf '%s\n' "$got"; return 1; }
}
check "the zero-asset path creates then publishes" 0 "" zero_asset_writes
# Assets: the hook contract permits any filename the consumer drops in # Assets: the hook contract permits any filename the consumer drops in
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw # RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
@ -1062,18 +1102,179 @@ check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
printf 'x\n' >"$TMP/release asset.tgz" printf 'x\n' >"$TMP/release asset.tgz"
printf 'y\n' >"$TMP/a&b.tgz" printf 'y\n' >"$TMP/a&b.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \ check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \ check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the upload targets the created release id" 0 "releases/42/assets" \ check "the upload targets the created release id" 0 "releases/42/assets" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the asset rides as a multipart attachment" 0 "attachment=@" \ check "the asset rides as a multipart attachment" 0 "attachment=@" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
happy_asset_writes() {
release_stub 201 '{"id":42}' 201 '{}' 201 '{}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" "$TMP/a&b.tgz" >/dev/null 2>&1
local got want
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*| -F.*|$)//')"
want=$'POST repos/o/r/releases\nPOST repos/o/r/releases/42/assets?name=release%20asset.tgz\nPOST repos/o/r/releases/42/assets?name=a%26b.tgz\nPATCH repos/o/r/releases/42'
[ "$got" = "$want" ] || { printf '%s\n' "$got"; return 1; }
}
check "assets upload in argument order before the release is published" 0 "" happy_asset_writes
upload_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "an upload failure returns non-zero" 1 "asset upload failed" upload_failure
check "an upload failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
flip_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a publish flip failure returns non-zero" 1 "HTTP 500" flip_failure
check "a publish flip failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
create_failure() {
release_stub 500 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create failure returns non-zero" 1 "HTTP 500" create_failure
check "a create failure has nothing to delete" 1 "" grep -q '^DELETE ' "$WRITES"
missing_release_id() {
release_stub 201 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create without an id keeps its refusal" 1 "returned no release id" missing_release_id
check "a create without an id deletes nothing unknown" 1 "" grep -q '^DELETE ' "$WRITES"
stranded_cleanup_failure() {
release_stub 201 '{"id":42}' 500 '{"message":"upload refused"}' 500 '{"message":"delete refused"}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "cleanup failure preserves the original failure" 1 "asset upload failed" stranded_cleanup_failure
check "cleanup failure also names the stranded draft" 1 "stranded draft 42 for tag '1.2.3'" stranded_cleanup_failure
release_stub 204 '{}' 201 '{"id":42}' 200 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":true}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
stranded_draft_order() {
local got
got="$(grep -v '^GET ' "$WRITES" | head -2 | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'DELETE repos/o/r/releases/41\nPOST repos/o/r/releases' ] || { printf '%s\n' "$got"; return 1; }
}
check "a stranded same-tag draft is deleted before create" 0 "" stranded_draft_order
release_stub 409 '{}'
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
# ls-remote half here would destroy the documented delete-and-re-push recovery.
TAG_GUARD="$TMP/tag-release-guard.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-tag.steps[] | select(.name == "no published release may exist") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$TAG_GUARD"
chmod +x "$TAG_GUARD"
mkdir -p "$TMP/tag-guard-lib/lib"
# shellcheck disable=SC2016 # expanded when the generated helper runs, not while this test writes it
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/tag-guard-lib/lib/forge.sh"
tag_guard_extracted() { [ "$(wc -l <"$TAG_GUARD")" -ge 8 ]; }
run_tag_guard() {
CEREMONY_DIR="$TMP/tag-guard-lib" VER=1.2.3 RELEASE_EXISTS="$1" "$TAG_GUARD"
}
tag_door_has_no_tag_exists_guard() {
! yq -r '.jobs.release-on-tag.steps[].run // ""' "$ROOT/.github/workflows/release.yml" | grep -q 'ls-remote'
}
check "the tag door's published-release guard is extracted" 0 "" tag_guard_extracted
check "the tag door refuses an existing published release" 1 "release '1.2.3' already exists" run_tag_guard yes
check "the tag door proceeds when no published release exists" 0 "" run_tag_guard no
check "the tag door refuses when release state is unreadable" 1 "refusing rather than assuming" run_tag_guard error
check "the tag door carries no tag-exists guard" 0 "" tag_door_has_no_tag_exists_guard
# --- the github twins address their own paths ---------------------------- # --- the github twins address their own paths ----------------------------
. "$ROOT/lib/forge-github.sh" . "$ROOT/lib/forge-github.sh"

View file

@ -175,6 +175,93 @@ expect "an unreadable head version is silent — never nag on a guess" "" \
expect "a bare head over an unreadable base still warns" yes \ expect "a bare head over an unreadable base still warns" yes \
"$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)" "$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)"
# The pure matrix above proves the warning predicate. These two fixtures drive
# the sweep boundary that chooses WHICH base tree feeds it (#275): the
# reporting Forgejo payload exposes the PR's moving base tip beside its fixed
# merge base, and using the former manufactures a downgrade after an
# intervening release. A missing merge-base field deliberately keeps the
# specified base-tip fallback for backends that do not supply that fact.
release_shape_ref_probe() { # $1 = phantom | bump | fallback
(
# shellcheck disable=SC2030 # this probe intentionally isolates its repository fixture
REPO=owner/repo
LABELS_CONF="$FIXTURE_CONF"
CEREMONY_FORGE=github
mode="$1"
refs="$RTMP/release-shape-$mode-refs"
: >"$refs"
case "$mode" in
phantom)
head_ver=1.3.0 base_tip_ver=1.4.0 merge_base_ver=1.3.0
merge_base_json='"merge-base"' ;;
bump)
head_ver=1.4.0 base_tip_ver=1.3.0 merge_base_ver=1.3.0
merge_base_json='"merge-base"' ;;
fallback)
head_ver=1.4.0 base_tip_ver=1.4.0 merge_base_ver=unused
merge_base_json=null ;;
*) return 2 ;;
esac
# shellcheck disable=SC2317 # reached through the GitHub backend selected above
gh() {
if [ "$1" = label ] && [ "$2" = list ]; then
core_label_rows | cut -d'|' -f1
return 0
fi
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '701\n'
return 0
fi
if [ "$1" = pr ] && [ "$2" = view ]; then
jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
return 0
fi
if [ "$1" = issue ] && [ "$2" = edit ]; then return 0; fi
case "$(forge_stub_path "$*")" in
*'repos/owner/repo/pulls/701 --jq .requested_reviewers'*) return 0 ;;
*repos/owner/repo/pulls/701/reviews*) return 0 ;;
*repos/owner/repo/pulls/701)
jq -n --argjson merge_base "$merge_base_json" \
'{draft:false,user:{login:"fixture-builder"},
head:{sha:"head"},base:{sha:"base-tip"},merge_base:$merge_base,
labels:[{name:"state:addressing"},{name:"blocked"}],
requested_reviewers:[],created_at:"2026-08-31T20:00:00Z"}' ;;
*repos/owner/repo/commits/head*) printf '2026-08-31T20:00:00Z\n' ;;
*repos/owner/repo/contents/VERSION\?ref=head*)
printf 'head\n' >>"$refs"
printf '%s' "$head_ver" | base64 ;;
*repos/owner/repo/contents/VERSION\?ref=base-tip*)
printf 'base-tip\n' >>"$refs"
printf '%s' "$base_tip_ver" | base64 ;;
*repos/owner/repo/contents/VERSION\?ref=merge-base*)
printf 'merge-base\n' >>"$refs"
printf '%s' "$merge_base_ver" | base64 ;;
*) printf '[]\n' ;;
esac
}
main
)
}
phantom_shape="$(release_shape_ref_probe phantom)"
expect "an unchanged branch cut before a later base release emits no release-shape warning" \
no "$(grep -q 'release-shaped' <<<"$phantom_shape" && echo yes || echo no)"
expect "the unchanged-branch guard compares head with the PR merge base" \
$'head\nmerge-base' "$(cat "$RTMP/release-shape-phantom-refs")"
bump_shape="$(release_shape_ref_probe bump)"
expect "a genuine version bump relative to the merge base keeps the warning text" \
yes "$(grep -qF '#701 is release-shaped (version 1.3.0 -> 1.4.0 at its head)' \
<<<"$bump_shape" && echo yes || echo no)"
expect "the genuine-bump guard also compares head with the PR merge base" \
$'head\nmerge-base' "$(cat "$RTMP/release-shape-bump-refs")"
fallback_shape="$(release_shape_ref_probe fallback)"
expect "a null merge base falls back to the base tip without guessing a warning" \
no "$(grep -q 'release-shaped' <<<"$fallback_shape" && echo yes || echo no)"
expect "the null merge-base fallback compares head with the base tip" \
$'head\nbase-tip' "$(cat "$RTMP/release-shape-fallback-refs")"
# -- drafts are building, whoever is requested -------------------------------- # -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)" expect "draft PR is building" state:building "$(decide_state)"

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