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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,8 +546,11 @@ from that point on, OIDC handles everything.

After trusted publishing works, set npm's package publishing access to
require 2FA and disallow token publishing. Anvil also fails if
`NPM_TOKEN`, `NODE_AUTH_TOKEN`, `NPM_CONFIG_PROVENANCE`, or npm auth
material in `.npmrc` is present during publish.
`NPM_TOKEN`, a real `NODE_AUTH_TOKEN`, `NPM_CONFIG_PROVENANCE`, or
npm auth material in `.npmrc` is present during publish. The literal
`actions/setup-node` placeholders (`NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX`
and `_authToken=${NODE_AUTH_TOKEN}` in the generated `.npmrc`) are
accepted; npm 11.5+ ignores them during OIDC exchange.

### Why the caller-workflow trust model

Expand Down
2 changes: 1 addition & 1 deletion THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ of the defences listed below, that change needs explicit justification.
| Maintainer accidentally pushing the wrong commit | In manual mode, the GitHub Release trigger forces an explicit, reviewable action — the maintainer creates the Release and the action runs only in response. In auto mode, a conventional commit on main is the explicit human action, and the chained workflow (see `docs/design/chained-workflows.md`) runs in one CI run. Either way, a real human commit is the trigger; there is no scheduled or background release path. |
| Consumer repo leaking a PAT used to bridge the auto-release → release.yml event chain | Replaced event coupling with `workflow_call` coupling in v0.6. The chained auto-release flow needs no PAT — one workflow run, one `GITHUB_TOKEN`, no long-lived credential in the consumer repo. Legacy `GH_TOKEN` secret is still accepted but silently unused; a stale PAT in a consumer repo no longer weakens anything. |
| Unreviewed npm publish after the release workflow starts | The reusable workflow attaches the publish job to the `npm-publish` GitHub Environment by default. Consumers can configure that environment with required reviewers, prevent self-review, and release-ref restrictions, then bind npm trusted publishing to the same environment. |
| Maintainer accidentally leaving legacy npm token publishing enabled in the release job | `publish-npm` fails if `NPM_TOKEN`, `NODE_AUTH_TOKEN`, or npm auth material in `.npmrc` is present. npm publish must go through OIDC trusted publishing, and `publishConfig.provenance: true` is enforced before upload. |
| Maintainer accidentally leaving legacy npm token publishing enabled in the release job | `publish-npm` fails if `NPM_TOKEN` or a real `NODE_AUTH_TOKEN` is set, or if npm auth material is present in `.npmrc`. The literal `actions/setup-node` placeholders (`NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX` and `_authToken=${NODE_AUTH_TOKEN}`) are accepted because they carry no secret material; npm 11.5+ ignores them during OIDC exchange. npm publish must go through OIDC trusted publishing, and `publishConfig.provenance: true` is enforced before upload. |
| Supply-chain attack via the action's own transitive dependencies | The action has no Node dependencies. It invokes `bash`, `jq`, `gh`, `npm`, and `sed`/`awk`/`find`/`grep` from the GitHub-managed runner image. No fetched binaries. |
| Race between parallel releases publishing the same version twice | `publish-npm` is idempotent: if the exact version is already on the registry, it exits `0` without re-publishing. |
| Registry tarball substitution between publish and consumer fetch | `record-tarball` packs the artefact once and writes its sha512 (npm integrity format) plus sha256 to a meta file. `publish-npm` uploads that exact tarball — not a re-pack — and on a clean re-run compares the registry's `dist.integrity` to the recorded value: a mismatch fails the workflow loudly. The hashes are also stamped into the GitHub Release body so consumers can `curl | shasum` the registry tarball at any time. |
Expand Down
16 changes: 14 additions & 2 deletions steps/publish-npm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,24 @@ if ! jq -e '.publishConfig.provenance == true' "$pkg" >/dev/null; then
fi

[[ -z "${NPM_TOKEN:-}" ]] || die "NPM_TOKEN is set; use OIDC trusted publishing, not long-lived npm tokens"
[[ -z "${NODE_AUTH_TOKEN:-}" ]] || die "NODE_AUTH_TOKEN is set; use OIDC trusted publishing, not long-lived npm tokens"
# actions/setup-node writes literal placeholders when registry-url is set
# without a real token: NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX in env, and
# `_authToken=${NODE_AUTH_TOKEN}` in the generated .npmrc. npm 11.5+ ignores
# both during OIDC exchange, but a strict null/auth-material check would
# trip on them. We accept the exact placeholder forms and reject anything
# else. See actions/setup-node src/authutil.ts.
SETUP_NODE_TOKEN_PLACEHOLDER='XXXXX-XXXXX-XXXXX-XXXXX'
if [[ -n "${NODE_AUTH_TOKEN:-}" && "${NODE_AUTH_TOKEN}" != "$SETUP_NODE_TOKEN_PLACEHOLDER" ]]; then
die "NODE_AUTH_TOKEN is set; use OIDC trusted publishing, not long-lived npm tokens"
fi
[[ -z "${NPM_CONFIG_PROVENANCE:-}" ]] || die "NPM_CONFIG_PROVENANCE is set; use publishConfig.provenance instead"

for npmrc in .npmrc "${NPM_CONFIG_USERCONFIG:-}"; do
[[ -n "$npmrc" && -f "$npmrc" ]] || continue
if grep -Eq '(^|[/:])(_authToken|_auth|_password)[[:space:]]*=' "$npmrc"; then
# Allow the literal `${NODE_AUTH_TOKEN}` template form written by
# setup-node; reject any line whose auth value is anything else.
if grep -E '(^|[/:])(_authToken|_auth|_password)[[:space:]]*=' "$npmrc" \
| grep -vqE '=[[:space:]]*\$\{NODE_AUTH_TOKEN\}[[:space:]]*$'; then
die "npm auth material found in $npmrc; remove token auth before publishing"
fi
done
Expand Down
34 changes: 34 additions & 0 deletions test/publish-npm.bats
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,40 @@ EOF
! grep -q 'NPM_CALL: publish' "$NPM_LOG"
}

@test "publish-npm: refuses real NODE_AUTH_TOKEN" {
command -v jq >/dev/null 2>&1 || skip "jq not available"

setup_release_fixture "test-pkg" "1.0.0" "sha512-LOCAL"
export NODE_AUTH_TOKEN="npm_realToken123"

run "$ACTION_ROOT/steps/publish-npm.sh"
[ "$status" -eq 1 ]
[[ "$output" == *"NODE_AUTH_TOKEN is set"* ]]
! grep -q 'NPM_CALL: publish' "$NPM_LOG"
}

@test "publish-npm: allows setup-node placeholder NODE_AUTH_TOKEN" {
command -v jq >/dev/null 2>&1 || skip "jq not available"

setup_release_fixture "test-pkg" "1.0.0" "sha512-LOCAL"
export NODE_AUTH_TOKEN="XXXXX-XXXXX-XXXXX-XXXXX"

run "$ACTION_ROOT/steps/publish-npm.sh"
[ "$status" -eq 0 ]
grep -q "^NPM_CALL: publish --access public $meta_dir/test-pkg-1.0.0.tgz$" "$NPM_LOG"
}

@test "publish-npm: allows setup-node template _authToken in npmrc" {
command -v jq >/dev/null 2>&1 || skip "jq not available"

setup_release_fixture "test-pkg" "1.0.0" "sha512-LOCAL"
printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\nregistry=https://registry.npmjs.org/\n' > "$FIXTURE_DIR/.npmrc"

run "$ACTION_ROOT/steps/publish-npm.sh"
[ "$status" -eq 0 ]
grep -q "^NPM_CALL: publish --access public $meta_dir/test-pkg-1.0.0.tgz$" "$NPM_LOG"
}

@test "publish-npm: happy path runs dry-run then real publish on the recorded tarball" {
command -v jq >/dev/null 2>&1 || skip "jq not available"

Expand Down