From a3d18d730b2f7f26a711d40ee3a84c775f0a5216 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Wed, 16 Sep 2026 21:22:00 +0000 Subject: [PATCH 1/2] S0317-config-1: take the release path off npm registry tokens The publish job authenticates with the workflow's own OIDC identity and nothing else: no NPM_TOKEN or NODE_AUTH_TOKEN reaches any job or step, id-token: write and the protected release environment are the whole of its authentication, and setup-node no longer generates a user npmrc whose only purpose is carrying a token. The declaration gains publishPath.authentication, which the checker validates and compares against the workflow: the grant the method rests on must be on the publish job, the trusted publisher must name this workflow filename and environment, and a credential carrying registryAuth is a contradiction under a tokenless method. The documentation check now runs in both directions, so a rotation procedure for a credential nobody declares is a refusal too. scripts/publish-toolchain.mjs pins the npm CLI floor at merge time inside the required verify job, and refuses a release dry run declaring a different toolchain. --- .github/credential-surface.json | 82 +++---- .github/workflows/ci.yml | 27 +++ .github/workflows/release.yml | 107 +++++++--- RELEASING.md | 216 +++++++++++-------- npm-config-allow.json | 14 +- scripts/credential-surface.mjs | 194 ++++++++++++++++- scripts/publish-toolchain.mjs | 366 ++++++++++++++++++++++++++++++++ test/npm-config-allow.test.ts | 72 +++---- 8 files changed, 872 insertions(+), 206 deletions(-) create mode 100644 scripts/publish-toolchain.mjs diff --git a/.github/credential-surface.json b/.github/credential-surface.json index 2103747..58ceed3 100644 --- a/.github/credential-surface.json +++ b/.github/credential-surface.json @@ -4,7 +4,38 @@ "workflow": ".github/workflows/release.yml", "job": "publish", "environment": "release", - "command": "pnpm run release" + "command": "pnpm run release", + "authentication": { + "method": "github-oidc", + "summary": "The publish authenticates as the workflow itself. GitHub mints a short-lived OIDC token for a job that holds `id-token: write`, the npm CLI detects that environment and exchanges the token for a publish grant, and npm accepts it because a Trusted Publisher registered on npmjs.com names this repository, this workflow file and this environment. There is no long-lived registry credential anywhere on this path: no secret to store, to rotate, or to steal.", + "permission": { + "job": "publish", + "scope": "id-token", + "level": "write", + "note": "The one grant the whole path rests on. Removing it does not degrade the publish, it ends it: with no OIDC token to mint there is nothing else here that can authenticate, and npm's answer to an unauthenticated PUT is a 404 that reads like a missing package." + }, + "runtimeEvidence": [ + { + "variable": "ACTIONS_ID_TOKEN_REQUEST_URL", + "note": "The endpoint GitHub exposes to a job holding `id-token: write`. Its presence is what scripts/publish-preflight.mjs tests: it is the one observable that says this environment can mint an OIDC token, and it can be read without contacting anything." + }, + { + "variable": "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "note": "The bearer token for that endpoint. It is a credential: it is tested for PRESENCE only and its value is never read into a message, never logged and never compared. GitHub mints it per job and revokes it at job end, so there is nothing to store and nothing to rotate." + } + ], + "npmCliFloor": "11.5.1", + "npmCliFloorNote": "docs.npmjs.com/trusted-publishers: \"Trusted publishing requires npm CLI version 11.5.1 or later and Node version 22.14.0 or higher.\" The npm CLI is what makes the publish request on this path (`changeset publish` spawns `pnpm publish`, which packs and then calls `npm publish` through the `npm` it resolves off PATH), so this floor is on the publish path and is not satisfied by any pnpm version.", + "npmCliVersionSetting": "NPM_CLI_VERSION", + "npmCliVersionSettingNote": "The step-level variable that pins the npm CLI on the publish path to an exact version a reader can determine from the workflow file without running it. scripts/publish-toolchain.mjs reads it out of release.yml at merge time and refuses anything below npmCliFloor, and refuses a release dry run declaring a different toolchain.", + "trustedPublisher": { + "organization": "cosyte", + "repository": "config", + "workflow": "release.yml", + "environment": "release", + "note": "The four fields exactly as npmjs.com asks for them, for each of the eight published packages. The workflow field is a FILENAME WITH ITS EXTENSION and must match `release.yml` character for character; npm does not verify a trusted publisher configuration when it is saved, so a typo here surfaces only as an ENEEDAUTH at the first publish. RELEASING.md, \"Registering the npm Trusted Publisher\", carries the procedure and the allowed-action question that goes with it." + } + } }, "environmentNote": "Referencing an environment that does not exist makes GitHub silently auto-create an UNPROTECTED one of that name, so the reference below is evidence that the workflow ASKS for the gate and never evidence that the gate exists. The required reviewer and the main-only branch policy are verified out of band; RELEASING.md carries the API call that sets them and the symptom when they are missing.", "logRedaction": { @@ -16,40 +47,6 @@ "section": "Credential rotation, revocation, and compensating actions" }, "credentials": [ - { - "name": "NPM_TOKEN", - "tokenClass": "npm Automation token, or a granular access token with publish rights on the @cosyte scope. A classic Publish token demands a 2FA one-time password CI cannot supply and dies with EOTP at the last step, after a green build.", - "storage": "organization", - "storageNote": "An organization secret on the cosyte org, shared by every @cosyte/* repository. A repository-level NPM_TOKEN silently OVERRIDES the organization one, so it must live in exactly one place; rotating it affects every @cosyte/* repository, not just this one.", - "requiredForPublish": true, - "registryAuth": true, - "exposures": [ - { - "job": "publish", - "step": "Publish", - "as": "env", - "name": "NPM_TOKEN", - "mode": "value" - }, - { - "job": "publish", - "step": "Publish", - "as": "env", - "name": "NODE_AUTH_TOKEN", - "mode": "value" - }, - { - "job": "publish", - "step": "The publish configuration must be one the allow-set permits", - "as": "env", - "name": "NODE_AUTH_TOKEN", - "mode": "value", - "note": "S0081. The configuration allow-check runs earlier in this same gated job and must resolve the SAME configuration the publish resolves. The user npmrc actions/setup-node generates carries `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`, and npm expands that reference while resolving; without the variable this step would read an unresolvable reference, which that gate is fail-closed about. NPM_TOKEN itself is deliberately NOT exposed here: nothing on the configuration path reads it, so copying it would widen this surface for nothing. The check prints every credential-denoting key with a fixed redaction marker instead of its value, and scrubs credential-shaped text from every byte it emits; test/npm-config-allow.test.ts asserts both, on a pass and on a refusal." - } - ], - "issuedForms": [{ "id": "npm-automation-token", "pattern": "npm_[A-Za-z0-9]{36}" }], - "retiredWhen": "An npm Trusted Publisher is registered for all eight @cosyte/* packages against org cosyte, repository config, workflow release.yml, environment release. Removing this token before that publisher exists breaks every publish, so the order is not negotiable. RELEASING.md, 'Still deferred: OIDC trusted publishing', has the sequence." - }, { "name": "RELEASE_PR_TOKEN", "tokenClass": "Fine-grained personal access token scoped to this repository only, with Contents read+write, Pull requests read+write, Metadata read. It does NOT need Workflows write: `pnpm run version` only changes packages/*/package.json, packages/*/CHANGELOG.md and .changeset/.", @@ -129,10 +126,23 @@ "issuedForms": [ { "id": "actions-installation-token", "pattern": "gh[pousr]_[A-Za-z0-9]{36,}" } ], - "retiredWhen": "Never, while this repository uses GitHub Actions. What retires is a GRANT: `id-token: write` on the publish job becomes the ONLY npm credential once trusted publishing is registered, and `pull-requests: write` on the version job retires if the Version Packages PR ever stops being opened by this workflow." + "retiredWhen": "Never, while this repository uses GitHub Actions. What retires is a GRANT: `id-token: write` on the publish job is now the ONLY authentication the registry is offered, so it retires when this repository stops publishing, and `pull-requests: write` on the version job retires if the Version Packages PR ever stops being opened by this workflow." } ], "settings": [ + { + "name": "NPM_CLI_VERSION", + "purpose": "Not a secret, and declared for the same reason NPM_CONFIG_PROVENANCE is: it is the switch that decides whether the binary making the publish request can do trusted publishing at all. npm below `publishPath.authentication.npmCliFloor` has no OIDC exchange in it, so a publish on an older npm cannot authenticate and npm's answer to that is a 404 on the PUT, which reads like a missing package rather than like a refused credential. Pinned to an exact version so a reader can determine the npm on this path from the workflow file alone.", + "value": "11.19.1", + "exposures": [ + { + "job": "publish", + "step": "The npm CLI that publishes must be one trusted publishing can use", + "as": "env", + "name": "NPM_CLI_VERSION" + } + ] + }, { "name": "NPM_CONFIG_PROVENANCE", "purpose": "Not a secret, and declared anyway: it is the switch that makes npm provenance self-enabling on the publish step. Deleting it turns provenance off silently on a green run, and nothing else in this repository would notice.", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b40d38..ee9f7d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,20 @@ jobs: # is a report again. Zero-dependency node, like the five gates above, so it runs before # install. - run: node scripts/drift-check.js + # THE PUBLISH TOOLCHAIN GATE (S0317). Trusted publishing is the only authentication the release + # path has, and it requires an npm CLI at or above a floor the credential declaration states. + # Two things about that are decidable from committed text, so they are decided HERE rather than + # at publish time: the npm the publish path declares must meet the floor and must be + # determinable from the workflow file without running it (a Node release's BUNDLED npm is not, + # and it moves under us), and the release dry run below must declare the SAME toolchain, since + # a dry run on a different npm is not evidence about the real publish. + # + # It belongs in `verify` for the reason the six gates above do: `verify` is a REQUIRED status + # check in this repo's `config-ci-required-checks` ruleset, so this is the copy that can REFUSE + # A MERGE. Catching it on the release path instead would mean catching it after an approver has + # been asked to approve a run that cannot authenticate. Zero-dependency node, so it runs before + # install like the rest. + - run: node scripts/publish-toolchain.mjs - run: pnpm install --frozen-lockfile # The em-dash brand gate's FILE half also runs in no-emdash.yml, which additionally # covers the PR title, body and commit messages and carries the `edited` trigger those @@ -172,6 +186,19 @@ jobs: with: node-version: "22.14" cache: pnpm + # THE SAME TOOLCHAIN THE PUBLISH PATH DECLARES, PINNED THE SAME WAY, and scripts/ + # publish-toolchain.mjs refuses this workflow if the two ever drift apart. A dry run is only + # evidence about the real publish if it runs on the binary the real publish uses: `pnpm publish` + # calls `npm publish` through the `npm` it resolves off PATH, so the npm here decides what this + # job is actually proving. + - name: The npm CLI that publishes must be one trusted publishing can use + env: + NPM_CLI_VERSION: "11.19.1" + shell: bash + run: | + set -euo pipefail + npm install --global "npm@$NPM_CLI_VERSION" + echo "npm on the dry-run path is now $(npm --version)." - run: pnpm install --frozen-lockfile - run: pnpm build # Exercise the publish command path (auth-free dry run; never uploads). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c66f2c..c4cdbb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,12 +54,22 @@ permissions: # published nothing anyway. # --------------------------------------------------------------------------- # -# AUTH TODAY: NPM_TOKEN. This repo is PUBLIC, so npm provenance is already live; `NPM_CONFIG_PROVENANCE` -# auto-enables below on the public visibility check. The runner toolchain floor is bumped for OIDC -# (packageManager `pnpm@10.34.5` >= 10.16, Node `22.14`; publish runs via `pnpm run release`, so pnpm, -# not npm, carries trusted publishing). What remains for tokenless OIDC is a FOUNDER step, not a build: -# register the npm-side Trusted Publisher, then remove NPM_TOKEN / NODE_AUTH_TOKEN here. RELEASING.md -# has the turnkey steps. Until then, NPM_TOKEN must stay an npm *Automation* token. +# AUTH: THE WORKFLOW'S OWN OIDC IDENTITY, AND NOTHING ELSE. No registry credential is passed to any +# job or step in this file. The publish job holds `id-token: write`, GitHub mints a short-lived OIDC +# token for it, the npm CLI exchanges that token for a publish grant, and npm accepts it because a +# Trusted Publisher registered on npmjs.com names org `cosyte`, repository `config`, workflow file +# `release.yml` and environment `release`. `.github/credential-surface.json` is the declaration of +# that surface and `scripts/credential-surface.mjs` refuses this file if the two disagree. +# +# WHICH BINARY ACTUALLY PUBLISHES, because it decides which version floor is load-bearing. +# `pnpm run release` runs `changeset publish`; `@changesets/cli` spawns `pnpm publish` for a pnpm +# workspace; `pnpm publish` packs and then calls `npm publish` through the `npm` it resolves off +# PATH. So the npm CLI on the runner makes the publish request, trusted publishing needs npm +# >= 11.5.1, and the step below pins that npm to an exact version rather than inheriting whatever +# npm the Node release happens to bundle. pnpm does NOT carry trusted publishing on this path. +# +# This repo is PUBLIC, so npm provenance is already live; `NPM_CONFIG_PROVENANCE` auto-enables below +# on the public visibility check, and trusted publishing generates attestations by default. # # --------------------------------------------------------------------------- # WHY THIS IS STILL A STANDALONE WORKFLOW AND NOT A THIN CALLER OF @@ -194,7 +204,7 @@ jobs: # --------------------------------------------------------------------------- # VERSION ARM. Ungated. Opens or refreshes the "Version Packages" PR and NOTHING ELSE. No - # `publish:` input, no NPM_TOKEN, no `id-token`, so this job cannot reach the registry. + # `publish:` input and no `id-token: write`, so this job cannot authenticate to the registry. # --------------------------------------------------------------------------- version: needs: preflight @@ -258,8 +268,9 @@ jobs: version: pnpm run version # NO `publish:` INPUT, DELIBERATELY, AND IT IS THIS JOB'S WHOLE SAFETY PROPERTY. The action # only ever calls `runPublish` when a publish script was supplied; with none, the version - # arm is the only arm it can take. Combined with the absent NPM_TOKEN below, an ungated job - # that reached the registry would need two independent mistakes rather than one. + # arm is the only arm it can take. Combined with this job's `permissions:` block, which + # withholds `id-token: write`, an ungated job that reached the registry would need two + # independent mistakes rather than one. createGithubReleases: false env: # The other half of the version-PR fix: this is the token the action opens the PR with. It @@ -276,8 +287,8 @@ jobs: # need `Workflows: write`: `pnpm run version` changes `packages/*/package.json`, # `packages/*/CHANGELOG.md` and `.changeset/`, none of which is under `.github/workflows/`. # - # NOTE THE ABSENCE OF NPM_TOKEN / NODE_AUTH_TOKEN HERE. That absence is a control, not an - # oversight: see the `publish:` comment above. + # NOTE THE ABSENCE OF ANY REGISTRY AUTHENTICATION HERE, and of `id-token: write` on this + # job. That absence is a control, not an oversight: see the `publish:` comment above. GITHUB_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }} # THE GATE 1 SAFETY NET, RESTORED ON THIS ARM. Do not delete it as a duplicate of the step in @@ -368,8 +379,10 @@ jobs: contents: write # create tags / GitHub releases id-token: write # npm provenance # Release approval gate: the `release` environment carries a required reviewer (`NSchatz`) and a - # `main`-only deployment-branch policy, so the publish waits for an explicit approval. This is - # also the OIDC subject an npm Trusted Publisher would bind to. + # `main`-only deployment-branch policy, so the publish waits for an explicit approval. It is also + # the OIDC subject the npm Trusted Publisher binds to, so its NAME is part of the authentication + # and not only part of the approval: changing it here without changing it on npmjs.com ends every + # publish with an ENEEDAUTH. environment: release steps: - uses: actions/checkout@v7 @@ -377,12 +390,45 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: pnpm/action-setup@v6 + # NO `registry-url:` INPUT, AND ITS ABSENCE IS LOAD-BEARING RATHER THAN AN OMISSION. Given one, + # `actions/setup-node` GENERATES a user npmrc carrying + # `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` and points NPM_CONFIG_USERCONFIG at it. + # That file exists for exactly one purpose, handing a long-lived token to the registry, and on + # this path there is no such token for it to carry: the reference resolves to nothing, and what + # each resolver does with an unresolvable reference is a property of its own version rather + # than of anything committed here. The publish does not need the file. `registry` is npm's own + # default and `npm-config-allow.json` REQUIRES it at https://registry.npmjs.org/, checked + # against both resolvers' full effective maps, so the registry these tarballs go to is pinned + # by the gate below rather than by a generated file. - uses: actions/setup-node@v6 with: node-version: "22.14" - registry-url: https://registry.npmjs.org cache: pnpm + # THE npm CLI FLOOR, PINNED WHERE A READER CAN SEE IT. + # + # Trusted publishing requires npm >= 11.5.1 (docs.npmjs.com/trusted-publishers), and the npm + # CLI is what makes the publish request here: `changeset publish` spawns `pnpm publish`, which + # packs and then calls `npm publish` through the `npm` it resolves off PATH. A Node release's + # BUNDLED npm is not determinable from this file and moves under us, so it is not evidence + # about this publish; this step pins the binary to an exact version instead. + # + # `NPM_CLI_VERSION` is declared in .github/credential-surface.json and checked against it at + # merge time by scripts/publish-toolchain.mjs, which also refuses a release dry run declaring a + # different toolchain: a dry run on a different npm is not evidence about the real publish. + # + # It runs BEFORE the configuration gate below on purpose. That gate asks the package managers + # themselves what this publish would be configured with, so it has to ask the same npm the + # publish will use. + - name: The npm CLI that publishes must be one trusted publishing can use + env: + NPM_CLI_VERSION: "11.19.1" + shell: bash + run: | + set -euo pipefail + npm install --global "npm@$NPM_CLI_VERSION" + echo "npm on the publish path is now $(npm --version)." + # GATE 3: THE EFFECTIVE CONFIGURATION OF THIS PUBLISH MUST BE ONE SOMEBODY APPROVED. # # THE HOLE THIS CLOSES (S0055 finding F9). Everything that decides WHERE these tarballs go, @@ -403,25 +449,19 @@ jobs: # than no answer, because the conclusion is the only thing anyone reads. The accepted cost is # that a refusal here happens after an approver has already approved the run. # - # THE `env:` BLOCK BELOW IS THE PUBLISH STEP'S, MINUS EXACTLY ONE VARIABLE, AND BOTH HALVES OF - # THAT ARE DELIBERATE. It is DECLARED in .github/credential-surface.json (S0080), which is what - # made this decision a reviewed one rather than a default. + # THE `env:` BLOCK BELOW IS THE PUBLISH STEP'S npm CONFIGURATION, AND THAT IS THE WHOLE OF IT. + # It is DECLARED in .github/credential-surface.json (S0080), which is what made this a reviewed + # decision rather than a default. # # * NPM_CONFIG_PROVENANCE is npm CONFIGURATION supplied at STEP level, so a check that ran # without it would be reporting on a configuration the publish does not have. That is the # same class of error as running this check in `preflight`. - # * NODE_AUTH_TOKEN is here because the user npmrc `actions/setup-node` generates two steps - # above contains `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`, and npm expands - # that reference when it resolves configuration. Without the variable this step would be - # reading a DIFFERENT state of that file than the publish reads: an unresolvable - # reference, which this gate is fail-closed about. It would refuse every release, or worse - # depend on the npm version's tolerance for it. - # * NPM_TOKEN is NOT here, and that omission is the point of naming this block at all. - # Nothing on the configuration path reads it: npm reads only `npm_config_*` variables, and - # the registry credential reaches npm through the generated npmrc above. It is in the - # publish step because `changesets/action` wants it. Copying it here would widen this - # repository's declared credential surface by one step and buy this check nothing, and a - # surface that grows for nothing is exactly what credential-surface.mjs exists to refuse. + # * NO REGISTRY CREDENTIAL IS HERE, and none is needed. Authentication is the workflow's OIDC + # identity, which is a property of the JOB (`id-token: write`) rather than a value some + # step is handed, and nothing on the configuration path reads it: npm reads only + # `npm_config_*` variables when it resolves. With no generated user npmrc above, there is + # also no `${...}` reference for a resolver to be tolerant or intolerant about, so this + # step resolves the same effective configuration the publish resolves. # # If the publish step's npm CONFIGURATION env changes, change this with it, and update # .github/credential-surface.json in the same commit. The check never prints a credential @@ -432,7 +472,6 @@ jobs: # existing environment gate; it removes, loosens and relocates nothing. - name: The publish configuration must be one the allow-set permits env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: ${{ github.event.repository.visibility == 'public' }} run: node scripts/npm-config-allow.mjs @@ -473,8 +512,12 @@ jobs: createGithubReleases: false env: GITHUB_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # NO REGISTRY CREDENTIAL, AND NOTHING TO FALL BACK TO. npm authenticates with this job's + # OIDC identity; if that exchange fails there is no token in this environment for npm to + # try instead, so the run fails loudly rather than publishing under a second credential + # nobody reviewed. The accounting step below is what keeps a failed publish from reading + # as a green run. + # # npm provenance requires a PUBLIC source repo; auto-enables when this repo is public. NPM_CONFIG_PROVENANCE: ${{ github.event.repository.visibility == 'public' }} diff --git a/RELEASING.md b/RELEASING.md index 37b7092..d0f4ef1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -197,8 +197,10 @@ is actually there. Both choices exist to close the same hole: the accounting reads `npm view` rather than the run's own output. 2. If the re-run reports the same packages missing, the publish genuinely failed for them. Open the run's `npm-debug-log-config-run-attempt` artifact, which the workflow uploads on failure - with credentials redacted, and read the npm error. The usual causes are an expired or wrong-typed - `NPM_TOKEN` (see the authentication section) and an npm-side 403 on a scoped package. + with credentials redacted, and read the npm error. The usual causes are a trusted publisher npm + will not match (see [Authentication](#authentication), and note that npm reports a failed OIDC + publish as a **404 on the PUT**, which reads like a missing package rather than like a refused + credential) and an npm-side 403 on a scoped package. 3. Fix the cause and re-run again. **Do not hand-publish and do not bump the version to get past it.** A version consumed by a failed publish is not burned: the same version can be published again because nothing reached the registry under it. @@ -283,10 +285,9 @@ so a local refusal is not by itself evidence about the release. Read the source **What it protects.** Everything that decides **where** these tarballs go, **what** goes inside them and **what metadata rides along** is npm/pnpm configuration, and that configuration is assembled at -publish time out of sources nothing in this repository used to inspect: a global npmrc, the user -npmrc `actions/setup-node` **generates** on the runner and points `NPM_CONFIG_USERCONFIG` at, a -repository or per-package `.npmrc`, `pnpm-workspace.yaml`, the root manifest's `pnpm` block, each -package's `publishConfig`, `.changeset/config.json`, and the job's own environment. A redirected +publish time out of sources nothing in this repository used to inspect: a global npmrc, a user +npmrc, a repository or per-package `.npmrc`, `pnpm-workspace.yaml`, the root manifest's `pnpm` +block, each package's `publishConfig`, `.changeset/config.json`, and the job's own environment. A redirected `registry`, a disabled `provenance`, a widened `access` or an injected lifecycle-script setting changes what reaches the public registry **without changing a tracked file in a way review would see**. An npm publish is permanent and cannot be withdrawn. @@ -457,21 +458,89 @@ multi-package shape, and now the ungated-version / gated-publish split, which is against `cosyte/.github` in the evidence record. If the shared workflow ever grows a multi-package mode, revisit this. -## Authentication today - -This repository is **public**, so publishing authenticates with `NPM_TOKEN`, an org-level secret -shared across the `@cosyte/*` repositories, and **with provenance**. `NPM_CONFIG_PROVENANCE` is wired -to `github.event.repository.visibility == 'public'`, so provenance is on with no workflow edit. - -`NPM_TOKEN` **must be an npm _Automation_ token** (or a granular token). A classic _Publish_ token -demands a 2FA one-time password that CI cannot supply, and the publish dies with `EOTP This operation -requires a one-time password from your authenticator`: after a green build, at the very last step. -Note that a repository-level `NPM_TOKEN` silently overrides the org-level one, so keep the token in -exactly one place. - -`NPM_TOKEN` and `NODE_AUTH_TOKEN` are supplied to the `publish` job **only**. The ungated `version` -job has neither, and is given no `publish:` input either, so reaching the registry from an ungated job -would take two independent mistakes rather than one. Keep it that way. +## Authentication + +**There is no npm registry token anywhere on this path.** The publish authenticates as the workflow +itself: the `publish` job holds `id-token: write`, GitHub mints a short-lived OIDC token for that job, +the npm CLI exchanges it for a publish grant, and npm accepts it because a **Trusted Publisher** +registered on npmjs.com names this organization, this repository, this workflow file and this +environment. Nothing is stored, so nothing can be stolen from storage, and there is nothing to rotate. + +**The npm CLI is what makes the publish request, and its version is load-bearing.** `pnpm run release` +is `changeset publish`; `changeset publish` spawns `pnpm publish` for a pnpm workspace; `pnpm publish` +packs and then calls `npm publish` through the `npm` it resolves off `PATH`. So the binary that talks +to the registry is the **npm CLI**, and trusted publishing requires **npm 11.5.1 or later** +([docs.npmjs.com/trusted-publishers](https://docs.npmjs.com/trusted-publishers)). A Node release's +bundled npm is not determinable from the workflow file and moves between two runs of it, so +`release.yml`'s publish job **pins npm to an exact version** in an `NPM_CLI_VERSION` step variable and +installs it, and `ci.yml`'s `release-dry-run` job declares the same one. +`scripts/publish-toolchain.mjs` reads both at merge time, refuses a version below the floor declared in +`.github/credential-surface.json`, refuses a version that is only discoverable at run time, and refuses +a dry run whose toolchain differs from the publish path's. It runs in the **required** `verify` job. + +**What a failure looks like.** npm answers a publish it cannot authenticate with a **404 on the PUT**, +which reads like a missing package. There is no token in the environment to fall back to, by +construction, so a failed exchange fails the run rather than quietly publishing under a second +credential; the `Every bumped package must be published, tagged and released` step then refuses to +report success while any bumped package is absent from the registry. + +**Provenance is on and stays on.** This repository is public, `NPM_CONFIG_PROVENANCE` is wired to +`github.event.repository.visibility == 'public'`, and trusted publishing generates attestations by +default. The configuration allow-check pins `provenance` at `true` and permits no other value. + +**`id-token: write` is granted to the `publish` job only.** The ungated `version` job does not have it +and is given no `publish:` input either, so reaching the registry from an ungated job would take two +independent mistakes rather than one. Keep it that way. + +**Publishing by hand does not work, and that is the design.** `pnpm run release` run from a laptop +refuses in `scripts/publish-preflight.mjs`, because the OIDC request variables a GitHub Actions job +has are not there. There is no token to substitute. A release happens through the workflow or it does +not happen. + +### Registering the npm Trusted Publisher + +**A founder step on npmjs.com, not a build**, and npm **does not verify the configuration when you +save it**, so a wrong field surfaces only as `ENEEDAUTH Unable to authenticate` at the first publish. +Register one for **each of the eight published packages**: `@cosyte/eslint-config`, +`@cosyte/prettier-config`, `@cosyte/process`, `@cosyte/script-utils`, `@cosyte/test-utils`, +`@cosyte/tsconfig`, `@cosyte/tsup-config`, `@cosyte/vitest-config`. + +On the package's page: Settings, then Trusted Publisher, then GitHub Actions, then these four fields +exactly: + +| field | value | +| ----------------------------- | ----------------------------------------------------------- | +| Organization or user | `cosyte` | +| Repository | `config` | +| Workflow filename | `release.yml` (**with the `.yml` extension**, character for character) | +| Environment name | `release` | + +The same four are declared in `.github/credential-surface.json` under +`publishPath.authentication.trustedPublisher`, and `pnpm credentials:check` refuses a declaration whose +workflow filename or environment differs from the workflow that actually publishes. + +**Allowed actions: choose `npm publish` explicitly.** A configuration **created after 2026-09-03** is +automatically set to allow `npm stage publish`, and you choose whether to **also permit direct +publishing with `npm publish`**. This repository publishes with **`npm publish`** (through +`changeset publish`, then `pnpm publish`), so a registration that permits only `npm stage publish` +refuses every release here. Tick direct publishing. (Configurations created before 2026-05-20 allow +`npm publish` only; ones created between then and 2026-09-03 required an explicit choice.) A package +may carry up to 10 trusted publishers at once, so adding one does not displace another. + +**Verify it the only way it can be verified: by publishing.** npm's own configuration screen does not +check any of this. The first release after registering is the evidence, which is why this repository +registers and proves **one** package before all eight. + +**Ordering, and it is not negotiable.** Registering the publisher before this workflow stopped using a +token was safe: npm accepts OIDC *in addition to* a token. Removing the token before the publisher +exists is not: every publish fails until it does. If a release is refused with `ENEEDAUTH` or a 404 on +the PUT, check the registration before changing anything in this repository. + +**Then harden npm.** Set each package and the organization to "Require two-factor authentication and +disallow tokens". Trusted publishers keep working; any token that still exists anywhere becomes +useless. Delete the organization-level `NPM_TOKEN` secret last, and note that it is shared by every +`@cosyte/*` repository: deleting it breaks any sibling repository still publishing with it, so that +deletion is an estate-wide decision rather than this repository's. ### The surface is declared, not described @@ -515,49 +584,22 @@ token published. Every remedy below is a human action taken against npm or GitHu the compensating action per credential is the whole of it. **A published version is permanent.** A version that has reached the registry stays there and is not -undone by revoking the credential that published it. Revoking `NPM_TOKEN` stops the **next** publish; -it does nothing about the one that already happened. npm's unpublish window is narrow and its use is a -founder decision with consumers on the other side of it, so the compensating action for a bad publish -is a **new version**, not a withdrawal. Treat "the credential was compromised" and "the wrong bytes -shipped" as two separate incidents with two separate remedies, and do not let revoking a token feel -like it addressed the second. - -### `NPM_TOKEN` - -**Blast radius before you start.** This is an **organization** secret shared by every `@cosyte/*` -repository. Rotating it affects all of them, not just this one, and there is no per-repository -override that is safe to introduce: a repository-level `NPM_TOKEN` silently outranks the -organization-level one, which is exactly how two tokens end up live with nobody sure which one -published. - -- **Issue.** On npmjs.com as the `@cosyte` scope owner: Access Tokens, then Generate New Token, then - **Granular Access Token** (or **Automation**). Grant it read and write on the `@cosyte` scope, - nothing else, and set the shortest expiry the release cadence tolerates. **Do not issue a classic - Publish token**: it demands a 2FA one-time password that CI cannot supply, and the publish dies with - `EOTP This operation requires a one-time password from your authenticator` after a green build, at - the very last step. -- **Install.** Set it in **exactly one place**: the `cosyte` organization secrets - (`gh secret set NPM_TOKEN --org cosyte --visibility all`). Then confirm no repository-level copy - exists anywhere it would shadow the organization one: - `gh secret list --repo cosyte/config` must not list `NPM_TOKEN`. Delete any that does. -- **Verify.** Do not verify by publishing. Run the `Release` workflow on a `main` with no pending - changesets, approve the `publish` deployment, and read the run: `changeset publish` reports "no new - packages" and the accounting step says every bumped package is on the registry. A wrong-typed token - fails there with `EOTP` and an expired one with `E401`, both **before** anything is written, because - the publish preflight (`scripts/publish-preflight.mjs`) refuses an absent or empty token before the - build even runs. -- **Revoke.** On npmjs.com, Access Tokens, revoke the old token **after** the new one is installed and - verified, not before: the two steps overlap deliberately so no release window is left with no - working token. Then re-read `gh secret list --org cosyte` and confirm one entry, one value. -- **Compensating action.** If the token is believed compromised, invert the order: **revoke first**, - accept that every `@cosyte/*` publish is blocked until a replacement is installed, and say so in the - org channel because thirteen other repositories share it. Then `npm token list` and audit recent - versions of all eight packages (`npm view versions --json`) against this repository's tags. A - version that reached the registry cannot be pulled back by revoking the token that published it: if - the bytes are wrong, publish a corrected **new** version and, only if the content is genuinely - dangerous, take the unpublish question to the founder. Finally, harden: set the packages and the org - to "Require two-factor authentication and disallow tokens", which makes a stolen token useless and - is the same setting the OIDC cutover wants anyway. +undone by revoking the credential that published it, or by removing the trusted publisher that +authorized it. Ending a credential's access stops the **next** publish; it does nothing about the one +that already happened. npm's unpublish window is narrow and its use is a founder decision with +consumers on the other side of it, so the compensating action for a bad publish is a **new version**, +not a withdrawal. Treat "the credential was compromised" and "the wrong bytes shipped" as two separate +incidents with two separate remedies, and do not let revoking anything feel like it addressed the +second. + +**There is no registry credential in this section, and that is not an omission.** The publish path +authenticates with the workflow's own OIDC identity (see [Authentication](#authentication)), so there +is no npm token to issue, install, rotate or revoke. What plays the part of revocation is **removing +the trusted publisher** on npmjs.com for the affected package, which stops the next publish from this +workflow and is a founder action rather than a repository edit. A subsection appears here for every +credential `.github/credential-surface.json` declares and for no others, in **both** directions: +`pnpm credentials:check` refuses a declared credential with no procedure here, and a procedure here +for a credential the declaration does not name. ### `RELEASE_PR_TOKEN` @@ -681,27 +723,31 @@ with seven red cases on a tree whose only change was a `CHANGELOG.md`. The fix i than in the caller: **`scripts/attw.mjs` strips those two keys from the environment of the `attw` child**, in both copies of the wrapper, so every scaffolded parser inherits it. -## Still deferred: OIDC trusted publishing - -**Provenance is live** (the repository is public). **OIDC trusted publishing**, publishing with no -token at all, is the remaining step. A turnkey sequence: - -1. ~~**Bump the runner toolchain floor**~~: **DONE.** `packageManager` is now `pnpm@10.34.5` - (>= 10.16) and the `setup-node` pins are `22.14` (>= 22.14) across `ci.yml` (`release-dry-run`) - and `release.yml`; `engines.node` is `>=22.14`. Since publish runs via `pnpm run release`, **pnpm** - carries OIDC trusted publishing, so the npm-CLI floor (npm >= 11.5.1) is not on the publish path - and no `npm i -g npm@...` step is needed. `pnpm/action-setup@v6` reads `packageManager`, so the - dry-run and release jobs install 10.34.5. -2. **Configure the Trusted Publisher on npm**: for each of the eight `@cosyte/*` packages: Settings, - then Trusted Publisher, then GitHub org `cosyte`, repository `config`, workflow filename - `release.yml`, environment name `release`, allowed action `npm publish`. **The environment name is - still `release` after the 2026-08-22 split**, because the publish job is the one that kept it. -3. **Remove `NPM_TOKEN` / `NODE_AUTH_TOKEN`** from the workflow and repository secrets; keep - `permissions: id-token: write` on the `publish` job (already present). -4. **Harden npm**: set the package and org to "Require two-factor authentication and disallow tokens"; - OIDC trusted publishers keep working, stolen tokens become useless. - -Steps 2 to 4 are founder steps, not a build. +## The repository half of trusted publishing, in one place + +Done, and checked by gates rather than remembered: + +1. **The workflow carries no registry credential.** `release.yml` references no npm token in any job + or step, and `pnpm credentials:check` refuses the file if one ever comes back, in either direction + and whichever of the three files moves first (the workflow, the declaration, this document). The + `publish` job keeps `id-token: write` and the protected `release` environment, which together are + the whole of its authentication. +2. **The toolchain the publish needs is declared, not inherited.** `NPM_CLI_VERSION` pins the npm CLI + on the publish path to an exact version at or above the floor the declaration states, the release + dry run declares the same one, and `scripts/publish-toolchain.mjs` refuses a merge that breaks + either, inside the required `verify` job. +3. **The release command path refuses early.** `scripts/publish-preflight.mjs` runs first in + `pnpm run release`, before the build and long before anything is packed: it checks that the + authentication the declaration names is actually available in this environment, and that the + resolved npm meets the floor. Nothing is contacted and no credential value is read. + +What is **not** the repository's half is registering the publisher on npmjs.com and retiring the +organization secret. Both are founder actions: +see [Registering the npm Trusted Publisher](#registering-the-npm-trusted-publisher). + +**The environment name is still `release` after the 2026-08-22 split**, because the publish job is the +one that kept it. It is part of what the registry matches, so renaming it is a release-path change and +not a cosmetic one. ## The evidence behind all of this diff --git a/npm-config-allow.json b/npm-config-allow.json index 9bd2750..33f68be 100644 --- a/npm-config-allow.json +++ b/npm-config-allow.json @@ -26,27 +26,17 @@ { "key": "registry", "value": "https://registry.npmjs.org/", - "why": "The public npm registry, which is where these eight packages belong. actions/setup-node writes this into the user config it generates for the publish job, so it arrives as a contributed value rather than as npm's default. Only this one URL is permitted, and the `require` rule below re-checks it against the resolvers' full maps so a permissive entry here could not widen it." + "why": "The public npm registry, which is where these eight packages belong. Only this one URL is permitted, wherever it is contributed from, and the `require` rule below re-checks it against the resolvers' FULL maps (defaults included) so a permissive entry here could not widen it. On the publish path nothing contributes it any more: the publish job's setup-node step declares no `registry-url`, so npm's own default is what answers, and the `require` rule is what pins that." }, { "key": "provenance", "value": true, "why": "release.yml sets NPM_CONFIG_PROVENANCE from the repository's public visibility, and provenance attestations are wanted. `true` is the ONLY permitted value: a source contributing `provenance=false` is a disabled attestation and is exactly what this gate exists to refuse." }, - { - "key": "//registry.npmjs.org/:_authToken", - "anyValue": true, - "why": "The registry credential actions/setup-node writes into the generated user config. Permitted by KEY and never by value: the token is a secret, this file is public, and the check refuses an allow-set that pins one. The registry it authenticates to is pinned by the `require` rule below, so this cannot become a credential offered to somewhere else." - }, - { - "key": "always-auth", - "anyValue": true, - "why": "actions/setup-node has written this alongside the token in some versions and it is not pinned here because which value it writes varies by version. It only decides whether the credential is sent preemptively, and the registry that credential goes to is pinned by the `require` rule below, so neither value can offer the token to somewhere else." - }, { "key": "userconfig", "anyValue": true, - "why": "Where npm's user config lives. actions/setup-node GENERATES that file under RUNNER_TEMP and exports NPM_CONFIG_USERCONFIG to point at it, so the path is runner-specific and cannot be pinned. Permitting the pointer is safe because the check reads the file it points AT through npm's own resolver: whatever that file contributes is itself observed and judged by this allow-set." + "why": "Where npm's user config lives, which is derived from HOME and therefore machine-specific. Permitting the pointer is safe because the check reads the file it points AT through npm's own resolver: whatever that file contributes is itself observed and judged by this allow-set. THERE IS NO ENTRY FOR A REGISTRY CREDENTIAL KEY, and that absence is deliberate: the publish path authenticates with the workflow's OIDC identity, so an `_authToken` reaching the publish process is a token nobody on this path should have, and this gate refuses it rather than permitting it by name." }, { "key": "globalconfig", diff --git a/scripts/credential-surface.mjs b/scripts/credential-surface.mjs index b04456c..15ee2f3 100644 --- a/scripts/credential-surface.mjs +++ b/scripts/credential-surface.mjs @@ -27,8 +27,13 @@ // * a permissions grant on `GITHUB_TOKEN` that is wider than declared, or a job that declares no // permissions block at all and therefore silently inherits the workflow-level one; // * a registry-reaching job that does not declare the protected deployment environment; +// * a publish path whose declared authentication the workflow does not actually have: the grant it +// rests on missing from the job, or a trusted publisher registered against a different workflow +// filename or environment than the one that publishes; +// * a long-lived registry credential declared on a publish path declared tokenless; // * an issued credential form the release run's log scrubbing does not cover; -// * a declared credential the rotation / revocation / compensating-action documentation omits. +// * a declared credential the rotation / revocation / compensating-action documentation omits, and +// a documented credential the declaration does not name. // // IT RUNS BEFORE ANYONE IS ASKED FOR ANYTHING. `ci.yml`'s `verify` job is a REQUIRED status check in // this repository's `config-ci-required-checks` ruleset, so this gate is wired there, next to the @@ -91,6 +96,20 @@ const STORAGE_LOCATIONS = ["organization", "repository", "environment", "github- /** How a credential reaches the thing that consumes it. */ const EXPOSURE_ROUTES = ["env", "with"]; +/** + * How the publish path authenticates to the registry. + * + * `github-oidc` is the tokenless one: the workflow's own OIDC identity is what npm accepts, so a + * declared credential carrying registry authentication is a CONTRADICTION under it rather than an + * extra, and is reported as one. `npm-token` is kept in this list so that going back to a + * long-lived token stays expressible as a reviewed diff in the declaration rather than requiring a + * change to this checker: a gate that can only be satisfied one way gets edited out. + */ +const AUTHENTICATION_METHODS = ["github-oidc", "npm-token"]; + +/** A version the declaration can compare: exactly three dot-separated numbers. */ +const EXACT_VERSION = /^\d+\.\d+\.\d+$/; + /** What the workflow does with the credential at an exposure. */ const EXPOSURE_MODES = ["value", "presence-test"]; @@ -511,6 +530,7 @@ export function validateDeclaration(declaration) { problems.push(`\`publishPath.${field}\` must be a non-empty string`); } } + problems.push(...validateAuthentication(publishPath.authentication)); } const redaction = declaration.logRedaction; @@ -560,6 +580,99 @@ export function validateDeclaration(declaration) { return problems; } +/** + * Validate the publish path's declared authentication. + * + * REQUIRED, NOT OPTIONAL, and that is the whole point of it. A publish path that declares no + * authentication at all is a declaration nothing can be conformed to: every check below would pass + * on it, and so would `scripts/publish-preflight.mjs`, which derives what it demands of the release + * environment from exactly this block. "We could not tell" must never read as "there is nothing to + * tell". + * + * @param authentication The `publishPath.authentication` block. + * @returns A list of problems; empty means valid. + */ +function validateAuthentication(authentication) { + const label = "`publishPath.authentication`"; + if ( + authentication === undefined || + typeof authentication !== "object" || + authentication === null || + Array.isArray(authentication) + ) { + return [ + `${label} is missing, and a publish path that names no authentication cannot be conformed to`, + ]; + } + const problems = []; + if (!AUTHENTICATION_METHODS.includes(authentication.method)) { + problems.push(`${label}.method must be one of ${AUTHENTICATION_METHODS.join(", ")}`); + } + if (typeof authentication.summary !== "string" || authentication.summary.trim() === "") { + problems.push(`${label}.summary must say what authenticates this publish`); + } + + const permission = authentication.permission; + if (permission === null || typeof permission !== "object" || Array.isArray(permission)) { + problems.push(`${label}.permission must name the job, scope and level the publish path needs`); + } else { + for (const field of ["job", "scope", "level"]) { + if (typeof permission[field] !== "string" || permission[field].trim() === "") { + problems.push(`${label}.permission.${field} must be a non-empty string`); + } + } + } + + if (!Array.isArray(authentication.runtimeEvidence) || authentication.runtimeEvidence.length === 0) { + problems.push( + `${label}.runtimeEvidence must name at least one environment variable whose presence says this authentication is available, or the preflight would pass on any environment at all`, + ); + } else { + for (const evidence of authentication.runtimeEvidence) { + if ( + evidence === null || + typeof evidence !== "object" || + typeof evidence.variable !== "string" || + evidence.variable.trim() === "" + ) { + problems.push(`${label}.runtimeEvidence has an entry without a non-empty \`variable\``); + } + } + } + + if (typeof authentication.npmCliFloor !== "string" || !EXACT_VERSION.test(authentication.npmCliFloor)) { + problems.push(`${label}.npmCliFloor must be an exact version, such as 11.5.1`); + } + if ( + typeof authentication.npmCliVersionSetting !== "string" || + authentication.npmCliVersionSetting.trim() === "" + ) { + problems.push( + `${label}.npmCliVersionSetting must name the workflow variable that pins the npm CLI`, + ); + } + + const publisher = authentication.trustedPublisher; + if (publisher === null || typeof publisher !== "object" || Array.isArray(publisher)) { + problems.push(`${label}.trustedPublisher must name what is registered on the registry side`); + } else { + for (const field of ["organization", "repository", "workflow", "environment"]) { + if (typeof publisher[field] !== "string" || publisher[field].trim() === "") { + problems.push(`${label}.trustedPublisher.${field} must be a non-empty string`); + } + } + // The registry matches the workflow FILENAME, extension included, and does not verify the + // configuration when it is saved: a field without an extension here is a publish that fails + // with ENEEDAUTH and nothing to read about why. + if (typeof publisher.workflow === "string" && !/\.ya?ml$/.test(publisher.workflow)) { + problems.push( + `${label}.trustedPublisher.workflow must be the workflow FILENAME with its extension, which is what the registry matches`, + ); + } + } + return problems; +} + /** * @param credential One entry of `credentials`. * @param index Its position, for a message that can name it when `name` itself is missing. @@ -947,6 +1060,7 @@ export function checkCredentialSurface({ repoRoot, declarationPath, workflowPath const { references, opaque } = collectSecretReferences(workflow, jobs); checkStepNameAmbiguity(declaration, jobs, findings); + checkPublishAuthentication(declaration, jobs, findings); const matched = checkReferences(declaration, references, findings); checkDeclaredExposures(declaration, jobs, matched, findings); checkSettings(declaration, jobs, findings); @@ -996,6 +1110,69 @@ function checkStepNameAmbiguity(declaration, jobs, findings) { } } +/** + * The publish path's declared authentication must be the authentication the workflow actually has. + * + * WHY THIS IS A SEPARATE CHECK FROM THE PERMISSIONS ONE. `checkPermissions` compares the workflow's + * grants against the declaration's grants, so it goes green the moment the two agree: deleting + * `id-token: write` from the publish job AND from the declaration's permissions block in one commit + * passes it. That commit ends every publish, because on this path the OIDC token IS the credential. + * So the authentication block names the grant it depends on, and this check reads that grant off the + * workflow. The two directions together are what make a silent removal impossible. + * + * AND THE CONTRADICTION. Under `github-oidc` there is no long-lived registry credential by + * construction, so a declared credential carrying `registryAuth` is not an extra to be tolerated: it + * is the declaration saying two incompatible things about the same publish. Reported by name, which + * is one of the three directions a credential can come back through. + * + * @param declaration The declaration. + * @param jobs The job index. + * @param findings Accumulator. + */ +function checkPublishAuthentication(declaration, jobs, findings) { + const authentication = declaration.publishPath.authentication; + const { job: jobId, scope, level } = authentication.permission; + const job = jobs.get(jobId); + if (job === undefined) { + findings.push({ + code: "authentication-job-absent", + message: `the publish path authenticates with \`${authentication.method}\` on job "${jobId}" and the workflow has no such job`, + }); + } else { + const found = readPermissions(job.node); + const granted = found.present && found.grants !== null ? found.grants[scope] : undefined; + if (granted !== level) { + findings.push({ + code: "authentication-permission-absent", + message: `the publish path authenticates with \`${authentication.method}\`, which rests on \`${scope}: ${level}\` in job "${jobId}", and that job grants ${granted === undefined ? `no \`${scope}\` at all` : `\`${scope}: ${granted}\``}`, + }); + } + } + + const publisher = authentication.trustedPublisher; + if (!declaration.publishPath.workflow.endsWith(`/${publisher.workflow}`)) { + findings.push({ + code: "trusted-publisher-workflow-mismatch", + message: `the trusted publisher is registered against workflow \`${publisher.workflow}\` and this publish runs from \`${declaration.publishPath.workflow}\`; the registry matches the filename exactly, so these cannot differ`, + }); + } + if (publisher.environment !== declaration.publishPath.environment) { + findings.push({ + code: "trusted-publisher-environment-mismatch", + message: `the trusted publisher is registered against environment \`${publisher.environment}\` and the publish path declares \`${declaration.publishPath.environment}\`; the registry matches the environment name, so these cannot differ`, + }); + } + + if (authentication.method !== "github-oidc") return; + for (const credential of declaration.credentials) { + if (!credential.registryAuth) continue; + findings.push({ + code: "registry-credential-declared", + message: `\`${credential.name}\` is declared as registry authentication and the publish path authenticates with \`${authentication.method}\`, which is tokenless; a long-lived registry credential cannot be reintroduced without changing the declared authentication method in the same diff`, + }); + } +} + /** * Every reference the workflow makes must be one the declaration permits, at the scope it permits. * @@ -1347,6 +1524,21 @@ function checkDocumentation(declaration, root, docsPath, findings) { } } + // THE OTHER DIRECTION, and it is the one a documentation check usually leaves open. A subsection + // here is a procedure for issuing, installing and revoking a credential, so one the declaration + // does not name is either a credential nobody declared or a credential that was removed from the + // declaration and left live in the operator's runbook. Both read to an operator as "this token is + // still part of the publish path", which is exactly the disagreement this gate exists to refuse. + for (const section of owned.filter((candidate) => candidate.level === parent.level + 1)) { + if (declaration.credentials.some((credential) => section.heading.includes(credential.name))) { + continue; + } + findings.push({ + code: "docs-undeclared-credential", + message: `the "${wanted}" section of ${declaration.documentation.file} carries a subsection "${section.heading}" that no declared credential matches, so the documentation describes a credential this repository does not declare`, + }); + } + for (const credential of declaration.credentials) { const subsection = owned.find((section) => section.heading.includes(credential.name)); if (subsection === undefined) { diff --git a/scripts/publish-toolchain.mjs b/scripts/publish-toolchain.mjs new file mode 100644 index 0000000..c2da180 --- /dev/null +++ b/scripts/publish-toolchain.mjs @@ -0,0 +1,366 @@ +#!/usr/bin/env node +// scripts/publish-toolchain.mjs +// +// THE PUBLISH PATH'S npm CLI MUST MEET THE TRUSTED-PUBLISHING FLOOR, AND A READER MUST BE ABLE TO +// TELL THAT FROM THE WORKFLOW FILE. REFUSED AT MERGE TIME, NOT AT PUBLISH TIME. +// +// THE DEFECT CLASS. `.github/credential-surface.json` declares that this repository publishes with +// the workflow's own OIDC identity and nothing else. That authentication lives in the npm CLI: +// `pnpm run release` is `changeset publish`, `changeset publish` spawns `pnpm publish` for a pnpm +// workspace, and `pnpm publish` packs and then calls `npm publish` through the `npm` it resolves off +// PATH. npm below the declared floor has no OIDC exchange in it at all, so the publish arrives +// unauthenticated, and npm's answer to an unauthenticated PUT is a 404 that reads like a missing +// package rather than like a refused credential. Nothing downstream of that is a good place to find +// out. +// +// WHY THE VERSION HAS TO BE DECLARED RATHER THAN RESOLVED. A runner's npm is whatever the Node +// release happens to bundle, which is not determinable from any file in this repository and moves +// under us between two runs of the same workflow. A publish path whose npm version is discoverable +// only at run time therefore does not meet the floor, however new the npm on today's runner is: the +// claim cannot be checked before the merge that ships it. So the workflow PINS an exact version in a +// step-level variable the declaration names, and this gate reads that variable. +// +// AND WHY THE DRY RUN HAS TO MATCH. `ci.yml`'s `release-dry-run` job exists to prove the publish +// command path on every push. A dry run on a different toolchain is not evidence about the real +// publish; it is evidence about a publish nobody performs. So the two declared toolchains are +// compared and any difference is a refusal, naming both. +// +// WHERE IT RUNS. `ci.yml`'s `verify` job, which is a REQUIRED status check in this repository's +// `config-ci-required-checks` ruleset, so this is the copy that can refuse a merge. Zero-dependency +// node, like the gates beside it, so it runs before `pnpm install`. +// +// EXIT CODES, and they are a contract: +// 0 the publish path declares an npm CLI at or above the floor, and the dry run declares the same +// toolchain. +// 1 it does not. Every disagreement found is listed; the file and what was read are named. +// 2 this gate could not run: a bad invocation, or a declaration or workflow that is absent, +// unreadable or unparseable. Kept distinct from 1 for the reason every sibling gate here keeps +// it distinct: "we could not check" must not read as "we checked and it was fine". +// +// Usage: +// node scripts/publish-toolchain.mjs [--repo ] [--declaration ] +// [--release-workflow ] [--ci-workflow ] + +import { readFileSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; + +import { isCliEntrypoint } from "../packages/script-utils/index.js"; + +import { + DEFAULT_DECLARATION, + InvocationError, + loadDeclaration, + parseWorkflow, + WorkflowParseError, +} from "./credential-surface.mjs"; + +/** The CI workflow that carries the required `verify` job and the release dry run. */ +export const DEFAULT_CI_WORKFLOW = ".github/workflows/ci.yml"; + +/** The job in that workflow whose toolchain must match the publish path's. */ +export const DRY_RUN_JOB = "release-dry-run"; + +/** The action whose input declares the Node version of a job. */ +const SETUP_NODE = "actions/setup-node"; + +/** An exact version: three dot-separated numbers and nothing else. A range is not determinable. */ +const EXACT_VERSION = /^\d+\.\d+\.\d+$/; + +/** + * @param node A parsed node. + * @param key The key to read. + * @returns The child node, or undefined. + */ +function mapGet(node, key) { + if (node === undefined || node.kind !== "map") return undefined; + for (const [candidate, value] of node.entries) if (candidate === key) return value; + return undefined; +} + +/** + * @param node A parsed node. + * @returns Its scalar value, or undefined when it is not a scalar. + */ +function scalarOf(node) { + return node !== undefined && node.kind === "scalar" ? String(node.value) : undefined; +} + +/** + * Every step of one job, in order. + * + * @param workflow The parsed workflow. + * @param jobId The job. + * @returns The step nodes, or an empty list when the job or its steps are absent. + */ +function stepsOf(workflow, jobId) { + const job = mapGet(mapGet(workflow, "jobs"), jobId); + const steps = mapGet(job, "steps"); + return steps !== undefined && steps.kind === "seq" ? steps.items : []; +} + +/** + * Read the toolchain one job DECLARES, from the committed text alone. + * + * Both halves are read off a step rather than inferred: `node-version` from the `setup-node` step's + * inputs, and the npm CLI version from the step-level variable the declaration names. A step that + * sets that variable and never installs it is NOT a declaration of anything, so the step's own + * script has to install the version it names; otherwise the value is decoration and the real npm is + * whatever the runner had. + * + * @param workflow The parsed workflow. + * @param jobId The job to read. + * @param setting The variable name the declaration says pins the npm CLI. + * @returns `{ node, npm, npmInstalled, npmSteps }`. + */ +export function declaredToolchain(workflow, jobId, setting) { + let node; + let npm; + let npmInstalled = false; + let npmSteps = 0; + for (const step of stepsOf(workflow, jobId)) { + const uses = scalarOf(mapGet(step, "uses")); + if (uses !== undefined && uses.startsWith(`${SETUP_NODE}@`)) { + node = scalarOf(mapGet(mapGet(step, "with"), "node-version")) ?? node; + } + const declared = scalarOf(mapGet(mapGet(step, "env"), setting)); + if (declared === undefined) continue; + npmSteps += 1; + npm = declared; + const script = scalarOf(mapGet(step, "run")) ?? ""; + const installs = new RegExp( + `npm\\s+(?:install|i)\\s+(?:--global|-g)\\s+["']?npm@\\$\\{?${setting}\\}?["']?`, + ); + if (installs.test(script)) npmInstalled = true; + } + return { node, npm, npmInstalled, npmSteps }; +} + +/** + * Compare two exact versions. + * + * @param version The version found. + * @param floor The floor it must meet. + * @returns True when `version` is at or above `floor`. + */ +export function meetsFloor(version, floor) { + const left = version.split(".").map(Number); + const right = floor.split(".").map(Number); + for (let i = 0; i < 3; i += 1) { + if (left[i] > right[i]) return true; + if (left[i] < right[i]) return false; + } + return true; +} + +/** + * Read and parse one workflow, turning every failure into a refusal rather than a skip. + * + * @param path Absolute path. + * @returns `{ ok: true, workflow }` or `{ ok: false, code, message }`. + */ +function loadWorkflow(path) { + let text; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + return { + ok: false, + code: error.code === "ENOENT" ? "workflow-absent" : "workflow-unreadable", + message: `${path} could not be read, so the toolchain it declares cannot be compared: ${error.message}`, + }; + } + try { + return { ok: true, workflow: parseWorkflow(text) }; + } catch (error) { + if (!(error instanceof WorkflowParseError)) throw error; + return { + ok: false, + code: "workflow-unparseable", + message: `${path} could not be parsed, so the toolchain it declares cannot be compared: ${error.message}`, + }; + } +} + +/** + * Run the whole comparison. + * + * @param options.repoRoot The repository root every relative path resolves against. + * @param options.declarationPath Override for the declaration file. + * @param options.releaseWorkflowPath Override for the release workflow. + * @param options.ciWorkflowPath Override for the CI workflow. + * @returns `{ code, report }` where `code` is the process exit code. + */ +export function checkPublishToolchain({ + repoRoot, + declarationPath, + releaseWorkflowPath, + ciWorkflowPath, +} = {}) { + const root = resolve(repoRoot ?? join(import.meta.dirname, "..")); + const declarationFile = resolve(declarationPath ?? join(root, DEFAULT_DECLARATION)); + + const loaded = loadDeclaration(declarationFile); + if (!loaded.ok) { + return { + code: 2, + report: [ + "publish-toolchain: THE COMPARISON COULD NOT BE MADE, so this is a failure and not a pass.", + ` [${loaded.code}] ${loaded.message}`, + ...(loaded.problems ?? []).map((problem) => ` [declaration-invalid] ${problem}`), + ], + }; + } + const declaration = loaded.declaration; + const authentication = declaration.publishPath.authentication; + const floor = authentication.npmCliFloor; + const setting = authentication.npmCliVersionSetting; + + const releaseFile = resolve(releaseWorkflowPath ?? join(root, declaration.publishPath.workflow)); + const ciFile = resolve(ciWorkflowPath ?? join(root, DEFAULT_CI_WORKFLOW)); + const loadedRelease = loadWorkflow(releaseFile); + const loadedCi = loadWorkflow(ciFile); + const unreadable = [loadedRelease, loadedCi].filter((result) => !result.ok); + if (unreadable.length > 0) { + return { + code: 2, + report: [ + "publish-toolchain: THE COMPARISON COULD NOT BE MADE, so this is a failure and not a pass.", + ...unreadable.map((result) => ` [${result.code}] ${result.message}`), + ], + }; + } + + const release = loadedRelease.workflow; + const ci = loadedCi.workflow; + const publishJob = declaration.publishPath.job; + const publish = declaredToolchain(release, publishJob, setting); + const dryRun = declaredToolchain(ci, DRY_RUN_JOB, setting); + const findings = []; + + // ---- The floor (the publish path's own npm). -------------------------------------------------- + if (publish.npmSteps === 0) { + findings.push( + `${releaseFile}: job "${publishJob}" declares no \`${setting}\`, so the npm CLI that would ` + + `make the publish request is whatever the runner resolves at run time. A version ` + + `discoverable only at run time does not meet the ${floor} floor, because nothing before ` + + `the merge can read it.`, + ); + } else if (publish.npmSteps > 1) { + findings.push( + `${releaseFile}: job "${publishJob}" declares \`${setting}\` in ${publish.npmSteps} steps, ` + + `so a reader cannot tell which npm publishes. Declare it once.`, + ); + } else if (!EXACT_VERSION.test(publish.npm)) { + findings.push( + `${releaseFile}: job "${publishJob}" declares \`${setting}: ${publish.npm}\`, which is not ` + + `an exact version. A range, a tag or an expression resolves at run time, and the floor ` + + `${floor} has to be provable from this file.`, + ); + } else if (!meetsFloor(publish.npm, floor)) { + findings.push( + `${releaseFile}: job "${publishJob}" declares \`${setting}: ${publish.npm}\` and trusted ` + + `publishing needs npm ${floor} or later. Below that floor the npm CLI has no OIDC ` + + `exchange, so the publish cannot authenticate at all.`, + ); + } else if (!publish.npmInstalled) { + findings.push( + `${releaseFile}: job "${publishJob}" declares \`${setting}: ${publish.npm}\` and no step in ` + + `it installs that npm globally, so the declared version is decoration and the npm that ` + + `publishes is still whatever the runner resolved.`, + ); + } + + // ---- The parity (the dry run proves the real path or it proves nothing). ---------------------- + if (dryRun.npmSteps === 0) { + findings.push( + `${ciFile}: job "${DRY_RUN_JOB}" declares no \`${setting}\` and ${releaseFile}'s ` + + `"${publishJob}" declares \`${publish.npm ?? "none"}\`. A dry run on a different npm than ` + + `the publish is not evidence about the publish.`, + ); + } else if (dryRun.npm !== publish.npm) { + findings.push( + `${ciFile}: job "${DRY_RUN_JOB}" declares \`${setting}: ${dryRun.npm}\` and ` + + `${releaseFile}'s "${publishJob}" declares \`${setting}: ${publish.npm ?? "none"}\`. A dry ` + + `run on a different toolchain is not evidence about the real publish.`, + ); + } else if (!dryRun.npmInstalled) { + findings.push( + `${ciFile}: job "${DRY_RUN_JOB}" declares \`${setting}: ${dryRun.npm}\` and no step in it ` + + `installs that npm globally, so the dry run still runs on the runner's own npm.`, + ); + } + if (publish.node !== dryRun.node) { + findings.push( + `${ciFile}: job "${DRY_RUN_JOB}" declares \`node-version: ${dryRun.node ?? "none"}\` and ` + + `${releaseFile}'s "${publishJob}" declares \`node-version: ${publish.node ?? "none"}\`. A ` + + `dry run on a different toolchain is not evidence about the real publish.`, + ); + } + + if (findings.length === 0) { + return { + code: 0, + report: [ + `publish-toolchain: the publish path declares npm ${publish.npm} (floor ${floor}) on node ` + + `${publish.node}, and "${DRY_RUN_JOB}" declares the same.`, + ], + }; + } + return { + code: 1, + report: [ + `publish-toolchain: ${findings.length} problem(s) with the toolchain the publish path declares.`, + "Every problem found is listed; fix them together rather than one run at a time.", + ...findings.map((finding) => ` [publish-toolchain] ${finding}`), + ], + }; +} + +/** + * @param argv Arguments after the script name. + * @returns The parsed options. + */ +function parseArgs(argv) { + const options = {}; + const flags = { + "--repo": "repoRoot", + "--declaration": "declarationPath", + "--release-workflow": "releaseWorkflowPath", + "--ci-workflow": "ciWorkflowPath", + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const field = flags[arg]; + if (field === undefined) throw new InvocationError(`unknown argument ${JSON.stringify(arg)}`); + i += 1; + const value = argv[i]; + if (value === undefined) throw new InvocationError(`${arg} needs a value`); + options[field] = isAbsolute(value) ? value : resolve(value); + } + return options; +} + +/** + * @param argv Arguments after the script name. + * @returns The process exit code. + */ +export function main(argv) { + const result = checkPublishToolchain(parseArgs(argv)); + const stream = result.code === 0 ? process.stdout : process.stderr; + for (const line of result.report) stream.write(`${line}\n`); + return result.code; +} + +// Same guard, and the same reason, as its sibling gates: importing this file for tests must not run +// the CLI, and a broken invocation must not be able to read as a clean toolchain. +if (isCliEntrypoint(import.meta.url)) { + try { + process.exit(main(process.argv.slice(2))); + } catch (error) { + if (error instanceof InvocationError) { + process.stderr.write(`ERROR: publish-toolchain could not run: ${error.message}\n`); + process.exit(2); + } + throw error; + } +} diff --git a/test/npm-config-allow.test.ts b/test/npm-config-allow.test.ts index 6d2aa0b..0a9e7e9 100644 --- a/test/npm-config-allow.test.ts +++ b/test/npm-config-allow.test.ts @@ -654,16 +654,15 @@ describe("npm-config-allow: where the check runs", () => { ); expect(checkStepEnv).not.toBeNull(); const stepText = checkStepEnv?.[1] ?? ""; - expect(stepText).toContain("NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}"); expect(stepText).toContain( "NPM_CONFIG_PROVENANCE: ${{ github.event.repository.visibility == 'public' }}", ); - // AND NOT ONE VARIABLE MORE. `NPM_TOKEN` is in the publish step because `changesets/action` - // wants it; nothing on the CONFIGURATION path reads it, since npm reads only `npm_config_*` and - // the registry credential arrives through the generated npmrc. Copying it here would widen this - // repository's declared credential surface (S0080) by one step for no gain. Matched as an env - // KEY, because the line above legitimately contains the substring `secrets.NPM_TOKEN`. + // AND NOT ONE VARIABLE MORE. Authentication on this path is the job's OIDC identity, which is a + // property of the job rather than a value a step is handed, and nothing on the CONFIGURATION + // path reads a credential at all: npm reads only `npm_config_*` when it resolves. A registry + // credential here would widen this repository's declared credential surface (S0080) for no gain. expect(stepText).not.toMatch(/^\s+NPM_TOKEN:/m); + expect(stepText).not.toMatch(/^\s+NODE_AUTH_TOKEN:/m); // C20's workflow half: nothing may let this step be skipped or downgraded to a warning. A gate // that can be skipped is a gate that will be. expect(stepText).not.toContain("continue-on-error"); @@ -679,21 +678,17 @@ describe("npm-config-allow: where the check runs", () => { readFileSync(join(REPO_ROOT, ".github", "credential-surface.json"), "utf8"), ); const step = "The publish configuration must be one the allow-set permits"; - const npmToken = declaration.credentials.find( - (entry: { name: string }) => entry.name === "NPM_TOKEN", - ); - expect( - npmToken.exposures.some( - (exposure: { job: string; step: string; name: string }) => - exposure.job === "publish" && - exposure.step === step && - exposure.name === "NODE_AUTH_TOKEN", - ), - ).toBe(true); - // And NPM_TOKEN itself is declared nowhere but the publish step. - expect( - npmToken.exposures.filter((exposure: { name: string }) => exposure.name === "NPM_TOKEN"), - ).toEqual([{ job: "publish", step: "Publish", as: "env", name: "NPM_TOKEN", mode: "value" }]); + // No credential is declared at this step, because none reaches it: the declaration's credential + // list holds nothing with `registryAuth`, and the publish path's authentication is the workflow + // OIDC identity declared under `publishPath.authentication`. + for (const credential of declaration.credentials as { name: string; exposures: unknown[] }[]) { + expect( + (credential.exposures as { job: string; step: string }[]).filter( + (exposure) => exposure.job === "publish" && exposure.step === step, + ), + `${credential.name} must not be exposed to the configuration allow-check`, + ).toEqual([]); + } const provenance = declaration.settings.find( (entry: { name: string }) => entry.name === "NPM_CONFIG_PROVENANCE", @@ -726,10 +721,14 @@ describe("npm-config-allow: where the check runs", () => { expect(preflightLines.some((line) => /^ {4}environment:/.test(line))).toBe(false); expect(versionLines.some((line) => /^ {4}environment:/.test(line))).toBe(false); - // Registry credentials still reach no job but the gated one. - expect(preflight).not.toContain("secrets.NPM_TOKEN"); - expect(version).not.toContain("secrets.NPM_TOKEN"); - expect(publish).toContain("secrets.NPM_TOKEN"); + // Registry credentials reach no job at all any more, and the gated job is still the only one + // that can authenticate: `id-token: write` is granted there and nowhere else. + for (const job of [preflight, version, publish]) { + expect(job).not.toContain("secrets.NPM_TOKEN"); + } + expect(publish).toContain("id-token: write"); + expect(preflight).not.toContain("id-token: write"); + expect(version).not.toContain("id-token: write"); }); it( @@ -1181,18 +1180,12 @@ describe("npm-config-allow: the real repository", () => { NPM_CONFIG_GLOBALCONFIG: emptyRc, }; if (ci) { - // What `actions/setup-node@v6` with `registry-url` actually does: it GENERATES an npmrc under - // RUNNER_TEMP and exports NPM_CONFIG_USERCONFIG at it, then release.yml's publish step adds - // the provenance flag and the token the generated file expands. - const userRc = join(home, "setup-node.npmrc"); - writeFileSync( - userRc, - "registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n", - "utf8", - ); - env.NPM_CONFIG_USERCONFIG = userRc; + // What the gated publish job actually supplies, which is ONE variable. `actions/setup-node` is + // given no `registry-url` there, so it generates no user npmrc and there is no + // `NPM_CONFIG_USERCONFIG` and no registry credential anywhere in that job; the publish + // authenticates with the workflow's OIDC identity instead. The provenance flag is supplied at + // step level, which is why the check has to carry it to be judging the right process. env.NPM_CONFIG_PROVENANCE = "true"; - env.NODE_AUTH_TOKEN = FAKE_TOKEN; } return env; } @@ -1219,13 +1212,12 @@ describe("npm-config-allow: the real repository", () => { it( "passes in the context the GATED PUBLISH JOB will actually give it (C3, C10)", () => { - // The one that matters: the generated user config, its token, and the provenance flag the - // publish step supplies. If the committed allow-set does not cover this, the first real - // release refuses, and finding that out here costs nothing. + // The one that matters: the environment the publish step supplies, which after S0317 is the + // provenance flag and no credential at all. If the committed allow-set does not cover this, + // the first real release refuses, and finding that out here costs nothing. const { code, output } = runCheck(REPO_ROOT, repoContext(true)); expect(code).toBe(0); expect(output).toContain("provenance = true"); - expect(output).not.toContain(FAKE_TOKEN); }, SLOW, ); From 9d64e126ba31dbce45d71cfc01c5558053183418 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Wed, 16 Sep 2026 21:40:33 +0000 Subject: [PATCH 2/2] S0317-config-1: prove the tokenless path, at merge time and at run time The preflight stops proving a token is set and starts proving the authentication the declaration names for the publish path is available here, still before the build and without contacting the registry. It also asserts the resolved npm against the declared floor, so an npm that cannot do the OIDC exchange stops the release before anything is packed. Presence only: one of those variables is itself a credential and its value is never read, compared or printed. Two new suites, one per subject. tokenless-publish grades what the committed workflow, declaration and allow-set say (AC-1, AC-4, AC-5, AC-8, AC-12); publish-preflight-oidc grades what the command path does when it runs (AC-3, AC-6, AC-7, AC-9). Every grader is run against a mutation that breaks the property it asserts. test/publish-preflight.test.ts is superseded by the second file, which carries every case of it that is still true. The existing credential-surface and npm-config-allow suites had fixtures written around the token; those are retargeted at credentials that still exist, so what each case grades is unchanged. --- .github/workflows/release.yml | 4 +- RELEASING.md | 14 +- package.json | 1 + scripts/credential-surface.mjs | 17 +- scripts/publish-preflight.mjs | 227 ++++++++-- scripts/publish-toolchain.mjs | 3 +- test/credential-surface.test.ts | 108 +++-- test/npm-config-allow.test.ts | 20 +- test/publish-preflight-oidc.test.ts | 516 +++++++++++++++++++++++ test/publish-preflight.test.ts | 204 --------- test/tokenless-publish.test.ts | 616 ++++++++++++++++++++++++++++ 11 files changed, 1436 insertions(+), 294 deletions(-) create mode 100644 test/publish-preflight-oidc.test.ts delete mode 100644 test/publish-preflight.test.ts create mode 100644 test/tokenless-publish.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4cdbb7..878be03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ concurrency: permissions: contents: write # create the version PR / tags / releases - id-token: write # npm provenance + id-token: write # the publish job's whole authentication, and npm provenance with it pull-requests: write # open the "Version Packages" PR # Changesets flow: a push to main with pending changesets opens a "Version Packages" PR. Merging that @@ -377,7 +377,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # create tags / GitHub releases - id-token: write # npm provenance + id-token: write # THE credential: trusted publishing, and npm provenance with it # Release approval gate: the `release` environment carries a required reviewer (`NSchatz`) and a # `main`-only deployment-branch policy, so the publish waits for an explicit approval. It is also # the OIDC subject the npm Trusted Publisher binds to, so its NAME is part of the authentication diff --git a/RELEASING.md b/RELEASING.md index d0f4ef1..4612b1a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -508,12 +508,12 @@ Register one for **each of the eight published packages**: `@cosyte/eslint-confi On the package's page: Settings, then Trusted Publisher, then GitHub Actions, then these four fields exactly: -| field | value | -| ----------------------------- | ----------------------------------------------------------- | -| Organization or user | `cosyte` | -| Repository | `config` | -| Workflow filename | `release.yml` (**with the `.yml` extension**, character for character) | -| Environment name | `release` | +| field | value | +| -------------------- | ---------------------------------------------------------------------- | +| Organization or user | `cosyte` | +| Repository | `config` | +| Workflow filename | `release.yml` (**with the `.yml` extension**, character for character) | +| Environment name | `release` | The same four are declared in `.github/credential-surface.json` under `publishPath.authentication.trustedPublisher`, and `pnpm credentials:check` refuses a declaration whose @@ -532,7 +532,7 @@ check any of this. The first release after registering is the evidence, which is registers and proves **one** package before all eight. **Ordering, and it is not negotiable.** Registering the publisher before this workflow stopped using a -token was safe: npm accepts OIDC *in addition to* a token. Removing the token before the publisher +token was safe: npm accepts OIDC _in addition to_ a token. Removing the token before the publisher exists is not: every publish fails until it does. If a release is refused with `ENEEDAUTH` or a 404 on the PUT, check the registration before changing anything in this repository. diff --git a/package.json b/package.json index 92f3274..4aa9c43 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "credentials:check": "node scripts/credential-surface.mjs", "install-hardening": "node scripts/install-hardening.mjs", "publish:preflight": "node scripts/publish-preflight.mjs", + "publish:toolchain": "node scripts/publish-toolchain.mjs", "test:root": "vitest run", "test:packages": "pnpm --filter \"./packages/*\" run test", "test": "pnpm run test:root && pnpm run test:packages", diff --git a/scripts/credential-surface.mjs b/scripts/credential-surface.mjs index 15ee2f3..96a9ff6 100644 --- a/scripts/credential-surface.mjs +++ b/scripts/credential-surface.mjs @@ -623,7 +623,17 @@ function validateAuthentication(authentication) { } } - if (!Array.isArray(authentication.runtimeEvidence) || authentication.runtimeEvidence.length === 0) { + // REQUIRED TO BE AN ARRAY ALWAYS, AND REQUIRED TO BE NON-EMPTY UNDER `github-oidc`, where it is + // the only thing a preflight can observe: there is no credential to look for. A token method + // carries its evidence in `credentials[].requiredForPublish` instead, and may legitimately name + // none here; `scripts/publish-preflight.mjs` refuses the combination that names NEITHER, because a + // check with nothing to check passes on an empty environment. + if (!Array.isArray(authentication.runtimeEvidence)) { + problems.push(`${label}.runtimeEvidence must be an array (empty is allowed, and explicit)`); + } else if ( + authentication.method === "github-oidc" && + authentication.runtimeEvidence.length === 0 + ) { problems.push( `${label}.runtimeEvidence must name at least one environment variable whose presence says this authentication is available, or the preflight would pass on any environment at all`, ); @@ -640,7 +650,10 @@ function validateAuthentication(authentication) { } } - if (typeof authentication.npmCliFloor !== "string" || !EXACT_VERSION.test(authentication.npmCliFloor)) { + if ( + typeof authentication.npmCliFloor !== "string" || + !EXACT_VERSION.test(authentication.npmCliFloor) + ) { problems.push(`${label}.npmCliFloor must be an exact version, such as 11.5.1`); } if ( diff --git a/scripts/publish-preflight.mjs b/scripts/publish-preflight.mjs index 22b4bc7..44968a3 100644 --- a/scripts/publish-preflight.mjs +++ b/scripts/publish-preflight.mjs @@ -1,22 +1,38 @@ #!/usr/bin/env node // scripts/publish-preflight.mjs // -// REFUSE TO REACH THE REGISTRY WITH A CREDENTIAL MISSING, BEFORE THE BUILD IS PAID FOR. +// REFUSE TO REACH THE REGISTRY WITH THE PUBLISH PATH'S AUTHENTICATION MISSING, OR ON AN npm THAT +// CANNOT USE IT, BEFORE THE BUILD IS PAID FOR. // // THE DEFECT THIS CLOSES. `pnpm run release` is `changeset publish`, and `changeset publish` -// discovers a missing or empty npm token the only way it can: by asking the registry and being +// discovers that it cannot authenticate the only way it can: by asking the registry and being // refused. That refusal lands at the LAST step of a job that has already checked out, installed, // built eight packages and, on this repository, waited for a human to approve a protected -// deployment. The diagnostic is an `E401` or an `EOTP` from npm rather than a sentence naming the -// credential, and the operator's first question ("which token, and where is it supposed to live") -// is answered nowhere in that output. Worse, a PARTIAL failure of this shape is the expensive one: -// the approval has been spent, and the run must be approved again after the fix. +// deployment. The diagnostic is an `E401`, an `ENEEDAUTH`, or, for a failed OIDC exchange, a bare +// 404 on the PUT that reads like a missing package; the operator's first question ("what was this +// supposed to authenticate with, and where does that come from") is answered nowhere in that +// output. Worse, a PARTIAL failure of this shape is the expensive one: the approval has been spent, +// and the run must be approved again after the fix. // -// So this runs FIRST in the `release` script, ahead of `pnpm run build`, and it is a pure -// environment check: it reads `.github/credential-surface.json`, takes the credentials that -// declaration marks `requiredForPublish`, works out which environment variables the publish job is -// declared to hand them to, and refuses when one of them is absent or empty. Nothing is contacted, -// nothing is written, and no credential VALUE is ever read into a message. +// So this runs FIRST in the `release` script, ahead of `pnpm run build`, and it checks two things +// and contacts nothing: +// +// 1. THE AUTHENTICATION THE DECLARATION NAMES IS ACTUALLY AVAILABLE HERE. It reads +// `.github/credential-surface.json`, takes `publishPath.authentication` and every credential +// that declaration marks `requiredForPublish`, works out which environment variables those +// amount to, and refuses when one of them is absent or empty. On this repository that is the +// workflow's OIDC identity: GitHub exposes `ACTIONS_ID_TOKEN_REQUEST_URL` and +// `ACTIONS_ID_TOKEN_REQUEST_TOKEN` to a job holding `id-token: write`, and their PRESENCE is +// the whole observable. No value is read into a message, compared, or logged, and nothing is +// contacted: this check cannot distinguish a good token from a bad one and does not pretend to. +// +// 2. THE npm THAT WILL MAKE THE REQUEST CAN DO TRUSTED PUBLISHING AT ALL. `changeset publish` +// spawns `pnpm publish`, which packs and then calls `npm publish` through the `npm` it resolves +// off PATH. npm below `publishPath.authentication.npmCliFloor` has no OIDC exchange in it, so +// the publish arrives unauthenticated. The floor is asserted HERE, against the npm this process +// resolves, and independently at merge time by `scripts/publish-toolchain.mjs` against the +// version the workflow declares. Neither replaces the other: one reads committed text before a +// merge, this one reads the binary that is about to run. // // WHY IT LIVES ON THE PUBLISH COMMAND PATH RATHER THAN IN THE WORKFLOW'S STEP LIST. Two reasons. // The workflow's step list is `release.yml`'s control flow, which this change deliberately does not @@ -28,24 +44,31 @@ // warns loudly when it is absent; failing closed on it would take the release path down to protect // against a state this repository is already able to be in. The declaration marks it // `requiredForPublish: false` and this script honours that. The rule is the declaration's, not this -// script's: nothing here hardcodes a credential name. +// script's: nothing here hardcodes a credential name, a variable name or a version. // // EXIT CODES, and they are a contract: -// 0 every required credential is present and non-empty. -// 1 at least one required credential is absent or empty. Named, one per line. -// 2 the preflight could not run at all: a bad invocation, or a declaration that is absent, -// empty, unparseable or malformed. Distinct from 1 because "we could not check" is not -// "we checked and it was missing", and non-zero either way, so the publish stops regardless. +// 0 the declared authentication is present and the resolved npm meets the declared floor. +// 1 it is not, or it does not. Named, one per line, with no credential value in any of them. +// 2 the preflight could not run at all: a bad invocation, a declaration that is absent, empty, +// unparseable or malformed, a declaration that names no authentication for the publish path, or +// an npm this check could not ask for its version. Distinct from 1 because "we could not check" +// is not "we checked and it was missing", and non-zero either way, so the publish stops +// regardless. When both kinds of problem are found, 2 wins: an unchecked property is a weaker +// state than a failed one. // // Usage: -// node scripts/publish-preflight.mjs [--repo ] [--declaration ] +// node scripts/publish-preflight.mjs [--repo ] [--declaration ] [--npm-bin ] +import { execFileSync } from "node:child_process"; import { isAbsolute, join, resolve } from "node:path"; import { isCliEntrypoint } from "../packages/script-utils/index.js"; import { DEFAULT_DECLARATION, InvocationError, loadDeclaration } from "./credential-surface.mjs"; +/** How long the version probe gets. A hung binary must not hang a release. */ +const PROBE_TIMEOUT_MS = 60_000; + /** * Work out which environment variables must be set for the publish command to reach the registry. * @@ -72,15 +95,90 @@ export function requiredVariables(declaration) { return required; } +/** + * The variables that say the declared authentication is available in this environment. + * + * PRESENCE ONLY, AND THAT IS THE HONEST LIMIT OF IT. One of these variables is itself a credential: + * it is tested with `in`-style emptiness and never read, compared or printed. A check that examined + * the value would be a check that could leak it into a public build log for no gain, because + * whether the registry ACCEPTS the identity is a fact only the registry has. + * + * @param declaration A validated declaration. + * @returns A list of `{ method, variable, note }`, one per variable that must be present. + */ +export function requiredAuthentication(declaration) { + const authentication = declaration.publishPath.authentication; + return authentication.runtimeEvidence.map((evidence) => ({ + method: authentication.method, + variable: evidence.variable, + note: typeof evidence.note === "string" ? evidence.note : "", + })); +} + +/** + * Ask the npm that would make the publish request for its version. + * + * ASKED, NEVER ASSUMED. Which npm answers is the point: `pnpm publish` calls `npm` off PATH, so the + * binary this resolves is the binary that publishes. A probe that cannot be run, or whose answer is + * not a version, is a refusal rather than a skip, for the reason every gate in this repository + * refuses an input it could not read. + * + * @param npmBin The binary to ask. Defaults to `npm` off PATH. + * @returns `{ ok: true, version }` or `{ ok: false, message }`. + */ +export function resolvedNpmVersion(npmBin = "npm") { + let stdout; + try { + stdout = execFileSync(npmBin, ["--version"], { + encoding: "utf8", + timeout: PROBE_TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + return { + ok: false, + message: `\`${npmBin} --version\` could not be run, so the npm that would publish cannot be checked against the floor: ${error.message}`, + }; + } + const version = stdout.trim(); + if (!/^\d+\.\d+\.\d+/.test(version)) { + return { + ok: false, + message: `\`${npmBin} --version\` answered ${JSON.stringify(version)}, which is not a version this check can compare to the floor`, + }; + } + return { ok: true, version }; +} + +/** + * Compare two versions by their first three numeric parts. + * + * @param version The version found. + * @param floor The floor it must meet. + * @returns True when `version` is at or above `floor`. + */ +export function meetsFloor(version, floor) { + const left = version.split(".").map((part) => Number.parseInt(part, 10)); + const right = floor.split(".").map((part) => Number.parseInt(part, 10)); + for (let i = 0; i < 3; i += 1) { + const a = Number.isNaN(left[i]) ? 0 : (left[i] ?? 0); + const b = Number.isNaN(right[i]) ? 0 : (right[i] ?? 0); + if (a > b) return true; + if (a < b) return false; + } + return true; +} + /** * Run the preflight. * * @param options.repoRoot The repository root the declaration is resolved against. * @param options.declarationPath Override for the declaration file. * @param options.env The environment to inspect. Defaults to `process.env`. + * @param options.npmBin The npm binary to ask for its version. * @returns `{ code, report }` where `code` is the process exit code. */ -export function preflight({ repoRoot, declarationPath, env = process.env } = {}) { +export function preflight({ repoRoot, declarationPath, env = process.env, npmBin } = {}) { const root = resolve(repoRoot ?? join(import.meta.dirname, "..")); const file = resolve(declarationPath ?? join(root, DEFAULT_DECLARATION)); @@ -90,33 +188,54 @@ export function preflight({ repoRoot, declarationPath, env = process.env } = {}) code: 2, report: [ "publish-preflight: REFUSING TO PUBLISH. The credential declaration could not be read, so", - "nothing here can say which credentials this publish needs.", + "nothing here can say what this publish is supposed to authenticate with.", ` [${loaded.code}] ${loaded.message}`, ...(loaded.problems ?? []).map((problem) => ` [declaration-invalid] ${problem}`), ], }; } const declaration = loaded.declaration; + const authentication = declaration.publishPath.authentication; - const required = requiredVariables(declaration); - if (required.length === 0) { + const credentials = requiredVariables(declaration); + const identities = requiredAuthentication(declaration); + if (credentials.length + identities.length === 0) { return { code: 2, report: [ - "publish-preflight: REFUSING TO PUBLISH. The declaration marks no credential as required", - "for publishing, so this preflight would pass on any environment at all, including an empty", - "one. That is a declaration bug, not a clean run.", + "publish-preflight: REFUSING TO PUBLISH. The declaration names no authentication for the", + `publish path: \`${authentication.method}\` carries no runtime evidence and no credential is`, + "marked required for publishing, so this preflight would pass on any environment at all,", + "including an empty one. That is a declaration bug, not a clean run.", ], }; } - const missing = required.filter(({ variable }) => (env[variable] ?? "").trim() === ""); - if (missing.length > 0) { - const report = [ - `publish-preflight: REFUSING TO PUBLISH. ${missing.length} required credential(s) are absent`, - "or empty in this environment, and the registry has NOT been contacted.", - ]; - for (const { credential, variable } of missing) { + const report = []; + let missing = 0; + let unchecked = 0; + + const absent = (variable) => (env[variable] ?? "").trim() === ""; + const missingCredentials = credentials.filter(({ variable }) => absent(variable)); + const missingIdentities = identities.filter(({ variable }) => absent(variable)); + missing = missingCredentials.length + missingIdentities.length; + + if (missing > 0) { + report.push( + `publish-preflight: REFUSING TO PUBLISH. ${missing} thing(s) the declared authentication needs`, + "are absent or empty in this environment, and the registry has NOT been contacted.", + ); + for (const { method, variable, note } of missingIdentities) { + report.push( + ` ${variable} is empty. The declaration says this publish authenticates with`, + ` \`${method}\`, and that variable is the evidence this environment can do so.`, + ...(note === "" ? [] : [` ${note}`]), + ` ACTION: run the release through the \`${declaration.publishPath.job}\` job of`, + ` ${declaration.publishPath.workflow}, which is where that authentication exists. There is`, + " no credential to substitute here, by design, so a publish run by hand cannot authenticate.", + ); + } + for (const { credential, variable } of missingCredentials) { const declared = declaration.credentials.find((entry) => entry.name === credential); report.push( ` ${variable} is empty. It carries ${credential}, whose single permitted storage`, @@ -126,17 +245,39 @@ export function preflight({ repoRoot, declarationPath, env = process.env } = {}) } report.push( `The declaration is ${DEFAULT_DECLARATION}; ${declaration.documentation.file}, section`, - `"${declaration.documentation.section}", has the issue and install procedure for each.`, + `"${declaration.documentation.section}", has the procedure for each.`, ); - return { code: 1, report }; } + const floor = authentication.npmCliFloor; + const probe = resolvedNpmVersion(npmBin); + if (!probe.ok) { + unchecked += 1; + report.push( + "publish-preflight: REFUSING TO PUBLISH. The npm CLI on this publish path could not be", + "checked against the floor, and an unchecked floor is not a met one.", + ` [npm-version-unreadable] ${probe.message}`, + ); + } else if (!meetsFloor(probe.version, floor)) { + missing += 1; + report.push( + "publish-preflight: REFUSING TO PUBLISH. The npm CLI on this publish path is too old for", + "trusted publishing, and nothing has been packed.", + ` npm ${probe.version} is on this path and the floor is npm ${floor}.`, + " `pnpm publish` calls this npm to make the publish request, and below that floor it has no", + " OIDC exchange in it, so the publish would arrive unauthenticated.", + ); + } + + if (unchecked > 0) return { code: 2, report }; + if (missing > 0) return { code: 1, report }; + return { code: 0, report: [ - `publish-preflight: ${required.length} required credential(s) present: ${required - .map(({ variable }) => variable) - .join(", ")}. Proceeding to build and publish.`, + `publish-preflight: ${identities.length + credentials.length} authentication input(s) present: ` + + `${[...identities, ...credentials].map(({ variable }) => variable).join(", ")}.`, + `npm ${probe.version} is on this path, at or above the ${floor} floor. Proceeding to build and publish.`, ], }; } @@ -147,7 +288,15 @@ export function preflight({ repoRoot, declarationPath, env = process.env } = {}) */ function parseArgs(argv) { const options = {}; - const flags = { "--repo": "repoRoot", "--declaration": "declarationPath" }; + const flags = { + "--repo": "repoRoot", + "--declaration": "declarationPath", + "--npm-bin": "npmBin", + }; + // `--npm-bin` is NOT resolved against the working directory: a bare name is a PATH lookup, which + // is exactly how `pnpm publish` finds the npm it calls, and resolving it would silently turn that + // into a path that does not exist. + const paths = new Set(["repoRoot", "declarationPath"]); for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const field = flags[arg]; @@ -155,7 +304,7 @@ function parseArgs(argv) { i += 1; const value = argv[i]; if (value === undefined) throw new InvocationError(`${arg} needs a value`); - options[field] = isAbsolute(value) ? value : resolve(value); + options[field] = paths.has(field) && !isAbsolute(value) ? resolve(value) : value; } return options; } diff --git a/scripts/publish-toolchain.mjs b/scripts/publish-toolchain.mjs index c2da180..007463e 100644 --- a/scripts/publish-toolchain.mjs +++ b/scripts/publish-toolchain.mjs @@ -73,8 +73,7 @@ const EXACT_VERSION = /^\d+\.\d+\.\d+$/; */ function mapGet(node, key) { if (node === undefined || node.kind !== "map") return undefined; - for (const [candidate, value] of node.entries) if (candidate === key) return value; - return undefined; + return node.entries.get(key); } /** diff --git a/test/credential-surface.test.ts b/test/credential-surface.test.ts index 192a29f..f363943 100644 --- a/test/credential-surface.test.ts +++ b/test/credential-surface.test.ts @@ -187,13 +187,18 @@ describe("the committed surface is the declared surface (positive control)", () }); }); +/** The publish step's own `env:` block, which no other step in the workflow carries. */ +const PUBLISH_STEP_ENV = + " # npm provenance requires a PUBLIC source repo; auto-enables when this repo is public.\n" + + " NPM_CONFIG_PROVENANCE: ${{ github.event.repository.visibility == 'public' }}\n"; + describe("a secret the declaration does not name (AC2)", () => { it("fails, and reports both the secret and where it was found", () => { const root = fixture(); edit(root, WORKFLOW, (text) => text.replace( - " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n", - " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n SMUGGLED: ${{ secrets.LEGACY_DEPLOY_KEY }}\n", + PUBLISH_STEP_ENV, + `${PUBLISH_STEP_ENV} SMUGGLED: \${{ secrets.LEGACY_DEPLOY_KEY }}\n`, ), ); const result = run(["--repo", root]); @@ -215,10 +220,11 @@ describe("a secret the declaration does not name (AC2)", () => { }); it("does not mistake the workflow's prose for wiring", () => { - // release.yml's header comments name NPM_TOKEN, NODE_AUTH_TOKEN and RELEASE_PR_TOKEN in - // ordinary English several times. A gate that grepped would report every one of those. + // release.yml's comments name NODE_AUTH_TOKEN and RELEASE_PR_TOKEN in ordinary English several + // times, explaining what is no longer wired and what is optional. A gate that grepped would + // report every one of those. const workflow = readFileSync(join(REPO, WORKFLOW), "utf8"); - expect(workflow).toContain("# register the npm-side Trusted Publisher, then remove NPM_TOKEN"); + expect(workflow).toContain("RELEASE_PR_TOKEN is optional"); const root = fixture(); edit(root, WORKFLOW, (text) => text.replace( @@ -234,13 +240,13 @@ describe("a declared credential that is no longer where it says (AC3)", () => { it("fails and names the credential rather than passing because nothing extra was found", () => { const root = fixture(); edit(root, WORKFLOW, (text) => - text.replace(" NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n", ""), + text.replace(" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n", ""), ); const result = run(["--repo", root]); expect(result.status).toBe(1); expect(result.output).toContain("declared-exposure-absent"); - expect(result.output).toContain("NPM_TOKEN"); - expect(result.output).toContain("NODE_AUTH_TOKEN"); + expect(result.output).toContain("GITHUB_TOKEN"); + expect(result.output).toContain("GH_TOKEN"); expect(result.output).not.toContain("undeclared-secret"); }); @@ -249,27 +255,34 @@ describe("a declared credential that is no longer where it says (AC3)", () => { // `replaceAll`, not `replace`, and the difference is load-bearing rather than stylistic. This // case is about the credential being gone EVERYWHERE, so the mutation has to remove every // reference; a single-occurrence replace leaves one behind the moment a second step legitimately - // consumes the token, and the checker then correctly reports per-exposure findings instead of - // the whole-credential one. S0081 added exactly such a step (the configuration allow-check, which - // must resolve the same npmrc the publish resolves and therefore needs the same NODE_AUTH_TOKEN), - // and that is what turned this into a fixture bug rather than a checker bug. The assertion below - // is unchanged: the refusal this case exists for is intact. + // consumes the credential, and the checker then correctly reports per-exposure findings instead + // of the whole-credential one. + // + // The presence test is REWRITTEN rather than deleted: deleting it would leave a step with an + // `env:` block and no entries, which is a different defect (an unparseable workflow) and would + // grade the YAML reader rather than this refusal. edit(root, WORKFLOW, (text) => text - .replaceAll(" NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n", "") - .replaceAll(" NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n", ""), + .replaceAll( + " HAS_RELEASE_PR_TOKEN: ${{ secrets.RELEASE_PR_TOKEN != '' }}\n", + " HAS_RELEASE_PR_TOKEN: ${{ github.repository != '' }}\n", + ) + .replaceAll( + "${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }}", + "${{ secrets.GITHUB_TOKEN }}", + ), ); const result = run(["--repo", root]); expect(result.status).toBe(1); expect(result.output).toContain("declared-credential-absent"); - expect(result.output).toContain("NPM_TOKEN"); + expect(result.output).toContain("RELEASE_PR_TOKEN"); }); it("reports a declared step that no longer exists in the workflow", () => { const root = fixture(); editDeclaration(root, (declaration) => { - const npm = declaration.credentials.find((entry) => entry.name === "NPM_TOKEN"); - npm!.exposures[0].step = "A step nobody wrote"; + const github = declaration.credentials.find((entry) => entry.name === "GITHUB_TOKEN"); + github!.exposures[0].step = "A step nobody wrote"; }); const result = run(["--repo", root]); expect(result.status).toBe(1); @@ -279,25 +292,26 @@ describe("a declared credential that is no longer where it says (AC3)", () => { }); describe("a credential exposed more broadly than declared (AC4)", () => { - it("fails when a step-scoped token is hoisted to job level, reporting both scopes", () => { + it("fails when a step-scoped credential is hoisted to job level, reporting both scopes", () => { const root = fixture(); edit(root, WORKFLOW, (text) => text.replace( " environment: release\n steps:\n", - " environment: release\n env:\n NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n steps:\n", + " environment: release\n env:\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n steps:\n", ), ); const result = run(["--repo", root]); expect(result.status).toBe(1); expect(result.output).toContain("scope-widened"); expect(result.output).toContain('job-level `env` in job "publish"'); - expect(result.output).toContain('the declaration permits only step "Publish"'); + expect(result.output).toContain("the declaration permits only"); + expect(result.output).toContain("GITHUB_TOKEN"); }); - it("fails when a step-scoped token is hoisted to workflow level", () => { + it("fails when a step-scoped credential is hoisted to workflow level", () => { const root = fixture(); edit(root, WORKFLOW, (text) => - text.replace("jobs:\n", "env:\n NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\njobs:\n"), + text.replace("jobs:\n", "env:\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\njobs:\n"), ); const result = run(["--repo", root]); expect(result.status).toBe(1); @@ -305,12 +319,12 @@ describe("a credential exposed more broadly than declared (AC4)", () => { expect(result.output).toContain("workflow-level"); }); - it("fails when a token appears in a job the declaration never named", () => { + it("fails when a credential appears in a step the declaration never named", () => { const root = fixture(); edit(root, WORKFLOW, (text) => text.replace( " - name: Changesets must be able to bump something\n", - " - name: Changesets must be able to bump something\n env:\n NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n", + " - name: Changesets must be able to bump something\n env:\n GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n", ), ); const result = run(["--repo", root]); @@ -407,13 +421,38 @@ describe("the registry-reaching job and its protected environment (AC5)", () => }); it("fails when a registry credential turns up in an ungated job", () => { + // Both halves are needed to build this state, and that is the point of the case: a credential + // the DECLARATION marks as registry authentication, wired into a job that asks for no + // deployment environment. Neither file alone can produce it, which is what makes the gate's + // environment rule about the publish path rather than about one file's text. const root = fixture(); edit(root, WORKFLOW, (text) => text.replace( " - name: Report whether a version PR would be able to run its checks\n env:\n", - " - name: Report whether a version PR would be able to run its checks\n env:\n NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n", + " - name: Report whether a version PR would be able to run its checks\n env:\n A_REGISTRY_TOKEN: ${{ secrets.A_REGISTRY_TOKEN }}\n", ), ); + editDeclaration(root, (declaration) => { + declaration.credentials.push({ + name: "A_REGISTRY_TOKEN", + tokenClass: "A registry credential invented by this case, and by nothing else.", + storage: "organization", + requiredForPublish: false, + registryAuth: true, + exposures: [ + { + job: "version", + step: "Report whether a version PR would be able to run its checks", + as: "env", + name: "A_REGISTRY_TOKEN", + mode: "value", + }, + ], + issuedForms: [], + retiredWhen: + "When this case stops needing a registry credential to place in an ungated job.", + }); + }); const result = run(["--repo", root]); expect(result.status).toBe(1); expect(result.output).toContain("environment-missing"); @@ -514,19 +553,20 @@ describe("log scrubbing must cover every declared issued form (AC7)", () => { it("fails when a form is redacted but the post-redaction assertion stops looking for it", () => { const root = fixture(); - edit(root, WORKFLOW, (text) => - text.replace("if grep -rqiE 'npm_[A-Za-z0-9]{36}|", "if grep -rqiE 'placeholder_no_match|"), - ); + edit(root, WORKFLOW, (text) => text.replace("|github_pat_[A-Za-z0-9_]{22,}|(_auth", "|(_auth")); const result = run(["--repo", root]); expect(result.status).toBe(1); expect(result.output).toContain("redaction-assertion-incomplete"); - expect(result.output).toContain("npm_[A-Za-z0-9]{36}"); + expect(result.output).toContain("github_pat_[A-Za-z0-9_]{22,}"); }); it("fails when the scrubbing rule itself is dropped", () => { const root = fixture(); edit(root, WORKFLOW, (text) => - text.replace(" -e 's/npm_[A-Za-z0-9]{36}/npm_REDACTED/g' \\\n", ""), + text.replace( + " -e 's/github_pat_[A-Za-z0-9_]{22,}/github_pat_REDACTED/g' \\\n", + "", + ), ); const result = run(["--repo", root]); expect(result.status).toBe(1); @@ -671,11 +711,11 @@ describe("one run reports every disagreement (AC11)", () => { text // 1: an undeclared secret. .replace( - " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n", - " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n SMUGGLED: ${{ secrets.LEGACY_DEPLOY_KEY }}\n", + PUBLISH_STEP_ENV, + `${PUBLISH_STEP_ENV} SMUGGLED: \${{ secrets.LEGACY_DEPLOY_KEY }}\n`, ) // 2: a declared exposure that is gone. - .replace(" NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n", "") + .replace(" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n", "") // 3: the protected environment removed. .replace(" environment: release\n", "") // 4: a permission grant widened. diff --git a/test/npm-config-allow.test.ts b/test/npm-config-allow.test.ts index 0a9e7e9..fdefc04 100644 --- a/test/npm-config-allow.test.ts +++ b/test/npm-config-allow.test.ts @@ -174,6 +174,11 @@ function fixture(options: FixtureOptions = {}): Fixture { const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, + // The check measures the package managers' own defaults in a throwaway directory under the + // machine's temp dir, and every context it runs in for real supplies one. Carried through rather + // than left to its default so a case here is about the allow-set rather than about how much room + // `/tmp` happens to have on the machine running the suite. + ...(process.env.TMPDIR === undefined ? {} : { TMPDIR: process.env.TMPDIR }), NPM_CONFIG_GLOBALCONFIG: emptyRc, }; if (options.userNpmrc !== undefined) { @@ -722,13 +727,18 @@ describe("npm-config-allow: where the check runs", () => { expect(versionLines.some((line) => /^ {4}environment:/.test(line))).toBe(false); // Registry credentials reach no job at all any more, and the gated job is still the only one - // that can authenticate: `id-token: write` is granted there and nowhere else. + // that can authenticate: `id-token: write` is granted there and nowhere else. Matched as a + // JOB-LEVEL GRANT (six spaces, inside a `permissions:` block) rather than as a substring, for + // the same reason `environment: release` is above: this file's comments discuss the grant at + // length and a substring assertion would pass on the prose. for (const job of [preflight, version, publish]) { expect(job).not.toContain("secrets.NPM_TOKEN"); } - expect(publish).toContain("id-token: write"); - expect(preflight).not.toContain("id-token: write"); - expect(version).not.toContain("id-token: write"); + const grantsIdToken = (lines: string[]): boolean => + lines.some((line) => /^ {6}id-token: write(\s|$)/.test(line)); + expect(grantsIdToken(blocks.get("publish") ?? [])).toBe(true); + expect(grantsIdToken(preflightLines)).toBe(false); + expect(grantsIdToken(versionLines)).toBe(false); }); it( @@ -1177,6 +1187,8 @@ describe("npm-config-allow: the real repository", () => { const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, + // See `fixture()`: the defaults baseline needs a temp dir, and every real context supplies one. + ...(process.env.TMPDIR === undefined ? {} : { TMPDIR: process.env.TMPDIR }), NPM_CONFIG_GLOBALCONFIG: emptyRc, }; if (ci) { diff --git a/test/publish-preflight-oidc.test.ts b/test/publish-preflight-oidc.test.ts new file mode 100644 index 0000000..5e3277d --- /dev/null +++ b/test/publish-preflight-oidc.test.ts @@ -0,0 +1,516 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { parseWorkflow } from "../scripts/credential-surface.mjs"; + +// WHAT THE RELEASE COMMAND PATH DOES WHEN IT RUNS, WITH NO REGISTRY CREDENTIAL ANYWHERE +// (spec S0317-config-1). +// +// The sibling file `tokenless-publish.test.ts` grades what the committed workflow, declaration and +// allow-set SAY. This one runs the two scripts on the release command path and grades what they DO: +// +// scripts/publish-preflight.mjs runs first in `pnpm run release`, before the build and long +// before anything is packed. AC-3, AC-6, AC-7. +// scripts/npm-config-allow.mjs runs inside the gated publish job, before the publish. AC-9. +// +// BOTH DIRECTIONS, EVERY TIME. A preflight that refuses everything passes every negative case here +// and takes the release path down; a preflight that passes everything is not a preflight. And the +// refusals are separated by exit code the way every gate in this repository separates them: 1 is +// "checked, and it was not there", 2 is "could not check", because a broken gate and a caught defect +// must not be one signal in CI. +// +// NOTHING HERE READS A CREDENTIAL VALUE, and one case proves it: a sentinel is placed in the +// environment and the whole output is asserted not to contain it. This repository is public and its +// build logs are public with it. + +const REPO = join(import.meta.dirname, ".."); +const DECLARATION = join(".github", "credential-surface.json"); +const RELEASE_WORKFLOW = join(".github", "workflows", "release.yml"); +const PREFLIGHT = join(REPO, "scripts", "publish-preflight.mjs"); +const ALLOW_CHECK = join(REPO, "scripts", "npm-config-allow.mjs"); + +/** The allow-check spawns npm twice and pnpm twice. Vitest's five second default is not a budget. */ +const SLOW = 180_000; + +/** A value no message may ever echo. If it appears in the output, something leaked a secret. */ +const SENTINEL = "npm_thisvalueMUSTneverBEprinted000000"; + +/** What GitHub gives a job holding `id-token: write`, as the declaration names them. */ +const OIDC_URL = "ACTIONS_ID_TOKEN_REQUEST_URL"; +const OIDC_TOKEN = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; + +/** An environment in which the workflow's OIDC identity is available, as a runner presents it. */ +const AUTHENTICATED = { + [OIDC_URL]: "https://pipelines.example.invalid/_apis/distributedtask/hubs/Actions/oidctoken", + [OIDC_TOKEN]: SENTINEL, +}; + +const temporaryDirs: string[] = []; + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + } +}); + +/** + * Make a throwaway directory that is cleaned up after the test. + * + * @param prefix Name prefix. + * @returns The directory. + */ +function temp(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), `${prefix}-`)); + temporaryDirs.push(dir); + return dir; +} + +interface Run { + status: number; + stdout: string; + output: string; +} + +/** + * Run the shipped preflight in a controlled environment. + * + * Nothing is inherited from this process, so a variable that happens to be set on the machine the + * suite runs on cannot turn a negative case green. `--npm-bin` stands in for the BOUNDARY, the npm + * binary `pnpm publish` would call, and never for the preflight itself. + * + * @param root The repository root to check against. + * @param env The environment to inspect. + * @param npmBin The npm binary the preflight should ask for its version. + * @returns Its exit status and output. + */ +function preflight(root: string, env: Record, npmBin?: string): Run { + const args = [PREFLIGHT, "--repo", root, ...(npmBin === undefined ? [] : ["--npm-bin", npmBin])]; + const result = spawnSync(process.execPath, args, { + encoding: "utf8", + env: { PATH: process.env.PATH ?? "", ...env }, + }); + const stdout = result.stdout ?? ""; + return { status: result.status ?? -1, stdout, output: `${stdout}${result.stderr ?? ""}` }; +} + +/** + * Copy this repository's real declaration into a throwaway directory. + * + * @returns The fixture root. + */ +function fixture(): string { + const root = temp("publish-preflight-oidc"); + mkdirSync(join(root, ".github"), { recursive: true }); + copyFileSync(join(REPO, DECLARATION), join(root, DECLARATION)); + return root; +} + +/** + * Rewrite the fixture's declaration through a callback that mutates the parsed object. + * + * @param root The fixture root. + * @param mutate Receives the parsed declaration. + */ +function editDeclaration(root: string, mutate: (declaration: Declaration) => void): void { + const path = join(root, DECLARATION); + const declaration = JSON.parse(readFileSync(path, "utf8")) as Declaration; + mutate(declaration); + writeFileSync(path, `${JSON.stringify(declaration, null, 2)}\n`, "utf8"); +} + +interface Declaration { + publishPath: { + job: string; + workflow: string; + authentication: { + method: string; + npmCliFloor: string; + runtimeEvidence: { variable: string; note?: string }[]; + }; + }; + credentials: { name: string; requiredForPublish: boolean }[]; +} + +/** + * @returns This repository's committed declaration. + */ +function realDeclaration(): Declaration { + return JSON.parse(readFileSync(join(REPO, DECLARATION), "utf8")) as Declaration; +} + +/** + * Write an executable stand-in for the npm binary, answering one version. + * + * @param version What `npm --version` should print. Empty means print nothing usable. + * @returns The path to the fake binary. + */ +function fakeNpm(version: string): string { + const path = join(temp("fake-npm"), "npm"); + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' '${version}'\n`, "utf8"); + chmodSync(path, 0o755); + return path; +} + +/** An npm that meets the declared floor, so a case about authentication is only about that. */ +const NPM_AT_FLOOR = (): string => + fakeNpm(realDeclaration().publishPath.authentication.npmCliFloor); + +// --------------------------------------------------------------------------------------------- +// AC-6: the release command path checks that the declared authentication is available here, and +// refuses before the build, without contacting the registry, naming what was missing. +// --------------------------------------------------------------------------------------------- + +describe("AC-6: the declared authentication must be available in this environment", () => { + it("refuses with nothing set, naming every variable the declared authentication needs", () => { + const result = preflight(fixture(), {}, NPM_AT_FLOOR()); + expect(result.status).toBe(1); + expect(result.output).toContain("REFUSING TO PUBLISH"); + expect(result.output).toContain(OIDC_URL); + expect(result.output).toContain(OIDC_TOKEN); + expect(result.output).toContain("the registry has NOT been contacted"); + }); + + it("names the method the declaration gave, rather than a credential hardcoded here", () => { + const result = preflight(fixture(), {}, NPM_AT_FLOOR()); + expect(result.output).toContain(realDeclaration().publishPath.authentication.method); + }); + + it("says what to do about it: run it where that authentication exists", () => { + const declaration = realDeclaration(); + const result = preflight(fixture(), {}, NPM_AT_FLOOR()); + expect(result.output).toContain("ACTION:"); + expect(result.output).toContain(declaration.publishPath.job); + expect(result.output).toContain(declaration.publishPath.workflow); + }); + + it("treats an empty value as absent", () => { + const result = preflight(fixture(), { [OIDC_URL]: "", [OIDC_TOKEN]: "" }, NPM_AT_FLOOR()); + expect(result.status).toBe(1); + expect(result.output).toContain(OIDC_URL); + }); + + it("treats a whitespace-only value as absent", () => { + const result = preflight( + fixture(), + { [OIDC_URL]: " ", [OIDC_TOKEN]: " \t " }, + NPM_AT_FLOOR(), + ); + expect(result.status).toBe(1); + expect(result.output).toContain(OIDC_URL); + }); + + it("catches the half-set case, where one of the two variables is missing", () => { + const result = preflight(fixture(), { [OIDC_URL]: AUTHENTICATED[OIDC_URL] }, NPM_AT_FLOOR()); + expect(result.status).toBe(1); + expect(result.output).toContain(OIDC_TOKEN); + }); + + it("never echoes a credential value", () => { + // One of the two variables IS a credential. It is tested for presence and never read. + const result = preflight(fixture(), { [OIDC_TOKEN]: SENTINEL }, NPM_AT_FLOOR()); + expect(result.status).toBe(1); + expect(result.output).not.toContain(SENTINEL); + }); + + it("passes when the declared authentication is present (positive control)", () => { + const result = preflight(fixture(), AUTHENTICATED, NPM_AT_FLOOR()); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Proceeding to build and publish"); + expect(result.output).not.toContain(SENTINEL); + }); + + it("still names the token class and the one place it may live, for a credential that needs one", () => { + // The credential route is not dead code just because this repository authenticates without one: + // the declaration decides, and a token method would restore it. A declaration that requires a + // credential must still produce the operator's two questions ("which token, and where does it + // live") in the refusal, rather than only the OIDC sentence. + const root = fixture(); + const path = join(root, DECLARATION); + const declaration = JSON.parse(readFileSync(path, "utf8")) as { + publishPath: { job: string }; + credentials: Record[]; + }; + declaration.credentials.unshift({ + name: "A_REGISTRY_TOKEN", + tokenClass: "An automation token with publish rights, which is what this case is about.", + storage: "organization", + requiredForPublish: true, + registryAuth: true, + exposures: [ + { job: declaration.publishPath.job, step: "Publish", as: "env", name: "A_REGISTRY_TOKEN" }, + ], + issuedForms: [], + retiredWhen: "When the publish path stops declaring it as required for publishing.", + }); + writeFileSync(path, `${JSON.stringify(declaration, null, 2)}\n`, "utf8"); + + const result = preflight(root, AUTHENTICATED, NPM_AT_FLOOR()); + expect(result.status).toBe(1); + expect(result.output).toContain("A_REGISTRY_TOKEN"); + expect(result.output).toContain("organization"); + expect(result.output).toContain("automation token with publish rights"); + expect(result.output).toContain("Credential rotation, revocation, and compensating actions"); + }); + + it("does not fail closed on a credential the declaration marks optional", () => { + // RELEASE_PR_TOKEN is optional by design: failing on its absence would take the release path + // down to protect against a state this repository is already able to be in. + const declaration = realDeclaration(); + const optional = declaration.credentials.find((entry) => entry.name === "RELEASE_PR_TOKEN"); + expect(optional?.requiredForPublish, "RELEASE_PR_TOKEN is optional by design").toBe(false); + expect(preflight(fixture(), AUTHENTICATED, NPM_AT_FLOOR()).status).toBe(0); + }); + + it("runs before the build is paid for, and guards the command path rather than one step list", () => { + const manifest = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")) as { + scripts: Record; + }; + const release = manifest.scripts.release; + expect(release.startsWith("node scripts/publish-preflight.mjs &&")).toBe(true); + expect(release.indexOf("publish-preflight")).toBeLessThan(release.indexOf("build")); + expect(release.indexOf("publish-preflight")).toBeLessThan(release.indexOf("changeset publish")); + // The release workflow reaches the registry through `publish: pnpm run release`, so the guard + // sits on that command. A step in release.yml would guard release.yml and nothing else. + expect(readFileSync(join(REPO, RELEASE_WORKFLOW), "utf8")).toContain( + "publish: pnpm run release", + ); + }); +}); + +// --------------------------------------------------------------------------------------------- +// AC-7: a declaration that cannot be read, or that names no authentication at all for the publish +// path, is a refusal. A check that would pass on an empty environment is a declaration bug. +// --------------------------------------------------------------------------------------------- + +describe("AC-7: a preflight that cannot know what to check is not a preflight that passed", () => { + it("refuses when the declaration is absent", () => { + const root = fixture(); + rmSync(join(root, DECLARATION)); + const result = preflight(root, AUTHENTICATED, NPM_AT_FLOOR()); + expect(result.status).toBe(2); + expect(result.output).toContain("REFUSING TO PUBLISH"); + expect(result.output).toContain("declaration-absent"); + }); + + it("refuses when the declaration is not valid JSON", () => { + const root = fixture(); + writeFileSync(join(root, DECLARATION), "{ nope", "utf8"); + const result = preflight(root, AUTHENTICATED, NPM_AT_FLOOR()); + expect(result.status).toBe(2); + expect(result.output).toContain("declaration-unparseable"); + }); + + it("refuses a declaration carrying no authentication block at all", () => { + const root = fixture(); + const path = join(root, DECLARATION); + const declaration = JSON.parse(readFileSync(path, "utf8")) as { + publishPath: Record; + }; + delete declaration.publishPath.authentication; + writeFileSync(path, `${JSON.stringify(declaration, null, 2)}\n`, "utf8"); + + const result = preflight(root, AUTHENTICATED, NPM_AT_FLOOR()); + expect(result.status).toBe(2); + expect(result.output).toContain("publishPath.authentication"); + expect(result.output).toContain("cannot be conformed to"); + }); + + it("refuses a declaration naming nothing to check, rather than passing on any environment at all", () => { + const root = fixture(); + editDeclaration(root, (declaration) => { + // A declaration that says the path is token-authenticated, names no runtime evidence, and + // marks no credential required. Every check it implies is vacuous. + declaration.publishPath.authentication.method = "npm-token"; + declaration.publishPath.authentication.runtimeEvidence = []; + for (const credential of declaration.credentials) credential.requiredForPublish = false; + }); + const result = preflight(root, {}, NPM_AT_FLOOR()); + expect(result.status).toBe(2); + expect(result.output).toContain("names no authentication"); + expect(result.output).toContain("declaration bug"); + }); + + it("refuses an OIDC declaration whose runtime evidence was emptied", () => { + const root = fixture(); + editDeclaration(root, (declaration) => { + declaration.publishPath.authentication.runtimeEvidence = []; + }); + const result = preflight(root, AUTHENTICATED, NPM_AT_FLOOR()); + expect(result.status).toBe(2); + expect(result.output).toContain("runtimeEvidence"); + }); + + it("separates a bad invocation from a missing credential", () => { + const result = spawnSync(process.execPath, [PREFLIGHT, "--nonsense"], { + encoding: "utf8", + env: { PATH: process.env.PATH ?? "" }, + }); + expect(result.status).toBe(2); + expect(result.stderr).toContain("could not run"); + }); +}); + +// --------------------------------------------------------------------------------------------- +// AC-3: an npm below the floor fails the release before any package is packed, naming the version +// found and the floor it failed. +// --------------------------------------------------------------------------------------------- + +describe("AC-3: the npm on the publish path must be one trusted publishing can use", () => { + it("refuses an npm below the floor, naming the version it found and the floor", () => { + const floor = realDeclaration().publishPath.authentication.npmCliFloor; + const result = preflight(fixture(), AUTHENTICATED, fakeNpm("10.9.2")); + expect(result.status).toBe(1); + expect(result.output).toContain("REFUSING TO PUBLISH"); + expect(result.output).toContain("10.9.2"); + expect(result.output).toContain(floor); + expect(result.output).toContain("nothing has been packed"); + }); + + it("refuses the version one patch below the floor, so the comparison is not off by one", () => { + const result = preflight(fixture(), AUTHENTICATED, fakeNpm("11.5.0")); + expect(result.status).toBe(1); + expect(result.output).toContain("11.5.0"); + }); + + it("accepts the floor itself", () => { + expect(preflight(fixture(), AUTHENTICATED, fakeNpm("11.5.1")).status).toBe(0); + }); + + it("accepts an npm above the floor, and says which one it found", () => { + const result = preflight(fixture(), AUTHENTICATED, fakeNpm("11.19.1")); + expect(result.status).toBe(0); + expect(result.stdout).toContain("11.19.1"); + }); + + it("refuses when the npm it would publish with cannot be asked at all", () => { + const result = preflight(fixture(), AUTHENTICATED, join(temp("gone"), "no-such-npm")); + expect(result.status).toBe(2); + expect(result.output).toContain("npm-version-unreadable"); + }); + + it("refuses when npm answers something that is not a version", () => { + const result = preflight(fixture(), AUTHENTICATED, fakeNpm("not a version")); + expect(result.status).toBe(2); + expect(result.output).toContain("npm-version-unreadable"); + }); + + it("reads the floor from the declaration rather than from a number written here", () => { + const root = fixture(); + editDeclaration(root, (declaration) => { + declaration.publishPath.authentication.npmCliFloor = "12.0.0"; + }); + const result = preflight(root, AUTHENTICATED, fakeNpm("11.19.1")); + expect(result.status).toBe(1); + expect(result.output).toContain("12.0.0"); + }); +}); + +// --------------------------------------------------------------------------------------------- +// AC-9: the publish configuration allow-check resolves the effective configuration completely and +// permits it with no registry credential in its environment, and still refuses a `provenance` that +// is not true. +// --------------------------------------------------------------------------------------------- + +describe("AC-9: the configuration allow-check runs credential-free", () => { + /** + * The environment release.yml gives the allow-check step, read off the workflow rather than + * invented here, so a change to that step's `env:` block changes what this suite runs. + * + * @returns The variable names that step declares. + */ + function allowCheckEnvKeys(): string[] { + const workflow = parseWorkflow(readFileSync(join(REPO, RELEASE_WORKFLOW), "utf8")) as { + entries: Map }>; + }; + const jobs = workflow.entries.get("jobs") as { entries: Map }; + const publish = jobs.entries.get("publish") as unknown as { + entries: Map }> }[] }>; + }; + const steps = publish.entries.get("steps")!.items; + const step = steps.find( + (candidate) => + (candidate.entries.get("name") as unknown as { value?: string })?.value === + "The publish configuration must be one the allow-set permits", + ); + const env = step?.entries.get("env"); + return env === undefined ? [] : [...env.entries.keys()]; + } + + /** + * The publish job's context, with no registry credential in it because that job has none. + * + * @param overrides Extra variables for a specific case. + * @returns The environment. + */ + function publishJobContext(overrides: Record = {}): NodeJS.ProcessEnv { + const home = temp("allow-check-home"); + const emptyRc = join(home, "empty-npmrc"); + writeFileSync(emptyRc, "", "utf8"); + return { + PATH: process.env.PATH, + HOME: home, + // The check measures the package managers' own defaults in a throwaway directory under the + // machine's temp dir, and a runner supplies one. Carried through rather than left to default, + // so the case is about the allow-set and not about the size of `/tmp` on the machine. + ...(process.env.TMPDIR === undefined ? {} : { TMPDIR: process.env.TMPDIR }), + NPM_CONFIG_GLOBALCONFIG: emptyRc, + NPM_CONFIG_PROVENANCE: "true", + ...overrides, + }; + } + + it("is given no registry credential by the workflow, so the environment used below is the real one", () => { + const keys = allowCheckEnvKeys(); + expect(keys).toEqual(["NPM_CONFIG_PROVENANCE"]); + for (const key of keys) { + expect(key).not.toMatch(/TOKEN|AUTH|PASSWORD|SECRET/i); + } + }); + + it( + "resolves the effective configuration completely and permits it, with no credential present", + () => { + const result = spawnSync(process.execPath, [ALLOW_CHECK, "--workspace", REPO], { + encoding: "utf8", + env: publishJobContext(), + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + expect(result.status, output).toBe(0); + expect(output).toContain("npm-config-allow: OK"); + // RESOLVED, not merely unrefused: the publish configuration of all eight published packages + // was read, and both resolvers answered. A run that resolved nothing would also print OK. + expect(output.match(/publishConfig:access/g)?.length).toBe(8); + expect(output).toContain("provenance = true"); + expect(output).not.toContain("could not run"); + }, + SLOW, + ); + + it( + "still refuses a provenance that is not true, which is the value it exists to pin", + () => { + const result = spawnSync(process.execPath, [ALLOW_CHECK, "--workspace", REPO], { + encoding: "utf8", + env: publishJobContext({ NPM_CONFIG_PROVENANCE: "false" }), + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + expect(result.status, output).toBe(1); + expect(output).toContain("provenance"); + expect(output).toContain("NOT permitted"); + }, + SLOW, + ); +}); diff --git a/test/publish-preflight.test.ts b/test/publish-preflight.test.ts deleted file mode 100644 index b21bb72..0000000 --- a/test/publish-preflight.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -// THE NEGATIVE CONTROL FOR THE PUBLISH PREFLIGHT. -// -// The defect: `changeset publish` discovers a missing npm token by asking the registry and being -// refused, which happens at the LAST step of a job that has already checked out, installed, built -// eight packages and waited for a human to approve a protected deployment. The approval is spent, -// the build is paid for, and the diagnostic is an `E401` from npm rather than a sentence naming the -// credential and where it is supposed to live. -// -// The preflight is worth exactly what its pair of controls proves, so this suite asserts BOTH -// directions and one boundary: -// -// NEGATIVE a required credential that is absent, empty, or whitespace must exit NON-ZERO, -// name the variable and the credential behind it, and say the registry was not touched. -// POSITIVE a fully credentialed environment must exit ZERO. -// BOUNDARY a credential the declaration marks OPTIONAL must not fail the publish closed. -// `RELEASE_PR_TOKEN` is optional by design; failing on its absence would take the -// release path down to protect against a state this repository is already able to be in, -// which release.yml calls out explicitly. -// -// It drives the SHIPPED CLI, because what `pnpm run release` depends on is the process exit code. - -const REPO = join(import.meta.dirname, ".."); -const PREFLIGHT = join(REPO, "scripts", "publish-preflight.mjs"); -const DECLARATION = join(".github", "credential-surface.json"); - -/** A value no message may ever echo. If it appears in the output, the preflight leaked a secret. */ -const SENTINEL = "npm_thisvalueMUSTneverBEprinted000000"; - -const temporaryDirs: string[] = []; - -afterEach(() => { - while (temporaryDirs.length > 0) { - const dir = temporaryDirs.pop(); - if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); - } -}); - -interface Run { - status: number; - stdout: string; - stderr: string; - output: string; -} - -/** - * Run the shipped CLI in a controlled environment. - * - * @param root The repository root to check against. - * @param env The credential environment. Nothing is inherited from this process, so a token that - * happens to be set on the developer's machine cannot turn a negative case green. - * @returns Its exit status and both streams. - */ -function run(root: string, env: Record): Run { - const result = spawnSync(process.execPath, [PREFLIGHT, "--repo", root], { - encoding: "utf8", - env: { PATH: process.env.PATH ?? "", ...env }, - }); - const stdout = result.stdout ?? ""; - const stderr = result.stderr ?? ""; - return { status: result.status ?? -1, stdout, stderr, output: `${stdout}${stderr}` }; -} - -/** - * Copy this repository's real declaration into a throwaway directory. - * - * @returns The fixture root. - */ -function fixture(): string { - const root = mkdtempSync(join(tmpdir(), "publish-preflight-")); - temporaryDirs.push(root); - mkdirSync(join(root, ".github"), { recursive: true }); - copyFileSync(join(REPO, DECLARATION), join(root, DECLARATION)); - return root; -} - -/** Every environment variable the committed declaration requires, all present and plausible. */ -const FULLY_CREDENTIALED = { NPM_TOKEN: SENTINEL, NODE_AUTH_TOKEN: SENTINEL }; - -describe("a required credential that is absent or empty (AC8)", () => { - it("stops before the registry is contacted and names the missing credential", () => { - const result = run(fixture(), {}); - expect(result.status).toBe(1); - expect(result.output).toContain("REFUSING TO PUBLISH"); - expect(result.output).toContain("NPM_TOKEN"); - expect(result.output).toContain("NODE_AUTH_TOKEN"); - expect(result.output).toContain("the registry has NOT been contacted"); - }); - - it("treats an empty value as absent", () => { - const result = run(fixture(), { NPM_TOKEN: "", NODE_AUTH_TOKEN: "" }); - expect(result.status).toBe(1); - expect(result.output).toContain("NPM_TOKEN"); - }); - - it("treats a whitespace-only value as absent", () => { - const result = run(fixture(), { NPM_TOKEN: " ", NODE_AUTH_TOKEN: " \t " }); - expect(result.status).toBe(1); - expect(result.output).toContain("NPM_TOKEN"); - }); - - it("catches the half-set case, where one of a credential's two variables is missing", () => { - const result = run(fixture(), { NPM_TOKEN: SENTINEL }); - expect(result.status).toBe(1); - expect(result.output).toContain("NODE_AUTH_TOKEN"); - }); - - it("tells the operator the token class and the one place it may live", () => { - const result = run(fixture(), {}); - expect(result.output).toContain("organization"); - expect(result.output).toContain("Automation token"); - expect(result.output).toContain("Credential rotation, revocation, and compensating actions"); - }); - - it("never echoes a credential value", () => { - const result = run(fixture(), { NPM_TOKEN: SENTINEL }); - expect(result.status).toBe(1); - expect(result.output).not.toContain(SENTINEL); - }); - - it("passes when every required credential is present (positive control)", () => { - const result = run(fixture(), FULLY_CREDENTIALED); - expect(result.status).toBe(0); - expect(result.stdout).toContain("Proceeding to build and publish"); - expect(result.output).not.toContain(SENTINEL); - }); -}); - -describe("an optional credential must not fail the publish closed", () => { - it("passes with RELEASE_PR_TOKEN absent, which is a supported state", () => { - const declaration = JSON.parse(readFileSync(join(REPO, DECLARATION), "utf8")) as { - credentials: { name: string; requiredForPublish: boolean }[]; - }; - const optional = declaration.credentials.find((c) => c.name === "RELEASE_PR_TOKEN"); - expect(optional?.requiredForPublish, "RELEASE_PR_TOKEN is optional by design").toBe(false); - expect(run(fixture(), FULLY_CREDENTIALED).status).toBe(0); - }); -}); - -describe("the preflight runs before the build is paid for", () => { - it("is the first thing the release command does", () => { - const manifest = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8")) as { - scripts: Record; - }; - const release = manifest.scripts.release; - expect(release.startsWith("node scripts/publish-preflight.mjs &&")).toBe(true); - expect(release.indexOf("publish-preflight")).toBeLessThan(release.indexOf("build")); - expect(release.indexOf("publish-preflight")).toBeLessThan(release.indexOf("changeset publish")); - }); - - it("guards the publish command path rather than one workflow's step list", () => { - // The release workflow reaches the registry through `publish: pnpm run release`, so the guard - // sits on that command. A step in release.yml would guard release.yml and nothing else. - const workflow = readFileSync(join(REPO, ".github", "workflows", "release.yml"), "utf8"); - expect(workflow).toContain("publish: pnpm run release"); - }); -}); - -describe("a preflight that cannot run is not a preflight that passed", () => { - it("refuses when the declaration is absent", () => { - const root = fixture(); - rmSync(join(root, DECLARATION)); - const result = run(root, FULLY_CREDENTIALED); - expect(result.status).toBe(2); - expect(result.output).toContain("REFUSING TO PUBLISH"); - expect(result.output).toContain("declaration-absent"); - }); - - it("refuses when the declaration is not valid JSON", () => { - const root = fixture(); - writeFileSync(join(root, DECLARATION), "{ nope", "utf8"); - const result = run(root, FULLY_CREDENTIALED); - expect(result.status).toBe(2); - expect(result.output).toContain("declaration-unparseable"); - }); - - it("refuses a declaration that requires nothing, rather than passing on any environment at all", () => { - const root = fixture(); - const path = join(root, DECLARATION); - const declaration = JSON.parse(readFileSync(path, "utf8")) as { - credentials: { requiredForPublish: boolean }[]; - }; - for (const credential of declaration.credentials) credential.requiredForPublish = false; - writeFileSync(path, `${JSON.stringify(declaration, null, 2)}\n`, "utf8"); - const result = run(root, {}); - expect(result.status).toBe(2); - expect(result.output).toContain("marks no credential as required"); - }); - - it("separates a bad invocation from a missing credential", () => { - const result = spawnSync(process.execPath, [PREFLIGHT, "--nonsense"], { - encoding: "utf8", - env: { PATH: process.env.PATH ?? "" }, - }); - expect(result.status).toBe(2); - expect(result.stderr).toContain("could not run"); - }); -}); diff --git a/test/tokenless-publish.test.ts b/test/tokenless-publish.test.ts new file mode 100644 index 0000000..6a7d7d4 --- /dev/null +++ b/test/tokenless-publish.test.ts @@ -0,0 +1,616 @@ +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { parseWorkflow } from "../scripts/credential-surface.mjs"; + +// WHAT THE COMMITTED RELEASE PATH SAYS: NO REGISTRY CREDENTIAL, AND AN npm THAT CAN PUBLISH WITHOUT +// ONE (spec S0317-config-1). +// +// The objective: no long-lived npm credential exists that could publish a forged `@cosyte/*` +// version, because the release workflow's own OIDC identity is what the registry accepts. What that +// costs, if it is got wrong in the other direction, is the ability to publish at all, and an npm +// version is permanent, so every criterion here fails closed. +// +// This file grades what the committed workflow, declaration and allow-set SAY. The sibling file +// `publish-preflight-oidc.test.ts` grades what the release command path DOES when it runs. +// +// EVERY GRADER HERE IS RUN AGAINST A MUTATION THAT BREAKS THE PROPERTY IT ASSERTS. A check that +// cannot fail is not evidence, and a workflow assertion is the easiest place in a repository to +// write one by accident: the file is long, the property is narrow, and "it passed" and "it matched +// nothing" look identical from the outside. +// +// The workflow is PARSED rather than grepped, with the repository's own reader, for the reason +// `scripts/credential-surface.mjs` documents: this file's comments discuss npm tokens in ordinary +// English, and a text scan cannot tell prose from wiring. A reference to a secret is structure. + +const REPO = join(import.meta.dirname, ".."); +const RELEASE_WORKFLOW = join(".github", "workflows", "release.yml"); +const CI_WORKFLOW = join(".github", "workflows", "ci.yml"); +const DECLARATION = join(".github", "credential-surface.json"); +const DOCS = "RELEASING.md"; +const SURFACE_CHECK = join(REPO, "scripts", "credential-surface.mjs"); +const TOOLCHAIN_CHECK = join(REPO, "scripts", "publish-toolchain.mjs"); + +const RELEASE_TEXT = readFileSync(join(REPO, RELEASE_WORKFLOW), "utf8"); +const CI_TEXT = readFileSync(join(REPO, CI_WORKFLOW), "utf8"); + +/** The names an npm registry credential has ever had on this path. */ +const REGISTRY_CREDENTIAL_NAMES = /NPM_TOKEN|NODE_AUTH_TOKEN/; + +const temporaryDirs: string[] = []; + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + } +}); + +interface Run { + status: number; + output: string; +} + +/** + * Run one of the shipped gates, because what CI depends on is the process exit code. + * + * @param script The gate to run. + * @param args Arguments after the script name. + * @returns Its exit status and both streams. + */ +function run(script: string, args: string[]): Run { + const result = spawnSync(process.execPath, [script, ...args], { encoding: "utf8" }); + return { status: result.status ?? -1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` }; +} + +/** + * Copy the four files the release path's credential surface is made of into a throwaway directory. + * + * Copies rather than miniatures: a hand-written workflow proves a checker can read a workflow + * written for the checker. + * + * @returns The fixture root. + */ +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "tokenless-publish-")); + temporaryDirs.push(root); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + for (const relative of [RELEASE_WORKFLOW, CI_WORKFLOW, DECLARATION, DOCS]) { + copyFileSync(join(REPO, relative), join(root, relative)); + } + return root; +} + +/** + * Rewrite one of the fixture's files, asserting the rewrite actually changed it. + * + * A stale replacement string would otherwise make a negative case pass by mutating nothing, which + * is the one way a suite of mutations can quietly stop being evidence. + * + * @param root The fixture root. + * @param relative Which file. + * @param rewrite Receives the current contents and returns the new contents. + */ +function edit(root: string, relative: string, rewrite: (text: string) => string): void { + const before = readFileSync(join(root, relative), "utf8"); + const after = rewrite(before); + expect(after, `the mutation of ${relative} changed nothing, so it would prove nothing`).not.toBe( + before, + ); + writeFileSync(join(root, relative), after, "utf8"); +} + +type Node = + | { kind: "scalar"; value: string; line: number } + | { kind: "seq"; items: Node[]; line: number } + | { kind: "map"; entries: Map; line: number }; + +/** + * @param text A workflow file. + * @returns Its parsed root node. + */ +function parse(text: string): Node { + return parseWorkflow(text) as Node; +} + +/** + * @param node A node. + * @param key The key to read. + * @returns The child node, or undefined. + */ +function get(node: Node | undefined, key: string): Node | undefined { + return node !== undefined && node.kind === "map" ? node.entries.get(key) : undefined; +} + +/** + * @param node A node. + * @returns Its scalar value, or undefined. + */ +function scalar(node: Node | undefined): string | undefined { + return node !== undefined && node.kind === "scalar" ? node.value : undefined; +} + +/** + * @param workflow A parsed workflow. + * @param jobId The job. + * @returns Its step nodes, in order. + */ +function steps(workflow: Node, jobId: string): Node[] { + const list = get(get(get(workflow, "jobs"), jobId), "steps"); + return list !== undefined && list.kind === "seq" ? list.items : []; +} + +/** + * Every place a name appears in the WIRING of a workflow: a key of any mapping, or any scalar value. + * + * Comments are not here, and that is the point. The parser drops them, so a header that explains in + * English why a token is gone is not mistaken for the token coming back, while a `${{ secrets.X }}` + * expression, an `env:` key and a `run:` script that reads `$X` all are wiring and are all reported. + * + * @param node The node to walk. + * @param path The path taken so far. + * @param found Accumulator. + * @returns Every `{ where, text }` pair in the tree. + */ +function wiring(node: Node, path: string[] = [], found: { where: string; text: string }[] = []) { + if (node.kind === "scalar") { + found.push({ where: path.join("."), text: node.value }); + return found; + } + if (node.kind === "seq") { + node.items.forEach((item, index) => wiring(item, [...path, String(index)], found)); + return found; + } + for (const [key, value] of node.entries) { + found.push({ where: [...path, key].join("."), text: key }); + wiring(value, [...path, key], found); + } + return found; +} + +/** + * Every way a registry credential could still be wired into a workflow. Empty means none is. + * + * @param text The workflow file. + * @returns One problem per place a registry credential name appears in the wiring. + */ +function gradeCredentialFreedom(text: string): string[] { + return wiring(parse(text)) + .filter((entry) => REGISTRY_CREDENTIAL_NAMES.test(entry.text)) + .map((entry) => `${entry.where} carries a registry credential name: ${entry.text}`); +} + +// --------------------------------------------------------------------------------------------- +// AC-1: no npm registry credential is exposed to any job or step of the release workflow, and the +// publish job's only authentication is `id-token: write` plus the protected environment. +// --------------------------------------------------------------------------------------------- + +describe("AC-1: the release workflow exposes no npm registry credential", () => { + it("references no NPM_TOKEN or NODE_AUTH_TOKEN secret anywhere in the file", () => { + expect(gradeCredentialFreedom(RELEASE_TEXT)).toEqual([]); + }); + + it("REFUSES a workflow where the token is wired back into the publish step", () => { + const mutated = RELEASE_TEXT.replace( + " GITHUB_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }}\n", + " GITHUB_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }}\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n", + ); + expect(mutated).not.toBe(RELEASE_TEXT); + expect(gradeCredentialFreedom(mutated).join("\n")).toContain("NODE_AUTH_TOKEN"); + }); + + it("REFUSES a workflow where the token is read by a run script rather than declared", () => { + const mutated = RELEASE_TEXT.replace( + " run: node scripts/npm-config-allow.mjs\n", + ' run: echo "$NPM_TOKEN" > /dev/null && node scripts/npm-config-allow.mjs\n', + ); + expect(mutated).not.toBe(RELEASE_TEXT); + expect(gradeCredentialFreedom(mutated).join("\n")).toContain("NPM_TOKEN"); + }); + + it("does not mistake the header's prose for wiring", () => { + // The header explains at length that no registry credential belongs here, and the configuration + // gate's comment names the npmrc variable that is no longer generated. A grep would report both. + expect(RELEASE_TEXT).toContain("NODE_AUTH_TOKEN"); + expect(gradeCredentialFreedom(RELEASE_TEXT)).toEqual([]); + }); + + it("keeps `id-token: write` and the protected release environment on the publish job, and grants them nowhere else", () => { + const workflow = parse(RELEASE_TEXT); + const jobs = get(workflow, "jobs"); + expect(jobs?.kind).toBe("map"); + + const publish = get(jobs, "publish"); + expect(scalar(get(publish, "environment"))).toBe("release"); + expect(scalar(get(get(publish, "permissions"), "id-token"))).toBe("write"); + + for (const jobId of ["preflight", "version"]) { + const job = get(jobs, jobId); + expect(scalar(get(get(job, "permissions"), "id-token")), `${jobId}`).toBeUndefined(); + expect(get(job, "environment"), `${jobId}`).toBeUndefined(); + } + }); + + it("generates no user npmrc whose only purpose is carrying a token", () => { + // `actions/setup-node` given `registry-url` WRITES `//registry.npmjs.org/:_authToken=${...}` into + // a generated user config and points NPM_CONFIG_USERCONFIG at it. On a tokenless path that file + // carries a reference to a variable nothing sets, and what each resolver does with an + // unresolvable reference is a property of its own version rather than of anything committed + // here. The registry itself is pinned by `npm-config-allow.json`'s `require` rule instead. + const setupNode = steps(parse(RELEASE_TEXT), "publish").filter((step) => + (scalar(get(step, "uses")) ?? "").startsWith("actions/setup-node@"), + ); + expect(setupNode.length).toBe(1); + expect(get(get(setupNode[0]!, "with"), "registry-url")).toBeUndefined(); + + const allowSet = JSON.parse(readFileSync(join(REPO, "npm-config-allow.json"), "utf8")) as { + allow: { key: string }[]; + require: { key: string; value: unknown }[]; + }; + // And the allow-set no longer permits a registry credential key by name, so one arriving from + // any source at all is a refusal rather than a permitted value. + expect(allowSet.allow.map((entry) => entry.key)).not.toContain( + "//registry.npmjs.org/:_authToken", + ); + expect(allowSet.require).toContainEqual( + expect.objectContaining({ key: "registry", value: "https://registry.npmjs.org/" }), + ); + }); +}); + +// --------------------------------------------------------------------------------------------- +// AC-4 and AC-5: the required `verify` check refuses a publish path that does not declare an npm at +// or above the floor, and refuses a release dry run declaring a different toolchain. +// --------------------------------------------------------------------------------------------- + +describe("AC-4: the npm CLI floor is provable from the workflow, at merge time", () => { + it("passes over this repository as committed (positive control)", () => { + const result = run(TOOLCHAIN_CHECK, ["--repo", REPO]); + expect(result.status).toBe(0); + expect(result.output).toContain("floor"); + }); + + it("runs inside the required verify job, before the install, with nothing neutralizing it", () => { + // `verify` is the only REQUIRED status check in this repository's ruleset, so this is the copy + // that can refuse a merge. A gate in any other job runs, is visible, and blocks nothing. + const verify = steps(parse(CI_TEXT), "verify"); + const gates = verify.filter((step) => + (scalar(get(step, "run")) ?? "").includes("scripts/publish-toolchain.mjs"), + ); + expect(gates.length).toBe(1); + expect(scalar(get(gates[0]!, "run"))).toBe("node scripts/publish-toolchain.mjs"); + expect(get(gates[0]!, "if")).toBeUndefined(); + expect(get(gates[0]!, "continue-on-error")).toBeUndefined(); + + const runs = verify.map((step) => scalar(get(step, "run")) ?? ""); + expect(runs.findIndex((script) => script.includes("publish-toolchain"))).toBeLessThan( + runs.findIndex((script) => script.includes("pnpm install")), + ); + }); + + it("REFUSES a publish path whose npm version is discoverable only at run time", () => { + const root = fixture(); + edit(root, RELEASE_WORKFLOW, (text) => + text.replace(' NPM_CLI_VERSION: "11.19.1"\n', ""), + ); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("release.yml"); + expect(result.output).toContain("NPM_CLI_VERSION"); + expect(result.output).toContain("discoverable only at run time"); + }); + + it("REFUSES a publish path declaring an npm below the floor, naming both", () => { + const root = fixture(); + edit(root, RELEASE_WORKFLOW, (text) => + text.replace( + ' NPM_CLI_VERSION: "11.19.1"\n', + ' NPM_CLI_VERSION: "11.5.0"\n', + ), + ); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("11.5.0"); + expect(result.output).toContain("11.5.1"); + }); + + it("REFUSES a range where an exact version was required, because a range resolves at run time", () => { + const root = fixture(); + edit(root, RELEASE_WORKFLOW, (text) => + text.replace( + ' NPM_CLI_VERSION: "11.19.1"\n', + ' NPM_CLI_VERSION: "^11.5.1"\n', + ), + ); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("not an exact version"); + }); + + it("REFUSES a declared version nothing installs, because a decorative pin is not a floor", () => { + const root = fixture(); + edit(root, RELEASE_WORKFLOW, (text) => + text.replace(' npm install --global "npm@$NPM_CLI_VERSION"\n', ""), + ); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("decoration"); + }); + + it("accepts the floor itself, so the comparison is not off by one", () => { + const root = fixture(); + for (const file of [RELEASE_WORKFLOW, CI_WORKFLOW]) { + edit(root, file, (text) => + text.replaceAll('NPM_CLI_VERSION: "11.19.1"', 'NPM_CLI_VERSION: "11.5.1"'), + ); + } + expect(run(TOOLCHAIN_CHECK, ["--repo", root]).status).toBe(0); + }); + + it("separates a gate that could not read its input from one that read it and refused", () => { + const root = fixture(); + writeFileSync(join(root, DECLARATION), "{ not json", "utf8"); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(2); + expect(result.output).toContain("THE COMPARISON COULD NOT BE MADE"); + }); +}); + +describe("AC-5: the release dry run declares the publish path's toolchain", () => { + it("REFUSES a dry run pinning a different npm, naming both", () => { + const root = fixture(); + edit(root, CI_WORKFLOW, (text) => + text.replace( + ' NPM_CLI_VERSION: "11.19.1"\n', + ' NPM_CLI_VERSION: "11.18.0"\n', + ), + ); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("11.18.0"); + expect(result.output).toContain("11.19.1"); + expect(result.output).toContain("not evidence about the real publish"); + }); + + it("REFUSES a dry run that declares no npm at all", () => { + const root = fixture(); + edit(root, CI_WORKFLOW, (text) => text.replace(' NPM_CLI_VERSION: "11.19.1"\n', "")); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("release-dry-run"); + }); + + it("REFUSES a dry run on a different node, naming both", () => { + const root = fixture(); + edit(root, CI_WORKFLOW, (text) => + text.replace( + ' - uses: actions/setup-node@v6\n with:\n node-version: "22.14"\n cache: pnpm\n # THE SAME TOOLCHAIN', + ' - uses: actions/setup-node@v6\n with:\n node-version: "24.0.0"\n cache: pnpm\n # THE SAME TOOLCHAIN', + ), + ); + const result = run(TOOLCHAIN_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("24.0.0"); + expect(result.output).toContain("22.14"); + }); +}); + +// --------------------------------------------------------------------------------------------- +// AC-8: a publish that cannot authenticate fails the run, is not retried under another credential, +// and cannot report success while a bumped package is absent from the registry. +// --------------------------------------------------------------------------------------------- + +/** + * Every way the publish job could fail to authenticate and still look green. Empty means it cannot. + * + * @param text The release workflow. + * @returns One problem per rule broken. + */ +function gradeFailedPublishHandling(text: string): string[] { + const problems: string[] = []; + const publish = steps(parse(text), "publish"); + + const publishers = publish.filter((step) => get(get(step, "with"), "publish") !== undefined); + if (publishers.length !== 1) { + problems.push(`the publish job has ${publishers.length} publishing steps, not exactly one`); + } + for (const step of publish) { + if (get(step, "continue-on-error") !== undefined) { + problems.push( + `step "${scalar(get(step, "name")) ?? scalar(get(step, "uses"))}" carries continue-on-error, so its failure cannot fail the run`, + ); + } + } + + const accounting = publish.find( + (step) => + scalar(get(step, "name")) === "Every bumped package must be published, tagged and released", + ); + if (accounting === undefined) { + problems.push("the publish job has no per-package registry accounting step"); + return problems; + } + const condition = scalar(get(accounting, "if")) ?? ""; + if (!condition.includes("!cancelled()")) { + problems.push( + `the accounting step runs on \`${condition}\`, so a failed publish skips it and the run reports what the publish said about itself`, + ); + } + const script = scalar(get(accounting, "run")) ?? ""; + if (!script.includes("npm view")) { + problems.push("the accounting step does not ask the registry whether each package is there"); + } + if (!/Bumped but never published/.test(script) || !/exit 1/.test(script)) { + problems.push("the accounting step does not fail the run when a bumped package is missing"); + } + return problems; +} + +describe("AC-8: a publish that cannot authenticate fails the run and stays failed", () => { + it("holds for the workflow as shipped", () => { + expect(gradeFailedPublishHandling(RELEASE_TEXT)).toEqual([]); + }); + + it("has nothing to fall back to: the publish step carries no credential but the GitHub one", () => { + const publish = steps(parse(RELEASE_TEXT), "publish"); + const publisher = publish.find((step) => get(get(step, "with"), "publish") !== undefined); + const env = get(publisher, "env"); + expect(env?.kind).toBe("map"); + expect([...(env as { entries: Map }).entries.keys()]).toEqual([ + "GITHUB_TOKEN", + "NPM_CONFIG_PROVENANCE", + ]); + }); + + it("REFUSES a workflow whose accounting step is skipped when the publish fails", () => { + const mutated = RELEASE_TEXT.replace( + " if: ${{ !cancelled() && steps.notes.outputs.is-release == 'true' }}\n", + " if: ${{ steps.changesets.outputs.published == 'true' }}\n", + ); + expect(mutated).not.toBe(RELEASE_TEXT); + expect(gradeFailedPublishHandling(mutated).join("\n")).toContain("skips it"); + }); + + it("REFUSES a workflow whose accounting step stopped asking the registry", () => { + const mutated = RELEASE_TEXT.replaceAll('npm view "${name}@${version}" version', "true"); + expect(mutated).not.toBe(RELEASE_TEXT); + expect(gradeFailedPublishHandling(mutated).join("\n")).toContain("ask the registry"); + }); + + it("REFUSES a workflow whose publish step was made advisory", () => { + const mutated = RELEASE_TEXT.replace( + " - name: Publish\n id: changesets\n", + " - name: Publish\n id: changesets\n continue-on-error: true\n", + ); + expect(mutated).not.toBe(RELEASE_TEXT); + expect(gradeFailedPublishHandling(mutated).join("\n")).toContain("continue-on-error"); + }); + + it("keeps the other direction too: a publish the notes gate never saw still reds the run", () => { + const publish = steps(parse(RELEASE_TEXT), "publish"); + const refusal = publish.find( + (step) => scalar(get(step, "name")) === "Refuse a publish the notes gate never saw", + ); + expect(refusal).toBeDefined(); + expect(scalar(get(refusal, "run"))).toContain("exit 1"); + }); +}); + +// --------------------------------------------------------------------------------------------- +// AC-12: a registry credential reintroduced into any ONE of the workflow, the declaration or +// RELEASING.md, without the other two following it, is refused by name. Both directions. +// --------------------------------------------------------------------------------------------- + +describe("AC-12: a credential cannot come back through one file alone", () => { + it("passes over an untouched copy of all three (positive control)", () => { + expect(run(SURFACE_CHECK, ["--repo", fixture()]).status).toBe(0); + }); + + it("REFUSES a token wired into the workflow that the declaration does not name", () => { + const root = fixture(); + edit(root, RELEASE_WORKFLOW, (text) => + text.replace( + " GITHUB_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }}\n", + " GITHUB_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || secrets.GITHUB_TOKEN }}\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n", + ), + ); + const result = run(SURFACE_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("undeclared-secret"); + expect(result.output).toContain("secrets.NPM_TOKEN"); + }); + + it("REFUSES a registry credential declared while the workflow and the docs stay tokenless", () => { + const root = fixture(); + const path = join(root, DECLARATION); + const declaration = JSON.parse(readFileSync(path, "utf8")) as { + credentials: Record[]; + }; + declaration.credentials.unshift({ + name: "NPM_TOKEN", + tokenClass: "npm Automation token with publish rights on the @cosyte scope, reintroduced.", + storage: "organization", + requiredForPublish: true, + registryAuth: true, + exposures: [ + { job: "publish", step: "Publish", as: "env", name: "NODE_AUTH_TOKEN", mode: "value" }, + ], + issuedForms: [{ id: "npm-automation-token", pattern: "npm_[A-Za-z0-9]{36}" }], + retiredWhen: "When the trusted publisher this repository already uses is trusted again.", + }); + writeFileSync(path, `${JSON.stringify(declaration, null, 2)}\n`, "utf8"); + + const result = run(SURFACE_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + // The contradiction with the declared authentication method is reported first, and the two + // files that did not follow are reported with it rather than one run at a time. + expect(result.output).toContain("registry-credential-declared"); + expect(result.output).toContain("declared-credential-absent"); + expect(result.output).toContain("docs-credential-absent"); + }); + + it("REFUSES a rotation procedure for a credential the declaration does not name", () => { + const root = fixture(); + edit(root, DOCS, (text) => + text.replace( + "### `RELEASE_PR_TOKEN`\n", + [ + "### `NPM_TOKEN`\n", + "", + "- **Issue.** On npmjs.com, generate a granular access token for the @cosyte scope.", + "- **Install.** `gh secret set NPM_TOKEN --org cosyte --visibility all`, exactly one place.", + "- **Verify.** Run the release workflow and read the publish job's output carefully.", + "- **Revoke.** On npmjs.com, revoke it after the replacement is installed and verified.", + "- **Compensating action.** Revoke first if compromised, then audit every published version.", + "", + "### `RELEASE_PR_TOKEN`\n", + ].join("\n"), + ), + ); + const result = run(SURFACE_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("docs-undeclared-credential"); + expect(result.output).toContain("NPM_TOKEN"); + }); + + it("REFUSES a publish job stripped of the grant its declared authentication rests on", () => { + // The quietest way back: delete `id-token: write` from the job AND from the declaration's + // permissions block in one commit. The permissions comparison agrees with itself and goes + // green; the publish path has lost the only credential it has. + const root = fixture(); + // Matched as the JOB-LEVEL grant (six spaces, inside a `permissions:` block) rather than by its + // trailing comment, which is prose and may be reworded without changing what is granted. + edit(root, RELEASE_WORKFLOW, (text) => text.replace(/^ {6}id-token: write.*\n/m, "")); + const path = join(root, DECLARATION); + const declaration = JSON.parse(readFileSync(path, "utf8")) as { + credentials: { permissions?: { jobs?: Record> } }[]; + }; + for (const credential of declaration.credentials) { + if (credential.permissions?.jobs?.publish === undefined) continue; + delete credential.permissions.jobs.publish["id-token"]; + } + writeFileSync(path, `${JSON.stringify(declaration, null, 2)}\n`, "utf8"); + + const result = run(SURFACE_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("authentication-permission-absent"); + }); + + it("REFUSES a trusted publisher registered against a workflow filename that does not publish", () => { + const root = fixture(); + const path = join(root, DECLARATION); + const declaration = JSON.parse(readFileSync(path, "utf8")) as { + publishPath: { authentication: { trustedPublisher: { workflow: string } } }; + }; + declaration.publishPath.authentication.trustedPublisher.workflow = "publish.yml"; + writeFileSync(path, `${JSON.stringify(declaration, null, 2)}\n`, "utf8"); + + const result = run(SURFACE_CHECK, ["--repo", root]); + expect(result.status).toBe(1); + expect(result.output).toContain("trusted-publisher-workflow-mismatch"); + }); +});