diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..e1c3026 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -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 +# -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+, 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+ 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 diff --git a/examples/npm/.github/workflows/main.yml b/examples/npm/.github/workflows/main.yml new file mode 100644 index 0000000..7342463 --- /dev/null +++ b/examples/npm/.github/workflows/main.yml @@ -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 }} diff --git a/examples/npm/.github/workflows/release.yml b/examples/npm/.github/workflows/release.yml new file mode 100644 index 0000000..cf01278 --- /dev/null +++ b/examples/npm/.github/workflows/release.yml @@ -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 -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 diff --git a/tests/publish-npm.test.sh b/tests/publish-npm.test.sh new file mode 100755 index 0000000..47e4a0d --- /dev/null +++ b/tests/publish-npm.test.sh @@ -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 @ 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