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
45 changes: 45 additions & 0 deletions .github/actions/npm-release/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Publish VIP CLI npm release
description: Build, test, stage and publish the exact validated package tarball.
inputs:
mode:
description: stable or prerelease
required: true
npm-tag:
description: npm tag for prereleases
default: next
runs:
using: composite
steps:
- uses: actions/setup-node@v7
with:
node-version: 'lts/*'
registry-url: https://registry.npmjs.org/
- name: Prepare release tools
shell: bash
run: |
npm install --global npm@11
git config --global user.name 'WordPress VIP Bot'
git config --global user.email '<22917138+wpcomvip-bot@users.noreply.github.com>'
# Keep tools from this workflow revision when the stable script changes branches.
tools_dir=$(mktemp -d "$RUNNER_TEMP/vip-release-tools.XXXXXXXX")
cp -R "$GITHUB_WORKSPACE/.github/scripts/npm-release/." "$tools_dir/"
printf 'VIP_RELEASE_TOOLS=%s\n' "$tools_dir" >> "$GITHUB_ENV"
- name: Validate and publish release
shell: bash
env:
RELEASE_MODE: ${{ inputs.mode }}
NPM_TAG: ${{ inputs.npm-tag }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_REF: ${{ github.head_ref }}
PR_ASSIGNEE: ${{ github.actor }}
USE_TRUSTED_PUBLISHING: 'true'
PROVENANCE: 'true'
CONVENTIONAL_COMMITS: 'true'
SMOKE_SCRIPT: smoke:release
run: |
case "$RELEASE_MODE" in
stable) bash "$VIP_RELEASE_TOOLS/publish.sh" ;;
prerelease) bash "$VIP_RELEASE_TOOLS/publish-prerelease.sh" ;;
*) echo 'Unsupported release mode' >&2; exit 1 ;;
esac
53 changes: 53 additions & 0 deletions .github/scripts/npm-release/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# VIP CLI npm release tooling

The local `.github/actions/npm-release` composite action runs `publish.sh` for
stable releases and `publish-prerelease.sh` for prereleases. Recovery calls
`pack.sh` directly. These scripts replace the npm publishing actions previously
used from `Automattic/vip-actions`; no change to that repository is required.

Stable publishing retains release-PR file/type validation, release branch and
clean-checkout checks, build/tests, GitHub release creation, npm publication and
the next development-version PR. Prereleases retain their selected npm tag,
clean-checkout check, build/tests and prerelease creation. The workflow supplies
npm trusted publishing credentials and keeps the existing permissions and
`npm-publish` environment. GitHub errors during release-PR inspection stop the
release. Only `npm version` uses silent logging to keep its captured version
string clean.

## Artifact preparation

`pack.sh SOURCE OUTPUT TAG [SMOKE_SCRIPT]` expects a built and tested checkout,
plus an existing output directory outside that checkout. Requires Node/npm,
`rsync`, Python 3 and `tar`, available on GitHub-hosted Ubuntu runners. It never
publishes.

The helper runs `prepublishOnly`, `prepack` and `prepare` in the source checkout
with the npm token cleared. It copies to a fresh temporary directory using
`rsync -a` **without `-H`**, so native build hard links become independent files.
It then packs with lifecycle scripts disabled, validates package identity and
rejects links, duplicate paths and unsafe entries. `postpack` runs in the source
checkout after packing. `smoke:release` runs inside an extraction of that exact
archive using bundled dependencies.

The only stdout is the validated tarball's absolute path. Build output goes to
stderr. The staging directory is always cleaned; the caller owns output cleanup.
Dry-run and publication use that exact tarball with `--ignore-scripts`. Neither
rebuild nor repack afterward. Directory `publish`/`postpublish` hooks are not run;
VIP CLI does not define them.

The workflow saves these tools under `RUNNER_TEMP` before changing the source
checkout. This lets recovery build an old release tag, such as `4.1.2`, using
tools from the workflow revision without adding or changing files in that tag.

## Tests

```sh
python3 -m unittest discover -s .github/scripts/npm-release/tests -v
shellcheck .github/scripts/npm-release/*.sh
actionlint .github/workflows/npm-publish.yml .github/workflows/npm-release-tests.yml
```

Tests build real fixture archives without registry access. Publisher integration
tests mock GitHub, git and npm publication, so they cannot publish or create
remote releases. The Ubuntu packaging workflow runs them separately from CLI
runtime tests.
55 changes: 55 additions & 0 deletions .github/scripts/npm-release/pack.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# stdout is exclusively the validated tarball path. All build output goes to stderr.
set -euo pipefail

source_dir=$(cd "${1:-.}" && pwd -P)
output_dir=$(cd "${2:?Provide an existing output directory outside the source tree}" && pwd -P)
tag=${3:-latest}
smoke_script=${4:-}
case "$output_dir/" in
"$source_dir/"*) echo 'Output directory must be outside the source tree.' >&2; exit 1 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/npm-pack-staged.XXXXXXXX")
trap 'rm -rf "$work_dir"' EXIT
case "$work_dir/" in
"$source_dir/"*) echo 'TMPDIR must be outside the source tree.' >&2; exit 1 ;;
esac

# Preserve validation/pack hooks, but run all artifact-producing hooks before copying.
# Never expose the registry token to lifecycle scripts or smoke tests.
export NODE_AUTH_TOKEN=''
export NPM_CONFIG_LOGLEVEL=error
export npm_config_tag="$tag"
cd "$source_dir"
npm run prepublishOnly --if-present >&2
npm run prepack --if-present >&2
npm run prepare --if-present >&2

mkdir "$work_dir/package"
# -a intentionally omits -H: every hard-linked name becomes an independent file.
# A fresh destination is essential; updating an old staging tree is not sufficient.
rsync -a --exclude='/.git' --exclude='/.npmrc' "$source_dir/" "$work_dir/package/"
(
cd "$work_dir/package"
npm pack --ignore-scripts --json --pack-destination "$work_dir" > "$work_dir/pack.json"
)
filename=$(node -e 'const p = require(process.argv[1]); if (p.length !== 1 || require("node:path").basename(p[0].filename) !== p[0].filename) process.exit(1); process.stdout.write(p[0].filename);' "$work_dir/pack.json")
archive="$work_dir/$filename"
python3 "$script_dir/validate.py" "$archive" "$source_dir/package.json" >&2
npm run postpack --if-present >&2

if [[ -n "$smoke_script" ]]; then
mkdir "$work_dir/smoke"
tar -xzf "$archive" -C "$work_dir/smoke"
# Run against the extracted artifact, not the original checkout or staging tree.
(cd "$work_dir/smoke/package" && npm run "$smoke_script") >&2
fi

# Only expose a completed, validated artifact. The caller owns output_dir cleanup.
if [[ -e "$output_dir/$filename" ]]; then
echo "Refusing to overwrite an existing artifact: $output_dir/$filename" >&2
exit 1
fi
cp "$archive" "$output_dir/$filename"
printf '%s\n' "$output_dir/$filename"
90 changes: 90 additions & 0 deletions .github/scripts/npm-release/publish-prerelease.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/bin/sh

set -eu

# Set inputs
: "${NPM_TAG:=next}"
: "${PROVENANCE:=false}"

echo_title() {
echo ""
echo "== $1 =="
}

# Fetch some basic package information
echo_title "Fetching local package info"
LOCAL_NAME=$(node -p "require('./package.json').name")
LOCAL_VERSION=$(node -p "require('./package.json').version")
LOCAL_BRANCH=$(git branch --show-current)
echo "✅ Found ${LOCAL_NAME} ${LOCAL_VERSION} on branch ${LOCAL_BRANCH}"

# If not using Trusted Publishing, validate npm is logged in and ready
if [ "${USE_TRUSTED_PUBLISHING:-}" != "true" ]; then
echo_title "Checking npm auth"
if ! NPM_USER=$(npm whoami); then
echo "❌ npm cli is not authenticated. Please make sure you're logged in or NPM_TOKEN is set."
exit 202
fi
echo "✅ Logged in as ${NPM_USER} and ready to publish"
fi

# Validate no uncommitted changes.
# Shouldn't happen in CI but protects against local runs.
echo_title "Checking for local changes"
if ! git diff-index --quiet HEAD --; then
echo "❌ Working directory has uncommitted changes; please clean up before proceeding."
exit 204
fi
echo "✅ No local changes found"

# Install
echo_title "npm ci + test"

# Install dependencies but skip pre/post scripts since our auth token is in place
npm ci --ignore-scripts

# Run scripts + tests without auth token to prevent malicious access
NODE_AUTH_TOKEN='' npm rebuild
NODE_AUTH_TOKEN='' npm run prepare --if-present
NODE_AUTH_TOKEN='' npm test
echo "✅ npm install + npm test look good"

# Confirm y/n (if running locally)
if [ -t 0 ]; then
echo_title "Confirm release"
printf "Are you sure you want to publish a new release? (y/n)"
read -r yn
case $yn in
[Yy]*)
;;

*)
echo "❌ Aborting release"
exit 205
;;
esac
fi

# Pack once from a fresh copy; never rebuild or repack after validation.
echo_title "Pack and validate staged npm artifact"
artifact_dir=$(mktemp -d "${TMPDIR:-/tmp}/npm-publish-artifact.XXXXXXXX")
trap 'rm -rf "$artifact_dir"' EXIT
script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
PACKAGE_TARBALL=$(bash "$script_dir/pack.sh" "$PWD" "$artifact_dir" "${NPM_TAG}" "${SMOKE_SCRIPT:-smoke:release}")
npm publish "$PACKAGE_TARBALL" --access public --tag "${NPM_TAG}" --dry-run --ignore-scripts --loglevel error

# Publish on GitHub and tag
echo_title "Publishing a new release on GitHub and tagging"
gh release create "${LOCAL_VERSION}" --generate-notes --prerelease --target "${LOCAL_BRANCH}"
echo "✅ Released version ${LOCAL_VERSION} on GitHub and tagged"

# Publish to NPM
echo_title "npm publish"
OPTIONS="--access public"
if [ "${PROVENANCE}" = "true" ] && [ "${CI:-}" = "true" ] && [ "${GITHUB_ACTIONS:-}" = "true" ]; then
OPTIONS="${OPTIONS} --provenance"
fi

# shellcheck disable=SC2086 # OPTIONS contains the existing publish flags.
npm publish "$PACKAGE_TARBALL" ${OPTIONS} --tag "${NPM_TAG}" --ignore-scripts --loglevel error
echo "✅ Successfully published new release for ${LOCAL_NAME} as ${LOCAL_VERSION}"
Loading
Loading