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
186 changes: 186 additions & 0 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# Reusable npm publication for a published stable release.
#
# Call from the caller's release.yml on `release: published`. Pre-releases are
# skipped. Two jobs keep dependency code away from publish credentials:
#
# - build (no id-token): checks the release, installs dependencies without
# install scripts, runs the package's prepublishOnly script, and packs the
# tarball.
# - publish (id-token: write): publishes that tarball with --ignore-scripts.
# Nothing from node_modules runs here.
#
# Trusted publishing: the npm package's trusted publisher names the caller's
# workflow file (for example release.yml), not this one. The caller must grant
# `id-token: write` to the calling job. npm adds provenance only for public
# repositories.
#
# The publish job serializes itself in the concurrency group
# <repository>-publish-npm-job; callers must not use that group name.
#
# Checks before building: the release commit is on the default branch, the tag
# is v<upstream>+<N>, <upstream> equals VERSION at that commit, and VERSION
# equals package.json's version. The version is skipped with a warning when it
# is already on npm.

name: Publish npm package

on:
workflow_call:
inputs:
version-file:
description: 'Path to the VERSION file'
required: false
default: 'VERSION'
type: string
runs-on:
description: 'GitHub-hosted runner to use; npm trusted publishing does not support self-hosted runners'
required: false
default: 'ubuntu-latest'
type: string

permissions:
contents: read

# Run steps with -eo pipefail rather than the implicit bash -e.
defaults:
run:
shell: bash

env:
NODE_VERSION: '24'
# The first npm release with trusted publishing.
NPM_MIN_VERSION: '11.5.1'
PACKAGE_ARTIFACT: npm-package

jobs:
build:
if: ${{ !(github.event_name == 'release' && github.event.release.prerelease) }}
runs-on: ${{ inputs.runs-on }}
outputs:
publish: ${{ steps.check.outputs.publish }}
steps:
- name: Require a release event
env:
EVENT: ${{ github.event_name }}
run: |
if [ "$EVENT" != release ]; then
echo "::error::publish-npm.yml publishes from a release event, not $EVENT"
exit 1
fi

- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false

- name: Require a commit on the default branch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
if ! git merge-base --is-ancestor HEAD "origin/$DEFAULT_BRANCH"; then
echo "::error::Release commit $(git rev-parse HEAD) is not on $DEFAULT_BRANCH"
exit 1
fi

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: false

- name: Check versions
id: check
env:
VERSION_FILE: ${{ inputs.version-file }}
TAG: ${{ github.event.release.tag_name }}
run: |
VERSION=$(tr -d '[:space:]' < "$VERSION_FILE")
VERSION="${VERSION#v}"
# Stable tag format written by release-version.yml.
if ! [[ $TAG =~ ^v([0-9][0-9A-Za-z.~-]*)\+([0-9]+)$ ]]; then
echo "::error::Release tag $TAG is not a v<upstream>+<N> stable tag"
exit 1
fi
if [ "${BASH_REMATCH[1]}" != "$VERSION" ]; then
echo "::error::Release $TAG is for ${BASH_REMATCH[1]}, but $VERSION_FILE at that commit is $VERSION"
exit 1
fi

PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$PKG_VERSION" != "$VERSION" ]; then
echo "::error::$VERSION_FILE ($VERSION) does not match package.json ($PKG_VERSION)"
exit 1
fi

PKG_NAME=$(node -p "require('./package.json').name")
if npm view "$PKG_NAME@$VERSION" version >/dev/null 2>&1; then
echo "::warning::$PKG_NAME@$VERSION is already on npm; nothing to publish. Bump $VERSION_FILE to release a new version."
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "$PKG_NAME@$VERSION is not on npm; publishing"
echo "publish=true" >> "$GITHUB_OUTPUT"
fi

- name: Build and pack
if: steps.check.outputs.publish == 'true'
run: |
if [ -f package-lock.json ]; then
npm ci --ignore-scripts
else
echo "::warning::No package-lock.json; dependencies resolve to the newest matching versions at publish time"
npm install --ignore-scripts
fi
npm run prepublishOnly --if-present
mkdir -p "$RUNNER_TEMP/pack"
npm pack --pack-destination "$RUNNER_TEMP/pack"

- name: Upload package
if: steps.check.outputs.publish == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ env.PACKAGE_ARTIFACT }}
path: ${{ runner.temp }}/pack/*.tgz
if-no-files-found: error

publish:
needs: build
if: needs.build.outputs.publish == 'true'
runs-on: ${{ inputs.runs-on }}
permissions:
contents: read
id-token: write
concurrency:
group: ${{ github.repository }}-publish-npm-job
cancel-in-progress: false
steps:
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false

- name: Require npm with trusted publishing
run: |
NPM_VERSION=$(npm --version)
if [ "$(printf '%s\n%s\n' "$NPM_MIN_VERSION" "$NPM_VERSION" | sort -V | head -n1)" != "$NPM_MIN_VERSION" ]; then
echo "::error::npm $NPM_VERSION is older than $NPM_MIN_VERSION, the first release with trusted publishing"
exit 1
fi
echo "npm $NPM_VERSION"

- name: Download package
uses: actions/download-artifact@v4
with:
name: ${{ env.PACKAGE_ARTIFACT }}
path: ${{ runner.temp }}/pack

- name: Publish
run: |
mapfile -t tarballs < <(find "$RUNNER_TEMP/pack" -name '*.tgz' -type f)
if [ "${#tarballs[@]}" -ne 1 ]; then
echo "::error::Expected one package tarball, found ${#tarballs[@]}"
exit 1
fi
npm publish "${tarballs[0]}" --ignore-scripts
42 changes: 42 additions & 0 deletions examples/npm/.github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Main branch release

on:
push:
branches: [main]
workflow_dispatch:

# Required: release-version.yml computes the next revision from existing tags,
# so two concurrent runs would compute the same one.
concurrency:
group: ${{ github.repository }}-main-release
cancel-in-progress: false

permissions:
contents: read

jobs:
# Optional: omit for repositories without tests.
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ./.github/actions/run-tests

version:
needs: tests
uses: halos-org/shared-workflows/.github/workflows/release-version.yml@v1

# Notes-only releases: publishing the draft starts release.yml.
stage-release:
needs: version
permissions:
contents: write
uses: halos-org/shared-workflows/.github/workflows/stage-release.yml@v1
with:
package-name: my-package
package-description: One-line description of my-package
debian-version: ${{ needs.version.outputs.debian-version }}
prerelease-tag: ${{ needs.version.outputs.prerelease-tag }}
stable-tag: ${{ needs.version.outputs.stable-tag }}
18 changes: 18 additions & 0 deletions examples/npm/.github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# The npm package's trusted publisher must name this file, release.yml.
# publish-npm.yml serializes publishes itself; do not add a concurrency group
# named <repository>-publish-npm-job here.
name: Publish stable release

on:
release:
types: [published]

permissions:
contents: read

jobs:
publish-npm:
permissions:
contents: read
id-token: write
uses: halos-org/shared-workflows/.github/workflows/publish-npm.yml@v1
124 changes: 124 additions & 0 deletions tests/publish-npm.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Tests for .github/workflows/publish-npm.yml step scripts.
set -euo pipefail
# shellcheck source=tests/lib/step.sh
source "$(dirname "$0")/lib/step.sh"

CHECK=$(extract_step publish-npm.yml build "Check versions")
BRANCH=$(extract_step publish-npm.yml build "Require a commit on the default branch")
NPM_CHECK=$(extract_step publish-npm.yml publish "Require npm with trusted publishing")

# Fake npm: `npm view <name>@<version> version` succeeds only for versions in
# $NPM_PUBLISHED; `npm --version` prints $NPM_FAKE_VERSION.
FAKE_BIN=$(mktemp -d)
cat > "$FAKE_BIN/npm" <<'FAKE'
#!/usr/bin/env bash
if [ "$1" = --version ]; then
echo "${NPM_FAKE_VERSION:?}"
exit 0
fi
[ "$1" = view ] || exit 2
for published in ${NPM_PUBLISHED:-}; do
if [ "$2" = "$published" ]; then
echo "${2##*@}"
exit 0
fi
done
exit 1
FAKE
chmod +x "$FAKE_BIN/npm"
PATH="$FAKE_BIN:$PATH"

setup() {
in_temp_dir
printf '%s\n' "${1:-1.2.0}" > VERSION
printf '{"name": "@example/pkg", "version": "%s"}\n' "${2:-1.2.0}" > package.json
export VERSION_FILE=VERSION TAG=${3:-v1.2.0+4} NPM_PUBLISHED=''
}

test_new_version_is_published() {
setup
run_step "$CHECK"
check "new version publishes" true "$(output publish)"
check "name read from package.json" 1 "$(grep -c '@example/pkg@1.2.0 is not on npm' "$STDOUT_FILE")"
}

test_existing_version_is_skipped() {
setup
export NPM_PUBLISHED=@example/pkg@1.2.0
run_step "$CHECK"
check "existing version skipped" false "$(output publish)"
check "warning shown" 1 "$(grep -c '::warning::' "$STDOUT_FILE")"
}

test_v_prefixed_version_file() {
setup " v1.2.0 " 1.2.0 v1.2.0+4
run_step "$CHECK"
check "v prefix and whitespace stripped" true "$(output publish)"
}

test_version_mismatch_with_package_json_fails() {
setup 1.2.0 1.3.0
check_status "VERSION differs from package.json" 1 run_step "$CHECK"
check "error names package.json" 1 "$(grep -c 'does not match package.json' "$STDOUT_FILE")"
}

test_tag_for_other_version_fails() {
setup 1.3.0 1.3.0 v1.2.0+4
check_status "tag for older VERSION fails" 1 run_step "$CHECK"
check "error names both" 1 "$(grep -c 'v1.2.0+4 is for 1.2.0.*1.3.0' "$STDOUT_FILE")"
}

test_malformed_tag_fails() {
local tag
for tag in v1.2.0+4_pre 1.2.0+4 v1.2.0; do
setup 1.2.0 1.2.0 "$tag"
check_status "tag $tag fails" 1 run_step "$CHECK"
done
}

# A clone whose origin has a default branch named trunk.
repo_with_origin() {
in_temp_dir
git init -q --bare origin.git
git clone -q origin.git work 2>/dev/null
cd work
git config user.email test@example.com
git config user.name test
git checkout -q -b trunk
git commit -q --allow-empty -m one
git push -q origin trunk
export DEFAULT_BRANCH=trunk
}

test_commit_on_default_branch_passes() {
repo_with_origin
git commit -q --allow-empty -m two
git push -q origin trunk
git checkout -q HEAD~1
check_status "ancestor of default branch passes" 0 run_step "$BRANCH"
}

test_commit_off_default_branch_fails() {
repo_with_origin
git checkout -q -b side
git commit -q --allow-empty -m unreviewed
git push -q origin side
check_status "commit only on another branch fails" 1 run_step "$BRANCH"
}

test_npm_version_requirement() {
in_temp_dir
local version
NPM_MIN_VERSION=$(yq '.env.NPM_MIN_VERSION' "$ROOT/.github/workflows/publish-npm.yml")
export NPM_MIN_VERSION
check "minimum read from the workflow" 11.5.1 "$NPM_MIN_VERSION"
for version in 11.5.1 11.17.0 12.0.0; do
NPM_FAKE_VERSION=$version check_status "npm $version passes" 0 run_step "$NPM_CHECK"
done
for version in 10.9.2 11.5.0; do
NPM_FAKE_VERSION=$version check_status "npm $version fails" 1 run_step "$NPM_CHECK"
done
}

run_tests
Loading