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
96 changes: 90 additions & 6 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.'
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -152,6 +178,51 @@ jobs:
exit 1
fi

# npm rejects a tag it could parse as a semver range, because
# `npm install pkg@<tag>` 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
Expand Down Expand Up @@ -217,13 +288,25 @@ 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

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)"
Expand Down Expand Up @@ -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

Expand All @@ -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
27 changes: 27 additions & 0 deletions .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
30 changes: 26 additions & 4 deletions examples/node-library/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions testdata/fixture-node-pnpm/packages/adapter/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions testdata/fixture-node-pnpm/packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions testdata/fixture-node-pnpm/packages/plugin/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading