name: release # 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 # IS their package; cast's source tarball is not runnable — it needs npm ci # 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 # 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 # ...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: "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 echo "tag '$GITHUB_REF_NAME' does not match package.json version '$ver' — creating nothing." >&2 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 "$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 # 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-$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 "$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