Skip to content

Commit db821b5

Browse files
proggeramlugRalph Küpper
andauthored
fix(release): publish through existing OIDC workflow before tag (#8804)
Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent b3e88f1 commit db821b5

22 files changed

Lines changed: 912 additions & 1992 deletions

.github/workflows/npm-stage-publish.yml

Lines changed: 0 additions & 394 deletions
This file was deleted.

.github/workflows/release-packages.yml

Lines changed: 181 additions & 81 deletions
Large diffs are not rendered by default.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
## Release engineering
2+
3+
- Publish all nine npm artifacts directly from GitHub Actions through npm
4+
Trusted Publisher/OIDC. The release path no longer needs an npm login, npm
5+
token, staged-package approval, or a local Socket credential.
6+
- Pack once in the existing `release-packages.yml` workflow, optionally
7+
Socket-scan those exact tarballs, publish the same bytes, and verify their public
8+
registry shasums before creating the version tag and GitHub Release last.

docs/src/contributing/releasing.md

Lines changed: 70 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ release gate.
88
## 1. Pre-release checklist (every release)
99

1010
Start from a clean checkout that contains current `origin/main`. Do not release
11-
from a detached HEAD or from a moving `main` branch. The staged pipeline records
12-
the candidate branch, commit SHA, and build run, then refuses to approve or tag
13-
if any of them changes.
11+
from a detached HEAD or from a moving `main` branch. The npm-first pipeline
12+
pins the candidate branch and commit SHA, retains the exact npm tarballs, and
13+
refuses to tag if the branch moves or any public registry shasum differs.
1414

1515
```bash
1616
# Confirm the checkout is current and clean.
@@ -61,27 +61,31 @@ gh workflow run test.yml --ref "release/v$VERSION" -f tier=full
6161
gh workflow run simctl-tests.yml --ref "release/v$VERSION"
6262
```
6363

64-
Then use the staged pipeline:
65-
66-
The local publish/approve process requires npm 11.17 or newer and a valid
67-
`SOCKET_API_TOKEN` for the mandatory tarball scan. CI uses OIDC for staging;
68-
do not set a long-lived npm publish token. The local npm account must be a
69-
maintainer of all nine packages because it lists and approves their staged
70-
entries.
71-
72-
```bash
73-
npm --version # must be >= 11.17.0
74-
# If needed: npm install -g npm@latest
75-
npm whoami # must succeed as an @perryts package maintainer
76-
```
77-
78-
One-time GitHub setup: create the environment named in the OIDC identity and
79-
store the Socket credential at environment scope (the secret value is entered
80-
interactively and must never be committed):
64+
Freeze that candidate SHA. Unrelated merges to `main` after the branch is cut do
65+
not invalidate successful gates and do not require another test cycle. Refresh
66+
the candidate only for a release-blocking fix or a required version change.
67+
68+
Then use the npm-first pipeline. npm publication happens only in GitHub Actions
69+
through Trusted Publisher/OIDC. There is no `npm login`, npm account session,
70+
2FA approval, or long-lived npm token in CI or on the maintainer machine. The
71+
local command uses GitHub authentication to dispatch/watch the workflow and
72+
anonymous registry reads to verify the published bytes.
73+
74+
Socket is an optional pre-publish tarball scan, not an npm credential. When
75+
enabled, Actions submits each of the exact nine `.tgz` files as a temporary
76+
Socket full scan and evaluates the results against the `perryts` Socket
77+
organization's security policy. Policy actions configured as `error` block
78+
publication; `warn` findings are recorded but do not block. The same tarballs
79+
that pass are then sent to npm. For the current release, omit `--socket-scan`;
80+
the workflow records an explicit skipped receipt and continues.
81+
82+
To enable the gate later, store `SOCKET_API_TOKEN` as a repository or
83+
organization Actions secret with permission to create/read full scans, read the
84+
organization policy, and read quota. No GitHub Environment is required. The
85+
value stays only in GitHub and must never be committed or exported locally:
8186

8287
```bash
83-
gh api --method PUT repos/PerryTS/perry/environments/npm-publish
84-
gh secret set SOCKET_API_TOKEN --repo PerryTS/perry --env npm-publish
88+
gh secret set SOCKET_API_TOKEN --repo PerryTS/perry
8589
```
8690

8791
Before the first nine-package release, an npm organization owner must confirm
@@ -90,51 +94,51 @@ Trusted Publisher configuration:
9094

9195
- provider: GitHub Actions
9296
- organization/repository: `PerryTS/perry`
93-
- workflow filename: `npm-stage-publish.yml`
94-
- environment: `npm-publish`
95-
- allowed action: **`npm stage publish`**
97+
- workflow filename: `release-packages.yml`
98+
- environment: none
99+
- allowed action: **`npm publish`**
96100

97-
npm permits only one trusted publisher per package. Configurations created
98-
before May 20, 2026 were carried forward with only direct **`npm publish`**
99-
allowed, so edit every existing package and explicitly enable
100-
**`npm stage publish`**; merely seeing a trusted publisher entry is not enough.
101-
The old `release-packages.yml` direct-publish path cannot occupy a second
102-
trusted-publisher slot and is not part of the canonical release.
101+
npm permits only one trusted publisher per package. The canonical identity is
102+
the existing `release-packages.yml` workflow above, with direct **`npm
103+
publish`** enabled. Do not configure `npm-stage-publish.yml` as a second
104+
publisher and do not add an environment name that the other packages do not
105+
use.
103106

104107
In particular, verify the ARM64 Windows package:
105108

106109
```bash
107110
npm view @perryts/perry-win32-arm64 name
108111
```
109112

110-
If that returns `E404`, an `@perryts` npm owner must make the initial public
111-
name-reservation publish (the repository permits version `0.0.0` only for this
112-
bootstrap), then configure the same Trusted Publisher fields above. The
113-
pipeline intentionally refuses a partial set.
113+
If that returns `E404`, stop: npm does not allow a Trusted Publisher to be
114+
configured for a package that does not exist, so OIDC cannot perform its first
115+
publish. An `@perryts` owner must provision that package name once before this
116+
nine-package OIDC-only flow can work, then configure the Trusted Publisher
117+
fields above. The release pipeline intentionally refuses a partial set.
114118

115119
```bash
116-
npm run publish:stage # CI builds all platforms, stages 9 npm packages,
117-
# verifies sha1, runs the mandatory Socket scan,
118-
# and downloads the exact proof tarballs locally
119-
npm run publish:status # inspect the commit/run/package receipt
120-
npm run publish:approve # explicit 2FA promote; waits for registry liveness
121-
# and only then creates v0.x.y + the GitHub Release
120+
npm run publish:release # one Release Packages run: exact-SHA gates/builds,
121+
# publish + verify all 9 via OIDC, then create
122+
# v0.x.y + the GitHub Release last
123+
# Later, once the repository/org Socket secret exists:
124+
npm run publish:release -- --socket-scan
125+
npm run publish:status # inspect the commit/run/package/Socket receipt
122126
```
123127

124-
Approval promotes nine packages sequentially. If 2FA or the network interrupts
125-
that loop after some packages are already public, re-run `publish:approve` with
126-
the same retained CI proof. It resumes only when each already-public package's
127-
immutable registry shasum matches the exact CI tarball; otherwise use a new
128-
version. Do not discard the proof directory until the tag and release exist.
128+
Publication sends the eight platform packages first and the wrapper last. If a
129+
network or registry error interrupts it before the tag exists, rerun the failed
130+
jobs (or `publish:release`) on the same candidate. Actions skips an
131+
already-public package only when its immutable registry shasum matches the exact
132+
CI tarball; otherwise it stops and requires a new version.
129133

130134
If the accumulated changelog fragments exceed the inline release-note budget,
131135
the publisher keeps the GitHub Release body concise and uploads the complete
132136
notes as the checksummed `release-notes-full.md` asset. No fragment is dropped.
133137

134-
Do not run `git tag`, manually publish a GitHub Release, or use the legacy
135-
`release-packages.yml cut_release=true` route for a normal release. That older
136-
route creates the tag before npm publication; it does not satisfy the stricter
137-
registry-first/tag-last contract.
138+
Do not run `npm login`, `git tag`, or manually publish a GitHub Release. The
139+
local command dispatches `release-packages.yml` with `cut_release=true`; that
140+
workflow creates the tag only after all nine npm versions are public and their
141+
registry shasums match the exact CI tarballs.
138142

139143
## 2. Additional major-release verification
140144

@@ -212,17 +216,17 @@ Artifacts are published to:
212216
4. **winget** — manifest auto-update
213217
5. **hub.perryts.com** — worker notification so cloud build workers refresh
214218

215-
In the canonical staged flow, any failing host or cross build prevents all npm
216-
staging, so no partial package set is promoted. Once a version has become public,
217-
fix-forward with a new patch version rather than amending an existing tag.
219+
In the canonical npm-first flow, any failing host or cross build prevents npm
220+
publication. The wrapper is not published when a platform package fails. Once a
221+
version has become public, fix-forward with a new patch version rather than
222+
amending an existing tag.
218223

219224
## 4. Release gates (what blocks a release)
220225

221-
`npm-stage-publish.yml` rejects a real stage unless `test.yml` has a successful
226+
`release-packages.yml` rejects a cut release unless `test.yml` has a successful
222227
**`full-suite-gate`** and `simctl-tests.yml` has a successful run on the exact
223-
candidate SHA. A green PR-tier or push-to-main sweep does *not* count. The same
224-
two gates are enforced by `release-packages.yml`'s legacy release path. See
225-
[CI tiers](../testing/ci-tiers.md). The full tier is:
228+
candidate SHA. A green PR-tier or push-to-main sweep does *not* count. See [CI
229+
tiers](../testing/ci-tiers.md). The full tier is:
226230

227231
- everything the PR gate and the post-merge sweep run (`lint`, `check`, `warnings`,
228232
`cargo test --workspace`, the gap suite, `gc-stress`, Windows x64 + ARM64 builds,
@@ -247,11 +251,12 @@ candidate's fault, fix it on `main` first (or open an issue and consciously
247251
re-add a job-level `continue-on-error: true` with that issue number) — do not
248252
publish past it.
249253

250-
The staging workflow then requires every host/cross package build, all nine npm
251-
stages, sha1 verification, and the Socket scan. `benchmark.yml`, docs, container
252-
tests, Homebrew, APT, winget, and worker refresh are tag riders or distribution
253-
steps: monitor them after the GitHub Release is created, but do not mistake them
254-
for pre-tag gates.
254+
The release workflow then requires every host/cross package build, all nine
255+
exact npm publishes, and public-registry sha1 verification. Socket is an
256+
optional pre-publish gate and is currently skipped. `benchmark.yml`, docs,
257+
container tests, Homebrew, APT, winget, and worker refresh are tag riders or
258+
distribution steps: monitor them after the GitHub Release is created, but do
259+
not mistake them for pre-tag gates.
255260

256261
## 4a. What tells you a release is overdue
257262

@@ -290,9 +295,9 @@ a release has been cut is not something a PR author can fix.
290295

291296
- **Wrong artifact published**: tag a new patch release with the fix; npm
292297
rejects re-publishes of the same version anyway.
293-
- **Broken build before approval**: fix it and stage the complete nine-package
294-
set again; the canonical flow will not promote a partial set.
295-
- **Broken binary discovered after approval**: ship a follow-up patch version;
298+
- **Broken build before npm publication**: fix it and rebuild the complete
299+
nine-package set; the canonical flow will not tag a partial set.
300+
- **Broken binary discovered after publication**: ship a follow-up patch version;
296301
neither npm versions nor release tags are mutable.
297302
- **A post-tag distribution hook failed**: re-run the failed workflow. To retry
298303
the legacy release-packages distribution legs, dispatch it with

package.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,8 @@
88
"tools:fix": "node scripts/soak/external-tools.mts --fix",
99
"tools:install": "node scripts/soak/external-tools.mts --install-all --shims",
1010
"test:scripts": "node --test scripts/soak/*.test.mts scripts/publish/*.test.mts",
11-
"publish:pipeline": "node scripts/publish/pipeline.mts",
12-
"publish:stage": "node scripts/publish/pipeline.mts --stage-only",
13-
"publish:scan": "node scripts/publish/pipeline.mts --scan-only",
14-
"publish:approve": "node scripts/publish/pipeline.mts --approve",
11+
"publish:release": "node scripts/publish/pipeline.mts --publish",
1512
"publish:status": "node scripts/publish/pipeline.mts --status",
16-
"publish:release": "node scripts/publish/pipeline.mts --release-only",
1713
"publish:brew": "node scripts/publish/brew/tap-publish.mts",
1814
"publish:ffi": "node scripts/publish/cargo/ffi-publish.mts",
1915
"prepublishOnly": "node scripts/publish/auth-posture.mts --guard"

scripts/check_node_version_consistency.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -174,18 +174,6 @@ def _workflow_pins(text: str) -> list[str]:
174174
),
175175
locator=_workflow_pins,
176176
),
177-
Exemption(
178-
path=".github/workflows/npm-stage-publish.yml",
179-
value="26",
180-
major_tracks_oracle=True,
181-
reason=(
182-
"npm *publishing* toolchain (OIDC staged-upload + npm stage "
183-
"publishing), never a test oracle. Major literal for the same "
184-
"reason as release-packages.yml -- a gap-suite oracle bump must "
185-
"not move the runtime that publishes releases."
186-
),
187-
locator=_workflow_pins,
188-
),
189177
Exemption(
190178
path="benchmarks/public-baseline-config.json",
191179
value="v22.23.1",

scripts/publish/auth-posture.mts

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,22 @@
11
/**
22
* @file Auth-posture gate, modeled on a tiered registry-infra design
3-
* Perry's publish is OIDC-in-CI (the npm-stage-publish
4-
* workflow, id-token: write) + browser web-OTP 2FA locally (npm stage
5-
* approve). A long-lived npm token present during a publish is a DEFECT,
6-
* not a convenience — it bypasses provenance and outlives the release.
3+
* Perry's npm publication runs only in release-packages.yml with GitHub
4+
* OIDC (`id-token: write`). There is no local npm login/approval path. A
5+
* long-lived npm token present during a publish is a DEFECT, not a
6+
* convenience — it bypasses provenance and outlives the release.
77
*
88
* - PREFLIGHT: refuse BEFORE any upload if a long-lived token is present in
99
* CI (where OIDC is the only sanctioned path).
1010
* - POSTFLIGHT: scan command output for a failed OIDC exchange that
1111
* "succeeded" anyway (ERR_PNPM_AUTH_TOKEN_EXCHANGE / Skipped OIDC) — a
1212
* publish that landed after a broken exchange is treated as a failure.
1313
*
14-
* The read-only token (PERRY_NPM_READONLY_TOKEN) is permitted everywhere —
15-
* it powers registry reads (npm view, staged-download) and can never
16-
* publish.
14+
* Public registry verification uses anonymous `npm view` reads.
1715
*/
1816

1917
import process from 'node:process'
2018

21-
import {
22-
LONG_LIVED_NPM_TOKEN_ENV_VARS,
23-
READONLY_TOKEN_ENV,
24-
} from './constants.mts'
19+
import { LONG_LIVED_NPM_TOKEN_ENV_VARS } from './constants.mts'
2520

2621
/** True when running inside GitHub Actions (the OIDC-sanctioned channel). */
2722
export function isCI(): boolean {
@@ -37,15 +32,9 @@ export const OIDC_FAILURE_MARKERS = [
3732

3833
/**
3934
* Preflight: refuse before an upload if a long-lived publish token is present.
40-
* In CI, OIDC is the only sanctioned path. Locally, a direct publish is
41-
* permitted only for the 0.0.0 name reservation (escape hatch) — real
42-
* releases stage in CI and approve locally with 2FA. A direct publish of any
43-
* OTHER version with a long-lived token present is refused, so the escape
44-
* hatch cannot be widened into a general bypass.
45-
*
46-
* Pass `version` (the manifest version being published) so the reservation
47-
* can be enforced; without it, a direct upload with a long-lived token is
48-
* refused (fail-closed — the caller must prove it is the reservation).
35+
* In CI, OIDC is the only sanctioned path. Local npm writes are never
36+
* sanctioned, including package-name reservation; provisioning a missing npm
37+
* package is an external organization-owner prerequisite, not a release path.
4938
*
5039
* Returns a non-empty string reason when the posture is refused, or undefined
5140
* when it is clean.
@@ -70,22 +59,16 @@ export function publishAuthPreflight(config: {
7059
}
7160
if (!direct) {
7261
return (
73-
`Refusing to stage with a long-lived token present: ${present.join(', ')}. ` +
74-
'Staging runs in CI under OIDC; locally only the 2FA approve is sanctioned. ' +
75-
'Unset the token and dispatch the stage workflow instead.'
62+
`Refusing to dispatch with a long-lived token present: ${present.join(', ')}. ` +
63+
'npm publication runs in GitHub Actions under OIDC. ' +
64+
'Unset the token and dispatch the release workflow instead.'
7665
)
7766
}
78-
// direct local publish with a long-lived token: permitted ONLY for the 0.0.0
79-
// name reservation escape hatch. Any other version (or an unknown version)
80-
// is refused — the escape hatch must not become a general bypass.
81-
if (version !== '0.0.0') {
82-
return (
83-
`Refusing a direct publish with a long-lived token present: ${present.join(', ')}. ` +
84-
`The direct escape hatch is reserved for the 0.0.0 name reservation only (got version ${version ?? '<unknown>'}). ` +
85-
'Real releases stage in CI under OIDC and approve locally with 2FA — unset the token and dispatch the stage workflow.'
86-
)
87-
}
88-
return undefined
67+
return (
68+
`Refusing a local direct publish with a long-lived token present: ${present.join(', ')}. ` +
69+
`Local npm writes are not sanctioned (requested version ${version ?? '<unknown>'}); ` +
70+
'real releases publish in GitHub Actions under OIDC.'
71+
)
8972
}
9073

9174
/**
@@ -99,7 +82,7 @@ export function publishAuthPostflight(output: string): boolean {
9982
/**
10083
* The prepublishOnly guard entrypoint. Refuses any `npm publish` / `npm
10184
* publish` run that carries a long-lived token, and reminds the operator to
102-
* use the staged pipeline instead.
85+
* use the Actions OIDC pipeline instead.
10386
*/
10487
export function prepublishOnlyGuard(): number {
10588
const reason = publishAuthPreflight({
@@ -115,7 +98,7 @@ export function prepublishOnlyGuard(): number {
11598
return 1
11699
}
117100
console.error(
118-
"ERROR: use `npm run publish:pipeline` (staged) — direct publish is not the sanctioned path.",
101+
"ERROR: use `npm run publish:release` — npm publication is only sanctioned in GitHub Actions with OIDC.",
119102
)
120103
return 1
121104
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/** Shared shape for the exact npm tarballs packed, scanned, and published in CI. */
2+
3+
export const CI_PACKAGE_MANIFEST = 'npm-publish-manifest.json'
4+
5+
export interface CiPackageProof {
6+
name: string
7+
version: string
8+
path: string
9+
sha1: string
10+
}
11+
12+
export interface CiPackageManifest {
13+
packages: CiPackageProof[]
14+
version: string
15+
}

0 commit comments

Comments
 (0)