Files
pnpm/.github/workflows/release.yml
2026-07-16 01:20:04 +02:00

1068 lines
45 KiB
YAML

name: Release
# The unified release workflow for every product in this repository: the
# TypeScript pnpm CLI (v11), the Rust pnpm CLI + NAPI addon (v12), and the
# pnpr registry server. Versions are committed (bumped by `pnpm bump` via the
# release PR that create-release-pr.yml opens); pushing a version tag runs the
# `plan` job, which asks the npm registry which committed versions are not
# published yet, and only those products' jobs run. Tagging e.g. a v12
# prerelease therefore skips the TypeScript jobs whose version is already on
# npm, and vice versa.
on:
push:
tags:
# pnpm (TypeScript, v11.x) and pacquet (v12.x) share the `v<version>`
# namespace that get.pnpm.io's install scripts fetch by. pnpr gets its own
# Changesets-style `pnpr@<version>` namespace: pnpm's tag history includes
# v0.x/v1.x that pnpr's low version numbers would otherwise collide with.
- "v*.*.*"
- "pnpr@*.*.*"
# Manual trigger for rerunning a tag release or verifying TypeScript release
# artifacts from a branch without publishing.
workflow_dispatch:
inputs:
verify_ts_release:
description: Verify TypeScript release artifacts without publishing
required: false
type: boolean
default: false
# A release PR that bumps several products auto-tags each one (tag-release.yml),
# so several version tags can land together and start a run apiece. Serialize
# them: the plan job gates every publish on what npm reports as unpublished, so
# once the first run publishes a product the rest skip it — but only if they
# don't run concurrently and all read "unpublished" at once. Never cancel a
# run mid-publish.
concurrency:
group: release
cancel-in-progress: false
jobs:
validate-release-ref:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: {}
steps:
- name: Validate release ref
run: |
case "${GITHUB_REF}" in
refs/tags/v*.*.*) ;;
refs/tags/pnpr@*.*.*) ;;
*)
if [ "${{ inputs.verify_ts_release }}" = "true" ]; then
exit 0
fi
echo "::error::The release workflow must run from a version tag like v11.9.0 or pnpr@0.2.0. Current ref: ${GITHUB_REF}"
exit 1
;;
esac
plan:
name: Plan (which products need publishing)
needs: validate-release-ref
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
outputs:
ts: ${{ steps.decide.outputs.ts }}
ts_version: ${{ steps.decide.outputs.ts_version }}
rust: ${{ steps.decide.outputs.rust }}
rust_version: ${{ steps.decide.outputs.rust_version }}
rust_tag: ${{ steps.decide.outputs.rust_tag }}
pnpr: ${{ steps.decide.outputs.pnpr }}
pnpr_version: ${{ steps.decide.outputs.pnpr_version }}
pnpr_tag: ${{ steps.decide.outputs.pnpr_tag }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Read committed versions and query the registry
id: decide
# A product is released when its committed version is not on npm yet.
# `needs_release` treats only E404 as "not published"; any other npm
# failure aborts the plan so a registry outage can't be misread as a
# pending release. The gate packages are the ones each publish job
# publishes last (`pnpm` for both CLIs, `@pnpm/pnpr` for pnpr), so a
# rerun after a partial failure resumes instead of skipping.
run: |
set -eu
needs_release() {
local out
# Query from outside the checkout: npm enforces the repository's
# devEngines (which pin pnpm) for any command run inside it.
if out=$(cd "${RUNNER_TEMP:-/tmp}" && npm view "$1" version 2>&1); then
echo false
elif echo "$out" | grep -q 'E404'; then
echo true
else
echo "::error::npm view $1 failed: $out"
exit 1
fi
}
TS_VERSION=$(jq -r .version pnpm11/pnpm/package.json)
RUST_VERSION=$(jq -r .version pnpm/npm/pnpm/package.json)
NAPI_VERSION=$(jq -r .version pnpm/npm/napi/package.json)
PNPR_VERSION=$(jq -r .version pnpr/npm/pnpr/package.json)
# pacquet and @pnpm/napi are a versioning.fixed group; a drift means
# the versions were edited by hand inconsistently.
if [ "$RUST_VERSION" != "$NAPI_VERSION" ]; then
echo "::error::pnpm/npm/pnpm ($RUST_VERSION) and pnpm/npm/napi ($NAPI_VERSION) versions differ"
exit 1
fi
# A v12 prerelease must never move the production dist-tags; derive
# next-<major> the way the TypeScript packages' publishConfig does.
RUST_TAG="next-${RUST_VERSION%%.*}"
# A pnpr prerelease must not move the mutable `latest` dist-tag,
# mirroring the docker-pnpr job's image-tag handling.
case "$PNPR_VERSION" in
*-*) PNPR_TAG="next" ;;
*) PNPR_TAG="latest" ;;
esac
# Assign first so `set -e` aborts the plan on a `needs_release`
# failure. Inside `echo "$(needs_release …)"` the non-zero exit is
# the subshell's, discarded by echo, so a registry outage would
# otherwise be written out as an empty (falsy) value and silently
# skip the release.
TS_NEEDS=$(needs_release "pnpm@$TS_VERSION")
RUST_NEEDS=$(needs_release "pnpm@$RUST_VERSION")
PNPR_NEEDS=$(needs_release "@pnpm/pnpr@$PNPR_VERSION")
{
echo "ts=$TS_NEEDS"
echo "ts_version=$TS_VERSION"
echo "rust=$RUST_NEEDS"
echo "rust_version=$RUST_VERSION"
echo "rust_tag=$RUST_TAG"
echo "pnpr=$PNPR_NEEDS"
echo "pnpr_version=$PNPR_VERSION"
echo "pnpr_tag=$PNPR_TAG"
} | tee -a "$GITHUB_OUTPUT"
verify-ts-release:
name: Verify TypeScript pnpm release artifacts
needs:
- validate-release-ref
- plan
if: needs.plan.outputs.ts == 'true' || inputs.verify_ts_release
permissions:
contents: read
# Runs on macOS so the darwin artifacts can be ad-hoc signed with native
# `codesign` (no need to build/install `ldid` on the runner) and so
# `verify-binary.mjs` can smoke-test the darwin-arm64 SEA in place — a
# macos-latest runner is Apple Silicon and can execute the arm64 binary.
# Note: this does NOT fix the darwin-x64 crash (nodejs/node#62893) — that's
# an upstream Node.js SEA bug independent of signing; see pack-app docs.
# npm trusted publishing still requires this publish job to run on GitHub-hosted macOS.
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: garnet-org/action@2b7fc9d79b54f551b43358c27424a36064b3e078 # v2.0.2
with:
api_token: ${{ secrets.GARNET_API_TOKEN }}
- name: Install pnpm and Node
uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4
with:
runtime: node@26.5.0
- name: Build TypeScript executable artifacts
run: pn --filter=@pnpm/exe run build-artifacts
- name: Verify TypeScript npm packages
# Inspect the same tarballs that publish will upload before the first
# immutable npm publish, including the generated platform packages.
run: |
set -euo pipefail
release_tarballs=$(mktemp -d)
for package in pnpm11/pnpm/artifacts/{darwin-arm64,exe,linux-arm64,linux-arm64-musl,linux-x64,linux-x64-musl,win32-arm64,win32-x64} pnpm11/pnpm; do
package_tarballs="$release_tarballs/${package//\//_}"
mkdir "$package_tarballs"
(
cd "$package"
pn pack --pack-destination "$package_tarballs"
)
tarball=$(find "$package_tarballs" -type f -name '*.tgz' -print -quit)
test -n "$tarball"
node -e '
const fs = require("node:fs")
const path = require("node:path")
const manifestPath = path.resolve(process.argv[1])
const manifest = require(manifestPath)
const packageDir = path.dirname(manifestPath)
const excluded = (manifest.files ?? []).filter((file) => file.startsWith("!")).map((file) => globToRegExp(file.slice(1)))
const files = new Set()
for (const file of manifest.files ?? []) {
if (!file.startsWith("!") && !/[?*[]/.test(file)) add(file)
}
for (const file of manifest.publishConfig?.executableFiles ?? []) add(file)
for (const file of Object.values(typeof manifest.bin === "string" ? { default: manifest.bin } : manifest.bin ?? {})) add(file)
if (files.size === 0) throw new Error("No literal payload files declared by " + manifest.name)
console.log([...files].join("\n"))
function add(file) {
const relative = file.replace(/^\.\//, "")
if (excluded.some((pattern) => pattern.test(relative))) return
const source = path.join(packageDir, relative)
const stat = fs.statSync(source, { throwIfNoEntry: false })
if (stat?.isFile()) {
files.add(relative)
return
}
if (!stat?.isDirectory()) throw new Error("Missing payload file " + source)
for (const entry of fs.readdirSync(source, { withFileTypes: true })) add(path.join(relative, entry.name))
}
function globToRegExp(pattern) {
return new RegExp("^" + pattern.replace(/[.+^{}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\x00").replace(/\*\*/g, "\x01").replace(/\*/g, "[^/]*").replace(/\x00/g, "(?:.*/)?").replace(/\x01/g, ".*") + String.fromCharCode(36))
}
' "$package/package.json" | while IFS= read -r file; do
test -s "$package/$file"
test "$(tar -xOf "$tarball" "package/$file" | shasum -a 256 | cut -d ' ' -f 1)" = "$(shasum -a 256 "$package/$file" | cut -d ' ' -f 1)"
done
done
pnpm_tarball=$(find "$release_tarballs/pnpm11_pnpm" -type f -name '*.tgz' -print -quit)
test -n "$pnpm_tarball"
smoke_dir=$(mktemp -d)
tar -xzf "$pnpm_tarball" -C "$smoke_dir"
node "$smoke_dir/package/bin/pnpm.mjs" --version
exe_tarball=$(find "$release_tarballs/pnpm11_pnpm_artifacts_exe" -type f -name '*.tgz' -print -quit)
native_tarball=$(find "$release_tarballs/pnpm11_pnpm_artifacts_darwin-arm64" -type f -name '*.tgz' -print -quit)
test -n "$exe_tarball"
test -n "$native_tarball"
(
cd "$smoke_dir"
command -v node
node --version
command -v npm
npm --version
npm config get ignore-scripts
npm config get strict-allow-scripts
npm install --foreground-scripts --ignore-scripts=false --loglevel verbose "$exe_tarball" "$native_tarball"
)
ls -l "$smoke_dir/node_modules/@pnpm/exe/pnpm"
file "$smoke_dir/node_modules/@pnpm/exe/pnpm"
"$smoke_dir/node_modules/@pnpm/exe/pnpm" --version
release:
name: Release the TypeScript pnpm CLI
needs:
- validate-release-ref
- plan
- verify-ts-release
if: needs.plan.outputs.ts == 'true' && !inputs.verify_ts_release
permissions:
id-token: write # Required for OIDC
contents: write # for softprops/action-gh-release to create GitHub release
attestations: write # for actions/attest-build-provenance
runs-on: macos-latest
environment: release
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: garnet-org/action@2b7fc9d79b54f551b43358c27424a36064b3e078 # v2.0.2
with:
api_token: ${{ secrets.GARNET_API_TOKEN }}
- name: Install pnpm and Node
uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4
with:
runtime: node@26.5.0
# The publish phase is split into three sequential steps to control which packages
# use trusted publishing (OIDC) vs. a static token. `pnpm publish` currently bails
# out of OIDC as soon as a static `_authToken` is configured, so the only way to
# force trusted publishing for a given package today is to run its publish in a
# step that doesn't have NPM_TOKEN set. See https://github.com/pnpm/pnpm/pull/11495
# for the longer-term fix that lets OIDC override a configured token.
- name: Build TypeScript executable artifacts
run: pn --filter=@pnpm/exe run build-artifacts
- name: Publish @pnpm/exe (trusted publishing)
# No NPM_TOKEN: pnpm has no static token to short-circuit on, so it will perform
# the OIDC token exchange against npm's trusted-publishing config for `@pnpm/exe`.
run: |
pn --filter=@pnpm/exe publish --tag=next-11 --access=public --provenance
- name: Publish internal workspace packages (static token)
# The other workspace packages don't have trusted publishing configured on npm,
# so we still need a static token here. The token is removed from pnpm's config
# at the end of the step so it can't leak into the trusted-publishing step that
# follows (where its presence would silently downgrade `pnpm` to token publishing).
# The Rust products' wrapper packages are private workspace packages, so the
# recursive publish skips them; they are published by their own jobs below.
env:
# Setting the "npm_config_//registry.npmjs.org/:_authToken" env variable directly
# doesn't work — pnpm doesn't appear to pass auth tokens to child processes.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
trap 'pn config delete "//registry.npmjs.org/:_authToken" || true' EXIT
pn config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}"
pn publish --filter=!pnpm --filter=!@pnpm/exe --access=public --provenance
- name: Publish pnpm CLI (trusted publishing)
# No NPM_TOKEN — same rationale as the @pnpm/exe step above. This must come after
# the previous step has cleared its NPM_TOKEN from pnpm's config.
run: pn publish --filter=pnpm --tag=next-11 --access=public --provenance
- name: Copy Artifacts
run: pn copy-artifacts
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: 'dist/*'
- name: Generate release description
run: pn make-release-description
- name: Release
# `gh release create` could replace this, but the release pipeline is
# sensitive and softprops/action-gh-release is widely battle-tested.
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.0 # zizmor: ignore[superfluous-actions]
with:
draft: true
files: dist/*
body_path: RELEASE.md
build-rust:
needs: plan
if: needs.plan.outputs.rust == 'true' && !inputs.verify_ts_release
permissions:
contents: read
id-token: write # needed for actions/attest-build-provenance
attestations: write
strategy:
matrix:
include:
- os: blacksmith-8vcpu-windows-2025
target: x86_64-pc-windows-msvc
code-target: win32-x64
- os: blacksmith-8vcpu-windows-2025
target: aarch64-pc-windows-msvc
code-target: win32-arm64
- os: blacksmith-8vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
code-target: linux-x64
- os: blacksmith-8vcpu-ubuntu-2404
target: aarch64-unknown-linux-gnu
code-target: linux-arm64
- os: blacksmith-8vcpu-ubuntu-2404
target: x86_64-unknown-linux-musl
code-target: linux-x64-musl
- os: blacksmith-8vcpu-ubuntu-2404
target: aarch64-unknown-linux-musl
code-target: linux-arm64-musl
- os: blacksmith-12vcpu-macos-latest
target: x86_64-apple-darwin
code-target: darwin-x64
- os: blacksmith-12vcpu-macos-latest
target: aarch64-apple-darwin
code-target: darwin-arm64
name: Package pnpm (Rust) ${{ matrix.code-target }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install cross
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5
with:
tool: cross
# No build cache: GitHub Actions caches are writable by any workflow on
# the default branch, so restoring one here would let a poisoned cache
# entry inject code into the published, attested release binaries.
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Verify the committed version
# `pacquet --version` and the default User-Agent read the PNPM_VERSION
# constant, which `pnpm bump` keeps in sync with the npm wrapper's
# version. Verify instead of patching so the binary is reproducible
# from the tagged sources.
shell: bash
env:
VERSION: ${{ needs.plan.outputs.rust_version }}
run: |
grep -F "PNPM_VERSION: &str = \"$VERSION\"" pnpm/crates/config/src/defaults.rs
- name: Build with cross
run: cross build --locked -p pacquet-cli --bin pnpm --release --target=${{ matrix.target }}
- name: Build NAPI addon with cross
# musl targets enable `crt-static` by default, but a `cdylib` (the
# `.node` addon) can't be produced with a statically linked CRT. Disable
# it so the addon builds as a shared object that dynamically links musl
# libc at load time (this is what napi-rs does for musl prebuilds). The
# CLI binary keeps `crt-static` — it's built in the separate step above,
# so it stays fully static for portability across musl distros.
#
# Set RUSTFLAGS only for musl: an empty RUSTFLAGS is not a no-op — Cargo
# treats it as an override that discards any `rustflags` from
# `.cargo/config.toml`, so we leave it unset on every other target.
shell: bash
env:
TARGET: ${{ matrix.target }}
run: |
if [[ "$TARGET" == *musl* ]]; then
export RUSTFLAGS="-C target-feature=-crt-static"
fi
cross build --locked -p pacquet-napi --profile napi-release --target="$TARGET"
- name: Prepare NAPI addon
shell: bash
run: |
case "${{ runner.os }}" in
Windows) source="target/${{ matrix.target }}/napi-release/pacquet_napi.dll" ;;
macOS) source="target/${{ matrix.target }}/napi-release/libpacquet_napi.dylib" ;;
*) source="target/${{ matrix.target }}/napi-release/libpacquet_napi.so" ;;
esac
cp "$source" "pnpm-napi.${{ matrix.code-target }}.node"
# The binary is archived to fix permission loss
# (https://github.com/actions/upload-artifact#permission-loss) and ships
# under its final name: `pnpm-<target>` archives with a plain `pnpm`
# binary at the root — the layout the v11 releases use and get.pnpm.io's
# install scripts expect — so the github-release-rust job can upload them
# to the GitHub release byte-for-byte as attested here.
- name: Archive Binary
if: runner.os == 'Windows'
shell: bash
run: |
mv target/${{ matrix.target }}/release/pnpm.exe pnpm.exe
7z a pnpm-${{ matrix.code-target }}.zip pnpm.exe
- name: Archive Binary
if: runner.os != 'Windows'
# The binary is staged in a scratch directory because the repo root
# already has a `pnpm/` directory (the Rust sub-project) — renaming the
# binary to `pnpm` in place would move it *into* that directory and tar
# up the whole source tree instead.
run: |
mkdir stage
mv target/${{ matrix.target }}/release/pnpm stage/pnpm
tar czf pnpm-${{ matrix.code-target }}.tar.gz -C stage pnpm
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
pnpm-${{ matrix.code-target }}.*
pnpm-napi.${{ matrix.code-target }}.node
- name: Upload Binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
if-no-files-found: error
name: binaries-rust-${{ matrix.code-target }}
path: |
*.zip
*.tar.gz
*.node
verify-rust-artifacts:
name: Verify pnpm (Rust) npm artifacts
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- plan
- build-rust
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm and Node
uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4
with:
runtime: node@22
install: false
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-rust-*
merge-multiple: true
- name: Extract archives
# Every archive holds a binary named `pnpm` / `pnpm.exe` at its root
# (see the Archive Binary steps), so extracting them all into the same
# directory would clobber one target with another. Extract each into a
# scratch dir and move the binary out under its target-qualified name,
# which is what generate-packages.mjs reads.
run: |
for archive in pnpm-*.zip; do
rm -rf extract && mkdir extract
unzip -q "$archive" -d extract
mv extract/pnpm.exe "${archive%.zip}.exe"
done
for archive in pnpm-*.tar.gz; do
rm -rf extract && mkdir extract
tar -xzf "$archive" -C extract
mv extract/pnpm "${archive%.tar.gz}"
done
- name: Generate npm packages
# Rewrites the committed private `pacquet` wrapper into the publishable
# `pnpm` manifest and generates the per-platform packages.
run: |
node pnpm/npm/pnpm/scripts/generate-packages.mjs
node pnpm/npm/napi/scripts/generate-packages.mjs
cat pnpm/npm/pnpm/package.json
cat pnpm/npm/napi/package.json
for package in pnpm/npm/pacquet-* pnpm/npm/napi.*; do cat "$package/package.json" ; echo ; done
- name: Verify generated npm packages
# `pnpm publish` packages its input immediately before upload. Verify
# those tarballs before the first immutable npm publish, including the
# wrapper's preinstall path on this Linux runner.
run: |
set -euo pipefail
release_tarballs=$(mktemp -d)
for package in pnpm/npm/pacquet-* pnpm/npm/napi.* pnpm/npm/napi pnpm/npm/pnpm-exe pnpm/npm/pnpm; do
package_tarballs="$release_tarballs/${package//\//_}"
mkdir "$package_tarballs"
(
cd "$package"
pnpm pack --pack-destination "$package_tarballs"
)
tarball=$(find "$package_tarballs" -type f -name '*.tgz' -print -quit)
test -n "$tarball"
node -e '
const fs = require("node:fs")
const path = require("node:path")
const manifestPath = path.resolve(process.argv[1])
const manifest = require(manifestPath)
const packageDir = path.dirname(manifestPath)
const excluded = (manifest.files ?? []).filter((file) => file.startsWith("!")).map((file) => globToRegExp(file.slice(1)))
const files = new Set()
for (const file of manifest.files ?? []) {
if (!file.startsWith("!") && !/[?*[]/.test(file)) add(file)
}
for (const file of manifest.publishConfig?.executableFiles ?? []) add(file)
for (const file of Object.values(typeof manifest.bin === "string" ? { default: manifest.bin } : manifest.bin ?? {})) add(file)
if (files.size === 0) throw new Error("No literal payload files declared by " + manifest.name)
console.log([...files].join("\n"))
function add(file) {
const relative = file.replace(/^\.\//, "")
if (excluded.some((pattern) => pattern.test(relative))) return
const source = path.join(packageDir, relative)
const stat = fs.statSync(source, { throwIfNoEntry: false })
if (stat?.isFile()) {
files.add(relative)
return
}
if (!stat?.isDirectory()) throw new Error("Missing payload file " + source)
for (const entry of fs.readdirSync(source, { withFileTypes: true })) add(path.join(relative, entry.name))
}
function globToRegExp(pattern) {
return new RegExp("^" + pattern.replace(/[.+^{}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\x00").replace(/\*\*/g, "\x01").replace(/\*/g, "[^/]*").replace(/\x00/g, "(?:.*/)?").replace(/\x01/g, ".*") + String.fromCharCode(36))
}
' "$package/package.json" | while IFS= read -r file; do
test -s "$package/$file"
test "$(tar -xOf "$tarball" "package/$file" | sha256sum | cut -d ' ' -f 1)" = "$(sha256sum "$package/$file" | cut -d ' ' -f 1)"
done
done
smoke_dir=$(mktemp -d)
native_tarball=$(find "$release_tarballs/pnpm_npm_pacquet-linux-x64" -type f -name '*.tgz' -print -quit)
wrapper_tarball=$(find "$release_tarballs/pnpm_npm_pnpm" -type f -name '*.tgz' -print -quit)
test -n "$native_tarball"
test -n "$wrapper_tarball"
mkdir -p "$smoke_dir/node_modules/@pnpm/exe.linux-x64" "$smoke_dir/node_modules/pnpm"
tar -xzf "$native_tarball" \
-C "$smoke_dir/node_modules/@pnpm/exe.linux-x64" --strip-components=1
tar -xzf "$wrapper_tarball" \
-C "$smoke_dir/node_modules/pnpm" --strip-components=1
node "$smoke_dir/node_modules/pnpm/install.js"
"$smoke_dir/node_modules/pnpm/pnpm" --version
publish-rust:
name: Publish pnpm (Rust) and @pnpm/napi to npm
runs-on: ubuntu-latest
environment: release
permissions:
# Required for npm trusted publishing and provenance attestations via OIDC.
id-token: write
needs:
- plan
- build-rust
- verify-rust-artifacts
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm and Node
uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4
with:
runtime: node@22
install: false
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-rust-*
merge-multiple: true
- name: Extract archives
run: |
for archive in pnpm-*.zip; do
rm -rf extract && mkdir extract
unzip -q "$archive" -d extract
mv extract/pnpm.exe "${archive%.zip}.exe"
done
for archive in pnpm-*.tar.gz; do
rm -rf extract && mkdir extract
tar -xzf "$archive" -C extract
mv extract/pnpm "${archive%.tar.gz}"
done
- name: Generate npm packages
run: |
node pnpm/npm/pnpm/scripts/generate-packages.mjs
node pnpm/npm/napi/scripts/generate-packages.mjs
# Everything publishes via trusted publishing: the `pnpm` / `@pnpm/exe`
# trusted publishers are bound to this workflow file (they are shared
# with the TypeScript release job above), and the natives' and
# `@pnpm/napi`'s publishers are bound here too. Publishing the natives
# first keeps the wrappers' exact-version optionalDependencies
# installable the moment each wrapper goes live, and the `pnpm` wrapper
# goes last because the plan job uses it as the "release completed"
# gate. The trailing slash publishes the directory; the build dirs
# `pacquet-*` are published as `@pnpm/exe.<target>`.
- name: Publish (trusted publishing)
env:
TAG: ${{ needs.plan.outputs.rust_tag }}
run: |
for package in pnpm/npm/pacquet-* pnpm/npm/napi.* pnpm/npm/napi pnpm/npm/pnpm-exe pnpm/npm/pnpm; do
pnpm publish "$package/" --tag "$TAG" --access public --provenance --no-git-checks
done
github-release-rust:
name: Draft GitHub release for pnpm (Rust)
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write # create the draft release and its tag
needs:
- plan
- build-rust
- publish-rust
steps:
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-rust-*
merge-multiple: true
- name: Release
# The build job's archives already carry the release layout (a `pnpm`
# binary at the root of `pnpm-<target>.tar.gz` / `.zip`), so they're
# uploaded byte-for-byte as attested there — get.pnpm.io's install
# scripts fetch them by these exact names. Draft so a maintainer
# publishes it deliberately. `make_latest: false` keeps a published v12
# prerelease from stealing the "Latest" badge from the stable v11
# line.
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.0 # zizmor: ignore[superfluous-actions]
with:
draft: true
prerelease: ${{ contains(needs.plan.outputs.rust_version, '-') }}
make_latest: false
tag_name: v${{ needs.plan.outputs.rust_version }}
target_commitish: ${{ github.sha }}
name: pnpm ${{ needs.plan.outputs.rust_version }}
files: |
pnpm-*.tar.gz
pnpm-*.zip
build-pnpr:
needs: plan
if: needs.plan.outputs.pnpr == 'true' && !inputs.verify_ts_release
permissions:
contents: read
id-token: write # needed for actions/attest-build-provenance
attestations: write
strategy:
matrix:
include:
- os: blacksmith-8vcpu-windows-2025
target: x86_64-pc-windows-msvc
code-target: win32-x64
- os: blacksmith-8vcpu-windows-2025
target: aarch64-pc-windows-msvc
code-target: win32-arm64
- os: blacksmith-8vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
code-target: linux-x64
- os: blacksmith-8vcpu-ubuntu-2404
target: aarch64-unknown-linux-gnu
code-target: linux-arm64
- os: blacksmith-8vcpu-ubuntu-2404
target: x86_64-unknown-linux-musl
code-target: linux-x64-musl
- os: blacksmith-8vcpu-ubuntu-2404
target: aarch64-unknown-linux-musl
code-target: linux-arm64-musl
- os: blacksmith-12vcpu-macos-latest
target: x86_64-apple-darwin
code-target: darwin-x64
- os: blacksmith-12vcpu-macos-latest
target: aarch64-apple-darwin
code-target: darwin-arm64
name: Package pnpr ${{ matrix.code-target }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install cross
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5
with:
tool: cross
# No build cache: GitHub Actions caches are writable by any workflow on
# the default branch, so restoring one here would let a poisoned cache
# entry inject code into the published, attested release binaries.
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Verify the committed version
# `pnpr --version` reads CARGO_PKG_VERSION via clap's derive `version`
# attribute; `pnpm bump` keeps the crate version in sync with the npm
# wrapper's version. Verify instead of patching so the binary is
# reproducible from the tagged sources.
shell: bash
env:
VERSION: ${{ needs.plan.outputs.pnpr_version }}
run: grep -E "^version\s*=\s*\"$VERSION\"" pnpr/crates/pnpr/Cargo.toml
- name: Build with cross
run: cross build --locked -p pnpr --bin pnpr --release --target=${{ matrix.target }}
# The binary is zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
# Rename the binary to include the target triple so the archive
# that generate-packages.mjs picks up is already named
# `pnpr-<target>`.
- name: Archive Binary
if: runner.os == 'Windows'
shell: bash
run: |
BIN_NAME=pnpr-${{ matrix.code-target }}
mv target/${{ matrix.target }}/release/pnpr.exe $BIN_NAME.exe
7z a $BIN_NAME.zip $BIN_NAME.exe
# The binary is zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
- name: Archive Binary
if: runner.os != 'Windows'
run: |
BIN_NAME=pnpr-${{ matrix.code-target }}
mv target/${{ matrix.target }}/release/pnpr $BIN_NAME
tar czf $BIN_NAME.tar.gz $BIN_NAME
# Pin the binary's checksum at build time so the Docker job can
# verify the artifact it stages into the image context.
shasum -a 256 $BIN_NAME > $BIN_NAME.sha256
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
pnpr-${{ matrix.code-target }}*
- name: Upload Binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
if-no-files-found: error
name: binaries-pnpr-${{ matrix.code-target }}
path: |
*.zip
*.tar.gz
*.sha256
verify-pnpr-artifacts:
name: Verify pnpr npm artifacts
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- plan
- build-pnpr
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm and Node
uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4
with:
runtime: node@22
install: false
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-pnpr-*
merge-multiple: true
- name: Unzip
uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28 # v1.0.0
with:
args: unzip -qq *.zip -d .
- name: Extract tarballs
run: |
for archive in ./*.gz; do
tar xf "$archive"
done
- name: Generate npm packages
run: |
node pnpr/npm/pnpr/scripts/generate-packages.mjs
cat pnpr/npm/pnpr/package.json
for package in pnpr/npm/pnpr*; do cat "$package/package.json" ; echo ; done
- name: Verify generated npm packages
# `pnpm publish` packages its input immediately before upload. Verify
# those tarballs, then exercise the wrapper's native-binary install
# path before the first immutable npm publish.
run: |
set -euo pipefail
release_tarballs=$(mktemp -d)
for package in pnpr/npm/pnpr-* pnpr/npm/pnpr; do
package_tarballs="$release_tarballs/${package//\//_}"
mkdir "$package_tarballs"
(
cd "$package"
pnpm pack --pack-destination "$package_tarballs"
)
tarball=$(find "$package_tarballs" -type f -name '*.tgz' -print -quit)
test -n "$tarball"
node -e '
const fs = require("node:fs")
const path = require("node:path")
const manifestPath = path.resolve(process.argv[1])
const manifest = require(manifestPath)
const packageDir = path.dirname(manifestPath)
const excluded = (manifest.files ?? []).filter((file) => file.startsWith("!")).map((file) => globToRegExp(file.slice(1)))
const files = new Set()
for (const file of manifest.files ?? []) {
if (!file.startsWith("!") && !/[?*[]/.test(file)) add(file)
}
for (const file of manifest.publishConfig?.executableFiles ?? []) add(file)
for (const file of Object.values(typeof manifest.bin === "string" ? { default: manifest.bin } : manifest.bin ?? {})) add(file)
if (files.size === 0) throw new Error("No literal payload files declared by " + manifest.name)
console.log([...files].join("\n"))
function add(file) {
const relative = file.replace(/^\.\//, "")
if (excluded.some((pattern) => pattern.test(relative))) return
const source = path.join(packageDir, relative)
const stat = fs.statSync(source, { throwIfNoEntry: false })
if (stat?.isFile()) {
files.add(relative)
return
}
if (!stat?.isDirectory()) throw new Error("Missing payload file " + source)
for (const entry of fs.readdirSync(source, { withFileTypes: true })) add(path.join(relative, entry.name))
}
function globToRegExp(pattern) {
return new RegExp("^" + pattern.replace(/[.+^{}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\x00").replace(/\*\*/g, "\x01").replace(/\*/g, "[^/]*").replace(/\x00/g, "(?:.*/)?").replace(/\x01/g, ".*") + String.fromCharCode(36))
}
' "$package/package.json" | while IFS= read -r file; do
test -s "$package/$file"
test "$(tar -xOf "$tarball" "package/$file" | sha256sum | cut -d ' ' -f 1)" = "$(sha256sum "$package/$file" | cut -d ' ' -f 1)"
done
done
smoke_dir=$(mktemp -d)
native_tarball=$(find "$release_tarballs/pnpr_npm_pnpr-linux-x64" -type f -name '*.tgz' -print -quit)
wrapper_tarball=$(find "$release_tarballs/pnpr_npm_pnpr" -type f -name '*.tgz' -print -quit)
test -n "$native_tarball"
test -n "$wrapper_tarball"
mkdir -p "$smoke_dir/node_modules/@pnpm/pnpr.linux-x64" "$smoke_dir/node_modules/@pnpm/pnpr"
tar -xzf "$native_tarball" -C "$smoke_dir/node_modules/@pnpm/pnpr.linux-x64" --strip-components=1
tar -xzf "$wrapper_tarball" -C "$smoke_dir/node_modules/@pnpm/pnpr" --strip-components=1
node "$smoke_dir/node_modules/@pnpm/pnpr/install.js"
"$smoke_dir/node_modules/@pnpm/pnpr/bin/pnpr" --version
publish-pnpr:
name: Publish @pnpm/pnpr to npm
runs-on: ubuntu-latest
environment: release
permissions:
# Required for npm trusted publishing and provenance attestations via OIDC.
id-token: write
needs:
- plan
- build-pnpr
- verify-pnpr-artifacts
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm and Node
uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4
with:
runtime: node@22
install: false
- name: Download Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-pnpr-*
merge-multiple: true
- name: Unzip
uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28 # v1.0.0
with:
args: unzip -qq *.zip -d .
- name: Extract tarballs
run: |
for archive in ./*.gz; do
tar xf "$archive"
done
- name: Generate npm packages
run: node pnpr/npm/pnpr/scripts/generate-packages.mjs
- name: Publish npm packages
# Auth is via npm's trusted publishing: `id-token: write` above grants
# this job an OIDC token that pnpm/npm exchange with the registry,
# so no NPM_TOKEN is needed. `--provenance` attaches the same OIDC
# token to a provenance attestation on each tarball.
# The natives go first so the wrapper's exact-version
# optionalDependencies are installable the moment it goes live; the
# wrapper goes last because the plan job uses it as the "release
# completed" gate. The trailing slash publishes the directory.
env:
TAG: ${{ needs.plan.outputs.pnpr_tag }}
run: |
for package in pnpr/npm/pnpr-* pnpr/npm/pnpr; do
pnpm publish "$package/" --tag "$TAG" --access public --provenance --no-git-checks
done
docker-pnpr:
name: Publish the pnpr Docker image
runs-on: blacksmith-8vcpu-ubuntu-2404
environment: release
needs:
- plan
- build-pnpr
- publish-pnpr
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/pnpr
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Compute image tags
id: tags
# A version containing a hyphen (e.g. 0.2.3-rc.1) is a prerelease and
# must not move the mutable `latest` tag.
env:
VERSION: ${{ needs.plan.outputs.pnpr_version }}
run: |
set -eu
tags="${IMAGE}:${VERSION}"
case "$VERSION" in
*-*) ;;
*) tags="${tags},${IMAGE}:latest" ;;
esac
echo "tags=$tags" >> "$GITHUB_OUTPUT"
- name: Download Linux musl binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-pnpr-linux-*-musl
merge-multiple: true
- name: Stage binaries for the build context
id: stage
# The static musl binaries match what was just published to npm. The
# Dockerfile selects one via the build's TARGETARCH (amd64 / arm64) and
# re-verifies it against the build-time checksum surfaced here.
#
# Extract into a throwaway directory and move only the expected,
# checksum-verified regular files into the build context, so a malformed
# archive cannot escape it (path traversal, symlinks) and overwrite the
# Dockerfile or the staged binaries before they are pushed to GHCR.
run: |
set -eu
rm -rf extracted
mkdir extracted
for f in pnpr-linux-*-musl.tar.gz; do
tar -xzf "$f" -C extracted --no-same-owner
done
( cd extracted && sha256sum -c ../pnpr-linux-x64-musl.sha256 ../pnpr-linux-arm64-musl.sha256 )
for pair in x64:amd64 arm64:arm64; do
bin="extracted/pnpr-linux-${pair%:*}-musl"
test -f "$bin" && test ! -L "$bin"
mv "$bin" "pnpr/docker/pnpr-${pair#*:}"
done
{
echo "sha_amd64=$(awk '{print $1}' pnpr-linux-x64-musl.sha256)"
echo "sha_arm64=$(awk '{print $1}' pnpr-linux-arm64-musl.sha256)"
} >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: ./pnpr/docker
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
build-args: |
PNPR_VERSION=${{ needs.plan.outputs.pnpr_version }}
PNPR_SHA256_AMD64=${{ steps.stage.outputs.sha_amd64 }}
PNPR_SHA256_ARM64=${{ steps.stage.outputs.sha_arm64 }}
provenance: mode=max
sbom: true