Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 46 additions & 36 deletions .github/credential-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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/.",
Expand Down Expand Up @@ -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.",
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
Loading