Skip to content
Draft
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
12 changes: 8 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <stage-id>` 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:
Expand All @@ -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 }}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
45 changes: 45 additions & 0 deletions scripts/stage-publish.mjs
Original file line number Diff line number Diff line change
@@ -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 <stage-id>` 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<version>` 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" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass an npm dist-tag for prerelease versions.

If Changesets produces a version such as 5.1.0-beta.1, this command uses npm’s default latest tag. npm stage publish rejects prerelease versions without an explicit tag. Select the Changesets prerelease tag and pass --tag when staging that version. Otherwise, the prerelease workflow stops before it creates a stage. (docs.npmjs.com)

run("git", ["tag", tag]);
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Recover a stage when tag publication fails.

If staging succeeds but git tag or changesets/action’s tag push fails, npm retains the staged version. A fresh run then finds no published version and no remote tag. It attempts to stage the same version again, which npm rejects. Reconcile an existing pending stage before staging; when the remote tag is absent, let the run create the tag and emit New tag: without another upload. (docs.npmjs.com)


log(`Staged ${name}@${version}, approve it with 2FA to publish.`);
log(`New tag: ${tag}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Delay the public release signal until approval.

If a maintainer has not approved the stage, this New tag: line still makes changesets/action push v<version>, create a GitHub release, and report published=true. npm does not make the version installable until approval. A rejected stage therefore leaves a public release for a version that never went live. Use a separate staging signal, then create the release after approval. (raw.githubusercontent.com)

}
Loading