The rig twin of heavy-duty/box#96, from the release-ceremony retro: the tag was a separate, manual, silent-when-forgotten step, and a forgotten tag produces no red X — the worst failure shape. The ship decision already lives in the release PR; merging it is "ship". After that, tagging is transcription, and transcription belongs to machines. release.yml now also fires on pull_request closed into main, gated on merged AND the `release` label. The job asserts in order, each fail-loud and creating nothing: VERSION at the merge commit is non--dev; VERSION changed in THIS PR (base vs merge — the interlock that fails a mislabeled ordinary PR); the changelog section for that version extracts non-empty via the existing changelog_section from release-lib.sh; and no tag or release exists yet. Then, in the same job, it API-creates the tag at the merge commit and publishes the release with the extracted notes. Same-job is load-bearing: a GITHUB_TOKEN-created tag does not fire the tag-push trigger, so the publish must live next to the tag and the fallback job cannot double-publish; the nothing-exists assert covers a manual race. The tag-push path survives verbatim as the documented manual fallback and backfill, and CONTRIBUTING's Releasing section now reads merge-is-ship with the manual tag as fallback. test/release.sh pins the merge path in the house grep-pin style: the merged+labeled gate, the four asserts, the same-job tag+publish (awk from release-on-merge: to EOF), the asserts-precede-the-tag ordering, and the surviving tag-push trigger. Fixes #47 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
7.3 KiB
YAML
153 lines
7.3 KiB
YAML
name: release
|
|
# Two ways in, one release out (#47; box#96's design — the merge path — on
|
|
# top of #32/box#83's tag flow, kept verbatim as the fallback):
|
|
#
|
|
# - MERGE (the paved road): a release is a PR — `release: X.Y.Z`, carrying
|
|
# the `release` label, bumping VERSION and stamping CHANGELOG.md's
|
|
# Unreleased section — and MERGING it is the ship decision. The
|
|
# release-on-merge job asserts its way to certainty, then tags the merge
|
|
# commit and publishes, same job. No separate, silent-when-forgotten
|
|
# tagging step: a forgotten tag produces no red X, a failed run on main
|
|
# does — of two unreliabilities, pick the loud one.
|
|
# - TAG PUSH (the manual fallback and backfill): tag the merge commit bare
|
|
# `X.Y.Z` (no `v` prefix — box's tag scheme) and push; the release job
|
|
# below turns it into the GitHub release.
|
|
#
|
|
# Either way the body is the changelog section — the curated prose, never
|
|
# the auto-generated PR list — and no assets are uploaded on purpose: for a
|
|
# pure-bash tree, GitHub's source tarball for the tag IS the package
|
|
# (install.sh downloads archive/refs/tags/<tag>).
|
|
on:
|
|
push:
|
|
# Every tag, not a shape filter: a tag that mismatches VERSION must fail
|
|
# LOUDLY below, not be silently skipped by a pattern that didn't match.
|
|
tags: ['**']
|
|
pull_request:
|
|
types: [closed]
|
|
branches: [main]
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
jobs:
|
|
release:
|
|
# The tag-push path, gated to push events so a closed PR never lands
|
|
# here — the merge path is release-on-merge below.
|
|
if: github.event_name == 'push'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
# The tag names a tree; the tree names its own version. When they
|
|
# disagree, creating a release would put a version label on a tree
|
|
# that is not that version — exactly the lie the release flow exists
|
|
# to end — so: fail, create nothing.
|
|
- name: assert the tag matches the tree's VERSION
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
|
echo "tag '$GITHUB_REF_NAME' != VERSION '$ver' — refusing to create a release for a tree that says it is something else" >&2
|
|
exit 1
|
|
fi
|
|
- name: create the release from the changelog section
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
. .github/scripts/release-lib.sh
|
|
notes="$(changelog_section CHANGELOG.md "$GITHUB_REF_NAME")"
|
|
if [ -z "$notes" ]; then
|
|
echo "CHANGELOG.md has no '## $GITHUB_REF_NAME' section — stamp the Unreleased section in the release PR before tagging" >&2
|
|
exit 1
|
|
fi
|
|
gh release create "$GITHUB_REF_NAME" --verify-tag \
|
|
--title "$GITHUB_REF_NAME" --notes "$notes"
|
|
|
|
# The merge path (#47; box#96): the `release` label is the intent, the
|
|
# VERSION transition is the interlock. Four asserts in order, each
|
|
# fail-loud and creating NOTHING, then tag + publish in this same job.
|
|
# Same-job is load-bearing: the tag is created with GITHUB_TOKEN via the
|
|
# API, and GITHUB_TOKEN-created refs do not fire `on: push: tags`
|
|
# workflows — so the publish MUST live here (nothing else would run), and
|
|
# the fallback job above CANNOT double-publish off our tag. A manually
|
|
# pushed tag racing this run is caught by the nothing-exists assert.
|
|
# NOTE: test/release.sh pins this block by awk-ing from
|
|
# 'release-on-merge:' to EOF — keep it the last job.
|
|
release-on-merge:
|
|
if: >-
|
|
github.event_name == 'pull_request' &&
|
|
github.event.pull_request.merged == true &&
|
|
contains(github.event.pull_request.labels.*.name, 'release')
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# The merge commit is what ships — not the PR merge ref, which
|
|
# stops meaning anything once the PR closes. Full history so the
|
|
# base-side VERSION is readable for the interlock below.
|
|
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
|
fetch-depth: 0
|
|
# Assert 1 — the merged tree says it is a release. A `-dev` VERSION
|
|
# here means the label lied (or the ceremony PR forgot the bump).
|
|
- name: assert the merged tree is a release (non-dev VERSION)
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
case "$ver" in
|
|
*-dev)
|
|
echo "VERSION '$ver' is still -dev — a release PR ships a bare X.Y.Z; refusing to release a dev tree" >&2
|
|
exit 1 ;;
|
|
esac
|
|
# Assert 2 — THIS PR is the one that changed VERSION (base vs merge).
|
|
# The `-dev` transition as a safety interlock: an ordinary PR someone
|
|
# mislabels `release` fails here loudly instead of shipping main
|
|
# under a version some earlier PR minted.
|
|
- name: assert VERSION changed in this PR (the mislabel interlock)
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
base_ver="$(git show "$BASE_SHA:VERSION")"
|
|
if [ "$base_ver" = "$ver" ]; then
|
|
echo "VERSION did not change in this PR ('$ver' before and after) — a 'release'-labeled PR must be the ceremony PR that bumps it; refusing to release" >&2
|
|
exit 1
|
|
fi
|
|
# Assert 3 — the changelog names exactly this version, and the one
|
|
# extractor (shared with the tag job and test/release.sh) gets a
|
|
# non-empty body out of it. The notes are kept for the publish.
|
|
- name: assert the changelog section for this version extracts
|
|
run: |
|
|
. .github/scripts/release-lib.sh
|
|
ver="$(cat VERSION)"
|
|
changelog_section CHANGELOG.md "$ver" > "$RUNNER_TEMP/notes.md"
|
|
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
|
|
echo "CHANGELOG.md has no '## $ver' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
|
|
exit 1
|
|
fi
|
|
cat "$RUNNER_TEMP/notes.md"
|
|
# Assert 4 — nothing exists yet, tag or release: a re-run of this job
|
|
# (or a manual tag that beat it) must refuse, not clobber.
|
|
- name: assert no tag and no release exist yet (idempotent re-runs)
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
if git ls-remote --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then
|
|
echo "tag '$ver' already exists — this release already happened (or is mid-flight on the manual path); refusing to re-release" >&2
|
|
exit 1
|
|
fi
|
|
if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
echo "release '$ver' already exists — refusing to re-release" >&2
|
|
exit 1
|
|
fi
|
|
# Act — tag the merge commit via the API, then publish with the notes
|
|
# assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive
|
|
# workflow runs — see the job comment.)
|
|
- name: tag the merge commit and publish the release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
ver="$(cat VERSION)"
|
|
gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \
|
|
-f ref="refs/tags/$ver" -f sha="$MERGE_SHA"
|
|
gh release create "$ver" --verify-tag \
|
|
--title "$ver" --notes-file "$RUNNER_TEMP/notes.md" \
|
|
-R "$GITHUB_REPOSITORY"
|