Skip to content
Merged
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
86 changes: 86 additions & 0 deletions .github/actions/npm-publish-hardened/README.md
Original file line number Diff line number Diff line change
@@ -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 <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@<sha> # 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@<sha>
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.
34 changes: 34 additions & 0 deletions .github/actions/npm-publish-hardened/action.yml
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions .github/actions/npm-publish-hardened/publish.sh
Original file line number Diff line number Diff line change
@@ -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
Loading