diff --git a/.github/actions/npm-publish-hardened/README.md b/.github/actions/npm-publish-hardened/README.md new file mode 100644 index 0000000..b3c8c96 --- /dev/null +++ b/.github/actions/npm-publish-hardened/README.md @@ -0,0 +1,86 @@ +# npm-publish-hardened + +Composite action that publishes a single npm package using **OIDC +trusted publishing** with **SLSA v1 provenance**. Refuses to fall +back to `NPM_TOKEN` auth and is idempotent over the package version. + +## What it does + +1. Hard-fails if `NPM_TOKEN` or `NODE_AUTH_TOKEN` is in the + environment. Trusted publishing is via OIDC token exchange; if a + legacy token is present the publish would silently use it instead + and defeat the purpose. +2. Verifies the npm CLI is v11.5.1+ (the cutoff for trusted + publishing support). +3. Skips publish if the exact `name@version` from `package.json` is + already on the registry (re-runs of the same release are no-ops). +4. Runs `npm publish --provenance --access public --tag `. npm + detects the GitHub Actions OIDC env vars + (`ACTIONS_ID_TOKEN_REQUEST_URL` and `_TOKEN`) and exchanges them + for a one-shot registry token automatically. +5. Retries with exponential-ish backoff if publish reports failure + but the version becomes visible (registry eventual consistency). + +## Caller requirements + +```yaml +permissions: + id-token: write # required for the OIDC exchange + contents: read # if the caller checks out the repo + attestations: write # if the caller wants provenance attestations + # uploaded to GitHub as well +``` + +The calling job must have set up Node + npm 11.5.1+ before calling +the action. The standard pattern: + +```yaml +- uses: actions/setup-node@ # v6 + with: + node-version: "22" + registry-url: https://registry.npmjs.org +- run: npm install --global npm@11 +``` + +## One-time per-package configuration on npmjs.com + +Trusted publishing also needs the npm side to know which repository +and workflow are allowed to publish: + +1. Sign in to npmjs.com as a maintainer of the package. +2. Package settings → **Publishing access** → **Add trusted + publisher**. +3. Select GitHub Actions. Fill in: + - Organization: `stella` + - Repository: e.g. `anonymize` + - Workflow filename: `release.yml` + - Environment: leave empty unless the caller uses a deployment + environment (recommended for added gating) +4. Save. + +Until that record exists for a package, the action will fail at +publish time with a 401. + +## Usage + +```yaml +- uses: stella/.github/.github/actions/npm-publish-hardened@ + with: + package-dir: packages/anonymize + # tag: latest # default +``` + +## Inputs + +| Name | Required | Default | Description | +| ------------- | -------- | -------- | -------------------------------------------------------------------- | +| `package-dir` | yes | — | Working directory containing the package's `package.json` | +| `tag` | no | `latest` | npm dist-tag for the publish | + +## Why a composite action and not a reusable workflow + +The publish step is what's actually shared across publishing repos. +Build/test scaffolding diverges (napi-rs cross-compilation, +TypeScript bundling, etc.) and a full reusable workflow would need +30+ inputs to handle every shape. Composite keeps the scope tight to +the part that's identical across all callers. diff --git a/.github/actions/npm-publish-hardened/action.yml b/.github/actions/npm-publish-hardened/action.yml new file mode 100644 index 0000000..88413fb --- /dev/null +++ b/.github/actions/npm-publish-hardened/action.yml @@ -0,0 +1,34 @@ +name: Hardened npm publish (OIDC + idempotent) +description: > + Publish a single npm package via OIDC trusted publishing with SLSA + provenance attestation. Idempotent — skips if the exact version is + already on the registry. Refuses to fall back to NPM_TOKEN auth. + + Caller workflow must declare `permissions: id-token: write` on the + calling job, and have set up npm CLI v11.5.1+ (which detects the + GitHub Actions OIDC env vars and performs the token exchange + automatically). + + One-time per-package configuration is required on the npm side: + npmjs.com → package settings → Publishing → add a trusted publisher + pointing at this GitHub repository and workflow path. Without this, + the action will fail at publish time with a 401. + +inputs: + package-dir: + description: Working directory containing the package's package.json + required: true + tag: + description: npm dist-tag for the publish (e.g. latest, next, rc) + required: false + default: latest + +runs: + using: composite + steps: + - name: Publish + shell: bash + working-directory: ${{ inputs.package-dir }} + env: + DIST_TAG: ${{ inputs.tag }} + run: ${{ github.action_path }}/publish.sh diff --git a/.github/actions/npm-publish-hardened/publish.sh b/.github/actions/npm-publish-hardened/publish.sh new file mode 100755 index 0000000..b2e5e00 --- /dev/null +++ b/.github/actions/npm-publish-hardened/publish.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Hardened npm publish via OIDC trusted publishing. +# See action.yml for the contract. + +set -euo pipefail + +# Defence in depth: trusted publishing performs auth via the OIDC token +# exchange. If a legacy token is in env, the publish below would silently +# fall back to bearer auth and the whole point of this action is lost. +if [[ -n "${NPM_TOKEN:-}" || -n "${NODE_AUTH_TOKEN:-}" ]]; then + printf '::error::NPM_TOKEN/NODE_AUTH_TOKEN must not be set when using %s\n' \ + "the hardened publish action — trusted publishing only." >&2 + exit 2 +fi + +# npm 11.5.1 introduced trusted publishing support. Older clients silently +# skip the OIDC exchange and try anonymous publish → 401. +NPM_VERSION=$(npm --version) +IFS='.' read -r NPM_MAJOR NPM_MINOR NPM_PATCH <<<"${NPM_VERSION}" +# Strip any pre-release suffix from the patch component (e.g. "1-beta.0"). +NPM_PATCH=${NPM_PATCH%%-*} +NPM_MAJOR=${NPM_MAJOR:-0} +NPM_MINOR=${NPM_MINOR:-0} +NPM_PATCH=${NPM_PATCH:-0} +if (( NPM_MAJOR < 11 )) \ + || (( NPM_MAJOR == 11 && NPM_MINOR < 5 )) \ + || (( NPM_MAJOR == 11 && NPM_MINOR == 5 && NPM_PATCH < 1 )); then + printf '::error::npm %s is too old; trusted publishing requires 11.5.1+.\n' \ + "${NPM_VERSION}" >&2 + exit 2 +fi + +PACKAGE_NAME=$(node -p "require('./package.json').name") +PACKAGE_VERSION=$(node -p "require('./package.json').version") + +# Idempotency: skip if exact version is already published. `npm view` +# exits non-zero when the version doesn't exist, so the && guard handles +# both "not published" and any view-time errors uniformly. +already_published() { + local seen + seen=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>/dev/null) || return 1 + [[ "${seen}" == "${PACKAGE_VERSION}" ]] +} + +if already_published; then + printf '::notice::%s@%s already published; skipping.\n' \ + "${PACKAGE_NAME}" "${PACKAGE_VERSION}" + exit 0 +fi + +# Publish. npm 11.5+ auto-detects ACTIONS_ID_TOKEN_REQUEST_URL and +# ACTIONS_ID_TOKEN_REQUEST_TOKEN (set by GitHub Actions when the calling +# job has `id-token: write`) and exchanges the OIDC token for a one-shot +# registry token. --provenance generates the SLSA v1 attestation. +PUBLISH_LOG="${RUNNER_TEMP:-/tmp}/npm-publish-${PACKAGE_NAME//\//-}.log" +if npm publish --provenance --access public --tag "${DIST_TAG}" 2>"${PUBLISH_LOG}"; then + exit 0 +fi + +cat "${PUBLISH_LOG}" >&2 + +# Eventual-consistency retry: npm publish occasionally reports failure +# while the artifact has actually been accepted, but registry visibility +# lags behind by a few seconds. Poll for the new version before giving up. +for attempt in 1 2 3 4 5; do + sleep "${attempt}" + if already_published; then + printf '::notice::%s@%s became visible after publish failure; treating as success.\n' \ + "${PACKAGE_NAME}" "${PACKAGE_VERSION}" + exit 0 + fi +done + +exit 1