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 — the PR that added the merge path included. The old assert pair turned
every such merge into a red run on main. The fused decide step reads the
version against the PR base and answers all states: -dev unchanged = work,
green NOTICE no-op; bare unchanged but already released = work in the
post-release window (cast's whole pre-0.1.1 era included), same no-op;
-dev-but-changed and bare-unchanged-never-released = half-ceremonies,
refused loudly; bare-and-changed = the ceremony. Shared steps gate on the
decide (tag-push path unaffected). Pins anchor on the echo strings, since
the workflow's own comment table paraphrases the states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-19 15:28:58 +00:00
parent 775af63063
commit a5910eacee
2 changed files with 65 additions and 33 deletions

View file

@ -68,39 +68,64 @@ jobs:
exit 1 exit 1
fi fi
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
- name: "merged release PR: the version must have left -dev in THIS PR" # The decide step — the version asserts fused, because the `release`
# label carries TWO legitimate meanings (LABELS.md: "release flow and
# version/packaging work"): the ceremony PR that ships a version, and
# ordinary work ON the release machinery — the PR that added this very
# trigger included. The version tells them apart, in four states:
# -dev, unchanged → work under the label: green NOTICE
# no-op, not a red run per infra PR
# -dev, changed → a bump that forgot to leave -dev:
# half a ceremony, refuse
# bare, unchanged, released → work merged in the post-release
# window (ceremony landed, the -dev
# bump has not — and cast's ENTIRE
# pre-0.1.1 era, since 0.1.0 never
# carried -dev): green NOTICE no-op
# bare, unchanged, UNreleased→ the label says ship but this PR did
# not mint the version: refuse to
# guess. This is also the known
# first-release edge (#111): the 0.1.0
# ceremony (#110) ships by manual tag,
# the fallback path; the automation
# applies from 0.1.1 on.
# bare, changed → the ceremony: proceed
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
env: env:
BASE_SHA: ${{ github.event.pull_request.base.sha }} BASE_SHA: ${{ github.event.pull_request.base.sha }}
GH_TOKEN: ${{ github.token }}
run: | run: |
# Assert 1 — the merged tree carries a release version, read via # Versions read via node, never regex (the pkg_version discipline).
# node, never regex (the pkg_version discipline).
ver="$(node -p 'require("./package.json").version')" ver="$(node -p 'require("./package.json").version')"
case "$ver" in
*-dev)
echo "package.json version '$ver' is a -dev version — creating nothing." >&2
echo "A release PR bumps package.json OFF -dev (CONTRIBUTING.md, Releasing); this merge did not." >&2
exit 1 ;;
esac
# Assert 2 — the version CHANGED in this PR (PR base vs merge):
# the -dev transition is the interlock, so a mislabeled ordinary
# PR — whose merge leaves the version untouched — fails HERE,
# loudly, instead of re-releasing main's standing version.
#
# Known first-release edge (#111): 0.1.0 never carried -dev (cast
# predates the -dev ritual), so this interlock correctly does NOT
# fire for the 0.1.0 ceremony (#110) — that one ships by manual
# tag, the fallback path; the automation applies from 0.1.1 on.
git fetch --depth=1 origin "$BASE_SHA" git fetch --depth=1 origin "$BASE_SHA"
git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json" git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json"
base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')" base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')"
case "$ver" in
*-dev)
if [ "$base" = "$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') but is still -dev — half a ceremony; a release PR ships a bare X.Y.Z (CONTRIBUTING.md, Releasing) — creating nothing." >&2
exit 1 ;;
esac
if [ "$base" = "$ver" ]; then if [ "$base" = "$ver" ]; then
echo "package.json version is '$ver' both before and after this PR — creating nothing." >&2 if gh release view "$ver" > /dev/null 2>&1; then
echo "A release-labeled PR must BE the version transition (#111). If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump." >&2 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 — creating nothing." >&2
echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&2
exit 1 exit 1
fi fi
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
- name: release notes — the version's own CHANGELOG.md section - name: release notes — the version's own CHANGELOG.md section
if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes'
# Assert 3 on the merge path, the same fact on the tag path: # Assert 3 on the merge path, the same fact on the tag path:
# release-notes.sh fails loudly on a missing/empty section, which # release-notes.sh fails loudly on a missing/empty section, which
# fails the release here — before anything is created. # fails the release here — before anything is created.
@ -108,7 +133,7 @@ jobs:
bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md" bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md"
- name: "merged release PR: nothing exists yet, then tag the merge commit" - name: "merged release PR: nothing exists yet, then tag the merge commit"
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request' && steps.decide.outputs.ceremony == 'yes'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
@ -133,6 +158,7 @@ jobs:
gh api "repos/$GITHUB_REPOSITORY/git/refs" \ gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA" -f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA"
- name: build the prebuilt dist asset - name: build the prebuilt dist asset
if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes'
# Build ONCE, in CI — the whole point of the asset (#96): the # Build ONCE, in CI — the whole point of the asset (#96): the
# installer's release channels never run npm or tsc. Deliberately no # installer's release channels never run npm or tsc. Deliberately no
# check/tests here: ci.yml already gated the merge commit this # check/tests here: ci.yml already gated the merge commit this
@ -147,6 +173,7 @@ jobs:
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/" cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/"
tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION" tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION"
- name: create the release - name: create the release
if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |

View file

@ -193,19 +193,24 @@ describe("release.yml", () => {
); );
}); });
it("the merge path asserts its four facts IN ORDER, all before tag-create, build, and publish", () => { it("the merge path decides, then asserts, IN ORDER, all before tag-create, build, and publish", () => {
// Assert 1: non--dev version at the merge commit. Assert 2: the version // The decide step (the fused version asserts — see the workflow's
// changed in THIS PR (the -dev interlock; base read from git, version // four-state table): base read from git, versions via node, work under
// read via node). Assert 3: the shared notes extraction. Assert 4: no // the label no-ops green, half-ceremonies refuse. Then: the shared
// existing tag or release. Only then the acts: API-tag the merge // notes extraction, the no-existing-tag/release asserts, and only then
// commit, build, publish — every marker present, strictly in file // the acts — API-tag the merge commit, build, publish. Every marker
// order, fail-closed. // present, strictly in file order, fail-closed.
const markers = [ const markers = [
"is a -dev version", // assert 1 'git show "$BASE_SHA:package.json"', // decide — base vs merge
'git show "$BASE_SHA:package.json"', // assert 2 — base vs merge // Code-unique phrasings (the workflow's own comment table paraphrases
".github/scripts/release-notes.sh", // assert 3 // these states, so the pins anchor on the echo strings, not prose):
'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert 4a "release-flow work under the release label, not a ceremony. Nothing to publish.", // work no-op, green
'gh release view "$RELEASE_VERSION"', // assert 4b "— half a ceremony; a release PR ships a bare X.Y.Z", // -dev but changed: refuse
"release-flow work merged in the post-release window (before the -dev bump)", // window no-op
"Refusing to guess — creating nothing.", // bare, unchanged, unreleased: refuse
".github/scripts/release-notes.sh", // assert: notes extract
'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert: no tag
'gh release view "$RELEASE_VERSION"', // assert: no release (the decide's own view sits earlier — count checked below)
'gh api "repos/$GITHUB_REPOSITORY/git/refs"', // act: tag the merge commit 'gh api "repos/$GITHUB_REPOSITORY/git/refs"', // act: tag the merge commit
"npm prune --omit=dev", // act: build "npm prune --omit=dev", // act: build
'gh release create "$RELEASE_VERSION"', // act: publish 'gh release create "$RELEASE_VERSION"', // act: publish