From 835961ef3fe1fcbce0f4e31ae692645fef34b4ce Mon Sep 17 00:00:00 2001 From: DevBot Date: Thu, 3 Sep 2026 23:52:10 +0800 Subject: [PATCH] feat(release): migrate npm publication to Trusted Publishing/OIDC (#1187 B2.12) 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). --- .github/workflows/autoflow-release.yml | 37 +++++++--- .../ADR-0108-deno-native-npm-distribution.md | 5 +- docs/runbooks/npm-trusted-publishing.md | 70 +++++++++++++++++++ tools/autoflow/__tests__/policy.test.ts | 39 ++++++----- .../autoflow/__tests__/pr-ci-workflow.test.ts | 48 +++++++++++++ tools/autoflow/__tests__/release.test.ts | 10 +-- tools/autoflow/release.ts | 9 ++- tools/publish-npm.ts | 2 + 8 files changed, 187 insertions(+), 33 deletions(-) create mode 100644 docs/runbooks/npm-trusted-publishing.md diff --git a/.github/workflows/autoflow-release.yml b/.github/workflows/autoflow-release.yml index 8264d1cc3..627bb45a2 100644 --- a/.github/workflows/autoflow-release.yml +++ b/.github/workflows/autoflow-release.yml @@ -26,6 +26,9 @@ jobs: timeout-minutes: 120 permissions: contents: write + # npm Trusted Publishing/OIDC (#1187): id-token is the ONLY npm + # credential. Requires per-package trusted-publisher registration on + # npmjs.com first — see docs/runbooks/npm-trusted-publishing.md. id-token: write # #997 / ADR-0134: the release-tier fullstack:evidence-freshness gate # reads the run history (scheduled and workflow_dispatch) of the @@ -44,7 +47,25 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: '22' + # Registry config only (provenance + publish target); auth comes + # exclusively from npm Trusted Publishing/OIDC (#1187). registry-url: 'https://registry.npmjs.org' + - name: Upgrade npm CLI for Trusted Publishing + # #1187 (Beta.2 slice, B2.12): publication authenticates via npm + # Trusted Publishing. The npm CLI performs the GitHub Actions OIDC + # exchange natively only from 11.5.1; Node 22's bundled npm is older, + # and switching the whole release lane to Node 24 would still leave + # the floor to whatever npm that image bundles, so the floor is + # pinned explicitly here and verified before publish runs. + run: | + npm install -g "npm@^11.5.1" + actual="$(npm --version)" + minimum="11.5.1" + if [ "$(printf '%s\n%s\n' "$minimum" "$actual" | sort -V | head -n1)" != "$minimum" ]; then + echo "npm CLI $actual is below the Trusted Publishing floor $minimum" + exit 1 + fi + echo "npm CLI $actual satisfies the Trusted Publishing floor ($minimum)" - name: Install Playwright browsers # All three engines: the release tier includes fixture:request-time:gate, # which runs the request-time fixture suite on Chromium, Firefox and @@ -54,12 +75,6 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Configure npm auth - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$HOME/.npmrc" - echo "registry=https://registry.npmjs.org/" >> "$HOME/.npmrc" - name: Download the exact named PR CI evidence artifact # #1156 (ADR-0146): publication fails closed unless the exact-SHA PR # full-CI record, produced by the named source run for the exact HEAD @@ -82,9 +97,15 @@ jobs: exit 1 fi - name: Publish version already merged to main + # PRECONDITION (#1187, maintainer web action, cannot be done in-repo): + # each of the five @openelement packages (element, app, adapter-vite, + # create, ui) must have this repo's GitHub Actions trusted publisher + # registered on npmjs.com — repo open-element/openelement, workflow + # filename autoflow-release.yml, no environment. See + # docs/runbooks/npm-trusted-publishing.md. Until that registration + # exists, this step fails at npm with an auth error; there is NO + # token fallback by design (the long-lived npm token path is removed). env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ inputs.version }} RELEASE_DRY_RUN: ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/docs/adr/ADR-0108-deno-native-npm-distribution.md b/docs/adr/ADR-0108-deno-native-npm-distribution.md index b286298ca..13312bbf3 100644 --- a/docs/adr/ADR-0108-deno-native-npm-distribution.md +++ b/docs/adr/ADR-0108-deno-native-npm-distribution.md @@ -86,7 +86,10 @@ openElement v0.41.0 distribution is **npm-primary distribution via `deno pack`** from the npm registry. 5. **Release**: `tools/autoflow/release.ts` runs `package-artifacts:check` before `publish:npm`; GitHub Actions uses `actions/setup-node` and - `secrets.NPM_TOKEN` for provenance publishing. + `secrets.NPM_TOKEN` for provenance publishing. _(Superseded by #1187 in + v0.44 Beta.2: publication now uses npm Trusted Publishing/OIDC — see + `docs/runbooks/npm-trusted-publishing.md`; the long-lived token path is + removed.)_ 6. **Smoke**: post-publish consumer smoke installs from npm and validates Node ESM, Deno `npm:`, jsDelivr browser-safe exports, and Nitro Node/Workers. diff --git a/docs/runbooks/npm-trusted-publishing.md b/docs/runbooks/npm-trusted-publishing.md new file mode 100644 index 000000000..f3ed38166 --- /dev/null +++ b/docs/runbooks/npm-trusted-publishing.md @@ -0,0 +1,70 @@ +# npm Trusted Publishing registration (maintainer runbook) + +#1187 (Beta.2 slice, B2.12): npm publication for the five `@openelement` +packages authenticates with npm Trusted Publishing/OIDC from GitHub Actions. +The long-lived npm token (`.npmrc` `_authToken` / `NPM_TOKEN` / +`NODE_AUTH_TOKEN`) is removed from the release path by design; there is no +token fallback. + +The in-repo side (workflow + tooling) is complete. The steps below are the +**npm-side registration**, which only a maintainer with npm web access to the +`@openelement` scope can perform. **Until every package below is registered, +a real (non-dry-run) release publish fails at npm with an auth error.** + +## What to register + +For **each** of the five packages: + +- `@openelement/element` +- `@openelement/app` +- `@openelement/adapter-vite` +- `@openelement/create` +- `@openelement/ui` + +register this exact trusted publisher on npmjs.com: + +| Field | Value | +| ------------------- | --------------------------------------------------------- | +| Publisher type | GitHub Actions | +| Organization / user | `open-element` | +| Repository | `openelement` | +| Workflow filename | `autoflow-release.yml` | +| Environment name | _(leave blank — the workflow uses no GitHub environment)_ | + +## Steps (per package) + +1. Sign in to https://www.npmjs.com with an account that administers the + `@openelement` scope. +2. Open the package page (e.g. `https://www.npmjs.com/package/@openelement/element`) + → **Settings** → **Publishing access** → **Trusted publishers**. +3. Choose **GitHub Actions** and enter exactly: + - Organization/user: `open-element` + - Repository: `openelement` + - Workflow filename: `autoflow-release.yml` (filename only, no path, no + `.github/workflows/` prefix) + - Environment: leave empty. +4. Save, then repeat for the remaining four packages. + +## Verification + +1. Confirm each package's Settings page lists the trusted publisher with the + exact values above. +2. Dispatch a **dry-run** release first (`autoflow-release.yml` with + `dry_run: true`) — the dry run exercises the full plan without contacting + npm for publication. +3. The first real publish after registration must show the Trusted Publishing + provenance attestation on each package page (npm links the Sigstore + provenance bundle automatically for trusted publishes; the publish + tooling also passes `--provenance` explicitly in the Actions lane). + +## Operational notes + +- The release job pins its own npm CLI floor (`npm install -g npm@^11.5.1` + with a runtime `>=11.5.1` assertion) because Node 22's bundled npm predates + native OIDC support; the floor is enforced mechanically in + `tools/autoflow/__tests__/pr-ci-workflow.test.ts`. +- The `NPM_TOKEN` repository secret can be deleted from GitHub after the + first successful trusted publish; nothing in the repo references it. +- Do not reintroduce `.npmrc` auth or token env vars into + `autoflow-release.yml`; the workflow test above fails closed on any + `NPM_TOKEN` / `NODE_AUTH_TOKEN` / `_authToken` reference. diff --git a/tools/autoflow/__tests__/policy.test.ts b/tools/autoflow/__tests__/policy.test.ts index ee1e72dff..8fe1da440 100644 --- a/tools/autoflow/__tests__/policy.test.ts +++ b/tools/autoflow/__tests__/policy.test.ts @@ -277,15 +277,18 @@ Deno.test('release: superseded theme is only recorded on a real version change', ); }); -Deno.test('release: local plan includes publish, smoke, gates, and GitHub release when credentials are present', () => { - // Simulate a local/manual environment that has the credentials required for - // npm publish and GitHub release creation. Force CI off so the plan follows - // the dev -> main path. +Deno.test('release: local plan runs gates and GitHub release but never publishes npm (OIDC-only publication)', () => { + // #1187: npm publication authenticates exclusively via Trusted + // Publishing/OIDC, which exists only in the GitHub Actions release lane. + // A local/manual run never includes the npm publish steps — even when a + // legacy token variable is set in the environment, it is ignored. const originalNpmToken = Deno.env.get('NPM_TOKEN'); const originalGitHubToken = Deno.env.get('GITHUB_TOKEN'); + const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS'); const originalCi = Deno.env.get('CI'); Deno.env.set('NPM_TOKEN', 'test-token'); Deno.env.set('GITHUB_TOKEN', 'test-token'); + Deno.env.delete('GITHUB_ACTIONS'); Deno.env.delete('CI'); try { const commands = createReleasePlan('0.39.1').map((step) => [ @@ -296,28 +299,31 @@ Deno.test('release: local plan includes publish, smoke, gates, and GitHub releas assert(commands.some(([name]) => name === 'package artifact gate')); assert(commands.some(([name]) => name === 'push dev')); assert(commands.some(([name]) => name === 'sync main from dev (fast-forward)')); - assert( + assertFalse( commands.some(([, command]) => command.includes('deno task publish:npm')), + 'local runs must not publish to npm: publication is OIDC-only in the Actions lane (#1187)', ); - assert( - commands.some(([, command]) => command.includes('tools/consumer-smoke.ts --version 0.39.1')), - ); + assertFalse(commands.some(([name]) => name === 'post-publish npm consumer smoke')); assert(commands.some(([name]) => name === 'create GitHub release')); } finally { if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN'); else Deno.env.set('NPM_TOKEN', originalNpmToken); if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN'); else Deno.env.set('GITHUB_TOKEN', originalGitHubToken); + if (originalGitHubActions === undefined) Deno.env.delete('GITHUB_ACTIONS'); + else Deno.env.set('GITHUB_ACTIONS', originalGitHubActions); if (originalCi === undefined) Deno.env.delete('CI'); else Deno.env.set('CI', originalCi); } }); Deno.test('release: CI plan publishes from main without touching dev', () => { - const originalNpmToken = Deno.env.get('NPM_TOKEN'); + // Publication capability in CI comes from the Actions OIDC environment + // (npm Trusted Publishing, #1187), not from a token variable. + const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS'); const originalGitHubToken = Deno.env.get('GITHUB_TOKEN'); const originalCi = Deno.env.get('CI'); - Deno.env.set('NPM_TOKEN', 'test-token'); + Deno.env.set('GITHUB_ACTIONS', 'true'); Deno.env.set('GITHUB_TOKEN', 'test-token'); Deno.env.set('CI', 'true'); try { @@ -338,8 +344,8 @@ Deno.test('release: CI plan publishes from main without touching dev', () => { assert(names.includes('push tag')); assert(names.includes('create GitHub release')); } finally { - if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN'); - else Deno.env.set('NPM_TOKEN', originalNpmToken); + if (originalGitHubActions === undefined) Deno.env.delete('GITHUB_ACTIONS'); + else Deno.env.set('GITHUB_ACTIONS', originalGitHubActions); if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN'); else Deno.env.set('GITHUB_TOKEN', originalGitHubToken); if (originalCi === undefined) Deno.env.delete('CI'); @@ -347,14 +353,13 @@ Deno.test('release: CI plan publishes from main without touching dev', () => { } }); -Deno.test('release: patch release plan omits publish and GitHub release without credentials', () => { - const originalNpmToken = Deno.env.get('NPM_TOKEN'); +Deno.test('release: patch release plan omits publish and GitHub release outside the Actions lane', () => { + // No GitHub credentials and no Actions OIDC environment: neither npm + // publish nor GitHub release creation may enter the plan (#1187). const originalGitHubToken = Deno.env.get('GITHUB_TOKEN'); const originalGhToken = Deno.env.get('GH_TOKEN'); const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS'); const originalCi = Deno.env.get('CI'); - Deno.env.delete('NPM_TOKEN'); - Deno.env.delete('NODE_AUTH_TOKEN'); Deno.env.delete('GITHUB_TOKEN'); Deno.env.delete('GH_TOKEN'); Deno.env.delete('GITHUB_ACTIONS'); @@ -366,8 +371,6 @@ Deno.test('release: patch release plan omits publish and GitHub release without assert(names.includes('tag release')); assert(names.includes('push tag')); } finally { - if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN'); - else Deno.env.set('NPM_TOKEN', originalNpmToken); if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN'); else Deno.env.set('GITHUB_TOKEN', originalGitHubToken); if (originalGhToken === undefined) Deno.env.delete('GH_TOKEN'); diff --git a/tools/autoflow/__tests__/pr-ci-workflow.test.ts b/tools/autoflow/__tests__/pr-ci-workflow.test.ts index a040667c1..595e1f41a 100644 --- a/tools/autoflow/__tests__/pr-ci-workflow.test.ts +++ b/tools/autoflow/__tests__/pr-ci-workflow.test.ts @@ -132,3 +132,51 @@ Deno.test('R7 probe 5: the publication workflow retrieves the named artifact and 'the evidence artifact must be downloaded before publication runs', ); }); + +Deno.test('B2.12 (#1187): publication authenticates with npm Trusted Publishing/OIDC, never a long-lived token', async () => { + const source = await Deno.readTextFile(RELEASE_WORKFLOW_PATH); + const doc = parse(source); + const job = jobsOf(doc)['release']; + assert(job, 'autoflow-release.yml lacks the release job'); + // The OIDC token endpoint is the only npm credential: no long-lived token + // secret, no NODE_AUTH_TOKEN, and no .npmrc auth anywhere in the workflow. + const permissions = (job as unknown as { permissions?: Record }).permissions; + assertEquals(permissions?.['id-token'], 'write', 'the release job must grant id-token: write'); + for (const forbidden of ['NPM_TOKEN', 'NODE_AUTH_TOKEN', '_authToken', 'secrets.NPM_TOKEN']) { + assert( + !source.includes(forbidden), + `autoflow-release.yml must not reference ${forbidden}: the long-lived-token publish path is removed (#1187)`, + ); + } + // npm CLI native OIDC support has a hard floor (11.5.1); the workflow must + // upgrade the CLI explicitly rather than trusting whatever npm the Node + // image bundles. + assertStringIncludes( + source, + 'npm install -g "npm@^11.5.1"', + 'the job must pin an explicit npm CLI upgrade at the OIDC floor', + ); + assertStringIncludes( + source, + '11.5.1', + 'the npm CLI floor for Trusted Publishing must be recorded', + ); + const steps = job.steps ?? []; + const upgrade = steps.findIndex((step) => String(step.run ?? '').includes('npm install -g')); + const publish = steps.findIndex((step) => + String(step.run ?? '').includes('autoflow:publish-existing') + ); + assert(upgrade !== -1 && publish !== -1, 'npm upgrade and publish steps must both exist'); + assert(upgrade < publish, 'the npm CLI upgrade must run before the publish step'); + const publishStep = steps[publish]; + const publishEnv = (publishStep.env ?? {}) as Record; + for (const key of Object.keys(publishEnv)) { + assert( + key !== 'NPM_TOKEN' && key !== 'NODE_AUTH_TOKEN', + `the publish step must not pass ${key}`, + ); + } + // registry-url stays: provenance and the publish target need the explicit + // npmjs registry configuration (registry config, not auth). + assertStringIncludes(source, "registry-url: 'https://registry.npmjs.org'"); +}); diff --git a/tools/autoflow/__tests__/release.test.ts b/tools/autoflow/__tests__/release.test.ts index 0d5897521..3a0f0779a 100644 --- a/tools/autoflow/__tests__/release.test.ts +++ b/tools/autoflow/__tests__/release.test.ts @@ -361,9 +361,11 @@ Deno.test('R9: preparation runs the fast tier only, never the local full matrix' }); Deno.test('two-phase release: publish-existing never bumps and verifies main CI first', () => { - const originalNpmToken = Deno.env.get('NPM_TOKEN'); + // The publish-existing plan publishes in the Actions OIDC lane (#1187): + // GITHUB_ACTIONS, not a token variable, gates the npm publish steps. + const originalGitHubActions = Deno.env.get('GITHUB_ACTIONS'); const originalGitHubToken = Deno.env.get('GITHUB_TOKEN'); - Deno.env.set('NPM_TOKEN', 'test-token'); + Deno.env.set('GITHUB_ACTIONS', 'true'); Deno.env.set('GITHUB_TOKEN', 'test-token'); try { 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 ); assertFalse(names.includes('bump patch version')); } finally { - if (originalNpmToken === undefined) Deno.env.delete('NPM_TOKEN'); - else Deno.env.set('NPM_TOKEN', originalNpmToken); + if (originalGitHubActions === undefined) Deno.env.delete('GITHUB_ACTIONS'); + else Deno.env.set('GITHUB_ACTIONS', originalGitHubActions); if (originalGitHubToken === undefined) Deno.env.delete('GITHUB_TOKEN'); else Deno.env.set('GITHUB_TOKEN', originalGitHubToken); } diff --git a/tools/autoflow/release.ts b/tools/autoflow/release.ts index a249bbdd9..1005d51d5 100644 --- a/tools/autoflow/release.ts +++ b/tools/autoflow/release.ts @@ -809,8 +809,13 @@ function canCreateGitHubRelease(): boolean { } function canPublishNpm(): boolean { - // npm publish needs an access token. In CI it comes from secrets.NPM_TOKEN. - return isTruthyEnv('NPM_TOKEN') || isTruthyEnv('NODE_AUTH_TOKEN'); + // #1187 (Beta.2): npm publication authenticates via Trusted + // Publishing/OIDC, which exists only in the GitHub Actions release lane + // (autoflow-release.yml grants id-token: write and upgrades the npm CLI to + // the >=11.5.1 OIDC floor). The long-lived token path is removed — a local + // or manual release never publishes to npm, even if a legacy token + // variable happens to be set. + return Deno.env.get('GITHUB_ACTIONS') === 'true'; } /** diff --git a/tools/publish-npm.ts b/tools/publish-npm.ts index 94d2a2c1b..776bb1940 100644 --- a/tools/publish-npm.ts +++ b/tools/publish-npm.ts @@ -240,6 +240,8 @@ export async function publishPackage( ? ['publish', tar, '--dry-run', '--access', 'public'] : ['publish', tar, '--access', 'public']; // Provenance requires GitHub Actions OIDC; skip locally and on other CI providers. + // #1187: in the Actions lane, auth is npm Trusted Publishing (no token); + // `--provenance` stays explicit so the attestation intent is visible here. if (!dryRun && Deno.env.get('GITHUB_ACTIONS') === 'true') { args.push('--provenance'); }