Commit the pnpm-managed Cargo source block. The block is a function of Cargo.lock, so an install regenerates it byte for byte and the tracked `.cargo/config.toml` stops reporting as modified after every install. A new Rust CI step fails if the committed block and the lockfile drift apart. Cargo now resolves every crate through `.pnpm/crates` and falls back to its own registry nowhere, so each job that runs cargo needs an install behind it. Eight did not have one: both cargo-unused jobs, the micro-benchmark, the integrated benchmark's build and executor jobs, and the three release builds. The new `install-crates` action gives them one and narrows the JavaScript half with `--filter pacquet`, which a filter does not do to the Cargo half. The release builds go through `cross`, which mounts the checkout at `/project`. pnpm links each crate into `.pnpm/crates` with a relative symlink, so a store outside the checkout stops resolving under that mount. Those jobs install into a store inside the checkout instead, on Linux, the one host where cross containerizes the build at all. The two workflows that commit no longer discard the block. It is tracked content now, and a run that changes it should carry the change. Claude-Session: https://claude.ai/code/session_01Gg6uVUzLw1MniCLC81TQjP Claude-Session: https://claude.ai/code/session_01HwJS1pAz9HHQpJWEJAiaUu Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1670 lines
71 KiB
YAML
1670 lines
71 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 releases exactly the product whose committed version the
|
|
# tag names, so tags pushed together release their products in parallel runs.
|
|
# The plan job also asks the npm registry whether that version is already
|
|
# published: rerunning a tag resumes a partial release, and a completed one
|
|
# skips every job.
|
|
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 is tagged per product by hand, with
|
|
# a signed annotated tag pushed from a maintainer's machine (see RELEASING.md),
|
|
# so several version tags can land together and start a run apiece. Each run
|
|
# releases only the product its tag names (see the plan job), so simultaneous
|
|
# runs publish disjoint packages and may proceed in parallel. The group is
|
|
# scoped per ref because GitHub keeps at most one *pending* run per group and
|
|
# cancels the rest — a shared group would silently drop one product's release
|
|
# whenever several tags land together. Reruns of the same tag still serialize.
|
|
# Never cancel a run mid-publish.
|
|
concurrency:
|
|
group: release-${{ github.ref }}
|
|
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
|
|
|
|
# The release trust root: a tag reaches a publish job only if it carries a
|
|
# signature from a key committed under .github/release-keys. Every publish path
|
|
# descends from `plan`, and `plan` descends from this job.
|
|
#
|
|
# This guards against an unsigned tag, not against an actor who can push an
|
|
# arbitrary one. A tag push runs the workflow file and checks out the tree from
|
|
# the pushed tag, so a tag supplies both this job and the keys it trusts.
|
|
# Restricting who may create `v*` and `pnpr@*` tags is a repository ruleset
|
|
# concern that no workflow can enforce for itself.
|
|
verify-release-tag:
|
|
name: Verify the release tag is maintainer-signed
|
|
needs: validate-release-ref
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
# A tag push checks out the commit; the tag *object* carrying the
|
|
# signature is a separate object that only comes down with the tag
|
|
# refs, so verification would have nothing to read without this.
|
|
fetch-tags: true
|
|
persist-credentials: false
|
|
|
|
- name: Verify the tag signature
|
|
env:
|
|
TAG: ${{ github.ref_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# The verify_ts_release dispatch runs from a branch to inspect
|
|
# artifacts and never publishes, so there is no tag to verify. This is
|
|
# a step-level exit rather than a job-level `if` on purpose: a skipped
|
|
# job skips everything that `needs` it, which would take `plan` and
|
|
# the whole dispatch path down with it.
|
|
case "${GITHUB_REF}" in
|
|
refs/tags/*) ;;
|
|
*)
|
|
echo "$GITHUB_REF is not a tag; nothing to verify and nothing will publish."
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
# An isolated keyring, so verification can only ever succeed against a
|
|
# key committed to this repository — never one that happens to be on
|
|
# the runner.
|
|
GNUPGHOME="$(mktemp -d)"
|
|
export GNUPGHOME
|
|
chmod 700 "$GNUPGHOME"
|
|
trap 'rm -rf "$GNUPGHOME"' EXIT
|
|
|
|
shopt -s nullglob
|
|
keys=(.github/release-keys/*.asc)
|
|
if [ ${#keys[@]} -eq 0 ]; then
|
|
echo "::error::No release keys committed under .github/release-keys"
|
|
exit 1
|
|
fi
|
|
gpg --batch --quiet --import "${keys[@]}"
|
|
|
|
# No separate check that the tag is annotated: verify-tag already fails
|
|
# on a lightweight tag, having no object to read a signature from.
|
|
if ! git verify-tag "$TAG"; then
|
|
echo "::error::$TAG is not signed by a key in .github/release-keys. Release tags must be signed by a maintainer — see RELEASING.md."
|
|
exit 1
|
|
fi
|
|
|
|
echo "$TAG carries a valid maintainer signature."
|
|
|
|
plan:
|
|
name: Plan (which products need publishing)
|
|
needs:
|
|
- validate-release-ref
|
|
- verify-release-tag
|
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Read committed versions and query the registry
|
|
id: decide
|
|
# The tag names the product to release: the one whose committed
|
|
# version it matches. A tag matching no product's committed version
|
|
# is an error, never a fallback release. The matched product is then
|
|
# released only if its 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 state
|
|
state=$(.github/scripts/npm-package-publication-state.sh "$1")
|
|
case "$state" in
|
|
published) echo false ;;
|
|
missing) echo true ;;
|
|
*) echo "::error::Unexpected npm publication state"; exit 1 ;;
|
|
esac
|
|
}
|
|
|
|
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
|
|
|
|
# `needs_release` results land in plain assignments so `set -e`
|
|
# aborts the plan on a 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=false
|
|
RUST_NEEDS=false
|
|
PNPR_NEEDS=false
|
|
case "$GITHUB_REF" in
|
|
refs/tags/pnpr@*)
|
|
TAG_VERSION="${GITHUB_REF#refs/tags/pnpr@}"
|
|
if [ "$TAG_VERSION" != "$PNPR_VERSION" ]; then
|
|
echo "::error::Tag pnpr@${TAG_VERSION} matches no product: the committed @pnpm/pnpr version is ${PNPR_VERSION}"
|
|
exit 1
|
|
fi
|
|
PNPR_NEEDS=$(needs_release "@pnpm/pnpr@$PNPR_VERSION")
|
|
;;
|
|
refs/tags/v*)
|
|
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
|
if [ "$TAG_VERSION" = "$TS_VERSION" ]; then
|
|
TS_NEEDS=$(needs_release "pnpm@$TS_VERSION")
|
|
elif [ "$TAG_VERSION" = "$RUST_VERSION" ]; then
|
|
RUST_NEEDS=$(needs_release "pnpm@$RUST_VERSION")
|
|
else
|
|
echo "::error::Tag v${TAG_VERSION} matches no product: the committed TypeScript pnpm version is ${TS_VERSION} and the committed Rust pnpm version is ${RUST_VERSION}"
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
# Only reachable via the verify_ts_release dispatch —
|
|
# validate-release-ref rejects every other non-tag ref. Nothing
|
|
# is released.
|
|
;;
|
|
esac
|
|
|
|
{
|
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
- uses: garnet-org/action@3d47f4a9004f7356c980a0e8d420ef5984750e3c # v2.2.0
|
|
with:
|
|
api_token: ${{ secrets.GARNET_API_TOKEN }}
|
|
- name: Initialize Rust before dependency installation
|
|
uses: $/.github/actions/rustup
|
|
with:
|
|
restore-cache: false
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
with:
|
|
runtime: node@26.8.2
|
|
- 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.
|
|
# zizmor: ignore[adhoc-packages] -- This smoke test intentionally
|
|
# installs locally built release tarballs before publication.
|
|
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 -f "$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"
|
|
|
|
# dist/node_modules carries every runtime dependency, so the published
|
|
# manifest must declare no dependency field of any kind: a surviving
|
|
# `dependencies` or `optionalDependencies` is resolved and installed a
|
|
# second time, and `devDependencies` names workspace packages that are
|
|
# never published at all. .meta-updater/src/index.ts additionally
|
|
# forbids `optionalDependencies` and `peerDependencies` on this
|
|
# package outright.
|
|
#
|
|
# Assert on the packed tarball rather than on any of the strippers,
|
|
# because an npm publish cannot be taken back and only two of these
|
|
# fields are stripped at pack time: the .pnpmfile.cjs beforePacking
|
|
# hook deliberately leaves `optionalDependencies` alone (the v12 Rust
|
|
# wrapper needs them for its natives), which leaves meta-updater — a
|
|
# source normalizer, not a release gate — as the only thing between
|
|
# that field and the registry.
|
|
tar -xOf "$pnpm_tarball" package/package.json | node -e '
|
|
const manifest = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
|
|
const fields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
|
|
const declared = fields.filter((field) => manifest[field] != null)
|
|
if (declared.length > 0) {
|
|
throw new Error("The published pnpm manifest must not declare " + declared.join(" or ") + " - every runtime dependency is bundled into dist/node_modules")
|
|
}
|
|
'
|
|
|
|
smoke_dir=$(mktemp -d)
|
|
tar -xzf "$pnpm_tarball" -C "$smoke_dir"
|
|
command -v node
|
|
node --version
|
|
set +e
|
|
node "$smoke_dir/package/bin/pnpm.mjs" --version >"$smoke_dir/pnpm.stdout" 2>"$smoke_dir/pnpm.stderr"
|
|
pnpm_exit=$?
|
|
set -e
|
|
cat "$smoke_dir/pnpm.stdout"
|
|
cat "$smoke_dir/pnpm.stderr" >&2
|
|
test "$pnpm_exit" -eq 0
|
|
|
|
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 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
- name: Assert the version tag points at this commit
|
|
# The plan job dispatches on the tag's version string, which cannot
|
|
# prove the tag points at this commit — a retagged release, or another
|
|
# commit carrying the same committed version, would still dispatch.
|
|
# The artifacts built below and the GitHub release that lands on
|
|
# v<ts_version> must both come from the commit that tag names.
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
VERSION: ${{ needs.plan.outputs.ts_version }}
|
|
run: |
|
|
tag_commit=$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${VERSION}" --jq .sha)
|
|
if [ "$tag_commit" != "$GITHUB_SHA" ]; then
|
|
echo "::error::Tag v${VERSION} points at ${tag_commit}, but this run would release commit ${GITHUB_SHA}. Rerun the release workflow from the v${VERSION} tag."
|
|
exit 1
|
|
fi
|
|
- uses: garnet-org/action@3d47f4a9004f7356c980a0e8d420ef5984750e3c # v2.2.0
|
|
with:
|
|
api_token: ${{ secrets.GARNET_API_TOKEN }}
|
|
- name: Initialize Rust before dependency installation
|
|
uses: $/.github/actions/rustup
|
|
with:
|
|
restore-cache: false
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
with:
|
|
runtime: node@26.8.2
|
|
# Internal workspace packages still publish with the static token. `@pnpm/exe`
|
|
# and `pnpm` use stage-only OIDC. CI stages both packages but cannot approve
|
|
# or publish them; a maintainer does that later with interactive 2FA. Keeping
|
|
# each stage operation in a token-free step also prevents an accidental
|
|
# fallback to token authentication.
|
|
- name: Build TypeScript executable artifacts
|
|
run: pn --filter=@pnpm/exe run build-artifacts
|
|
- 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: Stage @pnpm/exe (OIDC)
|
|
run: >-
|
|
.github/scripts/npm-staged-publication.sh stage
|
|
'TypeScript @pnpm/exe wrapper' next-11
|
|
pnpm11/pnpm/artifacts/exe
|
|
- name: Stage pnpm CLI (OIDC)
|
|
run: >-
|
|
.github/scripts/npm-staged-publication.sh stage
|
|
'TypeScript pnpm CLI' next-11
|
|
pnpm11/pnpm
|
|
- name: Copy Artifacts
|
|
run: pn copy-artifacts
|
|
- name: Attest build provenance
|
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
|
with:
|
|
subject-path: 'dist/*'
|
|
- name: Generate release description
|
|
env:
|
|
TS_VERSION: ${{ needs.plan.outputs.ts_version }}
|
|
run: |
|
|
rm -f RELEASE.md
|
|
if pn make-release-description && test -s RELEASE.md; then
|
|
exit 0
|
|
fi
|
|
echo "::warning::Release notes could not be generated; publishing the release with a diagnostic description"
|
|
cat > RELEASE.md <<EOF
|
|
## Release notes unavailable
|
|
|
|
pnpm ${TS_VERSION} was published successfully, but its release notes could not be generated from the pending changelog. The release artifacts are still available below.
|
|
|
|
See the [release workflow run](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) for the generation error.
|
|
EOF
|
|
- name: Release
|
|
id: github-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@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 # zizmor: ignore[superfluous-actions]
|
|
with:
|
|
# Draft so a maintainer publishes it deliberately, like the Rust and
|
|
# pnpr releases: the repository uses immutable releases, so a
|
|
# published release cannot be corrected, only deleted — and deleting
|
|
# it burns its tag for releases forever.
|
|
draft: true
|
|
# Explicit so the release's identity comes from the plan job like
|
|
# every other step here, never from the github.ref fallback.
|
|
tag_name: v${{ needs.plan.outputs.ts_version }}
|
|
name: pnpm ${{ needs.plan.outputs.ts_version }}
|
|
files: dist/*
|
|
body_path: RELEASE.md
|
|
- name: Verify draft release
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
RELEASE_ID: ${{ steps.github-release.outputs.id }}
|
|
run: |
|
|
test -n "$RELEASE_ID"
|
|
release_json=$(mktemp)
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" > "$release_json"
|
|
RELEASE_JSON="$release_json" node <<'NODE'
|
|
const fs = require('node:fs')
|
|
const normalizeNewlines = (text) => text?.replace(/\r\n?/g, '\n')
|
|
const actual = normalizeNewlines(JSON.parse(fs.readFileSync(process.env.RELEASE_JSON, 'utf8')).body)
|
|
const expected = normalizeNewlines(fs.readFileSync('RELEASE.md', 'utf8'))
|
|
if (actual !== expected) throw new Error('GitHub draft release body does not match RELEASE.md')
|
|
NODE
|
|
|
|
build-node-gyp-payload:
|
|
name: Bundle node-gyp
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
needs: plan
|
|
if: needs.plan.outputs.rust == 'true' && !inputs.verify_ts_release
|
|
permissions:
|
|
contents: read
|
|
# Every channel ships the same node-gyp, so it is resolved once here rather
|
|
# than per target: the tree is platform-independent, and one build is the
|
|
# only way the GitHub archives and the npm packages are guaranteed to carry
|
|
# identical bytes. Kept out of build-rust deliberately — that job produces
|
|
# the attested binaries and holds no npm dependency graph.
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Initialize Rust before dependency installation
|
|
uses: $/.github/actions/rustup
|
|
with:
|
|
restore-cache: false
|
|
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
with:
|
|
runtime: node@22
|
|
|
|
- name: Bundle node-gyp
|
|
run: pnpm --filter=pacquet run bundle-node-gyp
|
|
|
|
- name: Archive payload
|
|
# Archived rather than uploaded as a directory, for the same reason the
|
|
# binaries are: actions/upload-artifact drops the executable bit, and
|
|
# the node-gyp wrappers have to stay executable
|
|
# (https://github.com/actions/upload-artifact#permission-loss).
|
|
run: tar czf node-gyp-payload.tar.gz -C pnpm/npm/pnpm dist
|
|
|
|
- name: Upload payload
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
if-no-files-found: error
|
|
name: node-gyp-payload
|
|
path: node-gyp-payload.tar.gz
|
|
|
|
# The CLI binary and the NAPI addon are built by two matrices on separate
|
|
# runners. They are compiled under different Cargo profiles (`release` and
|
|
# `napi-release`, which differ for the reason documented on those profiles),
|
|
# so Cargo keeps them in separate target directories and they share no
|
|
# compiled dependency: sharing a runner would only make one leg walk the
|
|
# whole dependency graph twice in series, and the release waits on its
|
|
# slowest leg.
|
|
build-rust:
|
|
needs:
|
|
- plan
|
|
- build-node-gyp-payload
|
|
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-8vcpu-ubuntu-2404
|
|
target: riscv64gc-unknown-linux-gnu
|
|
code-target: linux-riscv64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: powerpc64le-unknown-linux-gnu
|
|
code-target: linux-ppc64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: s390x-unknown-linux-gnu
|
|
code-target: linux-s390x
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: x86_64-unknown-freebsd
|
|
code-target: freebsd-x64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: aarch64-linux-android
|
|
code-target: android-arm64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: x86_64-linux-android
|
|
code-target: android-x64
|
|
|
|
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Prepare the release target
|
|
uses: $/.github/actions/rust-release-target
|
|
with:
|
|
target: ${{ matrix.target }}
|
|
version: ${{ needs.plan.outputs.rust_version }}
|
|
|
|
- name: Install crates
|
|
uses: $/.github/actions/install-crates
|
|
with:
|
|
container-build: 'true'
|
|
|
|
- name: Build with cross
|
|
shell: bash
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
run: .github/scripts/cross-build.sh --locked -p pnpm-cli --bin pnpm --release --target="$TARGET"
|
|
|
|
- name: Download node-gyp payload
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: node-gyp-payload
|
|
# Kept out of the workspace root so it can't be swept up by the
|
|
# `*.tar.gz` glob the Upload Binary step publishes.
|
|
path: node-gyp-payload
|
|
|
|
# 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 beside a `dist/` holding node-gyp — the layout the
|
|
# v11 releases use, that get.pnpm.io's install scripts expect, and that
|
|
# the binary resolves node-gyp from at `<exe dir>/dist/node-gyp-bin` — 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
|
|
tar xzf node-gyp-payload/node-gyp-payload.tar.gz
|
|
7z a pnpm-${{ matrix.code-target }}.zip pnpm.exe dist
|
|
|
|
- name: Archive Binary
|
|
if: runner.os != 'Windows'
|
|
# Everything 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 xzf node-gyp-payload/node-gyp-payload.tar.gz -C stage
|
|
tar czf pnpm-${{ matrix.code-target }}.tar.gz -C stage pnpm dist
|
|
|
|
- name: Attest build provenance
|
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
|
with:
|
|
subject-path: pnpm-${{ matrix.code-target }}.*
|
|
|
|
- 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
|
|
|
|
build-rust-napi:
|
|
# Deliberately not waiting on build-node-gyp-payload: the addon ships no
|
|
# node-gyp, so these legs start as soon as the release is planned.
|
|
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:
|
|
# Every target build-rust above packages a CLI binary for also needs
|
|
# an addon — `pnpm/npm/napi/scripts/generate-packages.mjs` fails on a
|
|
# missing one — so keep the two matrices in sync.
|
|
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-8vcpu-ubuntu-2404
|
|
target: riscv64gc-unknown-linux-gnu
|
|
code-target: linux-riscv64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: powerpc64le-unknown-linux-gnu
|
|
code-target: linux-ppc64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: s390x-unknown-linux-gnu
|
|
code-target: linux-s390x
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: x86_64-unknown-freebsd
|
|
code-target: freebsd-x64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: aarch64-linux-android
|
|
code-target: android-arm64
|
|
|
|
- os: blacksmith-8vcpu-ubuntu-2404
|
|
target: x86_64-linux-android
|
|
code-target: android-x64
|
|
|
|
- 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/napi ${{ matrix.code-target }}
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Prepare the release target
|
|
uses: $/.github/actions/rust-release-target
|
|
with:
|
|
target: ${{ matrix.target }}
|
|
version: ${{ needs.plan.outputs.rust_version }}
|
|
|
|
- name: Install crates
|
|
uses: $/.github/actions/install-crates
|
|
with:
|
|
container-build: 'true'
|
|
|
|
- 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` — build-rust builds it untouched, 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
|
|
.github/scripts/cross-build.sh --locked -p pnpm-napi --profile napi-release --target="$TARGET"
|
|
|
|
- name: Prepare NAPI addon
|
|
shell: bash
|
|
run: |
|
|
case "${{ runner.os }}" in
|
|
Windows) source="target/${{ matrix.target }}/napi-release/pnpm_napi.dll" ;;
|
|
macOS) source="target/${{ matrix.target }}/napi-release/libpnpm_napi.dylib" ;;
|
|
*) source="target/${{ matrix.target }}/napi-release/libpnpm_napi.so" ;;
|
|
esac
|
|
cp "$source" "pnpm-napi.${{ matrix.code-target }}.node"
|
|
|
|
- name: Attest build provenance
|
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
|
with:
|
|
subject-path: pnpm-napi.${{ matrix.code-target }}.node
|
|
|
|
- name: Upload NAPI addon
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
if-no-files-found: error
|
|
# Downloaded by the same `binaries-rust-*` pattern the CLI archives
|
|
# are, so both land in one directory for the jobs downstream.
|
|
name: binaries-rust-napi-${{ matrix.code-target }}
|
|
# Named exactly, not globbed: the artifact must carry the one file
|
|
# the step above attested and nothing else that happens to be in the
|
|
# workspace root.
|
|
path: pnpm-napi.${{ matrix.code-target }}.node
|
|
|
|
verify-rust-artifacts:
|
|
name: Verify pnpm (Rust) npm artifacts
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
needs:
|
|
- plan
|
|
- build-rust
|
|
- build-rust-napi
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
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: Restore node-gyp payload
|
|
# The dist/ payload both wrappers ship, so install scripts that shell
|
|
# out to node-gyp build out of the box. Taken from the artifact rather
|
|
# than rebuilt, so the npm packages and the GitHub release archives
|
|
# carry the same bytes. Must land before the packages are generated:
|
|
# the `@pnpm/exe` wrapper copies dist/ too.
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: node-gyp-payload
|
|
path: node-gyp-payload
|
|
|
|
- name: Unpack node-gyp payload
|
|
run: tar xzf node-gyp-payload/node-gyp-payload.tar.gz -C pnpm/npm/pnpm
|
|
|
|
- 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 -f "$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"
|
|
wrapper_bin=$(node -p 'require(process.argv[1]).bin.pnpm' "$smoke_dir/node_modules/pnpm/package.json")
|
|
"$smoke_dir/node_modules/pnpm/$wrapper_bin" --version
|
|
# Corepack's entry point into the same tarball; it never runs
|
|
# install.js, so its only job here is to prove it was published and
|
|
# can reach the binary.
|
|
node "$smoke_dir/node_modules/pnpm/bin/pnpm.mjs" --version
|
|
|
|
publish-rust:
|
|
name: Publish pnpm (Rust) and @pnpm/napi to npm
|
|
runs-on: ubuntu-latest
|
|
environment: release
|
|
permissions:
|
|
# Required by pnpm/setup to resolve the pnpm 11 GitHub release asset.
|
|
contents: read
|
|
# Required for npm trusted publishing and provenance attestations via OIDC.
|
|
id-token: write
|
|
needs:
|
|
- plan
|
|
- build-rust
|
|
- build-rust-napi
|
|
- verify-rust-artifacts
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
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: Restore node-gyp payload
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: node-gyp-payload
|
|
path: node-gyp-payload
|
|
|
|
- name: Unpack node-gyp payload
|
|
run: tar xzf node-gyp-payload/node-gyp-payload.tar.gz -C pnpm/npm/pnpm
|
|
|
|
- name: Generate npm packages
|
|
run: |
|
|
node pnpm/npm/pnpm/scripts/generate-packages.mjs
|
|
node pnpm/npm/napi/scripts/generate-packages.mjs
|
|
|
|
# Every generated package uses stage-only OIDC. CI stages the native
|
|
# packages, `@pnpm/napi` / `@pnpm/exe` wrappers, and `pnpm`, then finishes
|
|
# without publishing any of them. A maintainer approves those layers later
|
|
# with interactive 2FA in the same dependency order. The `pnpm` wrapper is
|
|
# approved last because the plan job uses it as the "release completed"
|
|
# gate. The build dirs `pacquet-*` publish as `@pnpm/exe.<target>`.
|
|
- name: Stage Rust native packages (OIDC)
|
|
env:
|
|
TAG: ${{ needs.plan.outputs.rust_tag }}
|
|
run: >-
|
|
.github/scripts/npm-staged-publication.sh stage
|
|
'Rust native packages' "$TAG"
|
|
pnpm/npm/pacquet-* pnpm/npm/napi.*
|
|
- name: Stage Rust wrapper packages (OIDC)
|
|
env:
|
|
TAG: ${{ needs.plan.outputs.rust_tag }}
|
|
run: >-
|
|
.github/scripts/npm-staged-publication.sh stage
|
|
'Rust wrapper packages' "$TAG"
|
|
pnpm/npm/napi pnpm/npm/pnpm-exe
|
|
- name: Stage Rust pnpm package (OIDC)
|
|
env:
|
|
TAG: ${{ needs.plan.outputs.rust_tag }}
|
|
run: >-
|
|
.github/scripts/npm-staged-publication.sh stage
|
|
'Rust pnpm package' "$TAG"
|
|
pnpm/npm/pnpm
|
|
|
|
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
|
|
- build-rust-napi
|
|
- publish-rust
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Assert the version tag points at this commit
|
|
# Same tag↔commit invariant as the TypeScript release job's guard: the
|
|
# binaries below were built from this run's commit, and this assert is
|
|
# the only thing pinning the release to it. A `target_commitish` input
|
|
# cannot: GitHub ignores it whenever the tag already exists, and with
|
|
# immutable releases enabled its mere presence makes release creation
|
|
# fail with 403 "Resource not accessible by integration" for the
|
|
# workflow token (the v12.0.0-alpha.19 draft-release failure).
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
VERSION: ${{ needs.plan.outputs.rust_version }}
|
|
run: |
|
|
tag_commit=$(gh api "repos/${GITHUB_REPOSITORY}/commits/v${VERSION}" --jq .sha)
|
|
if [ "$tag_commit" != "$GITHUB_SHA" ]; then
|
|
echo "::error::Tag v${VERSION} points at ${tag_commit}, but this run built commit ${GITHUB_SHA}. Rerun the release workflow from the v${VERSION} tag."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Download Artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: binaries-rust-*
|
|
merge-multiple: true
|
|
|
|
- name: Generate release description
|
|
# The pending changelog written by `pnpm version -r` holds exactly this
|
|
# version's entry; its first line is a `## <version>` heading, redundant
|
|
# under the release title. A missing changelog only warns: the draft is
|
|
# still worth having for its artifacts, and a maintainer can fill in
|
|
# the description before publishing.
|
|
#
|
|
# The sponsors table is appended from a checked-in fragment, the same
|
|
# one the v11 release text carries inline. It is regenerated from
|
|
# pnpm.io's sponsors.json, so a missing file means someone moved it,
|
|
# not that there are no sponsors — warn rather than fail, since the
|
|
# sponsors table is not worth losing a release over.
|
|
env:
|
|
VERSION: ${{ needs.plan.outputs.rust_version }}
|
|
run: |
|
|
changelog=".changeset/changelogs/pacquet@${VERSION}.md"
|
|
if [ -f "$changelog" ]; then
|
|
tail -n +2 "$changelog" | sed '/./,$!d' > RELEASE.md
|
|
else
|
|
echo "::warning::No pending changelog at ${changelog}; drafting the release with an empty description"
|
|
: > RELEASE.md
|
|
fi
|
|
sponsors=".github/release-sponsors.md"
|
|
if [ -f "$sponsors" ]; then
|
|
printf '\n' >> RELEASE.md
|
|
cat "$sponsors" >> RELEASE.md
|
|
else
|
|
echo "::warning::No sponsors fragment at ${sponsors}; drafting the release without the sponsors table"
|
|
fi
|
|
|
|
- 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@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 # 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 }}
|
|
name: pnpm ${{ needs.plan.outputs.rust_version }}
|
|
body_path: RELEASE.md
|
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Install cross
|
|
uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2.87.10
|
|
with:
|
|
tool: cross
|
|
|
|
- name: Install clang-cl for Windows ARM64
|
|
if: matrix.target == 'aarch64-pc-windows-msvc'
|
|
shell: pwsh
|
|
run: |
|
|
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
|
$installPath = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath
|
|
& "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "$installPath" --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.VC.Tools.ARM64 --quiet --norestart
|
|
if ($LASTEXITCODE -ne 0) { throw "Visual Studio installer exited with $LASTEXITCODE" }
|
|
$llvmDir = Join-Path $installPath 'VC\Tools\Llvm'
|
|
$deadline = (Get-Date).AddMinutes(5)
|
|
$installerRunning = $true
|
|
$clangCl = $null
|
|
do {
|
|
$installerRunning = @(Get-Process -Name 'vs_installer', 'vs_setup', 'setup' -ErrorAction SilentlyContinue).Count -gt 0
|
|
$clangCl = Join-Path $llvmDir 'x64\bin\clang-cl.exe'
|
|
if (-not (Test-Path -LiteralPath $clangCl)) { $clangCl = $null }
|
|
if ($installerRunning -or $null -eq $clangCl) { Start-Sleep -Seconds 5 }
|
|
} while (($installerRunning -or $null -eq $clangCl) -and (Get-Date) -lt $deadline)
|
|
if ($installerRunning) { throw 'Visual Studio installer did not finish within five minutes' }
|
|
if ($null -eq $clangCl) { throw "clang-cl was not installed under $llvmDir" }
|
|
& $clangCl --version
|
|
$clangDir = Split-Path -Parent $clangCl
|
|
$clang = Join-Path $clangDir 'clang.exe'
|
|
if (-not (Test-Path -LiteralPath $clang)) { throw "clang was not installed under $clangDir" }
|
|
& $clang --version
|
|
"CC_aarch64_pc_windows_msvc=$clangCl" >> $env:GITHUB_ENV
|
|
"CXX_aarch64_pc_windows_msvc=$clangCl" >> $env:GITHUB_ENV
|
|
$clangDir >> $env:GITHUB_PATH
|
|
$vcvarsall = Join-Path $installPath 'VC\Auxiliary\Build\vcvarsall.bat'
|
|
$targetEnvironment = cmd.exe /c "`"$vcvarsall`" amd64_arm64 > nul && set"
|
|
if ($LASTEXITCODE -ne 0) { throw "vcvarsall exited with $LASTEXITCODE" }
|
|
if (-not ($targetEnvironment -match '(?i)^LIB=.*\\arm64')) { throw 'vcvarsall did not configure ARM64 libraries' }
|
|
foreach ($line in $targetEnvironment) {
|
|
if ($line -match '^(INCLUDE|LIB|LIBPATH|PATH)=(.*)$') {
|
|
"$($matches[1])=$($matches[2])" >> $env:GITHUB_ENV
|
|
}
|
|
}
|
|
|
|
# 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: Install crates
|
|
uses: $/.github/actions/install-crates
|
|
with:
|
|
container-build: 'true'
|
|
|
|
- name: Build with cross
|
|
shell: bash
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
run: .github/scripts/cross-build.sh --locked -p pnpr --bin pnpr --release --target="$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@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
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 -f "$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 by pnpm/setup to resolve the pnpm 11 GitHub release asset.
|
|
contents: read
|
|
# Required for npm trusted publishing and provenance attestations via OIDC.
|
|
id-token: write
|
|
needs:
|
|
- plan
|
|
- build-pnpr
|
|
- verify-pnpr-artifacts
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
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: |
|
|
set -euo pipefail
|
|
for package in pnpr/npm/pnpr-* pnpr/npm/pnpr; do
|
|
name=$(jq -r '.publishConfig.name // .name' "$package/package.json")
|
|
version=$(jq -r '.version' "$package/package.json")
|
|
state=$(.github/scripts/npm-package-publication-state.sh "$name@$version")
|
|
case "$state" in
|
|
published)
|
|
echo "$name@$version is already published; skipping"
|
|
;;
|
|
missing)
|
|
pnpm publish "$package/" --tag "$TAG" --access public --provenance --no-git-checks
|
|
;;
|
|
*)
|
|
echo "::error::Unexpected npm publication state for $name@$version"
|
|
exit 1
|
|
;;
|
|
esac
|
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
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@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
|
|
|
- name: Login to GHCR
|
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Build and push
|
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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
|
|
|
|
github-release-pnpr:
|
|
name: Draft GitHub release for pnpr
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
permissions:
|
|
contents: write # create the draft release on the pnpr tag
|
|
needs:
|
|
- plan
|
|
- build-pnpr
|
|
- publish-pnpr
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Assert the version tag points at this commit
|
|
# Same tag↔commit invariant as the TypeScript release job's guard.
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
VERSION: ${{ needs.plan.outputs.pnpr_version }}
|
|
run: |
|
|
tag_commit=$(gh api "repos/${GITHUB_REPOSITORY}/commits/pnpr@${VERSION}" --jq .sha)
|
|
if [ "$tag_commit" != "$GITHUB_SHA" ]; then
|
|
echo "::error::Tag pnpr@${VERSION} points at ${tag_commit}, but this run built commit ${GITHUB_SHA}. Rerun the release workflow from the pnpr@${VERSION} tag."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Download Artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: binaries-pnpr-*
|
|
merge-multiple: true
|
|
|
|
- name: Generate release description
|
|
# Same shape as the Rust job's description step; pnpr's changelog file
|
|
# name carries the `@pnpm!` scope prefix (`!` escapes `/`).
|
|
env:
|
|
VERSION: ${{ needs.plan.outputs.pnpr_version }}
|
|
run: |
|
|
changelog=".changeset/changelogs/@pnpm!pnpr@${VERSION}.md"
|
|
if [ -f "$changelog" ]; then
|
|
tail -n +2 "$changelog" | sed '/./,$!d' > RELEASE.md
|
|
else
|
|
echo "::warning::No pending changelog at ${changelog}; drafting the release with an empty description"
|
|
: > RELEASE.md
|
|
fi
|
|
|
|
- name: Release
|
|
# The build job's archives carry a target-qualified `pnpr-<target>`
|
|
# binary and ship with its build-time checksum, so they're uploaded
|
|
# byte-for-byte as attested there. Draft so a maintainer publishes it
|
|
# deliberately; `make_latest: false` keeps a pnpr release off the
|
|
# repository's "Latest" badge, which tracks the stable pnpm CLI.
|
|
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 # zizmor: ignore[superfluous-actions]
|
|
with:
|
|
draft: true
|
|
prerelease: ${{ contains(needs.plan.outputs.pnpr_version, '-') }}
|
|
make_latest: false
|
|
tag_name: pnpr@${{ needs.plan.outputs.pnpr_version }}
|
|
name: pnpr ${{ needs.plan.outputs.pnpr_version }}
|
|
body_path: RELEASE.md
|
|
files: |
|
|
pnpr-*.tar.gz
|
|
pnpr-*.zip
|
|
pnpr-*.sha256
|