name: npm Publish on: repository_dispatch: types: [npm-publish] workflow_dispatch: inputs: releaseSha: description: "Release commit SHA to publish (defaults to latest default-branch commit)" required: false type: string permissions: contents: read env: CI: true jobs: approve: name: Prepare npm publish runs-on: ubuntu-24.04 concurrency: group: npm-publish-approval cancel-in-progress: true outputs: release_sha: ${{ steps.release-sha.outputs.value }} default_branch: ${{ steps.release-sha.outputs.default_branch }} steps: - name: Resolve release commit SHA id: release-sha uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const dispatchReleaseShaRaw = context.payload?.client_payload?.releaseSha; const manualReleaseShaRaw = context.payload?.inputs?.releaseSha; const payloadDefaultBranchRaw = context.payload?.repository?.default_branch; const dispatchReleaseSha = typeof dispatchReleaseShaRaw === "string" ? dispatchReleaseShaRaw.trim() : ""; const manualReleaseSha = typeof manualReleaseShaRaw === "string" ? manualReleaseShaRaw.trim() : ""; const payloadDefaultBranch = typeof payloadDefaultBranchRaw === "string" ? payloadDefaultBranchRaw.trim() : ""; let releaseSha = dispatchReleaseSha || manualReleaseSha; let defaultBranch = payloadDefaultBranch; if (!defaultBranch) { const repoInfo = await github.rest.repos.get(context.repo); defaultBranch = repoInfo.data.default_branch; } if (!defaultBranch) { core.setFailed("Unable to determine repository default branch"); return; } if (!releaseSha) { if (context.eventName === "repository_dispatch") { core.setFailed("repository_dispatch payload is missing releaseSha"); return; } const ref = await github.rest.git.getRef({ ...context.repo, ref: `heads/${defaultBranch}`, }); releaseSha = ref.data.object.sha; core.info( `No release SHA provided; using ${defaultBranch} HEAD ${releaseSha}`, ); } if (!/^[0-9a-f]{40}$/i.test(releaseSha)) { core.setFailed( `releaseSha must be a full 40-character commit SHA: ${releaseSha}`, ); return; } core.setOutput("value", releaseSha); core.setOutput("default_branch", defaultBranch); cancel_pending: name: Cancel stale pending publishes needs: approve runs-on: ubuntu-24.04 permissions: actions: write contents: read env: DEFAULT_BRANCH: ${{ needs.approve.outputs.default_branch }} steps: - name: Cancel older queued/waiting runs continue-on-error: true uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; const currentRunId = context.runId; const currentRunNumber = context.runNumber; const defaultBranch = (process.env.DEFAULT_BRANCH || "").trim(); const workflowRef = process.env.GITHUB_WORKFLOW_REF || ""; const workflowPathMatch = workflowRef.match( /^[^/]+\/[^/]+\/(.+)@/, ); const workflowPath = workflowPathMatch?.[1] ?? ""; const workflowId = workflowPath.split("/").pop() || ""; let runs = []; if (workflowId) { runs = await github.paginate(github.rest.actions.listWorkflowRuns, { owner, repo, workflow_id: workflowId, per_page: 100, }); } else { core.warning( "Unable to resolve workflow filename; using repository-wide workflow run list", ); runs = await github.paginate( github.rest.actions.listWorkflowRunsForRepo, { owner, repo, per_page: 100, }, ); } const candidates = runs.filter((run) => { if (run.id === currentRunId) return false; if (run.run_number >= currentRunNumber) return false; if (run.status === "completed") return false; if (workflowPath && run.path !== workflowPath) return false; if (defaultBranch && run.head_branch && run.head_branch !== defaultBranch) { return false; } if ( run.event !== "repository_dispatch" && run.event !== "workflow_dispatch" ) { return false; } return true; }); let canceledCount = 0; for (const run of candidates) { const jobs = await github.paginate( github.rest.actions.listJobsForWorkflowRun, { owner, repo, run_id: run.id, per_page: 100, }, ); const publishJob = jobs.find((job) => job.name === "Publish to npm"); if (publishJob?.status === "in_progress") { core.info( `Keeping run ${run.id} because Publish to npm is in progress`, ); continue; } try { await github.rest.actions.cancelWorkflowRun({ owner, repo, run_id: run.id, }); canceledCount += 1; core.info(`Canceled stale pending run ${run.id}`); } catch (error) { core.warning( `Unable to cancel run ${run.id}: ${error.message}`, ); } } core.info(`Canceled ${canceledCount} stale pending run(s)`); summary: name: Release summary needs: - approve runs-on: ubuntu-24.04 outputs: target_count: ${{ steps.targets.outputs.targetCount }} targets_json: ${{ steps.targets.outputs.targetsJson }} env: RELEASE_SHA: ${{ needs.approve.outputs.release_sha }} DEFAULT_BRANCH: ${{ needs.approve.outputs.default_branch }} steps: - name: Checkout code repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ env.RELEASE_SHA }} - name: Validate release commit is on default branch run: | if [ -z "$RELEASE_SHA" ]; then echo "Missing releaseSha" >&2 exit 1 fi if [ -z "$DEFAULT_BRANCH" ]; then echo "Missing default branch" >&2 exit 1 fi head_sha=$(git rev-parse HEAD) if [ "$head_sha" != "$RELEASE_SHA" ]; then echo "Checked out commit does not match releaseSha payload" >&2 echo "HEAD=$head_sha" >&2 echo "releaseSha=$RELEASE_SHA" >&2 exit 1 fi git fetch origin "$DEFAULT_BRANCH" origin_default_sha=$(git rev-parse "origin/$DEFAULT_BRANCH") if ! git merge-base --is-ancestor "$RELEASE_SHA" "origin/$DEFAULT_BRANCH"; then echo "releaseSha is not an ancestor of origin/$DEFAULT_BRANCH; refusing to publish" >&2 echo "releaseSha=$RELEASE_SHA" >&2 echo "origin/$DEFAULT_BRANCH SHA=$origin_default_sha" >&2 exit 1 fi - name: Validate no pending changesets run: | pending=$(find .changeset -name '*.md' ! -name 'README.md' | sort) if [ -n "$pending" ]; then echo "::error::Release SHA has pending changesets, so changesets/action would update the version PR instead of publishing." echo "::error::Merge the version PR first, then dispatch with its merge commit as releaseSha." echo "Pending changesets:" echo "$pending" exit 1 fi - name: Resolve publish targets id: targets uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require("node:fs"); const path = require("node:path"); const fetchNpmMetadata = async (packageName) => { const encodedName = encodeURIComponent(packageName); const url = `https://registry.npmjs.org/${encodedName}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); try { const response = await fetch(url, { signal: controller.signal, }); if (response.ok) { return await response.json(); } if (response.status === 404) { return null; } throw new Error(`HTTP ${response.status}`); } finally { clearTimeout(timeout); } }; const parseStableSemver = (value) => { if (typeof value !== "string") return null; const match = value.match(/^(\d+)\.(\d+)\.(\d+)$/); if (!match) return null; return match.slice(1).map((part) => Number(part)); }; const compareStableSemver = (a, b) => { const parsedA = parseStableSemver(a); const parsedB = parseStableSemver(b); if (!parsedA || !parsedB) return null; for (let index = 0; index < 3; index += 1) { if (parsedA[index] > parsedB[index]) return 1; if (parsedA[index] < parsedB[index]) return -1; } return 0; }; const packagesDir = path.join(process.cwd(), "packages"); const entries = fs.readdirSync(packagesDir, { withFileTypes: true }); const prereleasePackages = []; const downgradePackages = []; const targets = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const manifestPath = path.join(packagesDir, entry.name, "package.json"); if (!fs.existsSync(manifestPath)) continue; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); if (manifest.private === true) continue; if (typeof manifest.version !== "string" || manifest.version.length === 0) { core.setFailed(`Package ${manifest.name} is missing a version`); return; } if (manifest.version.includes("-")) { prereleasePackages.push(`${manifest.name}@${manifest.version}`); continue; } const pkgName = manifest.name; const pkgVersion = manifest.version; let metadata = null; try { metadata = await fetchNpmMetadata(pkgName); } catch (error) { core.setFailed( `Failed to query npm metadata for ${pkgName}: ${error.message}`, ); return; } const latestTag = metadata?.["dist-tags"]?.latest ?? null; const hasVersion = Boolean(metadata?.versions?.[pkgVersion]); if (latestTag !== null) { const comparison = compareStableSemver(pkgVersion, latestTag); if (comparison === null) { core.setFailed( `Unsupported semver for ${pkgName}: local=${pkgVersion} latest=${latestTag}`, ); return; } if (comparison < 0) { downgradePackages.push( `${pkgName} local=${pkgVersion} latest=${latestTag}`, ); continue; } } if (hasVersion) { if (latestTag !== pkgVersion) { core.warning( `${pkgName}@${pkgVersion} is on npm but latest is ${latestTag ?? "unset"}; run: npm dist-tag add ${pkgName}@${pkgVersion} latest --registry https://registry.npmjs.org`, ); } } else { targets.push({ name: pkgName, version: pkgVersion, latestVersion: latestTag, dir: entry.name, }); } } if (prereleasePackages.length > 0) { core.setFailed( `Prerelease versions are not allowed on default-branch releases: ${prereleasePackages.join(", ")}`, ); return; } if (downgradePackages.length > 0) { core.setFailed( `Refusing to retag older versions as latest: ${downgradePackages.join(", ")}`, ); return; } targets.sort((a, b) => a.name.localeCompare(b.name)); core.info( `Targets: ${targets.length === 0 ? "none" : targets.map((target) => `${target.name}@${target.version}`).join(", ")}`, ); core.setOutput("targetCount", String(targets.length)); core.setOutput("targetsJson", JSON.stringify(targets)); - name: Write release summary if: steps.targets.outputs.targetCount != '0' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: TARGETS_JSON: ${{ steps.targets.outputs.targetsJson }} with: script: | const fs = require("node:fs"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const targets = JSON.parse(process.env.TARGETS_JSON || "[]"); const releaseSha = process.env.RELEASE_SHA; const runGit = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }).trim(); } catch { return ""; } }; const readJsonFromGit = (gitRef, filePath) => { try { return JSON.parse( execFileSync( "git", ["show", `${gitRef}:${filePath}`], { encoding: "utf8" }, ), ); } catch { return null; } }; function classifyVersionBump(previousVersion, newVersion) { const [pMajor, pMinor, pPatch] = previousVersion.split(".").map(Number); const [nMajor, nMinor, nPatch] = newVersion.split(".").map(Number); if (nMajor !== pMajor) return "major"; if (nMinor !== pMinor) return "minor"; if (nPatch !== pPatch) return "patch"; return "none"; } function isOutsideCaretRange(rangeVersion, newVersion) { const [rMajor, rMinor, rPatch] = rangeVersion.split(".").map(Number); const [nMajor, nMinor, nPatch] = newVersion.split(".").map(Number); if (rMajor === 0 && rMinor === 0) { return nMajor !== 0 || nMinor !== 0 || nPatch !== rPatch; } if (rMajor === 0) return nMajor !== 0 || nMinor !== rMinor; return nMajor !== rMajor; } // --- 1. Version changes table --- let summary = "## Release Summary\n\n"; summary += `**Release SHA:** \`${releaseSha.slice(0, 12)}\`\n`; summary += `**Packages:** ${targets.length}\n\n`; summary += "### Version Changes\n\n"; summary += "| Package | Previous | New |\n"; summary += "| --- | --- | --- |\n"; for (const t of targets) { const prev = t.latestVersion ?? "_new_"; summary += `| \`${t.name}\` | ${prev} | **${t.version}** |\n`; } summary += "\n"; const breakingTargets = targets .filter((target) => target.latestVersion) .map((target) => ({ ...target, previousVersion: target.latestVersion, bumpType: classifyVersionBump(target.latestVersion, target.version), })) .filter((target) => isOutsideCaretRange(target.previousVersion, target.version)) .sort((a, b) => a.name.localeCompare(b.name)); summary += "### Breaking Version Changes\n\n"; if (breakingTargets.length > 0) { summary += "These published versions move outside the previous `^` range and would be breaking for caret consumers if this release is published:\n\n"; summary += "| Package | Previous | New | Bump |\n"; summary += "| --- | --- | --- | --- |\n"; for (const target of breakingTargets) { summary += `| \`${target.name}\` | ${target.previousVersion} | **${target.version}** | ${target.bumpType} |\n`; } } else { summary += "No published package versions move outside their previous `^` range.\n"; } summary += "\n"; // --- 2. Cascade impact --- const packagesDir = path.join(process.cwd(), "packages"); const currentPackages = new Map(); const previousPackages = new Map(); for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const dir = entry.name; const pkgJsonPath = path.join(packagesDir, dir, "package.json"); let pkgJson; try { pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")); } catch { continue; } if (pkgJson.private) continue; currentPackages.set(pkgJson.name, { dir, ...pkgJson, }); const previousPkgJson = readJsonFromGit( `${releaseSha}^`, path.join("packages", dir, "package.json"), ); if (!previousPkgJson || previousPkgJson.private) continue; previousPackages.set(previousPkgJson.name, { dir, ...previousPkgJson, }); } const targetVersions = new Map( targets.map((target) => [target.name, target.version]), ); const cascadeByConsumer = new Map(); for (const previousPkg of previousPackages.values()) { const allDeps = { ...previousPkg.dependencies, ...previousPkg.peerDependencies, }; for (const [depName, range] of Object.entries(allDeps)) { if (typeof range !== "string" || !range.startsWith("^")) continue; const targetVersion = targetVersions.get(depName); if (!targetVersion) continue; const rangeVersion = range.replace(/^\^/, ""); if (!isOutsideCaretRange(rangeVersion, targetVersion)) continue; const currentPkg = currentPackages.get(previousPkg.name); const existing = cascadeByConsumer.get(previousPkg.name); if (existing) { existing.triggers.push({ name: depName, range, version: targetVersion, }); continue; } cascadeByConsumer.set(previousPkg.name, { name: previousPkg.name, previousVersion: previousPkg.version, newVersion: currentPkg?.version ?? previousPkg.version, triggers: [ { name: depName, range, version: targetVersion, }, ], }); } } const cascadeEntries = Array.from(cascadeByConsumer.values()).sort( (a, b) => a.name.localeCompare(b.name), ); if (cascadeEntries.length > 0) { summary += "### Downstream Impact\n\n"; summary += `${cascadeEntries.length} internal consumer(s) have \`^\` ranges that will be broken by these version changes:\n\n`; summary += "| Consumer | Version | Broken Dependencies |\n"; summary += "| --- | --- | --- |\n"; for (const c of cascadeEntries) { const brokenDeps = c.triggers .map((trigger) => `\`${trigger.name}\` (${trigger.range} -> ${trigger.version})`) .join(", "); summary += `| \`${c.name}\` | ${c.previousVersion} -> ${c.newVersion} | ${brokenDeps} |\n`; } summary += "\n"; } // --- 3. Changelog entries --- summary += "### Changelog\n\n"; for (const t of targets) { const changelogPath = path.join(packagesDir, t.dir, "CHANGELOG.md"); let changelogEntry = ""; try { const content = fs.readFileSync(changelogPath, "utf8"); const versionEscaped = t.version.replace(/\./g, "\\."); const headingRe = new RegExp("^## " + versionEscaped + "\\b", "m"); const headingMatch = content.match(headingRe); if (headingMatch) { const headingEnd = content.indexOf("\n", headingMatch.index); const afterHeading = headingEnd === -1 ? content.length : headingEnd + 1; const rest = content.slice(afterHeading); const nextIdx = rest.search(/^## /m); changelogEntry = (nextIdx === -1 ? rest : rest.slice(0, nextIdx)).trim(); } } catch { // No changelog file } if (changelogEntry) { summary += `
${t.name}@${t.version}\n\n`; summary += changelogEntry + "\n"; summary += "
\n\n"; } else { summary += `- \`${t.name}@${t.version}\` — _no changelog entry_\n`; } } summary += "\n"; // --- 4. Commit history --- const prevVersionCommit = runGit([ "log", "--format=%H", "--fixed-strings", "--grep=chore: update versions", `${releaseSha}^`, "--max-count=1", ]); if (prevVersionCommit) { const commitLog = runGit([ "log", "--oneline", "--no-merges", `${prevVersionCommit}..${releaseSha}`, ]); if (commitLog) { summary += "### Commits in This Release\n\n"; summary += "```\n" + commitLog + "\n```\n\n"; } } await core.summary.addRaw(summary).write(); core.info("Release summary written to job summary."); - name: No targets if: steps.targets.outputs.targetCount == '0' run: echo "No packages require publishing for this release SHA." publish: name: Publish to npm needs: - approve - cancel_pending - summary if: needs.summary.outputs.target_count != '0' environment: npm Publish runs-on: ubuntu-24.04 permissions: contents: write # Required for npm trusted publishing (OIDC). id-token: write concurrency: group: npm-publish-exec cancel-in-progress: false env: RELEASE_SHA: ${{ needs.approve.outputs.release_sha }} steps: - name: Checkout code repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ env.RELEASE_SHA }} - name: Install pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 # No cache - hardened against cache poisoning for sensitive publish operations - name: Install dependencies run: pnpm install --frozen-lockfile - name: Verify npm trusted publishing # Temporarily disabled: npm appears to reject the subsequent CLI OIDC # exchange after this pre-flight call within the same job, causing # "OIDC publish authorize: Invalid token" on every package. if: false uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: TARGETS_JSON: ${{ needs.summary.outputs.targets_json }} with: script: | const targets = JSON.parse(process.env.TARGETS_JSON || "[]"); const oidcRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; const oidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; if (!oidcRequestUrl || !oidcRequestToken) { core.setFailed("OIDC token request context is unavailable"); return; } const audience = "npm:registry.npmjs.org"; const oidcUrl = `${oidcRequestUrl}${oidcRequestUrl.includes("?") ? "&" : "?"}audience=${encodeURIComponent(audience)}`; const fetchOidcToken = async () => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); try { const response = await fetch(oidcUrl, { headers: { Authorization: `Bearer ${oidcRequestToken}` }, signal: controller.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const payload = await response.json(); if (!payload?.value) { throw new Error("OIDC response is missing token value"); } return payload.value; } finally { clearTimeout(timeout); } }; const failures = []; for (const target of targets) { let githubOidcToken; try { githubOidcToken = await fetchOidcToken(); } catch (error) { core.setFailed( `Failed to fetch GitHub OIDC token: ${error.message}`, ); return; } const encodedName = encodeURIComponent(target.name); const exchangeUrl = `https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${encodedName}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); let response; try { response = await fetch(exchangeUrl, { method: "POST", headers: { Authorization: `Bearer ${githubOidcToken}` }, signal: controller.signal, }); } catch (error) { failures.push(`${target.name}: network error (${error.message})`); continue; } finally { clearTimeout(timeout); } if (!response.ok) { const hint = response.status === 404 ? "not configured for npm trusted publishing" : `HTTP ${response.status}`; failures.push(`${target.name}: ${hint}`); } } if (failures.length > 0) { core.setFailed( [ `Pre-flight OIDC verification failed for ${failures.length} package(s):`, ...failures.map((f) => ` - ${f}`), "", "Configure trusted publishing for each failed package at:", " https://docs.npmjs.com/trusted-publishers", ].join("\n"), ); return; } core.info( `All ${targets.length} package(s) verified for npm trusted publishing.`, ); - name: Publish to npm and create GitHub releases id: changesets-publish uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: publish: pnpm ci:publish createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The action's fallback version path commits without ci:version's lockfile refresh, so the pre-commit hook's pnpm run fails on the stale lockfile. HUSKY: 0 - name: Recovery guidance if: failure() run: | echo "::error::Publish failed for release SHA ${RELEASE_SHA}." echo "::error::Rerun this workflow run, or run workflow_dispatch with releaseSha=${RELEASE_SHA}."