feat: merging a release-labeled PR is the release (#111)
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 <noreply@anthropic.com>
This commit is contained in:
parent
c0e4fab4d9
commit
775af63063
4 changed files with 203 additions and 37 deletions
125
.github/workflows/release.yml
vendored
125
.github/workflows/release.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
14
CHANGELOG.md
14
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) —
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue