From 3d604b43669c464a03975ecec00398c5ecb04e3c Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 30 Jul 2026 13:08:10 +0200 Subject: [PATCH 1/3] feat(security): make npm's bootstrap publish single-use by construction npm cannot attach a trusted publisher to a package that does not exist, and offers no way to reserve a name, so the first publish must authenticate with a token. The risk is not the bootstrap itself but that the credential quietly becomes permanent. So the workflow refuses the token path once the package exists on npmjs: a release fails while NPM_TOKEN is still present. Cleanup stops being something to remember and becomes something the pipeline enforces. All four states are handled explicitly and were tested: token + package absent -> publish, with a loud warning token + package exists -> refuse, tell the operator to delete the secret no token + absent -> refuse, explain why a token is needed no token + exists -> OIDC, the steady state Provenance is unaffected, which is the part worth knowing: --provenance derives from the job's Sigstore identity via id-token, not from how npm authenticates. v0.1.0 is therefore fully attested despite predating trusted publishing on the package. Dropped registry-url from setup-node. It writes an .npmrc line of //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}, which resolves to an empty token when no secret is set and would have made npm attempt token auth instead of falling back to OIDC -- breaking every release after the first. The registry comes from the tarball's own publishConfig. SECURITY.md documents the bootstrap as a four-step procedure with the token deliberately weak: granular, @matchory-scoped, 7-day expiry, stored as a release-environment secret rather than a repository one so the existing approval and tag gates already apply to it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++-- SECURITY.md | 67 +++++++++++++++++++++++++++++------ 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48d1780..c0915b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -288,11 +288,14 @@ jobs: with: persist-credentials: false + # Deliberately no `registry-url`: it writes an .npmrc line of + # `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`, which resolves to an empty token when no + # secret is set and would make npm attempt token auth instead of falling back to OIDC. The + # registry comes from the tarball's own publishConfig. - name: Set up Node uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 - registry-url: https://registry.npmjs.org - name: Pin npm run: npm install -g "npm@${NPM_VERSION}" @@ -308,15 +311,58 @@ jobs: cd dist sha256sum --check ./*.sha256 - # No NODE_AUTH_TOKEN: authentication is the OIDC token minted for the `release` environment, - # which npmjs is configured to accept for this package and nothing else. + # npm will not let a trusted publisher be attached to a package that does not exist yet, so the + # very first publish has to authenticate with a token. That path is deliberately single-use: + # once the package exists, a token becomes a hard failure rather than a quiet fallback, which is + # what stops the bootstrap credential from silently becoming permanent. + # + # Provenance is unaffected either way. `--provenance` is generated from this job's OIDC identity + # token via Sigstore and is independent of how npm authenticates, so v0.1.0 is fully attested + # even though it predates trusted publishing. + - name: Decide the authentication path + id: auth + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if npm view @matchory/coding-style version > /dev/null 2>&1; then + exists=true + else + exists=false + fi + + if [ -n "${NPM_TOKEN}" ] && [ "${exists}" = 'true' ]; then + echo "::error::@matchory/coding-style already exists on npmjs, so trusted publishing can be configured. Delete the NPM_TOKEN secret from the 'release' environment and revoke the token; this workflow will then authenticate via OIDC." + exit 1 + fi + + if [ -n "${NPM_TOKEN}" ]; then + echo 'method=token' >> "$GITHUB_OUTPUT" + echo "::warning::Bootstrapping the first publish with a token because the package does not exist yet. Immediately afterwards: configure the trusted publisher on npmjs, set publishing access to 'Require trusted publishing', then delete the NPM_TOKEN secret and revoke the token. The next release will fail until you do." + elif [ "${exists}" = 'false' ]; then + echo "::error::@matchory/coding-style does not exist on npmjs and no NPM_TOKEN is set. npm cannot attach a trusted publisher to a non-existent package, so the first publish needs a short-lived granular token in the 'release' environment. See SECURITY.md." + exit 1 + else + echo 'method=oidc' >> "$GITHUB_OUTPUT" + echo 'Publishing via OIDC trusted publishing' + fi + - name: Publish + env: + # Empty on every release after the first, in which case npm authenticates via OIDC. + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | + if [ '${{ steps.auth.outputs.method }}' = 'token' ]; then + npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" + fi + npm publish ./dist/*.tgz \ --provenance \ --access public \ --tag latest + # Leave nothing behind on the runner even though it is ephemeral. + npm config delete '//registry.npmjs.org/:_authToken' || true + publish-pypi: name: Publish to PyPI needs: [verify, build-python] diff --git a/SECURITY.md b/SECURITY.md index 905e844..1ecae51 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,7 +52,7 @@ or by repository configuration, not by convention. | Control | What it prevents | |---|---| -| **No long-lived registry credentials.** npm and PyPI both authenticate via OIDC trusted publishing. | There is no token in this repository, its secrets, or a maintainer's keychain to steal. | +| **No long-lived registry credentials.** npm and PyPI authenticate via OIDC trusted publishing. The single exception is npm's first publish, which is bootstrapped with a 7-day scoped token and then locked out by the workflow. | There is no standing token in this repository, its secrets, or a maintainer's keychain to steal. | | **`release` environment, restricted to `v*` tags.** | A workflow run from a branch or pull request cannot obtain an OIDC token whose environment claim the registries accept. | | **`release` environment requires a human approval.** | An automated or accidental tag push cannot publish unattended. | | **Signed, annotated tags only.** The workflow rejects a lightweight tag and asks GitHub whether the signature verifies. | An attacker with push access but no signing key cannot trigger a release. | @@ -97,6 +97,12 @@ Stated explicitly rather than left for someone to discover: - **Composer has no artefact provenance.** See above. The mitigation is repository protection plus signed tags. +- **npm's first publish authenticated with a token**, because npm cannot attach a trusted publisher to a + package that does not yet exist and offers no way to reserve a name. The token was scoped to the + `@matchory` scope, expired within 7 days, lived only as a `release` environment secret, and the + workflow now refuses the token path entirely — a release fails while the secret is present. The + artefact itself is unaffected: `--provenance` comes from the job's Sigstore identity, not from the + authentication method, so `v0.1.0` is attested like every version after it. - **A single maintainer can both author and approve a release.** `prevent_self_review` is off on the `release` environment because the team is small enough that enabling it would block releases entirely. Adding a second reviewer is the fix, and is a people problem rather than a configuration @@ -132,19 +138,58 @@ PyPI supports *pending* publishers, so this works before the project exists: ### npm -1. Publish or claim `@matchory/coding-style`, then open its settings on npmjs.com. -2. Under **Trusted publisher**, select GitHub Actions and enter: +npm cannot attach a trusted publisher to a package that does not exist, and there is no way to reserve +a name first. The first publish therefore has to authenticate with a token. This is a bootstrap, and +the workflow makes it single-use by construction. + +**Provenance is not affected.** `npm publish --provenance` derives its attestation from the job's OIDC +identity token via Sigstore, independently of how npm authenticates. So `v0.1.0` is fully attested even +though it predates trusted publishing on the package. + +#### Step 1 — mint a deliberately weak token + +At https://www.npmjs.com/settings/matchory/tokens, create a **granular access token**: + +| Setting | Value | +|---|---| +| Type | Granular access token | +| Expiration | The shortest offered (7 days) | +| Packages and scopes | Read and write, limited to the `@matchory` scope | +| Organisations | No access | + +Do not create a classic automation token: those are not scopable and do not expire. + +#### Step 2 — store it where the existing gates already apply + +Add it as a secret named `NPM_TOKEN` on the **`release` environment** — not as a repository secret. +Environment secrets are only readable by a job running in that environment, which already requires a +human approval and a `v*` tag: + +```bash +gh secret set NPM_TOKEN --env release --repo matchory/coding-style +``` + +#### Step 3 — release + +Push the signed tag. The workflow warns loudly that it is using the bootstrap path. + +#### Step 4 — close the door, immediately + +1. On the package settings page, add the **trusted publisher**: - Organisation/repository: `matchory/coding-style` - Workflow: `release.yml` - Environment: `release` -3. Set **Publishing access** to *Require trusted publishing*, which disallows token-based publishes - entirely. -4. Enable **Require two-factor authentication** for the package. - -npm may require the package to exist before a trusted publisher can be attached. If so, the first -publish needs a granular automation token, scoped to this package only, with the shortest available -expiry — then configure trusted publishing and delete the token. Check the current npm documentation -before assuming either way. +2. Set **Publishing access** to *Require trusted publishing*, which disallows token-based publishing + outright. +3. Enable **Require two-factor authentication** for the package. +4. Delete the secret and revoke the token: + ```bash + gh secret delete NPM_TOKEN --env release --repo matchory/coding-style + ``` + +Step 4 is not optional housekeeping. The workflow refuses to run the token path once the package +exists on npmjs, so **the next release fails until the secret is gone.** That is intentional: it turns +"remember to clean up" into something the pipeline enforces. ### Packagist From 06290ceb2ece2044798a4a747cb50769ba1df5a8 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 30 Jul 2026 13:44:42 +0200 Subject: [PATCH 2/3] fix(release): pin the publish registry and derive the npm dist-tag Audited the workflow against npm's documented guidance and against npm's own implementation. Three findings, two of them real defects. 1. The publish registry was not actually pinned publishConfig.registry does NOT beat an `@scope:registry` entry in an .npmrc, and libnpmpublish's ensureProvenanceGeneration validates the CI provider and the id-token but never the destination registry. A stray scope mapping could therefore have redirected a provenance-signed release to another registry, and npm would have signed it happily. Found empirically rather than by reading: `npm publish --dry-run` for this package resolved to npm.pkg.github.com, not npmjs, because a developer .npmrc maps the @matchory scope there. CI has no such file, so it would not have fired -- but "would not have fired" is not a control. --registry is now pinned on the publish and on the package-existence probe. That is strictly stronger than publishConfig or setup-node's registry-url, because nothing ambient can override it. 2. A prerelease would have been published as the default install The tag regex accepts prereleases, but --tag was hardcoded to latest, so v1.0.0-rc.1 would have become `latest` for every consumer. The dist-tag is now derived from the version: rc, beta, alpha, or next for an unrecognised identifier. GitHub releases are marked --prerelease to match. Tested across seven version shapes. 3. My stated reason for dropping setup-node's registry-url was wrong I claimed it writes an empty _authToken and breaks the OIDC fallback. npm's own documented workflow uses registry-url with no token at all, so that was an unverified assumption. It stays omitted, but for the correct reason: --registry on the command line cannot be overridden, whereas registry-url can. Also verified the build/publish split rather than assuming it. Provenance derives its subject from the tarball's own integrity digest and its build metadata from GITHUB_* environment variables, so publishing a pre-packed tarball generates provenance identically to publishing from a source directory. npm's documented workflow does not split build from publish, so this is a deliberate divergence, kept because it is what makes "publish cannot substitute content" true and because it matches PyPA's recommendation for PyPI. Recorded in SECURITY.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW --- .github/workflows/release.yml | 57 ++++++++++++++++++++++++++++++----- SECURITY.md | 9 ++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0915b2..d1c44ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,8 @@ jobs: actions: read outputs: version: ${{ steps.version.outputs.version }} + dist_tag: ${{ steps.version.outputs.dist_tag }} + prerelease: ${{ steps.version.outputs.prerelease }} steps: - name: Checkout uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 @@ -50,7 +52,7 @@ jobs: persist-credentials: false - id: version - name: Resolve version from the tag + name: Resolve version and dist-tag from the tag run: | version="${GITHUB_REF_NAME#v}" @@ -59,7 +61,25 @@ jobs: exit 1 fi - echo "version=${version}" >> "$GITHUB_OUTPUT" + # A prerelease must NOT become the default install for every consumer, which is what + # publishing it under the `latest` dist-tag would do. Derive the channel from the prerelease + # identifier: 1.0.0-rc.1 -> rc, 1.0.0-beta.2 -> beta, anything unrecognised -> next. + if printf '%s' "${version}" | grep -q -- '-'; then + channel="$(printf '%s' "${version#*-}" | cut -d. -f1 | tr -cd '[:alpha:]')" + [ -n "${channel}" ] || channel='next' + prerelease=true + else + channel='latest' + prerelease=false + fi + + { + echo "version=${version}" + echo "dist_tag=${channel}" + echo "prerelease=${prerelease}" + } >> "$GITHUB_OUTPUT" + + echo "Releasing ${version} to dist-tag '${channel}' (prerelease: ${prerelease})" # An unsigned tag is an unauthenticated instruction to publish. GitHub's own verification # status is used rather than importing keys here, so the check reflects the same trust GitHub @@ -288,10 +308,10 @@ jobs: with: persist-credentials: false - # Deliberately no `registry-url`: it writes an .npmrc line of - # `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`, which resolves to an empty token when no - # secret is set and would make npm attempt token auth instead of falling back to OIDC. The - # registry comes from the tarball's own publishConfig. + # npm's documented workflow uses setup-node's `registry-url`. It is omitted here because the + # publish command pins `--registry` explicitly, which is strictly stronger: it cannot be + # overridden by an ambient .npmrc, whereas `publishConfig.registry` and setup-node's config can + # both lose to an `@scope:registry` entry. - name: Set up Node uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: @@ -324,7 +344,7 @@ jobs: env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - if npm view @matchory/coding-style version > /dev/null 2>&1; then + if npm view @matchory/coding-style version --registry https://registry.npmjs.org > /dev/null 2>&1; then exists=true else exists=false @@ -346,6 +366,19 @@ jobs: echo 'Publishing via OIDC trusted publishing' fi + # `publishConfig.registry` in package.json does NOT win over an `@scope:registry` entry in an + # .npmrc, and npm's provenance code does not validate which registry it is publishing to. So a + # stray scope mapping — a developer's ~/.npmrc, a repository .npmrc added later, setup-node's + # registry wiring — could redirect a provenance-signed release to another registry entirely. + # Pinning --registry on the command line removes that vector, and this asserts it took effect. + - name: The resolved publish registry is npmjs + run: | + resolved="$(npm config get @matchory:registry)" + + if [ "${resolved}" != 'undefined' ] && [ "${resolved#https://registry.npmjs.org}" = "${resolved}" ]; then + echo "::warning::@matchory:registry resolves to ${resolved}; --registry overrides it for this publish" + fi + - name: Publish env: # Empty on every release after the first, in which case npm authenticates via OIDC. @@ -356,9 +389,10 @@ jobs: fi npm publish ./dist/*.tgz \ + --registry https://registry.npmjs.org \ --provenance \ --access public \ - --tag latest + --tag '${{ needs.verify.outputs.dist_tag }}' # Leave nothing behind on the runner even though it is ephemeral. npm config delete '//registry.npmjs.org/:_authToken' || true @@ -426,8 +460,15 @@ jobs: sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | sed '$d' } > notes.md + prerelease_flag='' + if [ '${{ needs.verify.outputs.prerelease }}' = 'true' ]; then + prerelease_flag='--prerelease' + fi + + # shellcheck disable=SC2086 gh release create "${GITHUB_REF_NAME}" \ --title "${GITHUB_REF_NAME}" \ --notes-file notes.md \ --verify-tag \ + ${prerelease_flag} \ artefacts/* diff --git a/SECURITY.md b/SECURITY.md index 1ecae51..2073e42 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,6 +66,8 @@ or by repository configuration, not by convention. | **`--ignore-scripts` when installing to build.** | A dependency lifecycle hook executing on the machine that produces a published artefact. | | **Lockfile enforced** (`--frozen-lockfile`). | A resolution change slipping in between review and release. | | **Version agreement checked** across all three manifests and the tag. | Publishing artefacts nobody can correlate across ecosystems. | +| **`--registry` pinned on the publish command.** | An ambient `.npmrc` redirecting a provenance-signed release to another registry. `publishConfig.registry` does *not* beat an `@scope:registry` entry, and npm's provenance code does not validate the destination registry. | +| **dist-tag derived from the version.** A prerelease publishes to `rc`/`beta`/`next`, never `latest`. | A prerelease silently becoming the default install for every consumer. | ## Repository configuration @@ -114,6 +116,13 @@ Stated explicitly rather than left for someone to discover: important half: `refs/tags/v*` cannot be deleted, updated, force-pushed, or created unsigned, so a published version tag cannot be moved to different code. Confirm the GitHub-native setting in Settings → General as well, if it is available to the organisation. +- **The build/publish split is not npm's documented shape.** npm's recommended workflow publishes from + the source directory in a single job; this one builds a tarball, attests it, and publishes that + artefact from a separate job. The split was kept because it is what makes "publish cannot substitute + content" true, and because it matches PyPA's recommendation for PyPI. It was verified against npm's + implementation rather than assumed: `libnpmpublish` derives the provenance subject from the tarball's + own integrity digest and the build metadata from GitHub Actions environment variables, so no source + directory is required and provenance is generated identically either way. - **No egress filtering on runners.** `step-security/harden-runner` is the usual recommendation and would detect a build step phoning home. It is deliberately absent: adding a third-party action to the publish path widens the trusted set on exactly the job where that matters most. Worth revisiting From d57cc54e6f0a2f79446cf6ab892fb07aefd6c9cf Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 30 Jul 2026 14:07:19 +0200 Subject: [PATCH 3/3] docs(security): document the placeholder bootstrap instead of a CI token Verified that npm has no equivalent of PyPI's pending publishers: a trusted publisher is configured per-package at npmjs.com/package//access, which does not exist until something has been published. The catch-22 is real, so something must be published before OIDC can be used at all. Crossing that gap with a deprecated 0.0.0 placeholder is better than bootstrapping with a CI token, which is what was documented before: - no npm token ever enters this repository, at any point in its history - 0.1.0, the first version anyone installs, is published entirely through OIDC with provenance The alternative would have put a short-lived token in a GitHub secret so that 0.1.0 could be published from CI. That gets provenance on 0.1.0 but accepts a credential in the repository. The placeholder trades a permanent deprecated 0.0.0 for never holding a token, which is the better deal. The placeholder is deliberately inert: a README and the licence, 1.3 kB, three files. No real configuration ships without provenance, and it is published under a `placeholder` dist-tag so it never becomes `latest`. The token path stays in release.yml as a fallback for bootstrapping some future package, and remains self-limiting: the workflow refuses to use a token once the package exists. Also documents the trap that `npm login`/`npm publish` need an explicit --registry, because an @matchory:registry entry in a developer .npmrc points the scope at GitHub Packages and beats publishConfig.registry. Found while checking this: the npmjs token in the local .npmrc is expired, so the placeholder has to be published after a fresh login. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW --- SECURITY.md | 97 +++++++++++++++++++++++++++-------------------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 2073e42..2850fab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,7 +52,7 @@ or by repository configuration, not by convention. | Control | What it prevents | |---|---| -| **No long-lived registry credentials.** npm and PyPI authenticate via OIDC trusted publishing. The single exception is npm's first publish, which is bootstrapped with a 7-day scoped token and then locked out by the workflow. | There is no standing token in this repository, its secrets, or a maintainer's keychain to steal. | +| **No registry credentials at all.** npm and PyPI both authenticate via OIDC trusted publishing. npm's package-creation problem was solved with a deprecated placeholder rather than a CI token. | There is no token in this repository or its secrets to steal, at any point in its history. | | **`release` environment, restricted to `v*` tags.** | A workflow run from a branch or pull request cannot obtain an OIDC token whose environment claim the registries accept. | | **`release` environment requires a human approval.** | An automated or accidental tag push cannot publish unattended. | | **Signed, annotated tags only.** The workflow rejects a lightweight tag and asks GitHub whether the signature verifies. | An attacker with push access but no signing key cannot trigger a release. | @@ -99,12 +99,11 @@ Stated explicitly rather than left for someone to discover: - **Composer has no artefact provenance.** See above. The mitigation is repository protection plus signed tags. -- **npm's first publish authenticated with a token**, because npm cannot attach a trusted publisher to a - package that does not yet exist and offers no way to reserve a name. The token was scoped to the - `@matchory` scope, expired within 7 days, lived only as a `release` environment secret, and the - workflow now refuses the token path entirely — a release fails while the secret is present. The - artefact itself is unaffected: `--provenance` comes from the job's Sigstore identity, not from the - authentication method, so `v0.1.0` is attested like every version after it. +- **`@matchory/coding-style@0.0.0` was published from a maintainer machine and has no provenance.** npm + cannot configure a trusted publisher for a package that does not exist, so something had to be + published first. The placeholder contains only a README and the licence, is deprecated, and is not + under the `latest` dist-tag. Every version anyone would actually install is published through OIDC + with provenance. No npm token has ever been stored in this repository. - **A single maintainer can both author and approve a release.** `prevent_self_review` is off on the `release` environment because the team is small enough that enabling it would block releases entirely. Adding a second reviewer is the fix, and is a people problem rather than a configuration @@ -147,58 +146,62 @@ PyPI supports *pending* publishers, so this works before the project exists: ### npm -npm cannot attach a trusted publisher to a package that does not exist, and there is no way to reserve -a name first. The first publish therefore has to authenticate with a token. This is a bootstrap, and -the workflow makes it single-use by construction. +npm cannot configure a trusted publisher for a package that does not exist, and offers no way to +reserve a name — there is no equivalent of PyPI's pending publishers. The package therefore has to +exist before OIDC can be used at all. -**Provenance is not affected.** `npm publish --provenance` derives its attestation from the job's OIDC -identity token via Sigstore, independently of how npm authenticates. So `v0.1.0` is fully attested even -though it predates trusted publishing on the package. +That gap is crossed with a **deprecated `0.0.0` placeholder**, published manually, so that no token +ever enters this repository and the first version anyone installs (`0.1.0`) is published entirely +through OIDC with provenance. -#### Step 1 — mint a deliberately weak token +#### Step 1 — publish the placeholder -At https://www.npmjs.com/settings/matchory/tokens, create a **granular access token**: +From a maintainer machine, `npm login` first: the `--registry` flag is required because an +`@matchory:registry` entry in a developer `.npmrc` points the scope at GitHub Packages and beats +`publishConfig.registry`. -| Setting | Value | -|---|---| -| Type | Granular access token | -| Expiration | The shortest offered (7 days) | -| Packages and scopes | Read and write, limited to the `@matchory` scope | -| Organisations | No access | - -Do not create a classic automation token: those are not scopable and do not expire. - -#### Step 2 — store it where the existing gates already apply +```bash +npm login --registry https://registry.npmjs.org +``` -Add it as a secret named `NPM_TOKEN` on the **`release` environment** — not as a repository secret. -Environment secrets are only readable by a job running in that environment, which already requires a -human approval and a `v*` tag: +Then publish a package containing nothing but a README and the licence, under a dist-tag that is not +`latest`: ```bash -gh secret set NPM_TOKEN --env release --repo matchory/coding-style +npm publish --registry https://registry.npmjs.org --access public --tag placeholder ``` -#### Step 3 — release +#### Step 2 — configure the trusted publisher -Push the signed tag. The workflow warns loudly that it is using the bootstrap path. +At `https://www.npmjs.com/package/@matchory/coding-style/access` — not the account-level packages page: -#### Step 4 — close the door, immediately +- Organisation/repository: `matchory/coding-style` +- Workflow: `release.yml` +- Environment: `release` -1. On the package settings page, add the **trusted publisher**: - - Organisation/repository: `matchory/coding-style` - - Workflow: `release.yml` - - Environment: `release` -2. Set **Publishing access** to *Require trusted publishing*, which disallows token-based publishing - outright. -3. Enable **Require two-factor authentication** for the package. -4. Delete the secret and revoke the token: - ```bash - gh secret delete NPM_TOKEN --env release --repo matchory/coding-style - ``` - -Step 4 is not optional housekeeping. The workflow refuses to run the token path once the package -exists on npmjs, so **the next release fails until the secret is gone.** That is intentional: it turns -"remember to clean up" into something the pipeline enforces. +Then set **Publishing access** to *Require trusted publishing*, which disallows token-based publishing +outright, and enable **Require two-factor authentication** for the package. + +#### Step 3 — release normally + +Tag `v0.1.0`. The workflow sees that the package exists and that no `NPM_TOKEN` secret is set, so it +authenticates via OIDC. `0.1.0` becomes `latest`. + +#### Step 4 — tidy up + +```bash +npm deprecate @matchory/coding-style@0.0.0 "Placeholder with no contents; install 0.1.0 or later." +npm dist-tag rm @matchory/coding-style placeholder +``` + +#### The token path exists but is a fallback + +`release.yml` still accepts an `NPM_TOKEN` secret on the `release` environment, for the case where a +future package has to be bootstrapped the other way. It is self-limiting: the workflow **refuses to +use a token once the package exists on npmjs**, so a bootstrap credential cannot quietly become +permanent. If you ever use it, make it a granular token scoped to `@matchory` with the shortest +available expiry, store it as an environment secret rather than a repository one, and delete it +immediately afterwards — the next release fails until you do. ### Packagist