From 73e6635dcbbffbd6db7c0d51925439ea95f7087d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 21 Oct 2026 22:56:57 -0500 Subject: [PATCH] feat(npm-publish): add dist-tag and provenance inputs dist-tag passes --tag to every npm publish, dry runs included, so you can ship a pre-release under next. npm 11 won't publish one without a tag. provenance: true publishes with --provenance. The publish job no longer declares its own permissions: a job-level id-token: write would be a ceiling every existing caller has to meet, and callers granting only contents: read would hit startup_failure. The job now runs with what the caller grants, and it fails before publishing anything when provenance is on without id-token: write, or when a package.json repository field does not point at the calling repository. --- .github/workflows/npm-publish.yml | 96 +++++++++++++++++-- .github/workflows/self-test.yml | 27 ++++++ README.md | 54 ++++++++++- examples/node-library/release.yml | 30 +++++- .../packages/adapter/package.json | 5 + .../packages/core/package.json | 5 + .../packages/plugin/package.json | 5 + 7 files changed, 211 insertions(+), 11 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index f019774..b073f60 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -16,6 +16,13 @@ name: NPM Publish # packed, and `pnpm pack` turns each `workspace:` range into the real version. # The tarball is checked for a leftover `workspace:` range and then handed to # `npm publish`, so what reaches the registry is exactly what was checked. +# +# The publish job declares no `permissions` of its own, on purpose. Inside a +# reusable workflow a job-level block is a ceiling the caller must meet, so +# listing `id-token: write` there would fail every existing caller that only +# grants `contents: read`, provenance or not. Without a block the job runs with +# exactly what the caller grants: `contents: read`, plus `id-token: write` when +# it sets `provenance: true`. on: workflow_call: @@ -83,6 +90,24 @@ on: version bump passes silently. type: boolean default: false + dist-tag: + description: >- + npm dist-tag to publish under, e.g. next for a pre-release. Empty + leaves it to npm, which means latest. npm 11 refuses to publish a + pre-release version without one, so a caller cutting 1.2.0-rc.1 must + set this. Letters, digits, `.`, `_` and `-` only, and nothing npm + could read as a version or range (1.2, v2, x). + type: string + default: '' + provenance: + description: >- + Publish with npm provenance, which links each package version to the + commit and workflow run that built it. The caller must grant + `id-token: write` (with `contents: read`) to the job that calls this + workflow, and every package.json needs a `repository` pointing at the + calling repository; both are checked before anything is published. + type: boolean + default: false secrets: NPM_TOKEN: description: 'Automation token with publish rights. Not required for a dry run.' @@ -96,8 +121,7 @@ jobs: publish: name: Publish runs-on: ubuntu-latest - permissions: - contents: read + # No permissions block: see the header. outputs: published: ${{ steps.publish.outputs.published }} steps: @@ -122,6 +146,8 @@ jobs: PACKAGES: ${{ inputs.packages }} VERSION: ${{ inputs.version }} PM: ${{ inputs.package-manager }} + DIST_TAG: ${{ inputs.dist-tag }} + PROVENANCE: ${{ inputs.provenance }} run: | set -euo pipefail @@ -152,6 +178,51 @@ jobs: exit 1 fi + # npm rejects a tag it could parse as a semver range, because + # `npm install pkg@` would then be ambiguous. This is stricter + # than npm (any leading digit, after an optional v), which costs + # nothing and keeps the rule easy to state. + if [ -n "$DIST_TAG" ]; then + if ! printf '%s' "$DIST_TAG" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*$'; then + echo "::error::dist-tag must start with a letter or digit and contain only letters, digits, '.', '_' and '-', got '$DIST_TAG'" + exit 1 + fi + if printf '%s' "$DIST_TAG" | grep -Eq '^([vV]?[0-9]|[xX*]$)'; then + echo "::error::dist-tag must not look like a version or range, got '$DIST_TAG'. Use a name such as next or beta." + exit 1 + fi + fi + + if [ "$PROVENANCE" = "true" ]; then + # The runner only sets this when the job may mint an OIDC token. + # Checked here, and on a dry run too, because npm would otherwise + # fail on the first real publish with a far less obvious error. + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::provenance is true but the job has no id-token: write permission. Grant id-token: write (and contents: read) to the job that calls this workflow." + exit 1 + fi + + # The registry rejects a provenance publish whose repository field + # does not name the repository that built it. Catch that before the + # loop, where the packages ahead of the bad one are already public. + want=$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]') + while read -r dir; do + repo=$(jq -r '.repository | if type == "object" then .url else . end // empty' "$dir/package.json" \ + | tr '[:upper:]' '[:lower:]' | sed -E 's#^git\+##; s#/+$##; s#\.git$##') + case "$repo" in + "$want"|"github:$want"|*"github.com/$want"|*"github.com:$want") ;; + "") + echo "::error::provenance needs a repository field in $dir/package.json, e.g. \"repository\": {\"type\": \"git\", \"url\": \"git+https://github.com/$GITHUB_REPOSITORY.git\"}" + exit 1 + ;; + *) + echo "::error::$dir/package.json repository is '$repo', but provenance requires it to point at github.com/$GITHUB_REPOSITORY" + exit 1 + ;; + esac + done < <(printf '%s' "$PACKAGES" | jq -r '.[]') + fi + - name: Require a token for a real publish if: ${{ !inputs.dry-run }} shell: bash @@ -217,6 +288,8 @@ jobs: BUILD_SCRIPT: ${{ inputs.build-script }} PM: ${{ inputs.package-manager }} SKIP_EXISTING: ${{ inputs.skip-existing }} + DIST_TAG: ${{ inputs.dist-tag }} + PROVENANCE: ${{ inputs.provenance }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail @@ -224,6 +297,16 @@ jobs: PUBLISHED='[]' PACK_ROOT="$RUNNER_TEMP/npm-publish-pack" + # Shared by the dry run and the real publish, so the rehearsal passes + # npm exactly the flags the release will. + publish_args=(--access "$ACCESS") + if [ -n "$DIST_TAG" ]; then + publish_args+=(--tag "$DIST_TAG") + fi + if [ "$PROVENANCE" = "true" ]; then + publish_args+=(--provenance) + fi + while read -r dir; do name=$(jq -r .name "$dir/package.json") echo "::group::$name ($dir)" @@ -287,10 +370,10 @@ jobs: fi if [ "$DRY_RUN" = "true" ]; then - npm publish "$target" --dry-run --access "$ACCESS" - echo "would publish $name@$published_version" + npm publish "$target" --dry-run "${publish_args[@]}" + echo "would publish $name@$published_version${DIST_TAG:+ under dist-tag $DIST_TAG}" else - npm publish "$target" --access "$ACCESS" + npm publish "$target" "${publish_args[@]}" PUBLISHED=$(printf '%s' "$PUBLISHED" | jq -c --arg e "$name@$published_version" '. + [$e]') fi @@ -305,12 +388,13 @@ jobs: shell: bash env: DRY_RUN: ${{ inputs.dry-run }} + DIST_TAG: ${{ inputs.dist-tag }} PUBLISHED: ${{ steps.publish.outputs.published }} run: | set -euo pipefail if [ "$DRY_RUN" = "true" ]; then echo "Dry run - nothing was published." >> "$GITHUB_STEP_SUMMARY" else - echo "Published:" >> "$GITHUB_STEP_SUMMARY" + echo "Published under dist-tag \`${DIST_TAG:-latest}\`:" >> "$GITHUB_STEP_SUMMARY" printf '%s' "${PUBLISHED:-[]}" | jq -r '.[] | "- `\(.)`"' >> "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index c27303d..4674af6 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -184,6 +184,32 @@ jobs: build-script: '' dry-run: true + smoke-npm-publish-prerelease: + name: 'Smoke: npm publish, pre-release with provenance dry run' + # id-token: write is what provenance: true requires of a caller, and the + # workflow fails fast without it, so this job also proves the grant reaches + # the called job. A pull request from a fork never gets id-token, so this + # job fails there by design. + permissions: + contents: read + id-token: write + uses: ./.github/workflows/npm-publish.yml + with: + package-manager: pnpm + workspace-directory: testdata/fixture-node-pnpm + packages: >- + ["testdata/fixture-node-pnpm/packages/core", + "testdata/fixture-node-pnpm/packages/adapter", + "testdata/fixture-node-pnpm/packages/plugin"] + # npm 11 refuses even a dry run of a pre-release without --tag, so this + # job only passes if dist-tag reaches npm publish. + version: '1.2.3-rc.1' + dist-tag: next + # The fixture manifests carry a repository field for this. + provenance: true + build-script: '' + dry-run: true + # A single stable check for branch protection to require. # # Requiring the individual jobs does not work here: their check names carry @@ -213,6 +239,7 @@ jobs: - smoke-node-npm - smoke-npm-publish-pnpm - smoke-npm-publish-npm + - smoke-npm-publish-prerelease runs-on: ubuntu-latest steps: - name: Check every gating job succeeded diff --git a/README.md b/README.md index 81b4f5e..c0bbb6e 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ permissions its jobs actually need. - `semantic-release.yml` needs `contents: write`, `issues: write` and `pull-requests: write`. Only the first is fatal if omitted; the other two degrade to a logged warning and skipped issue/PR comments. +- `npm-publish.yml` needs `contents: read`, plus `id-token: write` when you set + `provenance: true`. It's the one reusable workflow here whose job declares no + permissions of its own, so it runs with exactly what you grant. Ask for + provenance without `id-token: write` and it stops before publishing anything. GitHub validates the permissions of **every** nested job in a locally-called reusable workflow at parse time, including jobs skipped by `if:`. An under-grant @@ -404,5 +408,53 @@ never install them. can re-run a release that died half way through a long list. It's off by default because it hides a forgotten version bump just as quietly. +### Pre-releases and provenance + +Two inputs cover a release that isn't a plain stable publish. + +```yaml +jobs: + publish: + permissions: + contents: read + id-token: write # only needed for provenance + uses: xraph/workflows/.github/workflows/npm-publish.yml@v1 + with: + packages: '["packages/core","packages/react"]' + version: ${{ needs.prepare.outputs.version }} + dist-tag: ${{ needs.prepare.outputs.dist-tag }} + provenance: true + dry-run: false + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +`dist-tag` is passed to every `npm publish` as `--tag`, dry runs included. Leave +it empty and npm uses `latest`, as before. You'll want `next` for anything with +a pre-release suffix, so `npm install your-package` doesn't hand people an +`-rc.1`. npm 11 actually refuses to publish a pre-release without a tag, so +this isn't optional once you cut one. The workflow won't pick the tag for you. +Work it out in a prepare job from the version you already have: + +```bash +case "${version%%+*}" in # drop +build metadata first + *-*) echo "dist_tag=next" >> "$GITHUB_OUTPUT" ;; + *) echo "dist_tag=latest" >> "$GITHUB_OUTPUT" ;; +esac +``` + +A tag may hold letters, digits, `.`, `_` and `-`. Anything npm could read as a +version or range (`1.2`, `v2`, `x`) is rejected up front, because npm would +reject it too, only later. + +`provenance: true` publishes with `--provenance`, so npm shows which commit and +workflow run built each version. The calling job has to grant `id-token: write`: +a reusable workflow can't widen its caller's token, and npm needs that token to +sign. Every package.json also needs a `repository` field pointing at the repo +running the release, since the registry turns a provenance publish down when +they don't match. Both are checked before the first package goes out. A +half-published release, where the packages ahead of the bad one are already +public, is the outcome this avoids. + `examples/node-library` has a CI caller and a tag-driven release that publishes -a pnpm workspace. +a pnpm workspace, with `next` for pre-release tags and provenance on. diff --git a/examples/node-library/release.yml b/examples/node-library/release.yml index b9d070d..677e2ab 100644 --- a/examples/node-library/release.yml +++ b/examples/node-library/release.yml @@ -3,10 +3,14 @@ name: Release # Push a vX.Y.Z tag and every package below goes to npm at X.Y.Z, after CI # passes on the tagged commit. Dispatch it by hand to rehearse: dry-run is on # unless you switch it off. +# +# A tag with a pre-release suffix, like v1.3.0-rc.1, publishes under the next +# dist-tag, so `npm install` keeps resolving the last stable release. Anything +# else goes to latest. Every version also gets an npm provenance attestation. on: push: - tags: ['v[0-9]+.[0-9]+.[0-9]+'] + tags: ['v[0-9]+.[0-9]+.[0-9]+', 'v[0-9]+.[0-9]+.[0-9]+-*'] workflow_dispatch: inputs: dry-run: @@ -25,12 +29,14 @@ jobs: uses: xraph/workflows/.github/workflows/node-ci.yml@v1 # The expression language has no string replace, and npm-publish.yml refuses - # a version with a leading v, so the tag is trimmed here. + # a version with a leading v, so the tag is trimmed here. The dist-tag is + # picked here too: npm 11 won't publish a pre-release without one. prepare: runs-on: ubuntu-latest outputs: version: ${{ steps.resolve.outputs.version }} dry-run: ${{ steps.resolve.outputs.dry_run }} + dist-tag: ${{ steps.resolve.outputs.dist_tag }} steps: - id: resolve shell: bash @@ -42,15 +48,28 @@ jobs: run: | set -euo pipefail if [ "$EVENT" = push ]; then - echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + version="${REF_NAME#v}" echo "dry_run=false" >> "$GITHUB_OUTPUT" else - echo "version=${INPUT_VERSION#v}" >> "$GITHUB_OUTPUT" + version="${INPUT_VERSION#v}" echo "dry_run=${INPUT_DRY_RUN:-true}" >> "$GITHUB_OUTPUT" fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # A '-' before any '+build' metadata marks a pre-release in semver. + # Build metadata is cut first so 1.2.3+build-7 still counts as stable. + case "${version%%+*}" in + *-*) echo "dist_tag=next" >> "$GITHUB_OUTPUT" ;; + *) echo "dist_tag=latest" >> "$GITHUB_OUTPUT" ;; + esac publish: needs: [ci, prepare] + # Provenance mints an OIDC token, and a called workflow only gets the + # permissions its caller grants, so id-token: write has to be granted here. + permissions: + contents: read + id-token: write uses: xraph/workflows/.github/workflows/npm-publish.yml@v1 with: package-manager: pnpm @@ -59,6 +78,9 @@ jobs: packages: '["packages/core","packages/react"]' version: ${{ needs.prepare.outputs.version }} dry-run: ${{ needs.prepare.outputs.dry-run == 'true' }} + dist-tag: ${{ needs.prepare.outputs.dist-tag }} + # Each package.json needs a repository field naming this repository. + provenance: true build-script: build skip-existing: true secrets: diff --git a/testdata/fixture-node-pnpm/packages/adapter/package.json b/testdata/fixture-node-pnpm/packages/adapter/package.json index 392956e..2ec0752 100644 --- a/testdata/fixture-node-pnpm/packages/adapter/package.json +++ b/testdata/fixture-node-pnpm/packages/adapter/package.json @@ -1,6 +1,11 @@ { "name": "@xraph-fixture/adapter", "version": "0.0.0", + "repository": { + "type": "git", + "url": "git+https://github.com/xraph/workflows.git", + "directory": "testdata/fixture-node-pnpm/packages/adapter" + }, "main": "index.js", "files": ["index.js"], "scripts": { diff --git a/testdata/fixture-node-pnpm/packages/core/package.json b/testdata/fixture-node-pnpm/packages/core/package.json index 8702f9c..ad9f883 100644 --- a/testdata/fixture-node-pnpm/packages/core/package.json +++ b/testdata/fixture-node-pnpm/packages/core/package.json @@ -1,6 +1,11 @@ { "name": "@xraph-fixture/core", "version": "0.0.0", + "repository": { + "type": "git", + "url": "git+https://github.com/xraph/workflows.git", + "directory": "testdata/fixture-node-pnpm/packages/core" + }, "main": "index.js", "files": ["index.js"], "scripts": { diff --git a/testdata/fixture-node-pnpm/packages/plugin/package.json b/testdata/fixture-node-pnpm/packages/plugin/package.json index 2e7a4b0..6c71f05 100644 --- a/testdata/fixture-node-pnpm/packages/plugin/package.json +++ b/testdata/fixture-node-pnpm/packages/plugin/package.json @@ -1,6 +1,11 @@ { "name": "@xraph-fixture/plugin", "version": "0.0.0", + "repository": { + "type": "git", + "url": "git+https://github.com/xraph/workflows.git", + "directory": "testdata/fixture-node-pnpm/packages/plugin" + }, "main": "index.js", "files": ["index.js"], "scripts": {