From 48000a1286115a59413bdd1aa4619c57a1bea400 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 4 Sep 2026 00:20:06 +0200 Subject: [PATCH] ci: switch releases to npm trusted publishing --- .github/workflows/release.yml | 16 +++----- docs/RELEASING.md | 72 +++++++++++++-------------------- docs/release-readiness.md | 10 ++--- scripts/verify-release.mjs | 21 +++++++++- test/package/verify-package.mjs | 8 +++- 5 files changed, 66 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1617efa..b1574f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: type: choice options: [next, latest] confirmation: - description: Type publish 2.0.0 with latest + description: Type publish with required: true permissions: @@ -22,7 +22,7 @@ jobs: authorize: runs-on: ubuntu-latest steps: - - name: Require main and exact stable release confirmation + - name: Require main and exact release confirmation env: ACTUAL_REF: ${{ github.ref }} ACTUAL_VERSION: ${{ inputs.version }} @@ -30,9 +30,7 @@ jobs: ACTUAL_CONFIRMATION: ${{ inputs.confirmation }} run: | test "$ACTUAL_REF" = "refs/heads/main" - test "$ACTUAL_VERSION" = "2.0.0" - test "$ACTUAL_CHANNEL" = "latest" - test "$ACTUAL_CONFIRMATION" = "publish 2.0.0 with latest" + test "$ACTUAL_CONFIRMATION" = "publish $ACTUAL_VERSION with $ACTUAL_CHANNEL" verify: needs: authorize @@ -53,7 +51,7 @@ jobs: env: REQUESTED_CHANNEL: ${{ inputs.channel }} run: node scripts/verify-release-channel.mjs "$REQUESTED_CHANNEL" - - name: Verify 2.0.0 is absent from npm + - name: Verify requested version is absent from npm run: node scripts/verify-registry.mjs unpublished - name: Assert exactly one verified tarball run: | @@ -114,14 +112,12 @@ jobs: tarballs=(.artifacts/*.tgz) test "${#tarballs[@]}" -eq 1 printf 'VERIFIED_TARBALL=%s\n' "${tarballs[0]}" >> "$GITHUB_ENV" - - name: Reconfirm 2.0.0 is absent from npm + - name: Reconfirm requested version is absent from npm run: node scripts/verify-registry.mjs unpublished - - name: Publish verified tarball for initial package bootstrap + - name: Publish verified tarball with npm trusted publishing env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} REQUESTED_CHANNEL: ${{ inputs.channel }} run: | - test -n "$NODE_AUTH_TOKEN" npm publish "$VERIFIED_TARBALL" \ --access public \ --tag "$REQUESTED_CHANNEL" \ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c671763..4fa7a3c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,9 +1,8 @@ # Releasing -Releases are manual, protected, and run only from `main`. The current workflow is -locked to the first stable release: `@nipe-solutions/readonly-view@2.0.0` on the -`latest` npm channel. Its inputs remain versioned so a future release can update -the explicit authorization gate without redesigning the workflow. +Releases are manual, protected, and run only from `main`. The workflow requires +an exact version, npm channel, and matching confirmation phrase. Stable versions +use `latest`; prereleases use `next`. ## Hosted documentation @@ -19,72 +18,56 @@ If a documentation deployment is defective, restore the previous known-good Vercel deployment immediately and follow with a reviewed revert or fix on `main`. Do not publish a package merely to repair the hosted documentation. -## First-publication authentication - -Create a protected GitHub environment named `npm`, restrict deployments to -`main`, and require a reviewer when the repository plan supports it. Because npm -does not allow trusted-publisher setup until the package exists, the initial -`2.0.0` publication uses a short-lived granular npm access token: - -1. Immediately before dispatch, create a granular token with the shortest - practical expiry. Scope its package and organization permissions only as - narrowly as npm permits for the initial `@nipe-solutions/readonly-view` - publication, grant the required package write access, and select the token's - `Bypass 2FA` option. Bypassing 2FA is required for this non-interactive direct - publication because GitHub Actions cannot enter a one-time password. -2. Add it as an environment secret named `NPM_TOKEN` on the protected `npm` - environment, not as a repository-wide secret. Only the `publish` job reads - it. -3. After `2.0.0` exists and all checks below pass, configure the npm trusted - publisher with this exact tuple: +## Trusted publishing + +The protected GitHub environment is named `npm`, permits only `main`, and +requires review. npm trusts this exact publisher tuple: - repository owner/name: `NIPE-Solutions/readonly-view` - workflow filename: `release.yml` - GitHub environment: `npm` -4. Immediately after the trusted publisher is configured, delete the - `NPM_TOKEN` GitHub environment secret and revoke the granular token before - any further release work. For future versions, update the explicit release - authorization and remove `NODE_AUTH_TOKEN` from the publish step so npm - authenticates through the trusted publisher. - -The initial workflow uses the token for registry authentication and the -publish job's OIDC permission for `--provenance`; it is not an OIDC-only -publication. Never use a long-lived npm token. +The workflow uses GitHub's short-lived OIDC identity and npm provenance. No npm +token secret is stored in GitHub. npm publishing access is set to require 2FA +and disallow bypass-2FA tokens. Do not add a long-lived npm token. -## Stable 2.0.0 procedure +## Release procedure 1. On the reviewed `main` commit, use Node 24 and run `npm ci`, `npm run release:check`, and `npm run test:browser`. 2. Review `npm pack --dry-run`, declarations, bundle budgets, license, and the exact tarball. Confirm the production documentation checks above pass. -3. Confirm `npm view @nipe-solutions/readonly-view@2.0.0 version` returns an npm +3. Confirm `npm view @nipe-solutions/readonly-view@ version` returns an npm `E404`; an existing version must never be overwritten. -4. Dispatch the protected workflow with exactly these inputs: +4. Dispatch the protected workflow with matching inputs: ```bash + version=2.0.1 + channel=latest gh workflow run release.yml --ref main \ - -f version=2.0.0 \ - -f channel=latest \ - -f 'confirmation=publish 2.0.0 with latest' + -f version="$version" \ + -f channel="$channel" \ + -f "confirmation=publish $version with $channel" ``` 5. Approve the `npm` environment deployment, then monitor the publish job. It downloads the single tarball produced by the verified job, reconfirms registry absence, publishes those exact bytes with provenance, waits for - registry propagation, and only then creates GitHub release `v2.0.0` at the + registry propagation, and only then creates GitHub release `v` at the immutable workflow commit. 6. Verify the public result: ```bash - npm view @nipe-solutions/readonly-view@2.0.0 name version dist-tags.latest repository homepage - test -n "$(npm view @nipe-solutions/readonly-view@2.0.0 dist.attestations.url)" + version=2.0.1 + channel=latest + npm view "@nipe-solutions/readonly-view@$version" name version "dist-tags.$channel" repository homepage + test -n "$(npm view "@nipe-solutions/readonly-view@$version" dist.attestations.url)" consumer_dir="$(mktemp -d)" ( cd "$consumer_dir" npm init --yes >/dev/null - npm install --ignore-scripts --save-exact @nipe-solutions/readonly-view@2.0.0 + npm install --ignore-scripts --save-exact "@nipe-solutions/readonly-view@$version" node --input-type=module -e \ "import('@nipe-solutions/readonly-view').then(m => console.log(typeof m.readonlyView))" ) @@ -94,17 +77,18 @@ publication. Never use a long-lived npm token. "https://readonly-view.nipesolutions.com${route}" >/dev/null done - gh release view v2.0.0 --repo NIPE-Solutions/readonly-view \ + gh release view "v$version" --repo NIPE-Solutions/readonly-view \ --json tagName,targetCommitish,url ``` -The metadata must report version `2.0.0`, `latest` must resolve to `2.0.0`, the +The metadata must report the requested version, the requested channel must +resolve to that version, the homepage must be `https://readonly-view.nipesolutions.com`, the import must print `function` from the temporary consumer, the attestation URL must be nonempty, all production requests must succeed over HTTPS, and the GitHub release target must match the dispatched `main` SHA. Remove `consumer_dir` after inspection. -npm versions are immutable: never attempt to republish `2.0.0`. If the package +npm versions are immutable: never attempt to republish an existing version. If the package is defective, deprecate it with a precise warning when appropriate, publish a reviewed patch version, and move `latest` only through the guarded workflow. Do not deprecate or otherwise mutate the old `immuview` package. diff --git a/docs/release-readiness.md b/docs/release-readiness.md index a317447..161b45e 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -62,11 +62,11 @@ CI runs the Node 24 quality gate, Node 22 compatibility, and Chromium/Firefox/WebKit jobs. Release is manual, main-only, exact-confirmation gated, environment protected, checks version/channel policy and registry availability, carries the verified tarball through the browser gate, and waits -for propagation before creating a SHA-targeted GitHub release. The first -publication uses a short-lived granular `NPM_TOKEN` environment secret plus -OIDC provenance. After the package exists, that token must be revoked and -deleted and the documented npm trusted publisher must replace it for future -releases. +for propagation before creating a SHA-targeted GitHub release. The initial +publication used a short-lived granular token plus OIDC provenance. That +bootstrap token and its GitHub secret were revoked after publication. Future +releases use the configured npm trusted publisher and short-lived OIDC +credentials only. The local browser command passed all 22 scheduled tests: 11 on Chromium and 11 on Firefox. Playwright deliberately excluded WebKit on this macOS 14 host under diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index e022172..2764661 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -16,7 +16,10 @@ assert( pkg.homepage === 'https://readonly-view.nipesolutions.com', 'Homepage is stale', ); -assert(pkg.version === '2.0.0', 'Unexpected package version'); +assert( + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version), + 'Package version must be a publishable semver', +); assert(pkg.version === lock.version, 'package-lock root version is stale'); assert( pkg.version === lock.packages[''].version, @@ -60,4 +63,20 @@ assert( changelog.includes('## Unreleased'), 'CHANGELOG needs an Unreleased section', ); + +const releaseWorkflow = await readFile( + resolve(root, '.github/workflows/release.yml'), + 'utf8', +); +assert( + !releaseWorkflow.includes('secrets.NPM_TOKEN') && + !releaseWorkflow.includes('NODE_AUTH_TOKEN'), + 'Release workflow must use OIDC trusted publishing without a token secret', +); +assert( + releaseWorkflow.includes( + 'test "$ACTUAL_CONFIRMATION" = "publish $ACTUAL_VERSION with $ACTUAL_CHANNEL"', + ), + 'Release confirmation must follow the requested version and channel', +); console.log(`Release metadata verified for ${pkg.name}@${pkg.version}`); diff --git a/test/package/verify-package.mjs b/test/package/verify-package.mjs index 28c0ac2..68fbe1c 100644 --- a/test/package/verify-package.mjs +++ b/test/package/verify-package.mjs @@ -49,7 +49,13 @@ export async function verifyPackage() { ); await exec( 'npm', - ['install', '--ignore-scripts', '--no-package-lock'], + [ + 'install', + '--ignore-scripts', + '--no-package-lock', + '--no-audit', + '--no-fund', + ], { cwd: temporary }, );