From 775af63063a0a3438bbd10c906fd5d7ccf7401c6 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:21:07 +0000 Subject: [PATCH 1/9] feat: merging a release-labeled PR is the release (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit box#95 taught the family that a forgotten manual tag is the worst failure shape: silent, no red X, a release that simply doesn't happen. The ship decision already lives in the ceremony PR — the one whose whole diff is the version leaving -dev, carrying the reviews and the maintainer's merge — so tagging after it is transcription, and transcription belongs to the machine (box#96's design; this is cast's twin). release.yml now also triggers on pull_request closed against main, gated on merged == true AND the hand-set release label. The merge path asserts four facts in order, each fail-loud and creating nothing: the merged package.json version is non--dev (read via node, never regex — the pkg_version discipline); the version CHANGED in this PR (base vs merge — the -dev interlock, so a mislabeled ordinary PR fails loudly); the version's changelog section extracts non-empty via the existing release-notes.sh; and no tag or release exists yet (idempotent re-runs, and the loud answer to a manual-tag race). Then, in the same job, it tags the merge commit via the API and publishes. Same-job is load-bearing: a GITHUB_TOKEN-created tag triggers no workflows, so the tag-push path cannot fire on it and double-publish. Both trigger paths converge on literally the same steps — each entry step exports RELEASE_VERSION, and the notes extraction, the exact existing asset build (npm ci, npm run build, npm prune --omit=dev, staged as cast-X.Y.Z/), and the gh release create read only that — so the paths cannot drift and the installer keeps finding the one asset name it knows, cast-X.Y.Z.tgz. The tag-push path survives as the documented manual fallback and backfill, and it matters immediately: 0.1.0 never carried -dev (cast predates the ritual), so the interlock correctly does not fire for #110's ceremony — that one ships by manual tag, and the automation applies from 0.1.1 on. test/release.test.ts pins the new wiring in the house grep style, fail-closed: the merged+labeled gate, the four asserts strictly ordered ahead of tag/build/publish, the single job, the anti-recursion comment, and that no per-path asset name exists. CONTRIBUTING.md's Releasing now says it plainly: merge is the ship decision; the tag is the fallback. Fixes #111 Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 125 ++++++++++++++++++++++++++++------ CHANGELOG.md | 14 ++++ CONTRIBUTING.md | 34 +++++---- test/release.test.ts | 67 ++++++++++++++++-- 4 files changed, 203 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f23f78..725e112 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,18 @@ name: release -# The release publisher (#96; box#83's design), on a bare X.Y.Z tag push — -# no 'v' prefix, box's and rig's tag scheme. Two facts, then one act: the -# tag must name package.json's own version (a mismatch fails loudly and -# creates NOTHING — a wrong release is worse than a missing one), and the -# release body is that version's CHANGELOG.md section -# (.github/scripts/release-notes.sh, shared with test/release.test.ts) — -# the curated prose, not the generated PR list. +# The release publisher (#96; box#83's design) — two ways in, one act (#111; +# box#96's design): +# +# - Merging a `release`-labeled PR into main IS the release. The ceremony +# PR carries the bumped version and the stamped changelog; the +# maintainer's merge is the ship decision, and tagging after it is +# transcription — exactly where humans err silently and machines fail +# loudly. This path asserts four facts (each fail-loud, creating +# nothing), then tags the merge commit and publishes. +# - A bare X.Y.Z tag push (no 'v' prefix — box's and rig's tag scheme) +# stays as the documented manual fallback and backfill. +# +# Both paths converge on the SAME steps below — one notes extraction, one +# build, one asset name, one create — so they cannot drift. # # Where cast differs from its siblings: the release carries a PREBUILT # asset. box and rig are pure bash, so GitHub's source tarball for the tag @@ -19,20 +26,40 @@ on: # assert LOUDLY below, not be silently skipped by a pattern that didn't # match. tags: ["**"] + pull_request: + # The merge-is-the-release path (#111). `closed` is the only type that + # can mean "merged"; the job gate below drops closed-unmerged and + # unlabeled closures. + types: [closed] + branches: [main] permissions: - contents: write # gh release create + contents: write # tag create via the API + gh release create jobs: release: + # Tag pushes always enter (the asserts below are the filter). PR + # closures enter only when the PR actually MERGED and carries the + # hand-set `release` label (LABELS.md: `release` is the operator's — + # automation never guesses intent). + if: >- + github.event_name == 'push' || + (github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release')) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # Tag push: the tag. Merged PR: the MERGE COMMIT on main — the + # exact tree the maintainer shipped, which the tag created below + # will name. + ref: ${{ github.event.pull_request.merge_commit_sha || github.ref }} - uses: actions/setup-node@v4 with: node-version: "22" cache: npm - - name: the tag must name package.json's version + - name: "tag push: the tag must name package.json's version" + if: github.event_name == 'push' run: | ver="$(node -p 'require("./package.json").version')" if [ "$GITHUB_REF_NAME" != "$ver" ]; then @@ -40,29 +67,89 @@ jobs: echo "A release is a PR, then a tag (#96): the release PR bumps package.json (and package-lock.json) and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2 exit 1 fi + echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" + - name: "merged release PR: the version must have left -dev in THIS PR" + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + # Assert 1 — the merged tree carries a release version, read via + # node, never regex (the pkg_version discipline). + 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 show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json" + base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')" + if [ "$base" = "$ver" ]; then + echo "package.json version is '$ver' both before and after this PR — creating nothing." >&2 + 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 + exit 1 + fi + echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" - name: release notes — the version's own CHANGELOG.md section + # Assert 3 on the merge path, the same fact on the tag path: # release-notes.sh fails loudly on a missing/empty section, which # fails the release here — before anything is created. run: | - bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md" + bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" + - name: "merged release PR: nothing exists yet, then tag the merge commit" + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + # Assert 4 — no tag and no release exist for this version. Re-runs + # stay idempotent, and a manual race (an operator who tagged by + # hand between merge and here) fails loudly instead of + # double-publishing. + if git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION" > /dev/null; then + echo "tag '$RELEASE_VERSION' already exists — creating nothing (already released, or a manual tag won the race)." >&2 + exit 1 + fi + if gh release view "$RELEASE_VERSION" > /dev/null 2>&1; then + echo "release '$RELEASE_VERSION' already exists — creating nothing." >&2 + exit 1 + fi + # The act begins: tag the merge commit via the API. A tag created + # with GITHUB_TOKEN does not trigger other workflows, so the + # tag-push trigger above CANNOT fire on this tag and + # double-publish — which is also why the publish must happen in + # THIS job. + gh api "repos/$GITHUB_REPOSITORY/git/refs" \ + -f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA" - name: build the prebuilt dist asset # Build ONCE, in CI — the whole point of the asset (#96): the # installer's release channels never run npm or tsc. Deliberately no - # check/tests here: ci.yml already gated the merge commit this tag - # names, and the test suite needs `age`, which this runner does not - # install. The staged tree is exactly what an install needs to run. + # check/tests here: ci.yml already gated the merge commit this + # release names, and the test suite needs `age`, which this runner + # does not install. The staged tree is exactly what an install needs + # to run. run: | npm ci npm run build npm prune --omit=dev - mkdir -p "$RUNNER_TEMP/stage/cast-$GITHUB_REF_NAME" - cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$GITHUB_REF_NAME/" - tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$GITHUB_REF_NAME.tgz" "cast-$GITHUB_REF_NAME" + mkdir -p "$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" - name: create the release env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" --verify-tag \ - --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" \ - "$RUNNER_TEMP/cast-$GITHUB_REF_NAME.tgz" + gh release create "$RELEASE_VERSION" --verify-tag \ + --title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/notes.md" \ + "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7927f39..2972f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ actually cutting it, and this file starts there. ## Unreleased +### Added + +- **Merging a release-labeled PR is the release** (#111; box#96's design) — + `release.yml` now also fires when a `release`-labeled PR merges into + main: it asserts fail-loud (non-`-dev` version, version changed in the + PR — the `-dev` interlock, changelog section extracts non-empty, no + existing tag or release), then tags the merge commit, builds the + `cast-X.Y.Z.tgz` asset once, and publishes — the maintainer's merge is + the ship decision, no silent-when-forgotten manual tag step. The + tag-push path stays as the documented fallback and backfill, and both + paths run the same steps so they cannot drift. First-release edge: + 0.1.0 never carried `-dev`, so its ceremony (#110) ships by manual tag; + the automation applies from 0.1.1 on. + ### Fixed - **The release suite accepts the ceremony's own tree** (#108) — diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f166b6c..be9926a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,25 +44,31 @@ labels tell you where everything is without opening anything. ## Releasing -A release is a PR, then a tag ([#96](https://github.com/heavy-duty/cast/issues/96); -box#83's design): +A release is a PR, and merging it IS the release +([#111](https://github.com/heavy-duty/cast/issues/111); box#96's design, +on box#83's shape): 1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s `version` (and `package-lock.json`; `npm install --package-lock-only` keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as `## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR. -2. Merge, tag the merge commit bare `X.Y.Z` (no `v` prefix — box's tag - scheme), push the tag. [release.yml](.github/workflows/release.yml) - takes it from there: it asserts tag == `package.json` version (a - mismatch fails loudly and creates nothing), extracts that version's - changelog section as the release body - ([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh) — - a missing or empty section refuses the release), builds the package once - (`npm ci && npm run build && npm prune --omit=dev`), and attaches the - runnable tree — `bin/`, `dist/`, production `node_modules/`, - `package.json` — as `cast-X.Y.Z.tgz`. That asset is what the installer's - release channels download: the build happens once, in CI, never on an - operator's machine. +2. **Merge. That's the ship decision — nothing else to do.** + [release.yml](.github/workflows/release.yml) fires on the merged, + `release`-labeled PR and asserts, in order, each fail-loud and creating + nothing: the merged version is non-`-dev`; the version *changed in this + PR* (the `-dev` transition is the interlock — a mislabeled ordinary PR + fails here); that version's changelog section extracts non-empty + ([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh)); + and no tag or release exists for it yet. Then, in the same job, it tags + the merge commit bare `X.Y.Z` (no `v` prefix — box's tag scheme), builds + the package once (`npm ci && npm run build && npm prune --omit=dev`), and + publishes the release with the runnable tree — `bin/`, `dist/`, + production `node_modules/`, `package.json` — attached as + `cast-X.Y.Z.tgz`. That asset is what the installer's release channels + download: the build happens once, in CI, never on an operator's machine. + *Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge + commit yourself — the same workflow runs the same asserts, build, and + publish from the tag. 3. **Right after the release, a follow-up PR bumps `package.json` to `X.Y.(Z+1)-dev`** (and `package-lock.json` with it) — box#90's step of the family ritual. Installs are versioned by the tree's `package.json` diff --git a/test/release.test.ts b/test/release.test.ts index 3627cac..27dc2e5 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -172,30 +172,89 @@ describe("release-notes.sh", () => { describe("release.yml", () => { const RY = readFileSync(join(ROOT, ".github/workflows/release.yml"), "utf8"); - it("triggers on EVERY tag — a mismatch must fail loudly, not be pattern-skipped", () => { + it("triggers on EVERY tag — the manual fallback survives, and a mismatch must fail loudly, not be pattern-skipped", () => { expect(RY).toContain('tags: ["**"]'); }); + it("triggers on closed PRs into main, gated on merged AND the release label (#111)", () => { + expect(RY).toContain("types: [closed]"); + expect(RY).toContain("branches: [main]"); + expect(RY).toContain("github.event.pull_request.merged == true"); + expect(RY).toContain( + "contains(github.event.pull_request.labels.*.name, 'release')", + ); + }); + it("asserts tag == package.json version, and the assert precedes the create", () => { expect(RY).toContain('require("./package.json").version'); expect(RY).toContain("creating nothing"); expect(RY.indexOf("creating nothing")).toBeLessThan( - RY.indexOf('gh release create "$GITHUB_REF_NAME"'), + RY.indexOf('gh release create "$RELEASE_VERSION"'), ); }); + it("the merge path asserts its four facts IN ORDER, all before tag-create, build, and publish", () => { + // Assert 1: non--dev version at the merge commit. Assert 2: the version + // changed in THIS PR (the -dev interlock; base read from git, version + // read via node). Assert 3: the shared notes extraction. Assert 4: no + // existing tag or release. Only then the acts: API-tag the merge + // commit, build, publish — every marker present, strictly in file + // order, fail-closed. + const markers = [ + "is a -dev version", // assert 1 + 'git show "$BASE_SHA:package.json"', // assert 2 — base vs merge + ".github/scripts/release-notes.sh", // assert 3 + 'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert 4a + 'gh release view "$RELEASE_VERSION"', // assert 4b + 'gh api "repos/$GITHUB_REPOSITORY/git/refs"', // act: tag the merge commit + "npm prune --omit=dev", // act: build + 'gh release create "$RELEASE_VERSION"', // act: publish + ]; + let at = -1; + for (const m of markers) { + const i = RY.indexOf(m); + expect(i, m).toBeGreaterThan(at); + at = i; + } + }); + + it("the -dev interlock reads versions via node, never regex, and names the 0.1.0 first-release edge", () => { + expect(RY).not.toMatch(/grep.*version/); + expect(RY).toContain("node -p 'require(\"./package.json\").version'"); + // 0.1.0 never carried -dev, so the interlock correctly skips #110's + // ceremony — the workflow must say so where the next reader will look. + expect(RY).toContain("applies from 0.1.1"); + }); + + it("tag, build, and publish happen in the SAME job — a GITHUB_TOKEN tag fires no workflows", () => { + const jobs = RY.slice(RY.indexOf("\njobs:")).match(/^ {2}\S+:\s*$/gm) ?? []; + expect(jobs).toEqual([" release:"]); // one job under jobs: + expect(RY).toContain("does not trigger other workflows"); + expect(RY).toContain('-f "sha=$MERGE_SHA"'); + }); + it("the body comes from the shared extraction script", () => { expect(RY).toContain(".github/scripts/release-notes.sh"); }); - it("the release is bound to the pushed tag (--verify-tag)", () => { + it("the release is bound to its tag (--verify-tag)", () => { expect(RY).toContain("--verify-tag"); }); it("builds the prod-only tree once and attaches it as the asset", () => { expect(RY).toContain("npm prune --omit=dev"); expect(RY).toContain("cp -R bin dist node_modules package.json"); - expect(RY).toContain("cast-$GITHUB_REF_NAME.tgz"); + expect(RY).toContain("cast-$RELEASE_VERSION.tgz"); + }); + + it("both trigger paths converge on the SAME asset name — one build, one tar, no per-path naming", () => { + // Each path's entry step exports RELEASE_VERSION; everything downstream + // (notes, stage dir, tarball, release title) reads only that. A second + // tar or a $GITHUB_REF_NAME-named asset would be the paths drifting + // apart — the exact failure this shape exists to prevent. + expect(RY.match(/>> "\$GITHUB_ENV"/g)).toHaveLength(2); + expect(RY.match(/tar -C/g)).toHaveLength(1); + expect(RY).not.toContain("cast-$GITHUB_REF_NAME"); }); it("runs no tests — ci.yml gated the merge commit already", () => { From a5910eacee8ee7b1ffcb2f70a4f5d3dc3376cfa6 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:28:58 +0000 Subject: [PATCH 2/9] fix: the release label's two meanings part ways in a decide step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 69 ++++++++++++++++++++++++----------- test/release.test.ts | 29 +++++++++------ 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 725e112..6f12c37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,39 +68,64 @@ jobs: exit 1 fi 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' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} run: | - # Assert 1 — the merged tree carries a release version, read via - # node, never regex (the pkg_version discipline). + # Versions read via node, never regex (the pkg_version discipline). 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 show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json" 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 - echo "package.json version is '$ver' both before and after this PR — creating nothing." >&2 - 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 + if gh release view "$ver" > /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 — 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 fi + echo "ceremony=yes" >> "$GITHUB_OUTPUT" echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" - 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: # release-notes.sh fails loudly on a missing/empty section, which # 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" cat "$RUNNER_TEMP/notes.md" - 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: GH_TOKEN: ${{ github.token }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} @@ -133,6 +158,7 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/git/refs" \ -f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA" - 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 # installer's release channels never run npm or tsc. Deliberately no # 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/" tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION" - name: create the release + if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/test/release.test.ts b/test/release.test.ts index 27dc2e5..33a4820 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -193,19 +193,24 @@ describe("release.yml", () => { ); }); - it("the merge path asserts its four facts IN ORDER, all before tag-create, build, and publish", () => { - // Assert 1: non--dev version at the merge commit. Assert 2: the version - // changed in THIS PR (the -dev interlock; base read from git, version - // read via node). Assert 3: the shared notes extraction. Assert 4: no - // existing tag or release. Only then the acts: API-tag the merge - // commit, build, publish — every marker present, strictly in file - // order, fail-closed. + it("the merge path decides, then asserts, IN ORDER, all before tag-create, build, and publish", () => { + // The decide step (the fused version asserts — see the workflow's + // four-state table): base read from git, versions via node, work under + // the label no-ops green, half-ceremonies refuse. Then: the shared + // notes extraction, the no-existing-tag/release asserts, and only then + // the acts — API-tag the merge commit, build, publish. Every marker + // present, strictly in file order, fail-closed. const markers = [ - "is a -dev version", // assert 1 - 'git show "$BASE_SHA:package.json"', // assert 2 — base vs merge - ".github/scripts/release-notes.sh", // assert 3 - 'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert 4a - 'gh release view "$RELEASE_VERSION"', // assert 4b + 'git show "$BASE_SHA:package.json"', // decide — base vs merge + // Code-unique phrasings (the workflow's own comment table paraphrases + // these states, so the pins anchor on the echo strings, not prose): + "release-flow work under the release label, not a ceremony. Nothing to publish.", // work no-op, green + "— 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 "npm prune --omit=dev", // act: build 'gh release create "$RELEASE_VERSION"', // act: publish From fcb7ce3b7d29aa10bffe2539f6c433662d84c127 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:42:06 +0000 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20a=20-dev=20endstate=20is=20always=20?= =?UTF-8?q?work=20=E2=80=94=20the=20post-release=20bump=20must=20not=20run?= =?UTF-8?q?=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four-state table called '-dev but changed' half a ceremony and refused — but that state IS the mandatory post-release bump PR (bare -> X.Y.(Z+1)-dev after every release), a red run on main once per release, forever. A tree that ends -dev is by definition not a release: every such merge is work, green NOTICE no-op. Red now guards only bare endstates. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 11 +++++++---- test/release.test.ts | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f12c37..fe4e1ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,8 +75,10 @@ jobs: # 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 + # -dev, changed → still a dev tree, so still work — + # the post-release bump PR above all + # (bare -> -dev after every release): + # green NOTICE no-op # bare, unchanged, released → work merged in the post-release # window (ceremony landed, the -dev # bump has not — and cast's ENTIRE @@ -109,8 +111,9 @@ jobs: 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 ;; + echo "NOTICE: version changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish." + echo "ceremony=no" >> "$GITHUB_OUTPUT" + exit 0 ;; esac if [ "$base" = "$ver" ]; then if gh release view "$ver" > /dev/null 2>&1; then diff --git a/test/release.test.ts b/test/release.test.ts index 33a4820..7b0297e 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -205,7 +205,7 @@ describe("release.yml", () => { // Code-unique phrasings (the workflow's own comment table paraphrases // these states, so the pins anchor on the echo strings, not prose): "release-flow work under the release label, not a ceremony. Nothing to publish.", // work no-op, green - "— half a ceremony; a release PR ships a bare X.Y.Z", // -dev but changed: refuse + "a dev tree is by definition not a release", // -dev endstate: always work (the bump PR no-ops green) "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 From 84bd9f341f1a176a7563da36a9f565152bb9038b Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 15:47:34 +0000 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20the=20merge=20door=20rides=20pushes?= =?UTF-8?q?=20to=20main=20=E2=80=94=20fork=20PR=20tokens=20are=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 blocker (grok; claude's box twin): a pull_request run from a public fork gets a read-only GITHUB_TOKEN — permissions: cannot raise it — and every ceremony PR this org merges is cross-repo from the bot fork, so the tag create would 403 after green asserts, red on main per release. The door now triggers on push to main (in-repo event, full token); the decide step reads the transition from event.before (first- parent fallback for the all-zeros edge) and the release label — still the operator's declared intent — via the API off the merge commit's PR. A transition with no labeled PR behind it refuses. The steps split on the pushed ref: tags to the tag path, main to the merge path. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 68 +++++++++++++++++++++-------------- test/release.test.ts | 17 ++++++--- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe4e1ee..6117f36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,11 +26,15 @@ on: # assert LOUDLY below, not be silently skipped by a pattern that didn't # match. tags: ["**"] - pull_request: - # The merge-is-the-release path (#111). `closed` is the only type that - # can mean "merged"; the job gate below drops closed-unmerged and - # unlabeled closures. - types: [closed] + push: + # The merge-is-the-release path (#111) rides pushes to MAIN, not + # pull_request events, for one load-bearing reason the first review + # round caught: a workflow run triggered by a pull_request from a + # public FORK gets a READ-ONLY GITHUB_TOKEN — `permissions:` cannot + # raise that ceiling — and every ceremony PR this org has ever merged + # is cross-repo from the bot fork. The asserts would pass and the tag + # create would 403, red on main, every release. A push to main is an + # in-repo event with the full write token, whoever authored the PR. branches: [main] permissions: @@ -38,28 +42,27 @@ permissions: jobs: release: - # Tag pushes always enter (the asserts below are the filter). PR - # closures enter only when the PR actually MERGED and carries the - # hand-set `release` label (LABELS.md: `release` is the operator's — - # automation never guesses intent). - if: >- - github.event_name == 'push' || - (github.event.pull_request.merged == true && - contains(github.event.pull_request.labels.*.name, 'release')) + # Tag pushes and main pushes both enter (the asserts below are the + # filter); the steps split on the ref. The hand-set `release` label + # (LABELS.md: `release` is the operator's — automation never guesses + # intent) is read via the API off the merge commit's PR, inside the + # decide step — a push event carries no PR payload, and the PR itself + # lives on a fork (the trigger comment). + if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - # Tag push: the tag. Merged PR: the MERGE COMMIT on main — the - # exact tree the maintainer shipped, which the tag created below - # will name. - ref: ${{ github.event.pull_request.merge_commit_sha || github.ref }} + # Either door: the pushed ref — a tag, or main's new head (the + # merge commit the maintainer shipped, which the tag created + # below will name). + ref: ${{ github.sha }} - uses: actions/setup-node@v4 with: node-version: "22" cache: npm - name: "tag push: the tag must name package.json's version" - if: github.event_name == 'push' + if: startsWith(github.ref, 'refs/tags/') run: | ver="$(node -p 'require("./package.json").version')" if [ "$GITHUB_REF_NAME" != "$ver" ]; then @@ -94,14 +97,17 @@ jobs: # 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.ref == 'refs/heads/main' env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_SHA: ${{ github.event.before }} GH_TOKEN: ${{ github.token }} run: | # Versions read via node, never regex (the pkg_version discipline). ver="$(node -p 'require("./package.json").version')" - git fetch --depth=1 origin "$BASE_SHA" + # event.before is all-zeros on a branch-create push; the pushed + # head's first parent is main the instant before, either way. + case "$BASE_SHA" in *[!0]*) ;; *) BASE_SHA="$(git rev-parse "$GITHUB_SHA^1")" ;; esac + git fetch --depth=1 origin "$BASE_SHA" || true git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json" base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')" case "$ver" in @@ -125,10 +131,20 @@ jobs: 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 fi + # The version transitioned — now the LABEL, the operator's declared + # intent, read via the API because a push event carries no PR + # payload (and the PR lives on a fork — the trigger comment). No + # merged, release-labeled PR behind this commit = a transition + # nobody declared: refuse. + if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ + -q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then + echo "version transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#111), not a bare push — creating nothing." >&2 + exit 1 + fi echo "ceremony=yes" >> "$GITHUB_OUTPUT" echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" - name: release notes — the version's own CHANGELOG.md section - if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes' + if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes' # Assert 3 on the merge path, the same fact on the tag path: # release-notes.sh fails loudly on a missing/empty section, which # fails the release here — before anything is created. @@ -136,10 +152,10 @@ jobs: bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md" - name: "merged release PR: nothing exists yet, then tag the merge commit" - if: github.event_name == 'pull_request' && steps.decide.outputs.ceremony == 'yes' + if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGE_SHA: ${{ github.sha }} run: | # Assert 4 — no tag and no release exist for this version. Re-runs # stay idempotent, and a manual race (an operator who tagged by @@ -161,7 +177,7 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/git/refs" \ -f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA" - name: build the prebuilt dist asset - if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes' + if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes' # Build ONCE, in CI — the whole point of the asset (#96): the # installer's release channels never run npm or tsc. Deliberately no # check/tests here: ci.yml already gated the merge commit this @@ -176,7 +192,7 @@ jobs: 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" - name: create the release - if: github.event_name == 'push' || steps.decide.outputs.ceremony == 'yes' + if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/test/release.test.ts b/test/release.test.ts index 7b0297e..c28b034 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -176,13 +176,22 @@ describe("release.yml", () => { expect(RY).toContain('tags: ["**"]'); }); - it("triggers on closed PRs into main, gated on merged AND the release label (#111)", () => { - expect(RY).toContain("types: [closed]"); + it("the merge door rides pushes to main — fork PR tokens are read-only (#111 r1)", () => { + // A pull_request run from a public fork gets a read-only GITHUB_TOKEN + // (permissions: cannot raise it), and every ceremony PR this org merges + // is cross-repo from the bot fork — the tag create would 403 after + // green asserts. The door triggers on push to main; the doors split on + // the pushed ref; the release label — still the operator's declared + // intent — is read via the API off the merge commit's PR, and a + // transition with no labeled PR behind it refuses. expect(RY).toContain("branches: [main]"); - expect(RY).toContain("github.event.pull_request.merged == true"); + expect(RY).toContain("startsWith(github.ref, 'refs/tags/')"); + expect(RY).toContain("github.ref == 'refs/heads/main'"); + expect(RY).toContain("commits/$GITHUB_SHA/pulls"); expect(RY).toContain( - "contains(github.event.pull_request.labels.*.name, 'release')", + "no merged, release-labeled PR is behind this commit", ); + expect(RY).not.toContain("pull_request:"); }); it("asserts tag == package.json version, and the assert precedes the create", () => { From 121aa6bd5bf0f97caf27bc3629cd022d4507f4b1 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:04:55 +0000 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20the=20release=20re-arms=20main=20?= =?UTF-8?q?=E2=80=94=20the=20-dev=20bump=20folds=20into=20the=20release=20?= =?UTF-8?q?act?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator decision: the post-release bump PR is ceremony debris — a derivable one-liner (package.json + lock, via npm, never regex) with no judgment for a review to add. After tag + build + publish, the same job computes X.Y.(Z+1)-dev and pushes it to main directly (a GITHUB_TOKEN push fires no workflows: no recursion, no red run); if branch protection refuses, the step opens the bump PR itself, loudly. Merge-door only — the manual tag fallback does not rewrite main. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 32 ++++++++++++++++++++++++++++++++ test/release.test.ts | 9 +++++++++ 2 files changed, 41 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6117f36..9055332 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,3 +199,35 @@ jobs: gh release create "$RELEASE_VERSION" --verify-tag \ --title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/notes.md" \ "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" + # The post-release bump, folded into the release act (#111 followup — + # operator decision: a mechanical one-liner deserves no PR of its + # own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed + # straight to main with this job's token. A GITHUB_TOKEN push fires + # no workflows (anti-recursion), so the bump triggers neither the + # merge path nor a red run; should branch protection ever refuse the + # direct push, the step opens the bump PR itself and says so, loudly. + # Merge-door only (the decide gate): the manual tag path stays a + # fallback and does not rewrite main. + - name: bump main to the next -dev — the release re-arms main itself + if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes' + env: + GH_TOKEN: ${{ github.token }} + run: | + next="$(node -p 'const v = require("./package.json").version.split("."); v[2] = String(Number(v[2]) + 1) + "-dev"; v.join(".")')" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin main + git checkout -B main origin/main + npm pkg set version="$next" + npm install --package-lock-only --ignore-scripts + git add package.json package-lock.json + git commit -m "chore: bump main to $next — a dev install must not impersonate $RELEASE_VERSION" + if ! git push origin main; then + echo "direct push refused (branch protection?) — opening the bump PR instead" >&2 + git checkout -b "chore/bump-$next" + git push origin "chore/bump-$next" + gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \ + --title "chore: bump main to $next" \ + --body "The post-release re-arm, opened by release.yml because the direct push was refused." \ + --label release + fi diff --git a/test/release.test.ts b/test/release.test.ts index c28b034..8234e31 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -194,6 +194,15 @@ describe("release.yml", () => { expect(RY).not.toContain("pull_request:"); }); + it("the release re-arms main itself — the -dev bump folds into the release act", () => { + // Operator decision (#111 followup): the post-release bump PR was + // ceremony debris. Direct push with the job's token, PR fallback when + // branch protection refuses, merge-door only. + expect(RY).toContain("bump main to the next -dev"); + expect(RY).toContain("opening the bump PR instead"); + expect(RY).toContain("npm install --package-lock-only"); + }); + it("asserts tag == package.json version, and the assert precedes the create", () => { expect(RY).toContain('require("./package.json").version'); expect(RY).toContain("creating nothing"); From d4409c3f426149a6a9bad5833b9e6f3169a0047f Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:16:08 +0000 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20one=20on.push=20key=20=E2=80=94=20YA?= =?UTF-8?q?ML=20last-key-wins=20had=20dropped=20the=20tag=20door;=20format?= =?UTF-8?q?=20the=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grok's round-2 catches: (1) two sibling push: maps under on: leave only the second alive — the tag-push fallback stopped triggering entirely; both filters now live under one push key with the steps still split on the pushed ref, and a pin counts exactly one on.push. (2) CI red was the unformatted pin block — biome now clean. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 17 +++++++++-------- test/release.test.ts | 8 +++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9055332..7365451 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,21 +20,22 @@ name: release # and tsc first. So the build happens ONCE, here, and the asset is the # runnable tree: bin/, dist/, production node_modules/, package.json. on: + # ONE push key, both filters — YAML maps are last-key-wins, so a second + # sibling `push:` would silently REPLACE the first and kill a door + # (grok's round-2 catch: the tag fallback had stopped triggering). push: # Every tag, not a shape filter (box's and rig's precedent): a tag that # mismatches package.json — a habitual v0.1.0, a typo — must fail the # assert LOUDLY below, not be silently skipped by a pattern that didn't # match. tags: ["**"] - push: # The merge-is-the-release path (#111) rides pushes to MAIN, not - # pull_request events, for one load-bearing reason the first review - # round caught: a workflow run triggered by a pull_request from a - # public FORK gets a READ-ONLY GITHUB_TOKEN — `permissions:` cannot - # raise that ceiling — and every ceremony PR this org has ever merged - # is cross-repo from the bot fork. The asserts would pass and the tag - # create would 403, red on main, every release. A push to main is an - # in-repo event with the full write token, whoever authored the PR. + # pull_request events: a pull_request run from a public FORK gets a + # READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling — + # and every ceremony PR this org merges is cross-repo from the bot + # fork; the tag create would 403 after green asserts. A push to main + # is an in-repo event with the full write token, whoever authored the + # PR. The steps split on the pushed ref. branches: [main] permissions: diff --git a/test/release.test.ts b/test/release.test.ts index 8234e31..51a6305 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -185,12 +185,14 @@ describe("release.yml", () => { // intent — is read via the API off the merge commit's PR, and a // transition with no labeled PR behind it refuses. expect(RY).toContain("branches: [main]"); + // YAML maps are last-key-wins: a second sibling push: key silently + // replaces the first and kills a door (grok's round-2 catch — the tag + // fallback had stopped triggering). Exactly ONE push key may exist. + expect(RY.match(/^ push:$/gm)).toHaveLength(1); expect(RY).toContain("startsWith(github.ref, 'refs/tags/')"); expect(RY).toContain("github.ref == 'refs/heads/main'"); expect(RY).toContain("commits/$GITHUB_SHA/pulls"); - expect(RY).toContain( - "no merged, release-labeled PR is behind this commit", - ); + expect(RY).toContain("no merged, release-labeled PR is behind this commit"); expect(RY).not.toContain("pull_request:"); }); From a5d108cbf298cf0d415f5a0baf2a930f19ef2662 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:18:51 +0000 Subject: [PATCH 7/9] =?UTF-8?q?test:=20quantifier=20form=20in=20the=20on.p?= =?UTF-8?q?ush=20pin=20=E2=80=94=20biome=20rejects=20literal=20double=20sp?= =?UTF-8?q?aces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- test/release.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/release.test.ts b/test/release.test.ts index 51a6305..7adfb09 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -188,7 +188,7 @@ describe("release.yml", () => { // YAML maps are last-key-wins: a second sibling push: key silently // replaces the first and kills a door (grok's round-2 catch — the tag // fallback had stopped triggering). Exactly ONE push key may exist. - expect(RY.match(/^ push:$/gm)).toHaveLength(1); + expect(RY.match(/^ {2}push:$/gm)).toHaveLength(1); expect(RY).toContain("startsWith(github.ref, 'refs/tags/')"); expect(RY).toContain("github.ref == 'refs/heads/main'"); expect(RY).toContain("commits/$GITHUB_SHA/pulls"); From b92919d4d0fa00ed778e8f2ea569d814aa3826dd Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:31:40 +0000 Subject: [PATCH 8/9] fix: pull-requests scope for the door's two PR-API calls; docs catch up to the shipped shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-bot's round-4 blockers: (1) a declared permissions: block zeroes every unspecified scope, so the decide step's label read (commits//pulls) and the bump fallback's gh pr create could only 403 — every genuine ceremony would end red at the label check, the exact failure shape this feature exists to kill, one layer down. labels.yml already carries the precedent; pull-requests: write added with the two consumers named. (2) CONTRIBUTING still prescribed the follow-up bump PR the workflow now performs itself, and the changelog entry described the old PR-base interlock — both now tell the shipped story (event.before interlock, self-re-arm, manual-path bump stays the operator's). Nits taken: fetch-depth 2 for the all-zeros fallback's first parent, re-runs refuse-loudly wording, the bump-window arithmetic comment. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 21 +++++++++++++++++---- CHANGELOG.md | 29 ++++++++++++++++++----------- CONTRIBUTING.md | 13 ++++++++----- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7365451..38f826a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,11 @@ on: branches: [main] permissions: - contents: write # tag create via the API + gh release create + contents: write # tag create via the API + gh release create + the bump push + # Two consumers (labels.yml precedent — a declared permissions: block + # zeroes every unspecified scope): the decide step's label read + # (commits//pulls) and the bump fallback's `gh pr create --label`. + pull-requests: write jobs: release: @@ -58,6 +62,10 @@ jobs: # merge commit the maintainer shipped, which the tag created # below will name). ref: ${{ github.sha }} + # Depth 2: the pushed head's first parent must be resolvable for + # the decide step's all-zeros fallback (event.before on a + # branch-creation push). + fetch-depth: 2 - uses: actions/setup-node@v4 with: node-version: "22" @@ -159,9 +167,10 @@ jobs: MERGE_SHA: ${{ github.sha }} run: | # Assert 4 — no tag and no release exist for this version. Re-runs - # stay idempotent, and a manual race (an operator who tagged by - # hand between merge and here) fails loudly instead of - # double-publishing. + # of a completed ceremony REFUSE LOUDLY (red, creating nothing — + # the correct direction), and a manual race (an operator who + # tagged by hand between merge and here) fails the same way + # instead of double-publishing. if git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION" > /dev/null; then echo "tag '$RELEASE_VERSION' already exists — creating nothing (already released, or a manual tag won the race)." >&2 exit 1 @@ -214,6 +223,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # next is computed from the RELEASE tree (the checkout), then + # applied to whatever main is by the time of the push — if main + # moved in the window, release+1 still lands on the newer head, + # which is the intended arithmetic either way. next="$(node -p 'const v = require("./package.json").version.split("."); v[2] = String(Number(v[2]) + 1) + "-dev"; v.join(".")')" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2972f4e..b2e769b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,24 @@ actually cutting it, and this file starts there. ### Added -- **Merging a release-labeled PR is the release** (#111; box#96's design) — - `release.yml` now also fires when a `release`-labeled PR merges into - main: it asserts fail-loud (non-`-dev` version, version changed in the - PR — the `-dev` interlock, changelog section extracts non-empty, no - existing tag or release), then tags the merge commit, builds the - `cast-X.Y.Z.tgz` asset once, and publishes — the maintainer's merge is - the ship decision, no silent-when-forgotten manual tag step. The - tag-push path stays as the documented fallback and backfill, and both - paths run the same steps so they cannot drift. First-release edge: - 0.1.0 never carried `-dev`, so its ceremony (#110) ships by manual tag; - the automation applies from 0.1.1 on. +- **Merging a release-labeled PR is the release — and the release re-arms + main itself** (#111; box#96's design) — `release.yml` now also fires on + pushes to main (not `pull_request` events: fork-sourced ceremony PRs get + a read-only token there — the round-1 catch). A decide step reads the + version transition from the push (`event.before` → the pushed head) and + answers four states: release-flow *work* merged under the `release` + label — `-dev` endstates, and the post-release window — no-ops green + with a NOTICE; the two genuinely ambiguous bare states refuse loudly; + a true transition then requires a merged, `release`-labeled PR behind + the commit (read via the API — the label is the operator's declared + intent) before the door opens. It then tags the merge commit, builds + the `cast-X.Y.Z.tgz` asset once, publishes — and bumps main to + `X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR fallback, so + no follow-up bump PR exists on the paved road. The tag-push path stays + as the documented fallback and backfill, and both paths run the same + steps so they cannot drift. First-release edge: 0.1.0 never carried + `-dev`, so its ceremony (#110) ships by manual tag; the automation + applies from 0.1.1 on. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be9926a..e7adf03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,12 +69,15 @@ on box#83's shape): *Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge commit yourself — the same workflow runs the same asserts, build, and publish from the tag. -3. **Right after the release, a follow-up PR bumps `package.json` to - `X.Y.(Z+1)-dev`** (and `package-lock.json` with it) — box#90's step of - the family ritual. Installs are versioned by the tree's `package.json` - version, so a `CAST_REF=main` install between releases must land as +3. **The release re-arms main itself**: the same workflow run bumps + `package.json` (and `package-lock.json`) to `X.Y.(Z+1)-dev` and pushes + the commit straight to main — no follow-up PR (it opens one only if + branch protection refuses the direct push, and says so loudly). + Installs are versioned by the tree's `package.json` version, so a + `CAST_REF=main` install between releases must land as `versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must - not impersonate the release it merely descends from. + not impersonate the release it merely descends from. On the *manual* + tag path the bump stays yours: open the one-line PR after publishing. ## Labels — who sets what From c55e6bfc7c1f2a6b5ca6de42c92dfe6a7b006315 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 16:35:37 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20issues:=20write=20=E2=80=94=20the=20?= =?UTF-8?q?fallback=20PR's=20--label=20rides=20the=20issues=20API=20(label?= =?UTF-8?q?s.yml=20precedent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38f826a..bd87fcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,9 @@ permissions: # zeroes every unspecified scope): the decide step's label read # (commits//pulls) and the bump fallback's `gh pr create --label`. pull-requests: write + # ...and the --label on that fallback PR rides the ISSUES API (labels.yml + # grants the same pair for the same reason). + issues: write jobs: release: