Skip to content

Commit e07d403

Browse files
authored
Add a resumable release workflow (#114)
* Add resumable Actions release workflow * Document GitHub Actions releases * Isolate release credentials from verification * Reduce release helper complexity * Pin release jobs to the dispatch revision * Preflight npm before release ref updates * Retry post-publish registry verification
1 parent acd343e commit e07d403

9 files changed

Lines changed: 1474 additions & 15 deletions

File tree

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
/AGENTS.md @itsjling
55
/skills-lock.json @itsjling
66
/.github/workflows/automation-trust.yml @itsjling
7+
/.github/workflows/release.yml @itsjling
8+
/scripts/release-workflow.mjs @itsjling

.github/workflows/automation-trust.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ jobs:
3030
path === "AGENTS.md" ||
3131
path === "skills-lock.json" ||
3232
path === ".github/workflows/automation-trust.yml" ||
33+
path === ".github/workflows/release.yml" ||
34+
path === "scripts/release-workflow.mjs" ||
3335
path.startsWith(".codex/") ||
3436
path.startsWith(".agents/");
3537
const changed = [

.github/workflows/release.yml

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: Exact SemVer to publish (for example, 1.2.3 or 1.2.3-beta.1)
8+
required: true
9+
type: string
10+
11+
permissions: {}
12+
13+
concurrency:
14+
group: npm-release
15+
cancel-in-progress: false
16+
17+
jobs:
18+
prepare:
19+
name: Prepare verified release
20+
if: github.ref == 'refs/heads/main'
21+
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
steps:
25+
- name: Check out the dispatch revision without credentials
26+
uses: actions/checkout@v6
27+
with:
28+
ref: ${{ github.sha }}
29+
fetch-depth: 0
30+
persist-credentials: false
31+
- name: Create the pinned local release branch
32+
run: git checkout -B main "$GITHUB_SHA"
33+
- name: Set up pinned pnpm
34+
uses: pnpm/action-setup@v4
35+
- name: Set up Node
36+
uses: actions/setup-node@v6
37+
with:
38+
node-version: 24
39+
package-manager-cache: false
40+
- name: Install dependencies
41+
run: pnpm install --frozen-lockfile
42+
- name: Configure the release commit author
43+
run: |
44+
git config user.name "github-actions[bot]"
45+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
46+
- name: Prepare and verify the exact release
47+
env:
48+
RELEASE_VERSION: ${{ inputs.version }}
49+
run: node scripts/release-workflow.mjs prepare "$RELEASE_VERSION"
50+
- name: Upload the verified release
51+
uses: actions/upload-artifact@v4
52+
with:
53+
name: npm-release-${{ inputs.version }}
54+
path: |
55+
.cache/diffsplain-release.tgz
56+
.cache/diffsplain-release.json
57+
.cache/diffsplain-release-plan.json
58+
.cache/diffsplain-release.bundle
59+
include-hidden-files: true
60+
if-no-files-found: error
61+
retention-days: 90
62+
63+
release:
64+
needs: prepare
65+
name: Publish verified release
66+
if: github.ref == 'refs/heads/main'
67+
runs-on: ubuntu-latest
68+
environment: npm-publish
69+
permissions:
70+
contents: write
71+
id-token: write
72+
steps:
73+
- name: Check out the dispatch revision with release credentials
74+
uses: actions/checkout@v6
75+
with:
76+
ref: ${{ github.sha }}
77+
fetch-depth: 0
78+
- name: Set up Node and npm registry
79+
uses: actions/setup-node@v6
80+
with:
81+
node-version: 24
82+
registry-url: https://registry.npmjs.org
83+
package-manager-cache: false
84+
- name: Download the verified release
85+
uses: actions/download-artifact@v5
86+
with:
87+
name: npm-release-${{ inputs.version }}
88+
path: .cache
89+
- name: Confirm the trusted publishing client
90+
run: COREPACK_ENABLE_PROJECT_SPEC=0 corepack npm@11.5.1 --version
91+
- name: Push and publish the verified release
92+
env:
93+
RELEASE_VERSION: ${{ inputs.version }}
94+
run: node scripts/release-workflow.mjs finalize "$RELEASE_VERSION"

docs/content/development.mdx

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,66 @@ pnpm run benchmark:canary -- --runs 1 --reasoning minimal
284284

285285
## Publish a release
286286

287-
Publish releases by hand from a clean `main` checkout. First update the version
288-
and create its commit and tag. Pass `patch`, `minor`, an exact version, or any
289-
other value accepted by `pnpm version`:
287+
Use the GitHub Actions `Release` workflow for normal releases. Run it from
288+
`main` and enter one exact version such as `0.11.0` or `0.11.0-beta.1`. The input
289+
must be canonical SemVer: ranges, `patch` or `minor` aliases, surrounding
290+
whitespace, build metadata, and shell syntax are rejected.
291+
292+
Before the first run, configure the npm package's trusted publisher with this
293+
exact tuple:
294+
295+
- provider: GitHub Actions
296+
- organization or user: `itsjling`
297+
- repository: `diffsplain`
298+
- workflow filename: `release.yml`
299+
- environment: `npm-publish`
300+
- allowed action: `npm publish`
301+
302+
Create the protected `npm-publish` environment in GitHub. Allow this workflow's
303+
`GITHUB_TOKEN` to update `main` and release tags under your branch and tag
304+
rules. Configure any required environment reviewers. These npm and GitHub
305+
settings are prerequisites; the repository does not prove that they exist.
306+
307+
The workflow prepares and verifies the release without repository write access
308+
or an npm identity. It passes the tarball, verification receipt, release commit,
309+
and tag to a separate protected job. Only that final job receives GitHub write
310+
access and the npm OIDC identity. It independently checks the artifact, pushes
311+
the exact version commit and tag atomically, and publishes the tarball with
312+
package lifecycle scripts disabled. Stable versions use the `latest` npm tag
313+
and prereleases use `next`. Releases are serialized so two workflow runs cannot
314+
publish concurrently.
315+
316+
### Recover a workflow release
317+
318+
Rerun the failed publish job in the existing workflow run, or start a new run
319+
with the same exact version after a failure. The publish-job rerun retains the
320+
original verified artifact and dispatch commit, and is accepted when that
321+
commit is the parent of the pushed release. Rerunning every job from an older
322+
dispatch is rejected after `main` advances; start a new workflow run instead.
323+
The workflow accepts only these states:
324+
325+
- **Create:** `main` still contains the previous package version, the requested
326+
tag and npm version do not exist, and local `main` matches `origin/main`.
327+
- **Resume:** the requested package version and tag are already on the same
328+
commit at `origin/main`, but npm does not contain the version.
329+
- **Complete:** Git is in the resume state and npm contains the version. The
330+
workflow rebuilds the verified tarball and succeeds only when its SHA-512 SRI
331+
matches npm's `dist.integrity`.
332+
333+
A moved branch, conflicting tag, local-only version commit, or mismatched npm
334+
tarball is a mixed state and stops the workflow. Inspect and reconcile Git and
335+
npm before rerunning. If the atomic push failed and left only a local commit and
336+
tag in the runner, start a new run; if reproducing locally, delete or reconcile
337+
those local refs without overwriting remote history.
338+
339+
### Publish locally as a fallback
340+
341+
Use local publication only when the GitHub workflow cannot be used. Start from
342+
a clean `main` checkout, update the version, and create its commit and tag. Pass
343+
the exact intended version to `pnpm version`:
290344

291345
```sh
292-
corepack pnpm version patch
346+
corepack pnpm version 0.11.0
293347
```
294348

295349
Do not push yet. Verify the tagged commit and build the release tarball:
@@ -306,12 +360,12 @@ Sign in to npm when needed, then publish by naming the exact version again:
306360

307361
```sh
308362
COREPACK_ENABLE_PROJECT_SPEC=0 corepack npm@11.5.1 login --auth-type=web
309-
corepack pnpm run release:publish -- 1.0.0
363+
corepack pnpm run release:publish -- 0.11.0
310364
```
311365

312366
The publish command checks the clean tree, tag, commit, tarball hash, npm login,
313-
and registry before it publishes. Stable versions use the `latest` npm tag;
314-
prereleases use `next`. Local publication does not add npm provenance.
367+
and registry before it publishes. Local publication does not use the workflow's
368+
trusted-publisher identity or add npm provenance.
315369

316370
After npm confirms the version, push the version commit and tag:
317371

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"setup": "pnpm install --frozen-lockfile",
8484
"setup:smoke": "node scripts/setup-smoke.mjs",
8585
"test": "pnpm run test:unit && pnpm run test:integration && pnpm run test:coverage && pnpm run test:browser && pnpm run test:platform",
86-
"test:unit": "node --test tests/access-token.test.mjs tests/agent-config.test.mjs tests/agent-exclusions.test.mjs tests/agent-review.test.mjs tests/automation-trust.test.mjs tests/cache.test.mjs tests/cli-args.test.mjs tests/cli-docs.test.mjs tests/coding-agents.test.mjs tests/data-contract.test.mjs tests/doctor.test.mjs tests/landing-demo.test.mjs tests/package-manifest.test.mjs tests/plan-ledger.test.mjs tests/presenter-runtime.test.mjs tests/product-gate.test.mjs tests/release.test.mjs tests/review-chat.test.mjs tests/summary-path.test.mjs tests/support-record.test.mjs tests/test-lanes.test.mjs tests/tool-profiles.test.mjs",
86+
"test:unit": "node --test tests/access-token.test.mjs tests/agent-config.test.mjs tests/agent-exclusions.test.mjs tests/agent-review.test.mjs tests/automation-trust.test.mjs tests/cache.test.mjs tests/cli-args.test.mjs tests/cli-docs.test.mjs tests/coding-agents.test.mjs tests/data-contract.test.mjs tests/doctor.test.mjs tests/landing-demo.test.mjs tests/package-manifest.test.mjs tests/plan-ledger.test.mjs tests/presenter-runtime.test.mjs tests/product-gate.test.mjs tests/release-workflow.test.mjs tests/release.test.mjs tests/review-chat.test.mjs tests/summary-path.test.mjs tests/support-record.test.mjs tests/test-lanes.test.mjs tests/tool-profiles.test.mjs",
8787
"test:integration": "pnpm run build && node --test --test-concurrency=1 tests/dev.test.mjs tests/generate-summaries.test.mjs tests/live-update-speed.test.mjs tests/performance-gate.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/presenter-recovery.test.mjs tests/remote-targets.test.mjs tests/rendered-html.test.mjs tests/serve-built.test.mjs tests/setup-environments.test.mjs",
8888
"test:coverage": "pnpm run build && c8 node --test --test-concurrency=1 tests/cli-args.test.mjs tests/generate-summaries.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/remote-targets.test.mjs tests/serve-built.test.mjs",
8989
"test:browser": "pnpm run build && node --test tests/browser/*.test.mjs",

0 commit comments

Comments
 (0)