Skip to content

Commit f07d93f

Browse files
authored
Merge #8: Add release workflow
2 parents 0df9247 + 1ec8e11 commit f07d93f

17 files changed

Lines changed: 583 additions & 37 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Prepare release
2+
3+
# Manually-triggered release prep. Bumps the version, rolls the CHANGELOG, and opens a
4+
# PR — nothing is pushed to master directly. Merging the PR triggers release.yml.
5+
on:
6+
workflow_dispatch:
7+
inputs:
8+
version:
9+
description: "Target version, e.g. 0.2.0 (must be > current package.json version)"
10+
required: true
11+
12+
permissions: {}
13+
14+
concurrency:
15+
group: prepare-release
16+
cancel-in-progress: false
17+
18+
# Injection-safety: the free-text `version` input is bound ONCE here; every step below
19+
# references the quoted shell variable "$VERSION" and never expands ${{ inputs.version }}
20+
# inside a `run:` script (which Actions would substitute before the shell parses it).
21+
env:
22+
VERSION: ${{ inputs.version }}
23+
24+
jobs:
25+
prepare:
26+
runs-on: ubuntu-latest
27+
permissions:
28+
contents: write # push the release branch
29+
pull-requests: write # open the PR
30+
steps:
31+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
32+
with:
33+
persist-credentials: false
34+
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
35+
with:
36+
cache: true
37+
- run: npm ci
38+
- run: ./scripts/check-version-increases.ts "$VERSION"
39+
- run: ./scripts/roll-changelog.ts "$VERSION"
40+
- run: npm version "$VERSION" --no-git-tag-version
41+
- name: Commit on a branch and open PR
42+
env:
43+
GH_TOKEN: ${{ github.token }}
44+
run: |
45+
BRANCH="release/v${VERSION}"
46+
git config user.name 'github-actions[bot]'
47+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
48+
git switch -c "$BRANCH"
49+
git commit package.json package-lock.json CHANGELOG.md -m "Release v${VERSION}"
50+
# Credential helper reads the token from env at git runtime — token never lands
51+
# in argv (URL) or on-disk git config. Leading empty helper clears inherited ones.
52+
git -c credential.helper= \
53+
-c credential.helper='!f() { echo username=x-access-token; echo "password=${GH_TOKEN}"; }; f' \
54+
push "https://github.com/${{ github.repository }}.git" "$BRANCH"
55+
gh pr create --base master --head "$BRANCH" \
56+
--title "Release v${VERSION}" \
57+
--body "Automated release prep for v${VERSION}. Review the version bump and rolled CHANGELOG, then merge to trigger the gated release."

.github/workflows/release.yml

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
name: Release
2+
3+
# Triggered after CI succeeds on a push to master (i.e. after a release-prep PR merges).
4+
# A cheap, ungated `gate` job decides whether this push introduced a new version; only
5+
# then does the `release` job — behind a manual approval environment — tag, build, create
6+
# the GitHub Release, and publish to npm via OIDC.
7+
#
8+
# zizmor flags `workflow_run` as a dangerous trigger because it runs privileged and is
9+
# commonly misused to execute untrusted PR code. We use it safely: the gate requires a
10+
# *push* to *master* that *succeeded*, the checkout pins the triggering commit SHA (a
11+
# trusted master commit, never PR head), and the privileged publish sits behind a manual
12+
# approval environment. Hence the suppression below.
13+
on: # zizmor: ignore[dangerous-triggers]
14+
workflow_run:
15+
workflows: ["CI"] # matches name: in ci.yml
16+
types: [completed]
17+
branches: [master] # head branch of the CI run; excludes PR runs at the trigger
18+
19+
permissions: {}
20+
21+
# NOTE: no workflow-level `concurrency:` on purpose. A top-level group would put
22+
# every release RUN — including the ones that no-op at the gate — into one
23+
# serialized slot, and GitHub cancels the older *pending* run whenever a newer
24+
# one queues. Under a quick succession of merges A -> B -C where only A bumps
25+
# the version, B/C's no-op runs could evict A while it waits, so nothing
26+
# releases. Concurrency lives on the `release` job instead (below), where only
27+
# genuine releases land.
28+
29+
jobs:
30+
gate:
31+
# Belt-and-suspenders: the trigger's `branches: [master]` already excludes PR runs;
32+
# this also requires the CI run to be a *push* that *succeeded*.
33+
if: >-
34+
github.event.workflow_run.event == 'push' &&
35+
github.event.workflow_run.head_branch == 'master' &&
36+
github.event.workflow_run.conclusion == 'success'
37+
runs-on: ubuntu-latest
38+
permissions:
39+
contents: read
40+
steps:
41+
# workflow_run defaults to default-branch HEAD — must pin the triggering SHA.
42+
# fetch-depth: 2 so the first parent is present for the version-introduced diff.
43+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
44+
with:
45+
ref: ${{ github.event.workflow_run.head_sha }}
46+
fetch-depth: 2
47+
persist-credentials: false
48+
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
49+
with:
50+
cache: true
51+
- run: npm ci
52+
- id: decide
53+
run: |
54+
VERSION=$(node -p "require('./package.json').version")
55+
./scripts/validate-version.ts "$VERSION" # format check (defence-in-depth)
56+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
57+
58+
# Release ONLY when THIS commit introduced the version (vs its first
59+
# parent), not merely whenever master happens to carry an untagged
60+
# version. This ties the release to the prepare commit and makes any
61+
# later same-version commit a no-op regardless of CI/approval
62+
# ordering.
63+
# HEAD^ = first parent, so this covers both squash and merge-commit merges.
64+
git show "HEAD^:package.json" > /tmp/package.parent.json
65+
PARENT_VERSION=$(node -p "require('/tmp/package.parent.json').version")
66+
67+
if [ "$VERSION" != "$PARENT_VERSION" ]; then
68+
echo "should_release=true" >> "$GITHUB_OUTPUT"
69+
else
70+
echo "should_release=false" >> "$GITHUB_OUTPUT"
71+
fi
72+
outputs:
73+
should_release: ${{ steps.decide.outputs.should_release }}
74+
version: ${{ steps.decide.outputs.version }}
75+
sha: ${{ github.event.workflow_run.head_sha }}
76+
77+
78+
release:
79+
needs: gate
80+
if: needs.gate.outputs.should_release == 'true'
81+
name: Create git tag & GitHub Release, publish to npm
82+
runs-on: ubuntu-latest
83+
environment: tag-release-and-publish # <- MANUAL APPROVAL GATE (required reviewer)
84+
timeout-minutes: 15 # bound a hung job holding id-token: write
85+
# Serialize on the VERSION — the resource that actually needs mutual exclusion (it owns
86+
# the v$VERSION tag, the GitHub Release, and the npm version). Job-level concurrency can
87+
# read `needs.*` (it's evaluated after `gate`), unlike top-level concurrency. Keying by
88+
# version (not SHA) collapses two commits that target the SAME version (e.g. a bump, a
89+
# revert, then a re-bump) into one serialized slot with a single approval.
90+
concurrency:
91+
group: release-${{ needs.gate.outputs.version }}
92+
cancel-in-progress: false
93+
permissions:
94+
contents: write # create the Github release (and its tag)
95+
id-token: write # OIDC trusted publishing
96+
env:
97+
VERSION: ${{ needs.gate.outputs.version }} # from package.json, bound once
98+
SHA: ${{ needs.gate.outputs.sha }}
99+
steps:
100+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
101+
with:
102+
ref: ${{ needs.gate.outputs.sha }}
103+
persist-credentials: false
104+
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
105+
with:
106+
cache: true
107+
- run: npm ci
108+
- run: ./scripts/extract-notes.ts "$VERSION" /tmp/release-notes.md
109+
- run: npm run build
110+
- name: Create GitHub release (which creates the tag), then publish to npm
111+
env:
112+
GH_TOKEN: ${{ github.token }}
113+
run: |
114+
TARBALL=$(npm pack | tail -n1)
115+
# --target makes GitHub create the v$VERSION tag on the release commit as part of
116+
# creating the release — tag and release are born together from one API call, so
117+
# there's no separate git tag/push and no credential-helper dance. Release is
118+
# created before publish (publish is the least-reversible step).
119+
gh release create "v${VERSION}" --target "$SHA" --title "v${VERSION}" \
120+
--notes-file /tmp/release-notes.md "$TARBALL"
121+
npm publish "$TARBALL" # OIDC: no token, provenance automatic; same artifact attached above

package-lock.json

Lines changed: 12 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
"scripts": {
2121
"start": "./src/main.ts",
2222
"build": "tsdown",
23-
"test": "vitest run src/",
24-
"typecheck": "tsc --noEmit",
25-
"lint": "biome check src/"
23+
"test": "vitest run src/ scripts/",
24+
"typecheck": "tsc --build",
25+
"lint": "biome check src/ scripts/"
2626
},
2727
"devDependencies": {
2828
"@biomejs/biome": "^2.4.16",
2929
"@types/node": "^22",
30+
"@types/semver": "^7.7.1",
31+
"semver": "^7.8.5",
3032
"tsdown": "^0.22.2",
3133
"typescript": "^6",
3234
"vitest": "^4.1.8"

scripts/check-version-increases.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2+
import { readFileSync } from "node:fs";
3+
import { assertGreater } from "./lib/semver.ts";
4+
5+
// Prepare-time guard: verify the target version strictly increases over the current
6+
// package.json version. Mutates nothing — `npm version` does the actual bump.
7+
8+
const next = process.argv[2];
9+
if (next === undefined) {
10+
throw new Error("Usage: check-version-increases.ts <target-version>");
11+
}
12+
13+
const parsed: unknown = JSON.parse(readFileSync("./package.json", "utf8"));
14+
if (
15+
typeof parsed !== "object" ||
16+
parsed === null ||
17+
!("version" in parsed) ||
18+
typeof parsed.version !== "string"
19+
) {
20+
throw new Error("package.json does not contain a string `version` field.");
21+
}
22+
23+
assertGreater(parsed.version, next);

scripts/extract-notes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2+
import { readFileSync, writeFileSync } from "node:fs";
3+
import { extractReleaseNotes } from "./lib/changelog.ts";
4+
5+
// Release-time: pull the already-merged dated section's body out of CHANGELOG.md and
6+
// write it to a file for `gh release create --notes-file`.
7+
8+
const rawVersion = process.argv[2];
9+
const outPath = process.argv[3];
10+
if (rawVersion === undefined || outPath === undefined) {
11+
throw new Error("Usage: extract-notes.ts <version> <output-path>");
12+
}
13+
const version = rawVersion.replace(/^v/, "");
14+
15+
const content = readFileSync("CHANGELOG.md", "utf8");
16+
writeFileSync(outPath, extractReleaseNotes(content, version));

scripts/lib/changelog.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, test } from "vitest";
2+
import { extractReleaseNotes, rollUnreleasedSection } from "./changelog.ts";
3+
4+
const SAMPLE = `# Unreleased
5+
6+
## Features
7+
- A shiny new thing.
8+
9+
## Bug fixes
10+
- Fixed an old thing.
11+
12+
13+
# 0.1.0 (2026-06-15)
14+
Initial release.
15+
16+
- Did the first things.
17+
`;
18+
19+
describe("rollUnreleasedSection", () => {
20+
test("inserts a dated section and re-opens an empty Unreleased", () => {
21+
const rolled = rollUnreleasedSection(SAMPLE, "0.2.0", "2026-06-20");
22+
expect(rolled).toBe(`# Unreleased
23+
24+
25+
# 0.2.0 (2026-06-20)
26+
27+
## Features
28+
- A shiny new thing.
29+
30+
## Bug fixes
31+
- Fixed an old thing.
32+
33+
34+
# 0.1.0 (2026-06-15)
35+
Initial release.
36+
37+
- Did the first things.
38+
`);
39+
});
40+
41+
test("leaves prior dated sections untouched", () => {
42+
const rolled = rollUnreleasedSection(SAMPLE, "0.2.0", "2026-06-20");
43+
expect(rolled).toContain("# 0.1.0 (2026-06-15)\nInitial release.");
44+
});
45+
46+
test("throws when there is no Unreleased section", () => {
47+
expect(() =>
48+
rollUnreleasedSection(
49+
"# 0.1.0 (2026-06-15)\nstuff\n",
50+
"0.2.0",
51+
"2026-06-20",
52+
),
53+
).toThrow(/no .*unreleased/i);
54+
});
55+
56+
test("throws when the Unreleased section is empty", () => {
57+
const empty = "# Unreleased\n\n\n# 0.1.0 (2026-06-15)\nstuff\n";
58+
expect(() => rollUnreleasedSection(empty, "0.2.0", "2026-06-20")).toThrow(
59+
/empty/i,
60+
);
61+
});
62+
});
63+
64+
describe("extractReleaseNotes", () => {
65+
test("returns the trimmed body of the requested version", () => {
66+
expect(extractReleaseNotes(SAMPLE, "0.1.0")).toBe(
67+
"Initial release.\n\n- Did the first things.",
68+
);
69+
});
70+
71+
test("throws when the version section is absent", () => {
72+
expect(() => extractReleaseNotes(SAMPLE, "9.9.9")).toThrow(/no .*9\.9\.9/i);
73+
});
74+
75+
test("throws when the version section body is empty", () => {
76+
const emptyBody = "# 0.2.0 (2026-06-20)\n\n# 0.1.0 (2026-06-15)\nstuff\n";
77+
expect(() => extractReleaseNotes(emptyBody, "0.2.0")).toThrow(/empty/i);
78+
});
79+
});
80+
81+
describe("roll then extract round-trip", () => {
82+
test("extracts exactly the rolled notes for the new version", () => {
83+
const rolled = rollUnreleasedSection(SAMPLE, "0.2.0", "2026-06-20");
84+
expect(extractReleaseNotes(rolled, "0.2.0")).toBe(
85+
`## Features
86+
- A shiny new thing.
87+
88+
## Bug fixes
89+
- Fixed an old thing.`,
90+
);
91+
});
92+
});

0 commit comments

Comments
 (0)