Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions .github/workflows/autoflow-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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' || '' }}
Expand Down
5 changes: 4 additions & 1 deletion docs/adr/ADR-0108-deno-native-npm-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
70 changes: 70 additions & 0 deletions docs/runbooks/npm-trusted-publishing.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 21 additions & 18 deletions tools/autoflow/__tests__/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
Expand All @@ -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 {
Expand All @@ -338,23 +344,22 @@ 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');
else Deno.env.set('CI', originalCi);
}
});

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');
Expand All @@ -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');
Expand Down
48 changes: 48 additions & 0 deletions tools/autoflow/__tests__/pr-ci-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).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<string, unknown>;
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'");
});
10 changes: 6 additions & 4 deletions tools/autoflow/__tests__/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
}
Expand Down
9 changes: 7 additions & 2 deletions tools/autoflow/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
2 changes: 2 additions & 0 deletions tools/publish-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
Loading