Skip to content

Commit 736138b

Browse files
SisyphusZhengDevBot
andauthored
feat(release): migrate npm publication to Trusted Publishing/OIDC (#1187 B2.12) (#1293)
Remove the long-lived NPM_TOKEN/.npmrc auth path from autoflow-release.yml and gate npm publication capability on the GitHub Actions OIDC lane instead of token env vars. The release job pins an explicit npm CLI floor (npm@^11.5.1, verified at runtime) because Node 22's bundled npm predates native OIDC support. Publish keeps --provenance in the Actions lane. The no-token shape is mechanically enforced: pr-ci-workflow.test.ts fails closed on any NPM_TOKEN/NODE_AUTH_TOKEN/_authToken reference in the release workflow and requires id-token: write plus the npm floor step before publish. npm-side trusted-publisher registration for the five @OpenElement packages is a maintainer web precondition; see docs/runbooks/npm-trusted-publishing.md. Beta.2 slice of #1187 (B2.12 on #1288). Co-authored-by: DevBot <devbot@openelement.dev>
1 parent 09ddedd commit 736138b

8 files changed

Lines changed: 187 additions & 33 deletions

File tree

.github/workflows/autoflow-release.yml

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ jobs:
2626
timeout-minutes: 120
2727
permissions:
2828
contents: write
29+
# npm Trusted Publishing/OIDC (#1187): id-token is the ONLY npm
30+
# credential. Requires per-package trusted-publisher registration on
31+
# npmjs.com first — see docs/runbooks/npm-trusted-publishing.md.
2932
id-token: write
3033
# #997 / ADR-0134: the release-tier fullstack:evidence-freshness gate
3134
# reads the run history (scheduled and workflow_dispatch) of the
@@ -44,7 +47,25 @@ jobs:
4447
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
4548
with:
4649
node-version: '22'
50+
# Registry config only (provenance + publish target); auth comes
51+
# exclusively from npm Trusted Publishing/OIDC (#1187).
4752
registry-url: 'https://registry.npmjs.org'
53+
- name: Upgrade npm CLI for Trusted Publishing
54+
# #1187 (Beta.2 slice, B2.12): publication authenticates via npm
55+
# Trusted Publishing. The npm CLI performs the GitHub Actions OIDC
56+
# exchange natively only from 11.5.1; Node 22's bundled npm is older,
57+
# and switching the whole release lane to Node 24 would still leave
58+
# the floor to whatever npm that image bundles, so the floor is
59+
# pinned explicitly here and verified before publish runs.
60+
run: |
61+
npm install -g "npm@^11.5.1"
62+
actual="$(npm --version)"
63+
minimum="11.5.1"
64+
if [ "$(printf '%s\n%s\n' "$minimum" "$actual" | sort -V | head -n1)" != "$minimum" ]; then
65+
echo "npm CLI $actual is below the Trusted Publishing floor $minimum"
66+
exit 1
67+
fi
68+
echo "npm CLI $actual satisfies the Trusted Publishing floor ($minimum)"
4869
- name: Install Playwright browsers
4970
# All three engines: the release tier includes fixture:request-time:gate,
5071
# which runs the request-time fixture suite on Chromium, Firefox and
@@ -54,12 +75,6 @@ jobs:
5475
run: |
5576
git config user.name "github-actions[bot]"
5677
git config user.email "github-actions[bot]@users.noreply.github.com"
57-
- name: Configure npm auth
58-
env:
59-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
60-
run: |
61-
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$HOME/.npmrc"
62-
echo "registry=https://registry.npmjs.org/" >> "$HOME/.npmrc"
6378
- name: Download the exact named PR CI evidence artifact
6479
# #1156 (ADR-0146): publication fails closed unless the exact-SHA PR
6580
# full-CI record, produced by the named source run for the exact HEAD
@@ -82,9 +97,15 @@ jobs:
8297
exit 1
8398
fi
8499
- name: Publish version already merged to main
100+
# PRECONDITION (#1187, maintainer web action, cannot be done in-repo):
101+
# each of the five @openelement packages (element, app, adapter-vite,
102+
# create, ui) must have this repo's GitHub Actions trusted publisher
103+
# registered on npmjs.com — repo open-element/openelement, workflow
104+
# filename autoflow-release.yml, no environment. See
105+
# docs/runbooks/npm-trusted-publishing.md. Until that registration
106+
# exists, this step fails at npm with an auth error; there is NO
107+
# token fallback by design (the long-lived npm token path is removed).
85108
env:
86-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
87-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
88109
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
89110
RELEASE_VERSION: ${{ inputs.version }}
90111
RELEASE_DRY_RUN: ${{ inputs.dry_run && '--dry-run' || '' }}

docs/adr/ADR-0108-deno-native-npm-distribution.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ openElement v0.41.0 distribution is **npm-primary distribution via `deno pack`**
8686
from the npm registry.
8787
5. **Release**: `tools/autoflow/release.ts` runs `package-artifacts:check`
8888
before `publish:npm`; GitHub Actions uses `actions/setup-node` and
89-
`secrets.NPM_TOKEN` for provenance publishing.
89+
`secrets.NPM_TOKEN` for provenance publishing. _(Superseded by #1187 in
90+
v0.44 Beta.2: publication now uses npm Trusted Publishing/OIDC — see
91+
`docs/runbooks/npm-trusted-publishing.md`; the long-lived token path is
92+
removed.)_
9093
6. **Smoke**: post-publish consumer smoke installs from npm and validates Node
9194
ESM, Deno `npm:`, jsDelivr browser-safe exports, and Nitro Node/Workers.
9295

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# npm Trusted Publishing registration (maintainer runbook)
2+
3+
#1187 (Beta.2 slice, B2.12): npm publication for the five `@openelement`
4+
packages authenticates with npm Trusted Publishing/OIDC from GitHub Actions.
5+
The long-lived npm token (`.npmrc` `_authToken` / `NPM_TOKEN` /
6+
`NODE_AUTH_TOKEN`) is removed from the release path by design; there is no
7+
token fallback.
8+
9+
The in-repo side (workflow + tooling) is complete. The steps below are the
10+
**npm-side registration**, which only a maintainer with npm web access to the
11+
`@openelement` scope can perform. **Until every package below is registered,
12+
a real (non-dry-run) release publish fails at npm with an auth error.**
13+
14+
## What to register
15+
16+
For **each** of the five packages:
17+
18+
- `@openelement/element`
19+
- `@openelement/app`
20+
- `@openelement/adapter-vite`
21+
- `@openelement/create`
22+
- `@openelement/ui`
23+
24+
register this exact trusted publisher on npmjs.com:
25+
26+
| Field | Value |
27+
| ------------------- | --------------------------------------------------------- |
28+
| Publisher type | GitHub Actions |
29+
| Organization / user | `open-element` |
30+
| Repository | `openelement` |
31+
| Workflow filename | `autoflow-release.yml` |
32+
| Environment name | _(leave blank — the workflow uses no GitHub environment)_ |
33+
34+
## Steps (per package)
35+
36+
1. Sign in to https://www.npmjs.com with an account that administers the
37+
`@openelement` scope.
38+
2. Open the package page (e.g. `https://www.npmjs.com/package/@openelement/element`)
39+
**Settings****Publishing access****Trusted publishers**.
40+
3. Choose **GitHub Actions** and enter exactly:
41+
- Organization/user: `open-element`
42+
- Repository: `openelement`
43+
- Workflow filename: `autoflow-release.yml` (filename only, no path, no
44+
`.github/workflows/` prefix)
45+
- Environment: leave empty.
46+
4. Save, then repeat for the remaining four packages.
47+
48+
## Verification
49+
50+
1. Confirm each package's Settings page lists the trusted publisher with the
51+
exact values above.
52+
2. Dispatch a **dry-run** release first (`autoflow-release.yml` with
53+
`dry_run: true`) — the dry run exercises the full plan without contacting
54+
npm for publication.
55+
3. The first real publish after registration must show the Trusted Publishing
56+
provenance attestation on each package page (npm links the Sigstore
57+
provenance bundle automatically for trusted publishes; the publish
58+
tooling also passes `--provenance` explicitly in the Actions lane).
59+
60+
## Operational notes
61+
62+
- The release job pins its own npm CLI floor (`npm install -g npm@^11.5.1`
63+
with a runtime `>=11.5.1` assertion) because Node 22's bundled npm predates
64+
native OIDC support; the floor is enforced mechanically in
65+
`tools/autoflow/__tests__/pr-ci-workflow.test.ts`.
66+
- The `NPM_TOKEN` repository secret can be deleted from GitHub after the
67+
first successful trusted publish; nothing in the repo references it.
68+
- Do not reintroduce `.npmrc` auth or token env vars into
69+
`autoflow-release.yml`; the workflow test above fails closed on any
70+
`NPM_TOKEN` / `NODE_AUTH_TOKEN` / `_authToken` reference.

tools/autoflow/__tests__/policy.test.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -277,15 +277,18 @@ Deno.test('release: superseded theme is only recorded on a real version change',
277277
);
278278
});
279279

280-
Deno.test('release: local plan includes publish, smoke, gates, and GitHub release when credentials are present', () => {
281-
// Simulate a local/manual environment that has the credentials required for
282-
// npm publish and GitHub release creation. Force CI off so the plan follows
283-
// the dev -> main path.
280+
Deno.test('release: local plan runs gates and GitHub release but never publishes npm (OIDC-only publication)', () => {
281+
// #1187: npm publication authenticates exclusively via Trusted
282+
// Publishing/OIDC, which exists only in the GitHub Actions release lane.
283+
// A local/manual run never includes the npm publish steps — even when a
284+
// legacy token variable is set in the environment, it is ignored.
284285
const originalNpmToken = Deno.env.get('NPM_TOKEN');
285286
const originalGitHubToken = Deno.env.get('GITHUB_TOKEN');
287+
const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS');
286288
const originalCi = Deno.env.get('CI');
287289
Deno.env.set('NPM_TOKEN', 'test-token');
288290
Deno.env.set('GITHUB_TOKEN', 'test-token');
291+
Deno.env.delete('GITHUB_ACTIONS');
289292
Deno.env.delete('CI');
290293
try {
291294
const commands = createReleasePlan('0.39.1').map((step) => [
@@ -296,28 +299,31 @@ Deno.test('release: local plan includes publish, smoke, gates, and GitHub releas
296299
assert(commands.some(([name]) => name === 'package artifact gate'));
297300
assert(commands.some(([name]) => name === 'push dev'));
298301
assert(commands.some(([name]) => name === 'sync main from dev (fast-forward)'));
299-
assert(
302+
assertFalse(
300303
commands.some(([, command]) => command.includes('deno task publish:npm')),
304+
'local runs must not publish to npm: publication is OIDC-only in the Actions lane (#1187)',
301305
);
302-
assert(
303-
commands.some(([, command]) => command.includes('tools/consumer-smoke.ts --version 0.39.1')),
304-
);
306+
assertFalse(commands.some(([name]) => name === 'post-publish npm consumer smoke'));
305307
assert(commands.some(([name]) => name === 'create GitHub release'));
306308
} finally {
307309
if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN');
308310
else Deno.env.set('NPM_TOKEN', originalNpmToken);
309311
if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN');
310312
else Deno.env.set('GITHUB_TOKEN', originalGitHubToken);
313+
if (originalGitHubActions === undefined) Deno.env.delete('GITHUB_ACTIONS');
314+
else Deno.env.set('GITHUB_ACTIONS', originalGitHubActions);
311315
if (originalCi === undefined) Deno.env.delete('CI');
312316
else Deno.env.set('CI', originalCi);
313317
}
314318
});
315319

316320
Deno.test('release: CI plan publishes from main without touching dev', () => {
317-
const originalNpmToken = Deno.env.get('NPM_TOKEN');
321+
// Publication capability in CI comes from the Actions OIDC environment
322+
// (npm Trusted Publishing, #1187), not from a token variable.
323+
const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS');
318324
const originalGitHubToken = Deno.env.get('GITHUB_TOKEN');
319325
const originalCi = Deno.env.get('CI');
320-
Deno.env.set('NPM_TOKEN', 'test-token');
326+
Deno.env.set('GITHUB_ACTIONS', 'true');
321327
Deno.env.set('GITHUB_TOKEN', 'test-token');
322328
Deno.env.set('CI', 'true');
323329
try {
@@ -338,23 +344,22 @@ Deno.test('release: CI plan publishes from main without touching dev', () => {
338344
assert(names.includes('push tag'));
339345
assert(names.includes('create GitHub release'));
340346
} finally {
341-
if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN');
342-
else Deno.env.set('NPM_TOKEN', originalNpmToken);
347+
if (originalGitHubActions === undefined) Deno.env.delete('GITHUB_ACTIONS');
348+
else Deno.env.set('GITHUB_ACTIONS', originalGitHubActions);
343349
if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN');
344350
else Deno.env.set('GITHUB_TOKEN', originalGitHubToken);
345351
if (originalCi === undefined) Deno.env.delete('CI');
346352
else Deno.env.set('CI', originalCi);
347353
}
348354
});
349355

350-
Deno.test('release: patch release plan omits publish and GitHub release without credentials', () => {
351-
const originalNpmToken = Deno.env.get('NPM_TOKEN');
356+
Deno.test('release: patch release plan omits publish and GitHub release outside the Actions lane', () => {
357+
// No GitHub credentials and no Actions OIDC environment: neither npm
358+
// publish nor GitHub release creation may enter the plan (#1187).
352359
const originalGitHubToken = Deno.env.get('GITHUB_TOKEN');
353360
const originalGhToken = Deno.env.get('GH_TOKEN');
354361
const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS');
355362
const originalCi = Deno.env.get('CI');
356-
Deno.env.delete('NPM_TOKEN');
357-
Deno.env.delete('NODE_AUTH_TOKEN');
358363
Deno.env.delete('GITHUB_TOKEN');
359364
Deno.env.delete('GH_TOKEN');
360365
Deno.env.delete('GITHUB_ACTIONS');
@@ -366,8 +371,6 @@ Deno.test('release: patch release plan omits publish and GitHub release without
366371
assert(names.includes('tag release'));
367372
assert(names.includes('push tag'));
368373
} finally {
369-
if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN');
370-
else Deno.env.set('NPM_TOKEN', originalNpmToken);
371374
if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN');
372375
else Deno.env.set('GITHUB_TOKEN', originalGitHubToken);
373376
if (originalGhToken === undefined) Deno.env.delete('GH_TOKEN');

tools/autoflow/__tests__/pr-ci-workflow.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,51 @@ Deno.test('R7 probe 5: the publication workflow retrieves the named artifact and
132132
'the evidence artifact must be downloaded before publication runs',
133133
);
134134
});
135+
136+
Deno.test('B2.12 (#1187): publication authenticates with npm Trusted Publishing/OIDC, never a long-lived token', async () => {
137+
const source = await Deno.readTextFile(RELEASE_WORKFLOW_PATH);
138+
const doc = parse(source);
139+
const job = jobsOf(doc)['release'];
140+
assert(job, 'autoflow-release.yml lacks the release job');
141+
// The OIDC token endpoint is the only npm credential: no long-lived token
142+
// secret, no NODE_AUTH_TOKEN, and no .npmrc auth anywhere in the workflow.
143+
const permissions = (job as unknown as { permissions?: Record<string, unknown> }).permissions;
144+
assertEquals(permissions?.['id-token'], 'write', 'the release job must grant id-token: write');
145+
for (const forbidden of ['NPM_TOKEN', 'NODE_AUTH_TOKEN', '_authToken', 'secrets.NPM_TOKEN']) {
146+
assert(
147+
!source.includes(forbidden),
148+
`autoflow-release.yml must not reference ${forbidden}: the long-lived-token publish path is removed (#1187)`,
149+
);
150+
}
151+
// npm CLI native OIDC support has a hard floor (11.5.1); the workflow must
152+
// upgrade the CLI explicitly rather than trusting whatever npm the Node
153+
// image bundles.
154+
assertStringIncludes(
155+
source,
156+
'npm install -g "npm@^11.5.1"',
157+
'the job must pin an explicit npm CLI upgrade at the OIDC floor',
158+
);
159+
assertStringIncludes(
160+
source,
161+
'11.5.1',
162+
'the npm CLI floor for Trusted Publishing must be recorded',
163+
);
164+
const steps = job.steps ?? [];
165+
const upgrade = steps.findIndex((step) => String(step.run ?? '').includes('npm install -g'));
166+
const publish = steps.findIndex((step) =>
167+
String(step.run ?? '').includes('autoflow:publish-existing')
168+
);
169+
assert(upgrade !== -1 && publish !== -1, 'npm upgrade and publish steps must both exist');
170+
assert(upgrade < publish, 'the npm CLI upgrade must run before the publish step');
171+
const publishStep = steps[publish];
172+
const publishEnv = (publishStep.env ?? {}) as Record<string, unknown>;
173+
for (const key of Object.keys(publishEnv)) {
174+
assert(
175+
key !== 'NPM_TOKEN' && key !== 'NODE_AUTH_TOKEN',
176+
`the publish step must not pass ${key}`,
177+
);
178+
}
179+
// registry-url stays: provenance and the publish target need the explicit
180+
// npmjs registry configuration (registry config, not auth).
181+
assertStringIncludes(source, "registry-url: 'https://registry.npmjs.org'");
182+
});

tools/autoflow/__tests__/release.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,9 +361,11 @@ Deno.test('R9: preparation runs the fast tier only, never the local full matrix'
361361
});
362362

363363
Deno.test('two-phase release: publish-existing never bumps and verifies main CI first', () => {
364-
const originalNpmToken = Deno.env.get('NPM_TOKEN');
364+
// The publish-existing plan publishes in the Actions OIDC lane (#1187):
365+
// GITHUB_ACTIONS, not a token variable, gates the npm publish steps.
366+
const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS');
365367
const originalGitHubToken = Deno.env.get('GITHUB_TOKEN');
366-
Deno.env.set('NPM_TOKEN', 'test-token');
368+
Deno.env.set('GITHUB_ACTIONS', 'true');
367369
Deno.env.set('GITHUB_TOKEN', 'test-token');
368370
try {
369371
const steps = createPublishExistingPlan('0.41.0-alpha.11');
@@ -380,8 +382,8 @@ Deno.test('two-phase release: publish-existing never bumps and verifies main CI
380382
);
381383
assertFalse(names.includes('bump patch version'));
382384
} finally {
383-
if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN');
384-
else Deno.env.set('NPM_TOKEN', originalNpmToken);
385+
if (originalGitHubActions === undefined) Deno.env.delete('GITHUB_ACTIONS');
386+
else Deno.env.set('GITHUB_ACTIONS', originalGitHubActions);
385387
if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN');
386388
else Deno.env.set('GITHUB_TOKEN', originalGitHubToken);
387389
}

tools/autoflow/release.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,8 +809,13 @@ function canCreateGitHubRelease(): boolean {
809809
}
810810

811811
function canPublishNpm(): boolean {
812-
// npm publish needs an access token. In CI it comes from secrets.NPM_TOKEN.
813-
return isTruthyEnv('NPM_TOKEN') || isTruthyEnv('NODE_AUTH_TOKEN');
812+
// #1187 (Beta.2): npm publication authenticates via Trusted
813+
// Publishing/OIDC, which exists only in the GitHub Actions release lane
814+
// (autoflow-release.yml grants id-token: write and upgrades the npm CLI to
815+
// the >=11.5.1 OIDC floor). The long-lived token path is removed — a local
816+
// or manual release never publishes to npm, even if a legacy token
817+
// variable happens to be set.
818+
return Deno.env.get('GITHUB_ACTIONS') === 'true';
814819
}
815820

816821
/**

tools/publish-npm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ export async function publishPackage(
240240
? ['publish', tar, '--dry-run', '--access', 'public']
241241
: ['publish', tar, '--access', 'public'];
242242
// Provenance requires GitHub Actions OIDC; skip locally and on other CI providers.
243+
// #1187: in the Actions lane, auth is npm Trusted Publishing (no token);
244+
// `--provenance` stays explicit so the attestation intent is visible here.
243245
if (!dryRun && Deno.env.get('GITHUB_ACTIONS') === 'true') {
244246
args.push('--provenance');
245247
}

0 commit comments

Comments
 (0)