Skip to content

Publish to NPM

Publish to NPM #2

name: Publish to NPM
# This workflow handles all publishing to npm for releases and pre-release nightly
# builds. We must use a single file rather than separate workflow files because
# Trusted Publishing currently supports only a single workflow file.
on:
release:
types: [published]
schedule:
- cron: '0 2 * * *' # Nightly at 02:00 UTC
workflow_dispatch:
inputs:
publish_type:
description: 'Type of publish'
required: true
type: choice
options:
- release
- master-nightly
- minor-nightly
jobs:
# Determine which publish type(s) to run based on trigger
setup:
# Restrict who can trigger a publish to the listed maintainers. Both
# `workflow_dispatch` (manual) and `release` (a published GitHub Release)
# are user-initiated, so both are gated on `github.actor`; a publish
# triggered by anyone else skips this job, and `publish` (needs: setup)
# is skipped with it. `schedule` (nightly cron) is not user-initiated and
# is always allowed.
# This is the UX/first-line guard; actual enforcement against branch-based
# bypass is the `npm-publish` environment + npm trust `--env` binding below,
# and creation of the `v*` release tags is restricted by a tag ruleset.
if: >-
github.event_name == 'schedule' ||
((github.event_name == 'release' || github.event_name == 'workflow_dispatch') &&
(github.actor == 'michaelbromley' || github.actor == 'dlhck'))
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Determine publish matrix
id: set-matrix
run: |
if [ "${{ github.event_name }}" == "release" ]; then
echo 'matrix=["release"]' >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" == "schedule" ]; then
echo 'matrix=["master-nightly", "minor-nightly"]' >> $GITHUB_OUTPUT
else
echo 'matrix=["${{ inputs.publish_type }}"]' >> $GITHUB_OUTPUT
fi
publish:
needs: setup
runs-on: ubuntu-latest
# The OIDC token minted for this job carries an `environment: npm-publish`
# claim, which the npm trusted publishers require (`npm trust --env`). The
# environment's deployment-branch policy (master, minor, v* tags) is
# enforced by GitHub before the job runs, so a publish cannot originate
# from an arbitrary branch — closing the workflow_dispatch branch-bypass.
# The environment has NO required reviewers, so nightlies stay unattended.
environment: npm-publish
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
# Both nightly jobs publish new versions of the same packages. npm rejects
# a publish with E409 while it is still processing a previous publish of
# the same package, so the jobs must not run concurrently.
max-parallel: 1
matrix:
type: ${{ fromJson(needs.setup.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ matrix.type == 'minor-nightly' && 'minor' || matrix.type == 'master-nightly' && 'master' || '' }}
fetch-depth: ${{ matrix.type == 'release' && 1 || 0 }}
# For nightly builds: check if there were commits in the last 24 hours
- name: Check for new commits
id: commit_check
if: matrix.type != 'release'
run: |
COMMITS=$(git rev-list --count --since="24 hours" HEAD)
echo "Commits found: $COMMITS"
if [ "$COMMITS" -eq 0 ]; then
echo "should_publish=false" >> $GITHUB_OUTPUT
else
echo "should_publish=true" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
if: matrix.type == 'release' || steps.commit_check.outputs.should_publish == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'
- name: Update npm
if: matrix.type == 'release' || steps.commit_check.outputs.should_publish == 'true'
run: npm install -g npm@latest
- name: Setup Bun
if: matrix.type == 'release' || steps.commit_check.outputs.should_publish == 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: '1.3.10'
- name: Install dependencies
if: matrix.type == 'release' || steps.commit_check.outputs.should_publish == 'true'
run: bun install --frozen-lockfile
- name: Build
if: matrix.type == 'release' || steps.commit_check.outputs.should_publish == 'true'
run: bun run build
# Nightly builds: configure git and bump version
- name: Configure Git
if: matrix.type != 'release' && steps.commit_check.outputs.should_publish == 'true'
run: |
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Get current date
if: matrix.type != 'release' && steps.commit_check.outputs.should_publish == 'true'
id: date
run: echo "date=$(date +'%Y%m%d%H%M')" >> $GITHUB_OUTPUT
- name: Version packages (master-nightly)
if: matrix.type == 'master-nightly' && steps.commit_check.outputs.should_publish == 'true'
run: |
CURRENT_VERSION=$(node -p "require('./lerna.json').version")
IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION"
PATCH=$((VERSION_PARTS[2] + 1))
NEW_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${PATCH}-master-${{ steps.date.outputs.date }}"
bunx lerna version $NEW_VERSION --no-git-tag-version --yes --no-push --force-publish
- name: Version packages (minor-nightly)
if: matrix.type == 'minor-nightly' && steps.commit_check.outputs.should_publish == 'true'
run: |
CURRENT_VERSION=$(node -p "require('./lerna.json').version")
IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION"
MINOR=$((VERSION_PARTS[1] + 1))
NEW_VERSION="${VERSION_PARTS[0]}.${MINOR}.0-minor-${{ steps.date.outputs.date }}"
bunx lerna version $NEW_VERSION --no-git-tag-version --yes --no-push --force-publish
- name: Commit version changes
if: matrix.type != 'release' && steps.commit_check.outputs.should_publish == 'true'
run: |
git add .
git commit -m "chore: Bump version for ${{ matrix.type }} pre-release"
# Publish to npm with appropriate dist-tag.
# Note: we do not use `lerna publish` because at the time of writing, it fails to publish
# using the Trusted Publishing workflow for some reason.
# We use a custom script to handle publishConfig.directory for packages like @vendure/admin-ui
# that need to publish from a nested directory.
#
# Release publishes use `npm stage publish` — the version lands in npm's staging area and
# requires manual maintainer approval (with 2FA) before going live on the public registry.
# Approve all staged packages at once with: scripts/npm-stage-approve-all.sh <version>
# Nightly builds still use direct `npm publish` because they target non-default dist-tags
# (`master` / `minor`) that nobody installs unless they opt in explicitly.
#
# No --tag here is intentional: releases go to the default `latest` tag.
- name: Publish to NPM (release)
if: matrix.type == 'release'
run: |
bunx lerna exec --no-private -- 'publish_dir=$(node -p "require(\"./package.json\").publishConfig?.directory || \".\"") && cd "$publish_dir" && npm stage publish --access public'
# --no-bail: a failure in one package must not skip publishing the rest —
# downstream consumers expect ALL packages to exist at a given nightly
# version. The retry loop handles transient registry errors (e.g. E409
# while npm is still processing an earlier publish of the same package).
- name: Publish to NPM (master-nightly)
if: matrix.type == 'master-nightly' && steps.commit_check.outputs.should_publish == 'true'
run: |
bunx lerna exec --no-private --no-bail -- 'publish_dir=$(node -p "require(\"./package.json\").publishConfig?.directory || \".\"") && cd "$publish_dir" && for i in 1 2 3; do npm publish --access public --tag master && break; if [ "$i" = 3 ]; then exit 1; fi; echo "npm publish failed, retrying in 30s..."; sleep 30; done'
- name: Publish to NPM (minor-nightly)
if: matrix.type == 'minor-nightly' && steps.commit_check.outputs.should_publish == 'true'
run: |
bunx lerna exec --no-private --no-bail -- 'publish_dir=$(node -p "require(\"./package.json\").publishConfig?.directory || \".\"") && cd "$publish_dir" && for i in 1 2 3; do npm publish --access public --tag minor && break; if [ "$i" = 3 ]; then exit 1; fi; echo "npm publish failed, retrying in 30s..."; sleep 30; done'
- name: Skip publish (no new commits)
if: matrix.type != 'release' && steps.commit_check.outputs.should_publish == 'false'
run: echo "No new commits in last 24 hours – skipping ${{ matrix.type }} publish."