feat: merging a release-labeled PR is the release #112

Merged
dan-claude-bot merged 9 commits from feat/release-on-merge into main 2026-07-19 17:12:19 +00:00
4 changed files with 338 additions and 42 deletions

View file

@ -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
@ -13,26 +20,61 @@ 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: ["**"]
# The merge-is-the-release path (#111) rides pushes to MAIN, not
# 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:
contents: write # 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/<sha>/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:
# 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:
# 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 }}
# 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"
cache: npm
- name: the tag must name package.json's version
- name: "tag push: the tag must name package.json's version"
if: startsWith(github.ref, 'refs/tags/')
run: |
ver="$(node -p 'require("./package.json").version')"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
@ -40,29 +82,169 @@ 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"
# 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 → 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
# 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.ref == 'refs/heads/main'
env:
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')"
# 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
*-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 "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
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
# 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: 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.
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.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.sha }}
run: |
# Assert 4 — no tag and no release exist for this version. Re-runs
# 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
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
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 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
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
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"
# 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 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"
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

View file

@ -7,6 +7,27 @@ actually cutting it, and this file starts there.
## Unreleased
### Added
- **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
- **The release suite accepts the ceremony's own tree** (#108) —

View file

@ -44,31 +44,40 @@ 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.
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
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. **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

View file

@ -172,30 +172,114 @@ 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("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]");
// 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(/^ {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");
expect(RY).toContain("no merged, release-labeled PR is behind this commit");
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");
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 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 = [
'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
"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
'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
];
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", () => {