feat(release): auto-refresh embedded signing keys in the release PR (#12798)

* feat(release): auto-refresh embedded signing keys in the release PR

The create-release-pr workflow previously failed when the embedded npm
signing keys or Node.js release keys drifted from their canonical
sources, requiring a manual update PR before a release could proceed.
Now the workflow runs the update scripts in --update mode and commits
the refreshed keys together with the version bumps, so the new trust
roots are reviewed as part of the release PR diff. A synthesized
changeset records the refresh in the changelogs of the affected
packages.

Also embeds the Node.js release team's new signing key
655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD (Stewart X Addison), which the
last release run reported as missing, and fixes the Rust renderer in
update-node-release-keys.mjs so its output is lint-clean (angle-bracket
URL for dylint's bare-url lint, raw-string hashes only when the key
contains a quote for clippy's needless_raw_string_hashes) and matches
the committed file byte-for-byte on a no-drift run.

* fix(release): canonicalize npm key order and flag trust-root refreshes

Review follow-ups: sort the merged npm signing keys by keyid so a
reordering of npm's response cannot churn the generated file or
synthesize a spurious changeset, and leave an explicit warning comment
on the release PR whenever embedded trust roots were refreshed, so a
key change cannot slip through buried in a large version-bump diff.
This commit is contained in:
Zoltan Kochan
2026-07-04 20:47:45 +02:00
committed by GitHub
parent d53917264f
commit 2f389d6cf7
7 changed files with 117 additions and 36 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/crypto.shasums-file": patch
"pnpm": patch
---
Added the Node.js release team's new signing key (Stewart X Addison, `655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD`) to the embedded Node.js release keys, so runtimes whose `SHASUMS256.txt` is signed by the new releaser verify successfully.

View File

@@ -50,19 +50,53 @@ jobs:
git fetch origin "$TARGET"
git checkout -B "release-pr/$TARGET" FETCH_HEAD
# Fail the release if the npm registry signing keys embedded in
# @pnpm/deps.security.signatures have drifted 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.
- name: Check embedded npm signing keys are up to date
run: node pnpm11/deps/security/signatures/scripts/update-npm-signing-keys.mjs
# 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.
- name: Update embedded npm signing keys
run: node pnpm11/deps/security/signatures/scripts/update-npm-signing-keys.mjs --update
# Fail the release if the embedded Node.js release keys (used to verify the
# signature of a downloaded runtime's SHASUMS256.txt) have drifted from the
# canonical nodejs/release-keys list, so a new release signer cannot silently
# break Node.js runtime verification.
- name: Check embedded Node.js release keys are up to date
run: node pnpm11/crypto/shasums-file/scripts/update-node-release-keys.mjs
# 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
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
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. A no-op (no pending changesets) leaves the
@@ -117,3 +151,16 @@ jobs:
--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."

View File

@@ -2117,6 +2117,23 @@ sWjTVgUCaGA63AIbDAAKCRAgsaOQsWjTVu8oAP9Bc+QY+9FikX3YvMgWAqiDlVOy
o0y6UIZGBMSQlF80wAD/d34LqtVIVe9oe5NO3xA75+6Ew8tGeAjUq/ovagr5dAU=
=JsVv
-----END PGP PUBLIC KEY BLOCK-----
",
},
NodeReleaseKey {
fingerprint: "655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD",
armored_key: r"-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEakIjcxYJKwYBBAHaRw8BAQdAYRFWvBCAd9dDjKTePuAvvzAWxhvojAXco0m4
2AMh/MC0H1N0ZXdhcnQgWCBBZGRpc29uIDxzeGFAaWJtLmNvbT6ImQQTFgoAQRYh
BGVfO1wfs/qNGgymveSn0jK5NtL9BQJqQiNzAhsDBQkDwmcABQsJCAcCAiICBhUK
CQgLAgQWAgMBAh4HAheAAAoJEOSn0jK5NtL9FOEBAMpIjknm4fnEQvTmIlzy0kDu
VplF4HR78+lBef2i4590AP9i82wtP4pH1/vynoSBMkCauFIPmF1c9MYLFF0f53zq
Arg4BGpCI3MSCisGAQQBl1UBBQEBB0AVa1WJ4P8ir/M7NaCGrX7PDs0QU9qzpzme
a5TZ44b/YgMBCAeIfgQYFgoAJhYhBGVfO1wfs/qNGgymveSn0jK5NtL9BQJqQiNz
AhsMBQkDwmcAAAoJEOSn0jK5NtL9EzYA/Arr4hbKLaU6OUjAVbH7HGLEl0HSvxE8
5lEgWfuNbqOtAQCF9UT0wQEfiIj2R358Ce62zV43w2yaD8Xu0M/S+hz8AQ==
=DcLq
-----END PGP PUBLIC KEY BLOCK-----
",
},
];

View File

@@ -3,12 +3,13 @@
// signature of SHASUMS256.txt) from the canonical nodejs/release-keys repo into
// src/nodeReleaseKeys.ts and pacquet's matching Rust key module.
//
// node update-node-release-keys.mjs # check (CI / release gate)
// node update-node-release-keys.mjs # check
// node update-node-release-keys.mjs --update # rewrite the embedded keys
//
// `--check` fails when the authoritative keys.list contains a fingerprint that
// is not embedded, so a newly added release signer cannot silently break Node
// runtime verification.
// The create-release-pr workflow runs `--update`, so any drift from the
// authoritative keys.list is committed into the release PR and reviewed there —
// a newly added release signer cannot silently break Node runtime verification.
// Check mode reports the drift without writing.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -97,10 +98,13 @@ ${entries}
}
function renderRust (keys) {
// Hashed delimiters only when the key itself contains a quote, or clippy's
// needless_raw_string_hashes rejects the generated file.
const rustRawString = (s) => s.includes('"') ? `r#"${s}"#` : `r"${s}"`
const entries = keys.map(({ fingerprint, armored }) =>
` NodeReleaseKey {\n fingerprint: "${fingerprint}",\n armored_key: r#"${armored}\n"#,\n },`).join('\n')
` NodeReleaseKey {\n fingerprint: "${fingerprint}",\n armored_key: ${rustRawString(`${armored}\n`)},\n },`).join('\n')
return `// GENERATED - the Node.js release team's OpenPGP public keys, mirrored from
// https://github.com/nodejs/release-keys (keys.list + keys/<fingerprint>.asc).
// <https://github.com/nodejs/release-keys> (keys.list + keys/<fingerprint>.asc).
//
// Used to verify the signature of a Node.js release's SHASUMS256.txt before
// trusting its hashes. Refresh with:

View File

@@ -119,4 +119,8 @@ export const NODE_RELEASE_KEYS = [
fingerprint: '5BE8A3F6C8A5C01D106C0AD820B1A390B168D356',
armoredKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmDMEaGA63BYJKwYBBAHaRw8BAQdAo/yU+MutacFmmn0CEX495goNrBxR24235XLM\ncvHYjfq0L0FudG9pbmUgZHUgSGFtZWwgPGR1aGFtZWxhbnRvaW5lMTk5NUBnbWFp\nbC5jb20+iI4EExYKADYWIQRb6KP2yKXAHRBsCtggsaOQsWjTVgUCaGA63AIbAwQL\nCQgHBBUKCQgFFgIDAQACHgUCF4AACgkQILGjkLFo01afgwEA/sLHqsj7ml2vyDoT\nKDPE8n9a80ZOh14OfnlOe0cCZA8BAMEOOk7QFI69DIlV1nMiqcFCqQFoSzBU2LkI\nR17p/j4NtDNBbnRvaW5lIGR1IEhhbWVsIDxhbnRvaW5lLmR1aGFtZWxAcGxhdGZv\ncm1hdGljLmRldj6IjgQTFgoANhYhBFvoo/bIpcAdEGwK2CCxo5CxaNNWBQJpsCMx\nAhsDBAsJCAcEFQoJCAUWAgMBAAIeAQIXgAAKCRAgsaOQsWjTVr/sAPwIBsG8g6ND\nzoNRTX1wPKBvfZg1NP7tYCyM5sxQfrpuLAEA05AhG4xBILfhL/f0pqR5jXfxg6gz\nT6WfeVeS6zeHZwe4OARoYDrcEgorBgEEAZdVAQUBAQdAQVmtih8AO3ryBQMR/22x\nWHVKLjAbCiH2cMxNH+iy1RQDAQgHiHgEGBYKACAWIQRb6KP2yKXAHRBsCtggsaOQ\nsWjTVgUCaGA63AIbDAAKCRAgsaOQsWjTVu8oAP9Bc+QY+9FikX3YvMgWAqiDlVOy\no0y6UIZGBMSQlF80wAD/d34LqtVIVe9oe5NO3xA75+6Ew8tGeAjUq/ovagr5dAU=\n=JsVv\n-----END PGP PUBLIC KEY BLOCK-----\n",
},
{
fingerprint: '655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD',
armoredKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmDMEakIjcxYJKwYBBAHaRw8BAQdAYRFWvBCAd9dDjKTePuAvvzAWxhvojAXco0m4\n2AMh/MC0H1N0ZXdhcnQgWCBBZGRpc29uIDxzeGFAaWJtLmNvbT6ImQQTFgoAQRYh\nBGVfO1wfs/qNGgymveSn0jK5NtL9BQJqQiNzAhsDBQkDwmcABQsJCAcCAiICBhUK\nCQgLAgQWAgMBAh4HAheAAAoJEOSn0jK5NtL9FOEBAMpIjknm4fnEQvTmIlzy0kDu\nVplF4HR78+lBef2i4590AP9i82wtP4pH1/vynoSBMkCauFIPmF1c9MYLFF0f53zq\nArg4BGpCI3MSCisGAQQBl1UBBQEBB0AVa1WJ4P8ir/M7NaCGrX7PDs0QU9qzpzme\na5TZ44b/YgMBCAeIfgQYFgoAJhYhBGVfO1wfs/qNGgymveSn0jK5NtL9BQJqQiNz\nAhsMBQkDwmcAAAoJEOSn0jK5NtL9EzYA/Arr4hbKLaU6OUjAVbH7HGLEl0HSvxE8\n5lEgWfuNbqOtAQCF9UT0wQEfiIj2R358Ce62zV43w2yaD8Xu0M/S+hz8AQ==\n=DcLq\n-----END PGP PUBLIC KEY BLOCK-----\n",
},
] as const satisfies ReadonlyArray<{ fingerprint: string, armoredKey: string }>

View File

@@ -2,14 +2,15 @@
// Keeps the embedded npm registry signing keys (src/npmSigningKeys.ts) in sync
// with https://registry.npmjs.org/-/npm/v1/keys.
//
// node update-npm-signing-keys.mjs # check (CI / release gate)
// node update-npm-signing-keys.mjs # check
// node update-npm-signing-keys.mjs --update # rewrite the embedded keys
//
// `--check` fails when npm advertises a signing key that is not embedded
// verbatim, so a key rotation cannot silently break (or weaken) pnpm's
// signature verification. `--update` writes the union of npm's keys and any
// embedded keys npm no longer lists (older keys are kept so packages published
// before a rotation still verify).
// The create-release-pr workflow runs `--update`, so a key rotation is
// committed into the release PR and reviewed there rather than silently
// breaking (or weakening) pnpm's signature verification. `--update` writes the
// union of npm's keys and any embedded keys npm no longer lists (older keys are
// kept so packages published before a rotation still verify). Check mode
// reports the drift without writing.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -37,11 +38,13 @@ async function main () {
}
// Union: every npm key, plus embedded keys npm no longer lists (kept for
// verifying packages published before a rotation).
// verifying packages published before a rotation). Sorted so a reordering of
// npm's response cannot churn the generated file.
const merged = [...npmKeys]
for (const e of embedded) {
if (!merged.some((m) => m.keyid === e.keyid)) merged.push(e)
}
merged.sort((a, b) => a.keyid.localeCompare(b.keyid))
fs.writeFileSync(KEYS_FILE, render(merged))
console.log(missing.length === 0
? '✓ Embedded npm signing keys already current; rewrote file.'
@@ -87,8 +90,8 @@ function render (keys) {
// ${KEYS_URL}
//
// Refresh with: node deps/security/signatures/scripts/update-npm-signing-keys.mjs --update
// The release workflow runs \`--check\` and fails if these drift from npm, so a
// rotated key cannot silently break (or weaken) signature verification.
// The create-release-pr workflow refreshes these automatically, so a rotated
// key cannot silently break (or weaken) signature verification.
export const NPM_SIGNING_KEYS = ${body} as const satisfies ReadonlyArray<{
expires: string | null
keyid: string

View File

@@ -3,22 +3,22 @@
// https://registry.npmjs.org/-/npm/v1/keys
//
// Refresh with: node deps/security/signatures/scripts/update-npm-signing-keys.mjs --update
// The release workflow runs `--check` and fails if these drift from npm, so a
// rotated key cannot silently break (or weaken) signature verification.
// The create-release-pr workflow refreshes these automatically, so a rotated
// key cannot silently break (or weaken) signature verification.
export const NPM_SIGNING_KEYS = [
{
"expires": "2025-01-29T00:00:00.000Z",
"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
"keytype": "ecdsa-sha2-nistp256",
"scheme": "ecdsa-sha2-nistp256",
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="
},
{
"expires": null,
"keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
"keytype": "ecdsa-sha2-nistp256",
"scheme": "ecdsa-sha2-nistp256",
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g=="
},
{
"expires": "2025-01-29T00:00:00.000Z",
"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
"keytype": "ecdsa-sha2-nistp256",
"scheme": "ecdsa-sha2-nistp256",
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="
}
] as const satisfies ReadonlyArray<{
expires: string | null