fix: the release label's two meanings part ways in a decide step

LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — including every PR that improves this very workflow. The old assert
pair turned each of those merges into a red run on main. The fused decide
step reads the version against the PR base and answers all four states:
-dev unchanged = work, green NOTICE no-op; bare unchanged but already
released = work in the post-release window, same no-op; -dev-but-changed
and bare-unchanged-never-released = half-ceremonies, refused loudly;
bare-and-changed = the ceremony. Later steps gate on its output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-19 15:23:56 +00:00
parent 4ce1ab50aa
commit d08ec8c6f6
2 changed files with 59 additions and 20 deletions

View file

@ -88,32 +88,57 @@ jobs:
# base-side VERSION is readable for the interlock below. # base-side VERSION is readable for the interlock below.
ref: ${{ github.event.pull_request.merge_commit_sha }} ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0 fetch-depth: 0
# Assert 1 — the merged tree says it is a release. A `-dev` VERSION # The decide step — asserts 1+2 fused, because the `release` label
# here means the label lied (or the ceremony PR forgot the bump). # carries TWO legitimate meanings (LABELS.md: "release flow and
- name: assert the merged tree is a release (non-dev VERSION) # version/packaging work"): the ceremony PR that ships a version, and
run: | # ordinary work ON the release machinery — this very PR included.
ver="$(cat VERSION)" # The version tells them apart. A `-dev` VERSION left UNTOUCHED by the
case "$ver" in # PR is release-flow work: a green no-op, not a red run on main every
*-dev) # time the flow itself is improved. Everything in between is a
echo "VERSION '$ver' is still -dev — a release PR ships a bare X.Y.Z; refusing to release a dev tree" >&2 # half-ceremony and dies loudly:
exit 1 ;; # -dev, unchanged → work under the label: NOTICE + green no-op
esac # -dev, changed → a bump that forgot to leave -dev: refuse
# Assert 2 — THIS PR is the one that changed VERSION (base vs merge). # bare, unchanged,
# The `-dev` transition as a safety interlock: an ordinary PR someone # already released → work merged in the post-release window
# mislabels `release` fails here loudly instead of shipping main # (ceremony landed, the -dev bump has not):
# under a version some earlier PR minted. # NOTICE + green no-op
- name: assert VERSION changed in this PR (the mislabel interlock) # bare, unchanged,
# never released → the label says ship, the tree names an
# unshipped version this PR did not mint:
# genuinely ambiguous, refuse
# bare, changed → the ceremony: proceed
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
env:
GH_TOKEN: ${{ github.token }}
run: | run: |
ver="$(cat VERSION)" ver="$(cat VERSION)"
base_ver="$(git show "$BASE_SHA:VERSION")" base_ver="$(git show "$BASE_SHA:VERSION")"
case "$ver" in
*-dev)
if [ "$base_ver" = "$ver" ]; then
echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "VERSION changed ('$base_ver' -> '$ver') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z. Refusing." >&2
exit 1 ;;
esac
if [ "$base_ver" = "$ver" ]; then if [ "$base_ver" = "$ver" ]; then
echo "VERSION did not change in this PR ('$ver' before and after) — a 'release'-labeled PR must be the ceremony PR that bumps it; refusing to release" >&2 if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "VERSION '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess." >&2
exit 1 exit 1
fi fi
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
# Assert 3 — the changelog names exactly this version, and the one # Assert 3 — the changelog names exactly this version, and the one
# extractor (shared with the tag job and test/release.sh) gets a # extractor (shared with the tag job and test/release.sh) gets a
# non-empty body out of it. The notes are kept for the publish. # non-empty body out of it. The notes are kept for the publish.
- name: assert the changelog section for this version extracts - name: assert the changelog section for this version extracts
if: steps.decide.outputs.ceremony == 'yes'
run: | run: |
. .github/scripts/release-lib.sh . .github/scripts/release-lib.sh
ver="$(cat VERSION)" ver="$(cat VERSION)"
@ -126,6 +151,7 @@ jobs:
# Assert 4 — nothing exists yet, tag or release: a re-run of this job # Assert 4 — nothing exists yet, tag or release: a re-run of this job
# (or a manual tag that beat it) must refuse, not clobber. # (or a manual tag that beat it) must refuse, not clobber.
- name: assert no tag and no release exist yet (idempotent re-runs) - name: assert no tag and no release exist yet (idempotent re-runs)
if: steps.decide.outputs.ceremony == 'yes'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
@ -142,6 +168,7 @@ jobs:
# assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive # assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive
# workflow runs — see the job comment.) # workflow runs — see the job comment.)
- name: tag the merge commit and publish the release - name: tag the merge commit and publish the release
if: steps.decide.outputs.ceremony == 'yes'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |

View file

@ -141,10 +141,22 @@ check "release.yml: only a MERGED PR releases (closed-unmerged never fires)" 0 "
grep -qF "github.event.pull_request.merged == true" "$RY" grep -qF "github.event.pull_request.merged == true" "$RY"
check "release.yml: only the 'release' label carries the intent" 0 "" \ check "release.yml: only the 'release' label carries the intent" 0 "" \
grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$RY" grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$RY"
check "release.yml: assert 1 — a still-dev VERSION refuses" 0 "" \ # The decide step tells the label's two meanings apart (LABELS.md gives
grep -qF "refusing to release a dev tree" "$RY" # `release` to release-flow WORK as well as to the ceremony PR): work under
check "release.yml: assert 2 — an unchanged VERSION refuses (the mislabel interlock)" 0 "" \ # the label is a green NOTICE no-op — in the -dev steady state and in the
grep -qF "VERSION did not change in this PR" "$RY" # post-release window (bare, unchanged, already released) — while every
# half-ceremony refuses. Pin each verdict's message and the gating output.
check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \
grep -qF "release-flow work under the release label, not a ceremony" "$RY"
check "release.yml: decide — a half-ceremony (-dev but changed) refuses" 0 "" \
grep -qF "half a ceremony" "$RY"
check "release.yml: decide — post-release-window work no-ops green" 0 "" \
grep -qF "release-flow work merged in the post-release window" "$RY"
check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \
grep -qF "Refusing to guess" "$RY"
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
check "release.yml: decide gates every later step on ceremony=yes" 0 "" \
bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$RY"
check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \ check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \
grep -qF "refusing to publish an empty release" "$RY" grep -qF "refusing to publish an empty release" "$RY"
check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \ check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \