From ab5a93249c09ad5139c0e91ebf178eb53bd3f048 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:00:42 +0200 Subject: [PATCH 01/11] Add the Changesets flow for multi-package repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag-derived npm flow owns the single-package case and cannot be stretched further: it reads the bump from all commits since the last release with no way to attribute a commit to a package, so with two packages every change stream bumps both. Changesets scopes each change to the packages it names and cascades bumps through dependents — the one thing tags cannot recreate — so repositories crossing the two-package line get a flow shaped around it. npm-changesets.yml wraps changesets/action in the same posture as the rest of the family: SHA-pinned actions, the npm 12 pin, OIDC trusted publishing with a guard that checks what npm will actually authenticate with — NODE_AUTH_TOKEN and the generated npmrc, never org-scoped secrets — and provenance only when the source repository is public. The npmrc check allows setup-node's literal interpolation placeholder and sees through ini whitespace around the equals sign, both bugs caught in review of the guard's first in-repo incarnation. The caller is one push-to-main trigger with no event routing: merging the version pull request is the release act. First consumers: provider-devtool as it crosses the two-package line, xion.js when it migrates off its bespoke changesets workflow. --- .github/workflows/npm-changesets.yml | 173 +++++++++++++++++++++++++++ AGENTS.md | 23 +++- README.md | 6 + tests/workflows.test.mjs | 105 +++++++++++++++- 4 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/npm-changesets.yml diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml new file mode 100644 index 0000000..22a8442 --- /dev/null +++ b/.github/workflows/npm-changesets.yml @@ -0,0 +1,173 @@ +# The Changesets-shaped npm flow, for repositories that publish MULTIPLE +# interdependent packages from one workspace. +# +# The tag-derived flow (npm-main.yml / npm-release.yml) owns the single-package +# case and cannot be stretched to this one: it reads the bump from all commits +# since the last release, with no way to attribute a commit to a package, so +# with two packages every change stream bumps both. Changesets scopes each +# change to the packages it names and cascades bumps through dependents — +# that is the one thing it does that cannot be recreated with tags, and the +# reason both flows exist. One published package: use the tag flow. Two or +# more interdependent ones: use this. +# +# Versioning is Changesets' own: merges accumulate changeset files, the flow +# maintains a version pull request, and merging that pull request is the +# release act — this same flow then publishes the new versions to npm. The +# publish authenticates with GitHub OIDC trusted publishing; there is no +# token anywhere, and a guard fails the run if one appears. Provenance is +# requested only when the source repository is public, because the registry +# refuses it from private repositories (E422) rather than degrading. +# +# What a caller provides: +# - permissions: contents: write, pull-requests: write (the version pull +# request), id-token: write (trusted publishing) +# - concurrency with cancel-in-progress: false — publishing then tagging must +# not be cancelled between the two +# - the trusted publisher on npmjs.com for EVERY published package pointing +# at the CALLER's workflow file; OIDC identifies the top-level workflow, +# and npm allows one workflow per package, so all publishes must run from +# that one file +# - version-command / publish-command when its scripts differ from the +# defaults; both run from the repository root + +name: Burnt npm Changesets + +on: + workflow_call: + inputs: + quality-policy-path: + description: Repository-relative quality policy JSONC path + required: false + default: .github/quality-policy.jsonc + type: string + version-command: + description: Command Changesets runs to apply pending changesets + required: false + default: npm run version:packages + type: string + publish-command: + description: Command Changesets runs to publish the packages + required: false + default: npm run publish:packages + type: string + pr-title: + description: Title of the version pull request + required: false + default: "chore(release): version packages 🦋" + type: string + pr-commit: + description: Commit message of the version pull request + required: false + default: "chore: update versions" + type: string + +permissions: + contents: read + +jobs: + quality: + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@d169eff38ec93b6405f15a2b2dd86b4bcda21bcb # v1.4.0 + with: + quality-policy-path: ${{ inputs.quality-policy-path }} + + release: + name: Version or publish + needs: quality + runs-on: ubicloud-standard-4 + permissions: + # The version pull request, and the tags and releases Changesets + # creates after publishing. + contents: write + pull-requests: write + # npm trusted publishing (OIDC). + id-token: write + defaults: + run: + working-directory: ${{ fromJSON(needs.quality.outputs.quality-policy).workingDirectory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Changesets reads history and tags to decide what is unpublished. + fetch-depth: 0 + # Do not leave the job's token in `.git/config`. The install and + # build run package code before anything publishes, so a persisted + # credential is reachable by the dependency tree. `commitMode: + # github-api` below authenticates through GITHUB_TOKEN instead, and + # API commits are signed by GitHub, which branch protection tends + # to want. + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: lts/* + registry-url: https://registry.npmjs.org + - run: corepack enable + - name: Pin the npm CLI + # See "npm install-time security" in AGENTS.md. npm 12 is the floor + # for both halves of this flow: it enforces the install-time + # defaults, and it is well past the 11.5.1 that trusted publishing + # needs — Changesets shells out to `npm publish`, whichever package + # manager installed the workspace. + run: | + npm install --global npm@12 + if [ "$(npm --version | cut -d. -f1)" != "12" ]; then + echo "::error::Expected npm 12 on PATH, found $(npm --version). Something is shadowing the pinned CLI." + exit 1 + fi + - name: Install + run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.install }} + # Build before publishing rather than leaning on per-package + # `prepublishOnly`, so a broken build fails the job with its own error + # instead of surfacing as a publish failure halfway through a + # multi-package publish. + - name: Build + run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.build }} + # This checks what npm will actually authenticate with, NOT whether a + # token exists somewhere in scope — reading org-level secrets into the + # environment to assert they are empty reports the organization's + # configuration, not this job's, and fails repositories that never + # handed npm anything. What decides the outcome is NODE_AUTH_TOKEN, + # which setup-node's generated npmrc interpolates at read time; this + # flow never sets it, so npm falls through to OIDC. + - name: Verify trusted-publishing credentials + run: | + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::No OIDC token available. The calling workflow must grant id-token: write." + exit 1 + fi + if [ -n "${NODE_AUTH_TOKEN:-}" ]; then + echo "::error::An npm token is present. This flow publishes with trusted publishing and must not carry one." + exit 1 + fi + # setup-node writes an `_authToken` line holding the LITERAL text + # of the interpolation placeholder — the action does not expand it, + # npm does, at read time (actions/setup-node, src/authutil.ts). So + # the placeholder is expected content and must not be read as a + # credential; what this rejects is `_authToken` set to anything + # else, a hardcoded token committed or injected into the file. npm + # parses ini-style and trims whitespace around `=`, so the patterns + # allow it too — `_authToken = ` authenticates just as well. + npmrc="${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" + if [ -f "$npmrc" ] && + grep -E '_authToken[[:space:]]*=' "$npmrc" | + grep -qvE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$'; then + echo "::error::$npmrc sets _authToken to a literal credential. This flow publishes with trusted publishing and must not carry one." + exit 1 + fi + - name: Create release pull request or publish to npm + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + title: ${{ inputs.pr-title }} + commit: ${{ inputs.pr-commit }} + version: ${{ inputs.version-command }} + publish: ${{ inputs.publish-command }} + # Commit and tag over the API rather than the git CLI, which has no + # credentials now that checkout does not persist them. + commitMode: github-api + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The registry refuses provenance from private source repositories + # (E422) rather than publishing without the attestation, so the + # flag follows visibility: a private repository publishes through + # the same OIDC path without attesting, and starts attesting the + # moment it goes public, with no workflow change. + NPM_CONFIG_PROVENANCE: ${{ github.event.repository.private == false }} diff --git a/AGENTS.md b/AGENTS.md index e1c0a9b..16fb02b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ These are not preferences. Changes that break them will be rejected. ## The flows -Ten workflows. Eight are entry points; two are internal. +Eleven workflows. Nine are entry points; two are internal. | Workflow | Called by | Purpose | | ------------------------ | --------------------------- | ---------------------------------------------- | @@ -43,6 +43,7 @@ Ten workflows. Eight are entry points; two are internal. | `npm-pr.yml` | consumer, on `pull_request` | Quality, package dry run | | `npm-main.yml` | consumer, on push to main | Publish the candidate dist-tag, release drafts | | `npm-release.yml` | consumer, on `release` | Publish the release dist-tag | +| `npm-changesets.yml` | consumer, on push to main | Changesets version PR, multi-package publish | | `phala-deploy.yml` | consumer | Build and deploy a Phala CVM target | | `cloudflare-version.yml` | internal | One `wrangler versions upload` or `deploy` | | `npm-publish.yml` | internal | One `npm publish` via OIDC trusted publishing | @@ -198,6 +199,26 @@ same build to the same Worker twice. Note that this puts pull-request previews on that GitHub Environment, inheriting its secrets and protection rules; that is the cost of modelling one environment honestly. +### Two npm flow shapes + +The npm flows come in two shapes, chosen by how many packages a repository +publishes: + +- **One package** — `npm-main.yml` / `npm-release.yml`. Versions derive from + release tags and Conventional Commits; nothing is committed back. The caller + carries both triggers in one file with event routing, because npm allows one + trusted-publisher workflow per package and both the candidate and the + promoted publish must run from it. +- **Multiple interdependent packages** — `npm-changesets.yml`. The tag flow + cannot attribute a commit to a package, so with two packages every change + stream would bump both; Changesets scopes each change to the packages it + names and cascades bumps through dependents. The caller is a single + push-to-main trigger with no routing: merging the version pull request is + the release act, and the same run publishes. + +Both shapes share the publishing posture below — OIDC trusted publishing, no +tokens, provenance only from public repositories. + ### npm-policy.jsonc ```jsonc diff --git a/README.md b/README.md index 7f329ab..5ce4f24 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,12 @@ Workflows in this repository never create commits or push branches. ## npm +Repositories publishing multiple interdependent packages use +`npm-changesets.yml`, which wraps Changesets — version pull request on merge, +publish when it lands — in the same trusted-publishing posture as the rest of +the family. The tag-derived flows below are for repositories publishing one +package. + The npm workflow family performs a package dry run on pull requests, publishes `v-rc.` with the `next` dist-tag from main, and publishes the stable version with `latest` after manual or automatic promotion. Publishing diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 1cba172..f63b3a8 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -486,15 +486,21 @@ test("no workflow accepts an npm token", () => { // npm is retiring 2FA-bypass granular access tokens: they stop skipping 2FA // for account operations in August 2026 and lose publishing entirely around // January 2027. Publishing here is OIDC trusted publishing and nothing else, - // so the only mention of a token name allowed anywhere is npm-publish.yml - // refusing to run when one is present. + // so a token name may appear only where a guard refuses to run when one is + // present: the emptiness check, the refusal message, the grep pattern that + // exempts setup-node's literal interpolation placeholder, and comments + // explaining those. for (const name of fs.readdirSync(directory)) { if (!name.endsWith(".yml")) continue; const source = fs.readFileSync(`${directory}/${name}`, "utf8"); for (const [line] of source.matchAll( /^.*(NPM_TOKEN|NODE_AUTH_TOKEN).*$/gm, )) { - assert.match(line, /-n "\$\{|must not carry one/, `${name}: ${line}`); + assert.match( + line, + /-n "\$\{|must not carry one|^\s*#|NODE_AUTH_TOKEN\\\}/, + `${name}: ${line}`, + ); } } }); @@ -759,3 +765,96 @@ test("npm publish runs package-scoped steps in the package directory", () => { ); } }); + +test("npm changesets flow publishes with OIDC and API commits only", () => { + const source = fs.readFileSync(`${directory}/npm-changesets.yml`, "utf8"); + const workflow = parse(source); + const release = workflow.jobs.release; + assert.equal(release.permissions["id-token"], "write"); + const checkout = release.steps[0]; + assert.equal(checkout.with["fetch-depth"], 0); + assert.equal(checkout.with["persist-credentials"], false); + const publish = release.steps.at(-1); + assert.equal(publish.with.commitMode, "github-api"); + // Provenance follows source visibility; the registry refuses it from + // private repositories rather than degrading. + assert.match( + publish.env.NPM_CONFIG_PROVENANCE, + /repository\.private == false/, + ); + assert.match(source, /ACTIONS_ID_TOKEN_REQUEST_URL/); +}); + +test("npm changesets guard rejects real credentials, allows the placeholder", (t) => { + const workflow = parse( + fs.readFileSync(`${directory}/npm-changesets.yml`, "utf8"), + ); + const guard = workflow.jobs.release.steps.find( + (step) => step.name === "Verify trusted-publishing credentials", + ); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "npmrc-guard-")); + t.after(() => fs.rmSync(root, { recursive: true })); + const cases = [ + // [description, npmrc content or null, NODE_AUTH_TOKEN, OIDC url, status] + ["no oidc", null, "", "", 1], + ["env token", null, "npm_x", "https://oidc", 1], + ["no npmrc", null, "", "https://oidc", 0], + [ + "placeholder", + "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n", + "", + "https://oidc", + 0, + ], + [ + "empty value", + "//registry.npmjs.org/:_authToken=\n", + "", + "https://oidc", + 0, + ], + [ + "literal credential", + "//registry.npmjs.org/:_authToken=npm_realtoken\n", + "", + "https://oidc", + 1, + ], + // npm's ini parser trims whitespace around `=`, so this authenticates — + // the guard has to see through the spacing (caught in review of the + // original guard). + [ + "literal credential with spaces", + "//registry.npmjs.org/:_authToken = npm_realtoken\n", + "", + "https://oidc", + 1, + ], + [ + "placeholder with spaces", + "//registry.npmjs.org/:_authToken =${NODE_AUTH_TOKEN}\n", + "", + "https://oidc", + 0, + ], + ]; + for (const [ + index, + [label, npmrc, token, oidc, expected], + ] of cases.entries()) { + const env = { ...process.env, ACTIONS_ID_TOKEN_REQUEST_URL: oidc }; + delete env.NODE_AUTH_TOKEN; + if (token) env.NODE_AUTH_TOKEN = token; + if (npmrc === null) { + env.NPM_CONFIG_USERCONFIG = path.join(root, `absent-${index}`); + } else { + const file = path.join(root, `npmrc-${index}`); + fs.writeFileSync(file, npmrc); + env.NPM_CONFIG_USERCONFIG = file; + } + const result = spawnSync("bash", ["-euo", "pipefail", "-c", guard.run], { + env, + }); + assert.equal(result.status, expected, `${label}: ${result.stderr}`); + } +}); From 686336046605788f11b220b46b3a2564b193fe39 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:29:58 +0200 Subject: [PATCH 02/11] Harden the Changesets flow per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential guard moves ahead of Install, matching npm-publish.yml: a credential it would reject must fail the job before any consumer lifecycle or build code has had it in scope. It now also rejects NPM_TOKEN in the environment — changesets/action treats that as an alternate credential — and scans both the generated user config and the repository's own .npmrc for every credential key npm accepts, _auth and _password alongside _authToken. The release job enforces its own per-repository, non-cancelling concurrency group instead of trusting the caller comment: concurrent runs force-update the changeset-release branch, and a run cancelled between publish and tagging leaves registry versions with nothing behind them. The write-back invariant in AGENTS.md now names this flow's GitHub-API version pull request as the one authorized exception, and the README scopes the candidate/dist-tag lifecycle to the tag-derived pair. The header documents the accepted GITHUB_TOKEN exposure in Changesets' version and publish subprocesses — inherent to the model, required by the changelog writers, and one more reason single-package repositories belong on the tag flow. All caught in review. --- .github/workflows/npm-changesets.yml | 88 +++++++++++++++++----------- AGENTS.md | 9 ++- README.md | 2 +- tests/workflows.test.mjs | 84 +++++++++++++++++--------- 4 files changed, 119 insertions(+), 64 deletions(-) diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index 22a8442..91a2967 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -21,14 +21,26 @@ # What a caller provides: # - permissions: contents: write, pull-requests: write (the version pull # request), id-token: write (trusted publishing) -# - concurrency with cancel-in-progress: false — publishing then tagging must -# not be cancelled between the two # - the trusted publisher on npmjs.com for EVERY published package pointing # at the CALLER's workflow file; OIDC identifies the top-level workflow, # and npm allows one workflow per package, so all publishes must run from # that one file # - version-command / publish-command when its scripts differ from the # defaults; both run from the repository root +# +# Serialization is enforced here — the release job carries a per-repository, +# non-cancelling concurrency group — so a caller needs no concurrency of its +# own, though adding one is harmless. +# +# Known, accepted exposure: changesets/action hands its environment — +# GITHUB_TOKEN included — to the version and publish commands, which run +# consumer code. That is inherent to the Changesets model (the changelog +# writers need the token to read pull-request metadata) and identical to the +# bespoke changesets workflows this replaces; the quality gates, the +# lockfile, and npm 12's install-time script denial are the compensating +# controls. The tag-derived flow does not carry the token into consumer +# commands, which is one more reason single-package repositories belong +# there. name: Burnt npm Changesets @@ -74,6 +86,13 @@ jobs: name: Version or publish needs: quality runs-on: ubicloud-standard-4 + concurrency: + # Enforced here rather than trusted to the caller: two concurrent runs + # both force-update Changesets' changeset-release branch, and NOT + # cancel-in-progress because a run cancelled between publishing and + # tagging leaves versions on the registry with nothing behind them. + group: npm-changesets-${{ github.repository }} + cancel-in-progress: false permissions: # The version pull request, and the tags and releases Changesets # creates after publishing. @@ -113,46 +132,49 @@ jobs: echo "::error::Expected npm 12 on PATH, found $(npm --version). Something is shadowing the pinned CLI." exit 1 fi - - name: Install - run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.install }} - # Build before publishing rather than leaning on per-package - # `prepublishOnly`, so a broken build fails the job with its own error - # instead of surfacing as a publish failure halfway through a - # multi-package publish. - - name: Build - run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.build }} - # This checks what npm will actually authenticate with, NOT whether a - # token exists somewhere in scope — reading org-level secrets into the - # environment to assert they are empty reports the organization's - # configuration, not this job's, and fails repositories that never - # handed npm anything. What decides the outcome is NODE_AUTH_TOKEN, - # which setup-node's generated npmrc interpolates at read time; this - # flow never sets it, so npm falls through to OIDC. + # Before Install, matching npm-publish.yml: a credential this guard + # would reject must fail the job before any consumer lifecycle or build + # code has had it in scope. This checks what npm will actually + # authenticate with, NOT whether a token exists somewhere in secrets — + # reading org-level secrets into the environment to assert they are + # empty reports the organization's configuration, not this job's, and + # fails repositories that never handed npm anything. - name: Verify trusted-publishing credentials run: | if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then echo "::error::No OIDC token available. The calling workflow must grant id-token: write." exit 1 fi - if [ -n "${NODE_AUTH_TOKEN:-}" ]; then + if [ -n "${NODE_AUTH_TOKEN:-}" ] || [ -n "${NPM_TOKEN:-}" ]; then echo "::error::An npm token is present. This flow publishes with trusted publishing and must not carry one." exit 1 fi - # setup-node writes an `_authToken` line holding the LITERAL text - # of the interpolation placeholder — the action does not expand it, - # npm does, at read time (actions/setup-node, src/authutil.ts). So - # the placeholder is expected content and must not be read as a - # credential; what this rejects is `_authToken` set to anything - # else, a hardcoded token committed or injected into the file. npm - # parses ini-style and trims whitespace around `=`, so the patterns - # allow it too — `_authToken = ` authenticates just as well. - npmrc="${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" - if [ -f "$npmrc" ] && - grep -E '_authToken[[:space:]]*=' "$npmrc" | - grep -qvE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$'; then - echo "::error::$npmrc sets _authToken to a literal credential. This flow publishes with trusted publishing and must not carry one." - exit 1 - fi + # Both the generated user config and the repository's own .npmrc, + # and every credential key npm accepts — `_auth` and `_password` + # authenticate a registry just as `_authToken` does. setup-node + # writes an `_authToken` line holding the LITERAL text of the + # interpolation placeholder — the action does not expand it, npm + # does, at read time (actions/setup-node, src/authutil.ts) — so the + # placeholder is expected content and must not be read as a + # credential. npm parses ini-style and trims whitespace around `=`, + # so the patterns allow it too: `_authToken = ` + # authenticates just as well. + for npmrc in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" .npmrc; do + [ -f "$npmrc" ] || continue + if grep -E '(_authToken|_auth|_password)[[:space:]]*=' "$npmrc" | + grep -qvE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$'; then + echo "::error::$npmrc carries a registry credential. This flow publishes with trusted publishing and must not carry one." + exit 1 + fi + done + - name: Install + run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.install }} + # Build before publishing rather than leaning on per-package + # `prepublishOnly`, so a broken build fails the job with its own error + # instead of surfacing as a publish failure halfway through a + # multi-package publish. + - name: Build + run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.build }} - name: Create release pull request or publish to npm uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: diff --git a/AGENTS.md b/AGENTS.md index 16fb02b..dc40a28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,13 @@ policy files and thin trigger workflows; everything else lives here. These are not preferences. Changes that break them will be rejected. - Keep every commit signed. -- Workflows must never create commits or push branches. Version numbers are - derived from release tags, never written back to the repository. +- Workflows must never run `git commit` or `git push`, and the tag-derived + flows never write versions back to a repository. The one authorized + exception to write-back: `npm-changesets.yml` maintains its version pull + request and release tags through the GitHub API (`commitMode: github-api`) + — that write-back is Changesets' entire contract and the reason the flow + exists, and API commits are signed by GitHub, which branch protection + wants. Nothing may extend this exception to the git CLI. - Reusable deployment jobs must use the caller repository's actual target environment. Do not introduce `preview` or `preview-*` environments. - Candidate and release are semantic roles, mapped by repository policy. diff --git a/README.md b/README.md index 5ce4f24..41e6c74 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ publish when it lands — in the same trusted-publishing posture as the rest of the family. The tag-derived flows below are for repositories publishing one package. -The npm workflow family performs a package dry run on pull requests, publishes +The tag-derived pair performs a package dry run on pull requests, publishes `v-rc.` with the `next` dist-tag from main, and publishes the stable version with `latest` after manual or automatic promotion. Publishing uses npm trusted publishing through GitHub OIDC, with provenance when the diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index f63b3a8..678aa6d 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -771,9 +771,21 @@ test("npm changesets flow publishes with OIDC and API commits only", () => { const workflow = parse(source); const release = workflow.jobs.release; assert.equal(release.permissions["id-token"], "write"); + // Serialization is enforced here, not trusted to the caller: concurrent + // runs force-update the changeset-release branch, and a cancel between + // publish and tagging leaves registry versions with nothing behind them. + assert.match(release.concurrency.group, /github\.repository/); + assert.equal(release.concurrency["cancel-in-progress"], false); const checkout = release.steps[0]; assert.equal(checkout.with["fetch-depth"], 0); assert.equal(checkout.with["persist-credentials"], false); + // The guard runs before any consumer code: a credential it would reject + // must never have been in scope for install or build scripts. + const stepNames = release.steps.map((step) => step.name); + assert.ok( + stepNames.indexOf("Verify trusted-publishing credentials") < + stepNames.indexOf("Install"), + ); const publish = release.steps.at(-1); assert.equal(publish.with.commitMode, "github-api"); // Provenance follows source visibility; the registry refuses it from @@ -795,64 +807,80 @@ test("npm changesets guard rejects real credentials, allows the placeholder", (t const root = fs.mkdtempSync(path.join(os.tmpdir(), "npmrc-guard-")); t.after(() => fs.rmSync(root, { recursive: true })); const cases = [ - // [description, npmrc content or null, NODE_AUTH_TOKEN, OIDC url, status] - ["no oidc", null, "", "", 1], - ["env token", null, "npm_x", "https://oidc", 1], - ["no npmrc", null, "", "https://oidc", 0], + // [label, user npmrc, project npmrc, env overrides, expected status] + ["no oidc", null, null, { ACTIONS_ID_TOKEN_REQUEST_URL: "" }, 1], + ["env NODE_AUTH_TOKEN", null, null, { NODE_AUTH_TOKEN: "npm_x" }, 1], + ["env NPM_TOKEN", null, null, { NPM_TOKEN: "npm_x" }, 1], + ["no npmrc anywhere", null, null, {}, 0], [ "placeholder", "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n", - "", - "https://oidc", - 0, - ], - [ - "empty value", - "//registry.npmjs.org/:_authToken=\n", - "", - "https://oidc", + null, + {}, 0, ], + ["empty value", "//registry.npmjs.org/:_authToken=\n", null, {}, 0], [ "literal credential", "//registry.npmjs.org/:_authToken=npm_realtoken\n", - "", - "https://oidc", + null, + {}, 1, ], // npm's ini parser trims whitespace around `=`, so this authenticates — // the guard has to see through the spacing (caught in review of the - // original guard). + // guard's first incarnation). [ "literal credential with spaces", "//registry.npmjs.org/:_authToken = npm_realtoken\n", - "", - "https://oidc", + null, + {}, 1, ], [ "placeholder with spaces", "//registry.npmjs.org/:_authToken =${NODE_AUTH_TOKEN}\n", - "", - "https://oidc", + null, + {}, 0, ], + // The other credential keys npm accepts authenticate a registry just as + // _authToken does, and a project .npmrc is read by npm like the user one. + ["basic auth", "//registry.npmjs.org/:_auth=dXNlcjpwYXNz\n", null, {}, 1], + ["password", "//registry.npmjs.org/:_password=cGFzcw==\n", null, {}, 1], + [ + "project npmrc credential", + null, + "//registry.npmjs.org/:_authToken=npm_realtoken\n", + {}, + 1, + ], + ["project npmrc benign", null, "save-exact=true\n", {}, 0], ]; for (const [ index, - [label, npmrc, token, oidc, expected], + [label, userRc, projectRc, extra, expected], ] of cases.entries()) { - const env = { ...process.env, ACTIONS_ID_TOKEN_REQUEST_URL: oidc }; + const cwd = fs.mkdtempSync(path.join(root, `cwd-${index}-`)); + const env = { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: "https://oidc", + NPM_CONFIG_USERCONFIG: path.join(cwd, "absent-user-npmrc"), + ...extra, + }; delete env.NODE_AUTH_TOKEN; - if (token) env.NODE_AUTH_TOKEN = token; - if (npmrc === null) { - env.NPM_CONFIG_USERCONFIG = path.join(root, `absent-${index}`); - } else { - const file = path.join(root, `npmrc-${index}`); - fs.writeFileSync(file, npmrc); + delete env.NPM_TOKEN; + Object.assign(env, extra); + if (userRc !== null) { + const file = path.join(cwd, "user-npmrc"); + fs.writeFileSync(file, userRc); env.NPM_CONFIG_USERCONFIG = file; } + if (projectRc !== null) { + fs.writeFileSync(path.join(cwd, ".npmrc"), projectRc); + } const result = spawnSync("bash", ["-euo", "pipefail", "-c", guard.run], { + cwd, env, }); assert.equal(result.status, expected, `${label}: ${result.stderr}`); From 415376a37e3efbd3c12b2e517416feb819d5f71c Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:43:50 +0200 Subject: [PATCH 03/11] Scan the workspace root npmrc from any working directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard's bare .npmrc resolved under the quality policy's working directory, but changesets/action runs the version and publish commands from the repository root — under a non-root quality policy a root .npmrc credential escaped the scan while npm would still load it. The workspace root is now scanned explicitly alongside the step's own directory. Caught in review. --- .github/workflows/npm-changesets.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index 91a2967..68fc334 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -159,7 +159,10 @@ jobs: # credential. npm parses ini-style and trims whitespace around `=`, # so the patterns allow it too: `_authToken = ` # authenticates just as well. - for npmrc in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" .npmrc; do + # Both the repository root — where the action runs the version and + # publish commands — and this step's own working directory, which a + # policy may point elsewhere. Scanning a file twice is harmless. + for npmrc in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${GITHUB_WORKSPACE:-.}/.npmrc" .npmrc; do [ -f "$npmrc" ] || continue if grep -E '(_authToken|_auth|_password)[[:space:]]*=' "$npmrc" | grep -qvE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$'; then From f552c11b8cc2363dbef2e11065092e42d157c20f Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:44:38 +0200 Subject: [PATCH 04/11] Exercise the workspace-root npmrc scan The test for the previous commit: a credentialed .npmrc at GITHUB_WORKSPACE fails the guard even when the step runs from a different working directory. --- tests/workflows.test.mjs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 678aa6d..a3b3e7b 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -856,6 +856,16 @@ test("npm changesets guard rejects real credentials, allows the placeholder", (t 1, ], ["project npmrc benign", null, "save-exact=true\n", {}, 0], + // The action runs version/publish from the repository root, which can + // differ from this step's working directory under a non-root quality + // policy — the workspace root .npmrc must be scanned from anywhere. + [ + "workspace root credential from elsewhere", + null, + "workspace://registry.npmjs.org/:_authToken=npm_realtoken\n", + {}, + 1, + ], ]; for (const [ index, @@ -876,8 +886,17 @@ test("npm changesets guard rejects real credentials, allows the placeholder", (t fs.writeFileSync(file, userRc); env.NPM_CONFIG_USERCONFIG = file; } + const workspace = fs.mkdtempSync(path.join(root, `ws-${index}-`)); + env.GITHUB_WORKSPACE = workspace; if (projectRc !== null) { - fs.writeFileSync(path.join(cwd, ".npmrc"), projectRc); + if (projectRc.startsWith("workspace:")) { + fs.writeFileSync( + path.join(workspace, ".npmrc"), + projectRc.slice("workspace:".length), + ); + } else { + fs.writeFileSync(path.join(cwd, ".npmrc"), projectRc); + } } const result = spawnSync("bash", ["-euo", "pipefail", "-c", guard.run], { cwd, From c0dbf82f9a7aaec2ab4020e04347214faf25a104 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:53:57 +0200 Subject: [PATCH 05/11] Close two credential-scan gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second grep loses -q: under the default shell's pipefail, -q exiting at the first hit can SIGPIPE the producer and turn the pipeline's status into 141, which the if reads as no credential — consuming the full stream keeps the exit codes honest. And tokenHelper joins the scanned keys: it hands npm a token through a helper binary, which is a credential path like any other. Both caught in review, both covered by fixtures. --- .github/workflows/npm-changesets.yml | 8 ++++++-- tests/workflows.test.mjs | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index 68fc334..733e4f8 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -162,10 +162,14 @@ jobs: # Both the repository root — where the action runs the version and # publish commands — and this step's own working directory, which a # policy may point elsewhere. Scanning a file twice is harmless. + # No -q on the second grep: under the default shell's pipefail, -q + # exiting at the first hit can SIGPIPE the producer and turn the + # pipeline's status into 141, which reads as "no credential". + # Consuming the full stream keeps the exit codes honest. for npmrc in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${GITHUB_WORKSPACE:-.}/.npmrc" .npmrc; do [ -f "$npmrc" ] || continue - if grep -E '(_authToken|_auth|_password)[[:space:]]*=' "$npmrc" | - grep -qvE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$'; then + if grep -E '(_authToken|_auth|_password|tokenHelper)[[:space:]]*=' "$npmrc" | + grep -vE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$' > /dev/null; then echo "::error::$npmrc carries a registry credential. This flow publishes with trusted publishing and must not carry one." exit 1 fi diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index a3b3e7b..cba1593 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -848,6 +848,13 @@ test("npm changesets guard rejects real credentials, allows the placeholder", (t // _authToken does, and a project .npmrc is read by npm like the user one. ["basic auth", "//registry.npmjs.org/:_auth=dXNlcjpwYXNz\n", null, {}, 1], ["password", "//registry.npmjs.org/:_password=cGFzcw==\n", null, {}, 1], + [ + "token helper", + "//registry.npmjs.org/:tokenHelper=/usr/local/bin/npm-token\n", + null, + {}, + 1, + ], [ "project npmrc credential", null, From a7c52ec0b42ff09d657851e3840c57af7a76ef8d Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:04:25 +0200 Subject: [PATCH 06/11] Guard before npm runs, and reject nested working directories The credential guard moves ahead of the global npm pin: npm reads the generated user config while downloading itself, so a present credential would be expanded and sent before the job failed. Nothing before the guard now invokes npm at all. changesets/action resolves the workspace, the changeset files, and the CLI at the repository root, so a quality policy with a nested workingDirectory would have the quality gates and the release job silently disagreeing about what they are releasing. That invocation is rejected up front rather than accommodated. Both caught in review. --- .github/workflows/npm-changesets.yml | 54 ++++++++++++++++------------ tests/workflows.test.mjs | 17 +++++++-- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index 733e4f8..e3e26ef 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -100,10 +100,17 @@ jobs: pull-requests: write # npm trusted publishing (OIDC). id-token: write - defaults: - run: - working-directory: ${{ fromJSON(needs.quality.outputs.quality-policy).workingDirectory }} steps: + # Everything in this job runs at the repository root, because that is + # where changesets/action resolves the workspace, the changeset files, + # and the CLI. A nested quality workingDirectory would make the quality + # gates and this job silently disagree about what repository they are + # releasing, so it is rejected rather than accommodated. + - name: Validate invocation + if: fromJSON(needs.quality.outputs.quality-policy).workingDirectory != '.' + run: | + echo "::error::npm-changesets.yml requires the Changesets workspace — and the quality policy's workingDirectory — at the repository root." + exit 1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Changesets reads history and tags to decide what is unpublished. @@ -120,25 +127,16 @@ jobs: node-version: lts/* registry-url: https://registry.npmjs.org - run: corepack enable - - name: Pin the npm CLI - # See "npm install-time security" in AGENTS.md. npm 12 is the floor - # for both halves of this flow: it enforces the install-time - # defaults, and it is well past the 11.5.1 that trusted publishing - # needs — Changesets shells out to `npm publish`, whichever package - # manager installed the workspace. - run: | - npm install --global npm@12 - if [ "$(npm --version | cut -d. -f1)" != "12" ]; then - echo "::error::Expected npm 12 on PATH, found $(npm --version). Something is shadowing the pinned CLI." - exit 1 - fi - # Before Install, matching npm-publish.yml: a credential this guard - # would reject must fail the job before any consumer lifecycle or build - # code has had it in scope. This checks what npm will actually - # authenticate with, NOT whether a token exists somewhere in secrets — - # reading org-level secrets into the environment to assert they are - # empty reports the organization's configuration, not this job's, and - # fails repositories that never handed npm anything. + # Before ANY npm invocation — the global npm pin included, since npm + # reads the generated user config and would expand a present credential + # while downloading itself — and before Install, so no consumer + # lifecycle or build code ever has a rejected credential in scope. + # + # This checks what npm will actually authenticate with, NOT whether a + # token exists somewhere in secrets — reading org-level secrets into + # the environment to assert they are empty reports the organization's + # configuration, not this job's, and fails repositories that never + # handed npm anything. - name: Verify trusted-publishing credentials run: | if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then @@ -174,6 +172,18 @@ jobs: exit 1 fi done + - name: Pin the npm CLI + # See "npm install-time security" in AGENTS.md. npm 12 is the floor + # for both halves of this flow: it enforces the install-time + # defaults, and it is well past the 11.5.1 that trusted publishing + # needs — Changesets shells out to `npm publish`, whichever package + # manager installed the workspace. + run: | + npm install --global npm@12 + if [ "$(npm --version | cut -d. -f1)" != "12" ]; then + echo "::error::Expected npm 12 on PATH, found $(npm --version). Something is shadowing the pinned CLI." + exit 1 + fi - name: Install run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.install }} # Build before publishing rather than leaning on per-package diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index cba1593..5125e13 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -779,13 +779,24 @@ test("npm changesets flow publishes with OIDC and API commits only", () => { const checkout = release.steps[0]; assert.equal(checkout.with["fetch-depth"], 0); assert.equal(checkout.with["persist-credentials"], false); - // The guard runs before any consumer code: a credential it would reject - // must never have been in scope for install or build scripts. + // The guard runs before any npm invocation at all — the global pin + // downloads npm through the generated user config — and so before any + // consumer code has a rejected credential in scope. const stepNames = release.steps.map((step) => step.name); assert.ok( stepNames.indexOf("Verify trusted-publishing credentials") < - stepNames.indexOf("Install"), + stepNames.indexOf("Pin the npm CLI"), ); + assert.ok( + stepNames.indexOf("Pin the npm CLI") < stepNames.indexOf("Install"), + ); + // The action resolves the workspace at the repository root; a nested + // quality workingDirectory is rejected rather than accommodated. + const validate = release.steps.find( + (step) => step.name === "Validate invocation", + ); + assert.match(validate.if, /workingDirectory != '\.'/); + assert.match(validate.run, /repository root/); const publish = release.steps.at(-1); assert.equal(publish.with.commitMode, "github-api"); // Provenance follows source visibility; the registry refuses it from From e375cce7549c153cc14e99d22951084f80611f59 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:04:47 +0200 Subject: [PATCH 07/11] Find the checkout step by action, not position The Validate invocation step now leads the job, so the checkout is no longer steps[0]. Locate it by its uses reference. --- tests/workflows.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 5125e13..bc5cfa5 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -776,7 +776,9 @@ test("npm changesets flow publishes with OIDC and API commits only", () => { // publish and tagging leaves registry versions with nothing behind them. assert.match(release.concurrency.group, /github\.repository/); assert.equal(release.concurrency["cancel-in-progress"], false); - const checkout = release.steps[0]; + const checkout = release.steps.find((step) => + step.uses?.startsWith("actions/checkout@"), + ); assert.equal(checkout.with["fetch-depth"], 0); assert.equal(checkout.with["persist-credentials"], false); // The guard runs before any npm invocation at all — the global pin From 3b0c93d38467e871b3bd95ce178567ee6d0e9a15 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:16:04 +0200 Subject: [PATCH 08/11] Stand down stale runs, scan certificate auth, state the recovery contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency group serializes runs but does not order them: an older push whose quality job finished late can enter the group after a newer run and force-update the version pull request backwards. A freshness check before the Changesets step makes such a run stand down in favor of the newer commit's run. certfile and keyfile join the credential scan — npm authenticates a registry with client certificates as readily as with tokens — with a fixture. The header now states the partial-publish recovery contract: changeset publish skips versions that already exist, so the fix for a publish that failed partway is always a re-run, never registry surgery. All caught in review. --- .github/workflows/npm-changesets.yml | 29 +++++++++++++++++++++++++++- tests/workflows.test.mjs | 11 +++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index e3e26ef..d036862 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -32,6 +32,14 @@ # non-cancelling concurrency group — so a caller needs no concurrency of its # own, though adding one is harmless. # +# Recovery contract for a publish that fails partway: `changeset publish` +# is resumable. It skips versions that already exist on the registry, so a +# failed run's next attempt — a re-run, or simply the next push — publishes +# only what is missing and then pushes the tags. The pre-publish Build exists +# so that build-class failures never reach the sequential publish at all; +# what a partial publish can still mean is a registry-side failure, and the +# fix is always "run it again", never manual registry surgery. +# # Known, accepted exposure: changesets/action hands its environment — # GITHUB_TOKEN included — to the version and publish commands, which run # consumer code. That is inherent to the Changesets model (the changelog @@ -166,7 +174,7 @@ jobs: # Consuming the full stream keeps the exit codes honest. for npmrc in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${GITHUB_WORKSPACE:-.}/.npmrc" .npmrc; do [ -f "$npmrc" ] || continue - if grep -E '(_authToken|_auth|_password|tokenHelper)[[:space:]]*=' "$npmrc" | + if grep -E '(_authToken|_auth|_password|tokenHelper|certfile|keyfile)[[:space:]]*=' "$npmrc" | grep -vE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$' > /dev/null; then echo "::error::$npmrc carries a registry credential. This flow publishes with trusted publishing and must not carry one." exit 1 @@ -192,7 +200,26 @@ jobs: # multi-package publish. - name: Build run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.build }} + # The concurrency group serializes runs but does not order them: an + # older push whose quality job finished late can enter the group after + # a newer run and force-update the version pull request backwards, + # dropping the newer commit's changesets. A run that is no longer the + # branch head therefore stands down — the run for the newer commit + # covers everything this one would have done. + - name: Verify this run is still the branch head + id: freshness + env: + GH_TOKEN: ${{ github.token }} + run: | + head="$(gh api "repos/${{ github.repository }}/git/ref/heads/${{ github.ref_name }}" --jq .object.sha)" + if [ "$head" = "${{ github.sha }}" ]; then + echo "stale=false" >> "$GITHUB_OUTPUT" + else + echo "stale=true" >> "$GITHUB_OUTPUT" + echo "::notice::${{ github.ref_name }} has moved to $head; standing down in favor of that commit's run." + fi - name: Create release pull request or publish to npm + if: steps.freshness.outputs.stale == 'false' uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: title: ${{ inputs.pr-title }} diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index bc5cfa5..1ec41e5 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -801,6 +801,10 @@ test("npm changesets flow publishes with OIDC and API commits only", () => { assert.match(validate.run, /repository root/); const publish = release.steps.at(-1); assert.equal(publish.with.commitMode, "github-api"); + // A stale run — one whose branch has already moved on — must stand down + // rather than force-update the version pull request backwards. + assert.equal(publish.if, "steps.freshness.outputs.stale == 'false'"); + assert.ok(release.steps.find((step) => step.id === "freshness")); // Provenance follows source visibility; the registry refuses it from // private repositories rather than degrading. assert.match( @@ -868,6 +872,13 @@ test("npm changesets guard rejects real credentials, allows the placeholder", (t {}, 1, ], + [ + "client certificate", + "//registry.npmjs.org/:certfile=/etc/ssl/npm.crt\n//registry.npmjs.org/:keyfile=/etc/ssl/npm.key\n", + null, + {}, + 1, + ], [ "project npmrc credential", null, From ed4f9fd1e1575409d87afefde36156aae1fce252 Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:35:05 +0200 Subject: [PATCH 09/11] Keep the ref name out of the script source A branch name is caller-controlled text; interpolating it into the freshness check's script would let shell syntax in the name execute. It now reaches the script through the environment, like every other untrusted value. The README's no-commits statement also gains the same Changesets exception AGENTS.md's invariant carries, so the two do not contradict. Caught in review. --- .github/workflows/npm-changesets.yml | 12 +++++++++--- README.md | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index d036862..273849d 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -208,15 +208,21 @@ jobs: # covers everything this one would have done. - name: Verify this run is still the branch head id: freshness + # The ref name reaches the script through the environment, never + # interpolated into its source: a branch name is caller-controlled + # text, and expanding it inline would let shell syntax in the name + # execute. env: GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.ref_name }} + RUN_SHA: ${{ github.sha }} run: | - head="$(gh api "repos/${{ github.repository }}/git/ref/heads/${{ github.ref_name }}" --jq .object.sha)" - if [ "$head" = "${{ github.sha }}" ]; then + head="$(gh api "repos/${{ github.repository }}/git/ref/heads/$REF_NAME" --jq .object.sha)" + if [ "$head" = "$RUN_SHA" ]; then echo "stale=false" >> "$GITHUB_OUTPUT" else echo "stale=true" >> "$GITHUB_OUTPUT" - echo "::notice::${{ github.ref_name }} has moved to $head; standing down in favor of that commit's run." + echo "::notice::$REF_NAME has moved to $head; standing down in favor of that commit's run." fi - name: Create release pull request or publish to npm if: steps.freshness.outputs.stale == 'false' diff --git a/README.md b/README.md index 41e6c74..7738324 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,10 @@ SHA with a trailing version comment, so a compromised upstream tag cannot reach the jobs that hold deployment credentials. Dependabot advances the pins weekly and a test rejects any reference that is not a 40-character SHA. -Workflows in this repository never create commits or push branches. +Workflows in this repository never run `git commit` or `git push`, and the +tag-derived flows never write versions back to a repository. The one +write-back exception is the Changesets flow's version pull request, +maintained through the GitHub API — see the invariant in AGENTS.md. ## npm From cded44428b8310986bae659b7ed57084a9b257fb Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:45:23 +0200 Subject: [PATCH 10/11] Name the OIDC exposure alongside the token one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permissions are job-scoped, so consumer install, build, and prepublishOnly code runs in the job holding id-token: write — here and in npm-publish.yml alike. A tokenless build job would not close this: changeset publish still runs prepublishOnly under OIDC, and a dependency able to exfiltrate the token could as easily poison the artifact a split job would hand over. The lockfile and the gates in front of it are the boundary that holds, and the token mints only the repository's own publish identity. Raised in review; documented as accepted rather than restructured around. --- .github/workflows/npm-changesets.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index 273849d..d6db1ae 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -49,6 +49,16 @@ # controls. The tag-derived flow does not carry the token into consumer # commands, which is one more reason single-package repositories belong # there. +# +# The same acceptance covers OIDC: permissions are job-scoped, so consumer +# install, build, and prepublishOnly code runs in the job that holds +# id-token: write — here and in npm-publish.yml alike. Splitting the build +# into a tokenless job would not close this: `changeset publish` still +# executes each package's prepublishOnly under OIDC, and a dependency able +# to exfiltrate the token could as easily poison the artifact a split job +# would hand over. The boundary that actually holds is the lockfile and the +# gates in front of it — and the token itself mints only this repository's +# own publish identity. name: Burnt npm Changesets From c52eeb4f9a6bdffd82b0038bb62d46e2ca1dc59e Mon Sep 17 00:00:00 2001 From: 2xburnt <169301814+2xburnt@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:18:45 +0200 Subject: [PATCH 11/11] Write the shape down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layers with a hard boundary: this repository as the platform layer owning all logic, conditionals, and credential handling; consumer repositories as declaration layers holding only policy files and thin trigger callers. The routing rules that were settled while migrating provider-devtool — one package on the tag flow, two or more on Changesets, the load-bearing caller filename, sanctioned bespoke with a documented gap list, and the two-repository bar for schema growth — now live where the next repository onboards by reading instead of asking. The single multiplexed ci.yml is rejected in writing, with the reasons. --- AGENTS.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dc40a28..1f4d6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,51 @@ This repository holds the organization's required quality workflow and the reusable deployment and publishing workflows. Consumer repositories contribute policy files and thin trigger workflows; everything else lives here. +## The shape + +Two layers, hard boundary between them. + +**This repository is the platform layer.** It owns all logic, all +conditionals, and all credential handling. Nothing outside it touches a +secret, an OIDC token, or a publish/deploy decision. It is operated with +batched releases, Dependabot advancing consumer pins, and a capped policy +schema. + +**A consumer repository is a declaration layer.** Its `.github` contains +exactly two kinds of things, and no logic in either: + +- **Policy files** — every repository-specific fact, commented JSONC, + schema-validated here. Configuration goes in policy, never inline in a + workflow. +- **Trigger files** — thin callers of roughly ten lines with no `if:` + conditions. Each file exists only because GitHub attaches something + per-file that the flows need separated: trigger filters, a permissions + grant, a required-check identity, or a concurrency namespace. A file that + does not carry one of those should not exist; a single multiplexed "ci.yml" + is rejected because it must union every path's permissions onto every + event and replaces declarative `on:` filters with runs that no-op. + Repo-shaped odd jobs (a contract conformance check, a monitor) stay in the + consumer repository — they are not platform material. + +Routing rules, applied in order: + +1. A repository publishing **one** npm package uses the tag-derived flow + (`npm-main.yml` / `npm-release.yml`). **Two or more** interdependent + packages use `npm-changesets.yml`. See "Two npm flow shapes" below for + why the line is hard. +2. The npm caller's filename is load-bearing: npmjs.com binds each package's + trusted publisher to one workflow file, so every publish must run from + that file. Under the tag flow that forces one file with two triggers; the + Changesets flow needs only a push trigger. +3. A repository the standard cannot serve yet runs **sanctioned bespoke** + workflows: an in-repo flow whose header documents exactly which gaps keep + it off the standard, revisited when the standard grows. Silent divergence + is the failure mode; the gap list is what distinguishes an outlier from + drift. +4. The policy schema grows only for needs two or more repositories share. + A knob wanted by exactly one repository means that repository stays + bespoke for that piece instead. + ## Invariants These are not preferences. Changes that break them will be rejected.