diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48d1780..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,11 +308,14 @@ jobs: with: persist-credentials: false + # 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: node-version: 24 - registry-url: https://registry.npmjs.org - name: Pin npm run: npm install -g "npm@${NPM_VERSION}" @@ -308,14 +331,71 @@ 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 --registry https://registry.npmjs.org > /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 + + # `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. + 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 \ + --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 publish-pypi: name: Publish to PyPI @@ -380,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 905e844..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 both authenticate via OIDC trusted publishing. | There is no 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. | @@ -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 @@ -97,6 +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. +- **`@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 @@ -108,6 +115,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 @@ -132,19 +146,62 @@ 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: - - 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. +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. + +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 — publish the placeholder + +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`. + +```bash +npm login --registry https://registry.npmjs.org +``` + +Then publish a package containing nothing but a README and the licence, under a dist-tag that is not +`latest`: + +```bash +npm publish --registry https://registry.npmjs.org --access public --tag placeholder +``` + +#### Step 2 — configure the trusted publisher + +At `https://www.npmjs.com/package/@matchory/coding-style/access` — not the account-level packages page: + +- Organisation/repository: `matchory/coding-style` +- Workflow: `release.yml` +- Environment: `release` + +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