From f2d2a5bdabff7670a8ae239bd39e89b1cfb7348a Mon Sep 17 00:00:00 2001 From: Even Stensberg Date: Wed, 14 Oct 2026 17:59:52 +0200 Subject: [PATCH] ci: stage npm releases instead of publishing directly Replace `changeset publish` with a script that runs `npm stage publish` through the OIDC trusted publisher, so every release needs a maintainer's 2FA approval before it goes live. The script still prints "New tag:" so changesets/action pushes the tag and creates the GitHub release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 12 ++++++---- package.json | 2 +- scripts/stage-publish.mjs | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 scripts/stage-publish.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c3ada3..d03b6ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,14 +32,17 @@ jobs: cache: npm registry-url: "https://registry.npmjs.org" - # OIDC token exchange needs npm >= 11.5.1, which is newer than the npm - # bundled with some Node LTS lines. + # `npm stage publish` needs npm >= 11.15.0 (and Node >= 22.14.0), which is + # newer than the npm bundled with some Node LTS lines. - name: Update npm run: npm install -g npm@latest - run: npm ci - - name: Create Release Pull Request or Publish to npm + # `npm run release` stages the version instead of publishing it. A + # maintainer approves it with 2FA (`npm stage approve ` or the + # Staged Packages tab on the npm website) before it goes live. + - name: Create Release Pull Request or Stage to npm id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: @@ -48,6 +51,7 @@ jobs: commit: "chore(release): new release" title: "chore(release): new release" # No NPM_TOKEN: authentication goes through the npm trusted publisher - # configured for this repository and workflow file. + # configured for this repository and workflow file. Restrict that + # trusted publisher to staging only so a direct publish is rejected. env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package.json b/package.json index 6d868a5..4fd5c4b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "pretest": "npm run lint", "test": "npm run test:coverage", "prepare": "husky && npm run build", - "release": "changeset publish", + "release": "node scripts/stage-publish.mjs", "changeset": "changeset", "version-packages": "changeset version" }, diff --git a/scripts/stage-publish.mjs b/scripts/stage-publish.mjs new file mode 100644 index 0000000..4682d04 --- /dev/null +++ b/scripts/stage-publish.mjs @@ -0,0 +1,45 @@ +// Replacement for `changeset publish` that uses npm staged publishing. +// +// `npm stage publish` uploads the tarball to the npm stage queue through the +// trusted publisher (OIDC). The version only goes live after a maintainer +// approves it with 2FA (`npm stage approve ` or on npmjs.com). +// +// changesets/action detects a release in a single-package repo by looking for a +// "New tag:" line on stdout. It then pushes the `v` tag and creates the +// GitHub release, so this script creates the tag locally and prints that line. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const { name, version } = JSON.parse(readFileSync("package.json", "utf8")); +const tag = `v${version}`; + +const run = (command, args, options = {}) => + (execFileSync(command, args, { encoding: "utf8", ...options }) ?? "").trim(); + +const isPublished = () => { + try { + return run("npm", ["view", `${name}@${version}`, "version"]) === version; + } catch { + return false; + } +}; + +// The tag is pushed right after staging, so it also marks a version that is +// staged and still waiting for approval. +const isTagged = () => + run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`]) !== ""; + +const log = (message) => process.stdout.write(`${message}\n`); + +if (isPublished()) { + log(`${name}@${version} is already published, skipping.`); +} else if (isTagged()) { + log(`${tag} already exists, ${name}@${version} is staged, skipping.`); +} else { + run("npm", ["stage", "publish"], { stdio: "inherit" }); + run("git", ["tag", tag]); + + log(`Staged ${name}@${version}, approve it with 2FA to publish.`); + log(`New tag: ${tag}`); +}