Files
pnpm/.github/workflows/create-release-pr.yml
Zoltan Kochan 1860c1c75e chore: move get-pnpm into the workspace and pin npm's signing keys once (#17341)
get-pnpm moves from pnpm/get.pnpm.io into pnpm/npm/get-pnpm, at 0.0.9
(get.pnpm.io be4e983, where its history stays).

- The pnpm v12 npm package depends on it with `workspace:*`, so a pnpm
  release bundles get-pnpm's source as of that release. A wrapper fix no
  longer needs a get-pnpm release, a catalog bump, and a
  minimumReleaseAge exclusion first. bundle-node-gyp builds it before
  the deploy injects its build output.
- It is released on its own: a `get-pnpm` product in bump.ts and
  create-release-pr.yml (off by default), `get-pnpm@<version>` tags,
  and build-get-pnpm/publish-get-pnpm jobs in release.yml. It is private
  in the workspace so the TypeScript release's recursive publish skips
  it; the packed manifest drops the flag.
- Its runtime dependencies stay exact, through a `get-pnpm` named
  catalog. Its lint is the root ESLint config. Its TLS tests use the
  fixture the Rust network tests use.
- The npm-wrapper CI job runs its tests on Node.js 24 and 22, beside
  the wrapper's. The forwarding proxy in its download tests forwards
  only to the servers the tests start.

npm's registry signing keys were pinned in five places: v11, v12,
get-pnpm, and get.pnpm.io's install.sh and install.ps1. Only v11's copy
was refreshed, by the release PR, and the two repositories handled a
withdrawn key in opposite ways. They now come from
.config/npm-signing-keys.json:

- .github/scripts/update-npm-signing-keys.mjs refreshes it from npm on
  every release PR, not only v11 ones. A key npm stops listing stays,
  marked withdrawn.
- The meta-updater renders it into the v11, v12, and get-pnpm copies,
  so `pnpm lint:meta` fails on a copy edited by hand. pnpm v11 and v12
  keep trusting withdrawn keys, so packages signed before a rotation
  verify. get-pnpm drops them, as get.pnpm.io's installers do.
- get.pnpm.io's update script is to read this file.
2026-10-25 17:38:04 +01:00

248 lines
11 KiB
YAML

name: Create Release PR
on:
workflow_dispatch:
inputs:
target:
description: Branch to release (the PR base; e.g. main or release/11.1).
default: main
required: true
# Each product is a separate checkbox, so a release can cover any
# combination. Only the checked products' pending changesets are consumed;
# the rest stay in the ledger for a later release. This lets a rapid v12
# (Rust) cadence release without dragging the TypeScript CLI (v11) along —
# and with it the near-daily "Update available!" notification. Checking
# every box releases everything pending.
pnpm11:
description: Release pnpm v11 (the TypeScript CLI) and the rest of the default lane.
type: boolean
default: true
pnpm:
description: Release pnpm (the Rust CLI, v12 alpha prerelease) and its @pnpm/napi addon.
type: boolean
default: true
pnpr:
description: Release pnpr (the registry server).
type: boolean
default: true
# Off by default: get-pnpm releases on its own cadence, and the pnpm
# releases bundle its workspace source whether or not it is published.
get_pnpm:
description: Release get-pnpm (the installer package).
type: boolean
default: false
permissions:
contents: write
pull-requests: write
# Serialize per target so two dispatches for the same branch can't race on the
# force-pushed release-pr/<target> branch.
concurrency:
group: create-release-pr-${{ github.event.inputs.target }}
cancel-in-progress: false
jobs:
create-release-pr:
if: github.repository == 'pnpm/pnpm' # Only run on the main repository, not forks
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
# The husky hooks are a developer safety net; CI has its own gates. Without
# this, `pnpm install` wires the hooks and the pre-push hook runs the full
# TS compile/lint plus the Rust clippy/doc sweep during "Commit and push"
# (the shallow clone and URL remote make its change detection always
# conclude that Rust sources changed).
HUSKY: 0
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Don't persist the write-scoped token in .git/config; the install below
# runs third-party lifecycle scripts. Pushing is done with an explicit,
# single-use remote URL in the "Commit and push" step.
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@fa36a5ef7dcdefc3a7cb25655c122e00cf6e636e # v3.1.2
with:
runtime: node@26.3.0
# Base the release on the tip of the target branch, fetched explicitly so the
# run is correct even when dispatched from another ref. A force-push to
# release-pr/<target> reuses an already-open release PR for the same target
# rather than opening a second.
- name: Prepare release branch
env:
TARGET: ${{ github.event.inputs.target }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin "$TARGET"
git checkout -B "release-pr/$TARGET" FETCH_HEAD
# Refresh .config/npm-signing-keys.json from what npm advertises. pnpm v11,
# pnpm v12, and get-pnpm verify package-manager binaries against these keys,
# so a stale set could break verification after a key rotation. The bump
# step's update-manifests renders the file into each product's copy, and any
# drift is committed below along with the version bumps, so the new trust
# roots are reviewed as part of the release PR diff.
- name: Update embedded npm signing keys
run: node .github/scripts/update-npm-signing-keys.mjs --update
# Refresh the embedded Node.js release keys (used to verify the signature of
# a downloaded runtime's SHASUMS256.txt) from the canonical
# nodejs/release-keys list, so a new release signer cannot break Node.js
# runtime verification. Reviewed in the release PR diff like the npm keys.
- name: Update embedded Node.js release keys
if: github.event.inputs.pnpm11 == 'true'
run: node pnpm11/crypto/shasums-file/scripts/update-node-release-keys.mjs --update
# A refreshed trust root must show up in the changelogs, so synthesize a
# changeset for whichever key sets drifted before `pnpm bump` consumes the
# pending changesets. The `drifted` output drives an explicit review signal
# on the PR, so a trust-root change cannot hide in a large version-bump diff.
# The npm keys changeset names every product that embeds them; the intents
# of products this run does not release stay pending for their next one.
- name: Add changesets for refreshed keys
id: keys
run: |
drifted=""
if ! git diff --quiet -- .config/npm-signing-keys.json; then
drifted="npm registry signing keys"
cat > .changeset/release-refresh-npm-signing-keys.md <<'EOF'
---
"@pnpm/deps.security.signatures": patch
"pnpm": patch
"pacquet": patch
"get-pnpm": patch
---
Updated the embedded npm registry signing keys to the set currently advertised by npm.
EOF
fi
if ! git diff --quiet -- pnpm11/crypto/shasums-file/src/nodeReleaseKeys.ts; then
drifted="${drifted:+$drifted and }Node.js release keys"
cat > .changeset/release-refresh-node-release-keys.md <<'EOF'
---
"@pnpm/crypto.shasums-file": patch
"pnpm": patch
---
Updated the embedded Node.js release keys to the current canonical `nodejs/release-keys` list.
EOF
fi
echo "drifted=$drifted" >> "$GITHUB_OUTPUT"
# Consumes the pending changesets: bumps versions, writes changelogs, updates
# the ledger, and syncs manifests. Only the checked products' intents are
# consumed; the rest stay in the ledger for a later release. A no-op (nothing
# pending in scope) leaves the tree clean and the steps below skip.
- name: Bump versions
env:
RELEASE_PNPM11: ${{ github.event.inputs.pnpm11 }}
RELEASE_PNPM: ${{ github.event.inputs.pnpm }}
RELEASE_PNPR: ${{ github.event.inputs.pnpr }}
RELEASE_GET_PNPM: ${{ github.event.inputs.get_pnpm }}
run: |
args=()
[ "$RELEASE_PNPM11" = "true" ] && args+=(--release pnpm11)
[ "$RELEASE_PNPM" = "true" ] && args+=(--release pnpm)
[ "$RELEASE_PNPR" = "true" ] && args+=(--release pnpr)
[ "$RELEASE_GET_PNPM" = "true" ] && args+=(--release get-pnpm)
if [ ${#args[@]} -eq 0 ]; then
echo "Select at least one product to release." >&2
exit 1
fi
pnpm run bump -- "${args[@]}"
- name: Check for changes
id: changes
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
# One release PR covers every product; the commit subject lists the ones
# whose version actually changed against the target branch, e.g.
# "chore(release): 11.13.0, pacquet 12.0.0-alpha.9, pnpr 0.2.0". When only
# the TypeScript CLI bumped, the subject stays the historical bare version.
# `@pnpm/napi` is not listed separately: it is a versioning.fixed group with
# pacquet, so it always carries pacquet's version.
- name: Compose release summary
id: version
if: steps.changes.outputs.changed == 'true'
run: |
summary=""
add() {
local label="$1" manifest="$2" new old
new=$(jq -r .version "$manifest")
old=$(git show "FETCH_HEAD:$manifest" 2>/dev/null | jq -r .version || echo "")
if [ "$new" != "$old" ]; then
summary="${summary:+$summary, }${label:+$label }$new"
fi
}
add "" pnpm11/pnpm/package.json
add "pacquet" pnpm/npm/pnpm/package.json
add "pnpr" pnpr/npm/pnpr/package.json
add "get-pnpm" pnpm/npm/get-pnpm/package.json
# Changes without a version bump (e.g. refreshed trust-root changesets
# consumed into changelogs only) still need a commit subject.
if [ -z "$summary" ]; then
summary=$(jq -r .version pnpm11/pnpm/package.json)
fi
echo "version=$summary" >> "$GITHUB_OUTPUT"
- name: Commit and push
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.UPDATE_LOCKFILE_TOKEN }}
TARGET: ${{ github.event.inputs.target }}
VERSION: ${{ steps.version.outputs.version }}
run: |
git add -A
git commit -m "chore(release): ${VERSION}"
git push -f "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "release-pr/$TARGET"
- name: Create PR if needed
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.UPDATE_LOCKFILE_TOKEN }}
TARGET: ${{ github.event.inputs.target }}
VERSION: ${{ steps.version.outputs.version }}
run: |
BRANCH="release-pr/$TARGET"
# An already-open PR now points at the freshly force-pushed branch, so
# there is nothing more to do.
if [ -n "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
echo "PR already exists; the new versions were force-pushed to it"
else
gh pr create \
--title "chore(release): ${VERSION}" \
--body "Automated release PR created by the create-release-pr workflow.
Releasing \`${TARGET}\`: ${VERSION}. Merging this PR consumes the pending changesets and records them in the committed \`.changeset/ledger.yaml\`. Afterwards a maintainer pushes a signed tag per released product to run the release workflow — see RELEASING.md." \
--base "$TARGET" \
--head "$BRANCH"
fi
# Embedded trust roots changed in this release, so leave a hard-to-miss
# comment: the refreshed keys need deliberate review, not a scroll-past
# among the version bumps and changelogs.
- name: Flag refreshed trust roots on the PR
if: steps.changes.outputs.changed == 'true' && steps.keys.outputs.drifted != ''
env:
GH_TOKEN: ${{ secrets.UPDATE_LOCKFILE_TOKEN }}
TARGET: ${{ github.event.inputs.target }}
DRIFTED: ${{ steps.keys.outputs.drifted }}
run: |
PR=$(gh pr list --head "release-pr/$TARGET" --state open --json number --jq '.[0].number')
gh pr comment "$PR" --body "⚠️ This release refreshes embedded trust roots: **${DRIFTED}**. Review those key diffs deliberately before merging — they gate signature verification of Node.js runtimes and package-manager binaries."