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. Leaving all # three checked reproduces the historical "release everything pending" run. 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 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/ 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: Install pnpm and Node uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2.0.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/ 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 the npm registry signing keys embedded in # @pnpm/deps.security.signatures from what npm advertises. pnpm verifies # package-manager binaries (pacquet, the version-switch pnpm) against these # keys, so a stale set could break verification after a key rotation. Any # drift is committed below along with the version bumps, so the new trust # roots are reviewed as part of the release PR diff. # The embedded trust roots ship in the TypeScript CLI (v11) release, so they # are only refreshed when pnpm11 is one of the products being released. A run # that releases only the Rust products leaves v11 alone; any key drift is # picked up by the next release that includes pnpm11. - name: Update embedded npm signing keys if: github.event.inputs.pnpm11 == 'true' run: node pnpm11/deps/security/signatures/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. - name: Add changesets for refreshed keys id: keys if: github.event.inputs.pnpm11 == 'true' run: | drifted="" if ! git diff --quiet -- pnpm11/deps/security/signatures/src/npmSigningKeys.ts; then drifted="npm registry signing keys" cat > .changeset/release-refresh-npm-signing-keys.md <<'EOF' --- "@pnpm/deps.security.signatures": patch "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 }} run: | args=() [ "$RELEASE_PNPM11" = "true" ] && args+=(--release pnpm11) [ "$RELEASE_PNPM" = "true" ] && args+=(--release pnpm) [ "$RELEASE_PNPR" = "true" ] && args+=(--release pnpr) 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 # 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."