From 0709fb81eac79c7bb664bbe6765b653cc4774455 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Thu, 24 Sep 2026 12:18:06 -0500 Subject: [PATCH] fix: stop an ordinary merge from arming the release gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release.yml` fires on every push to `main`, and the release chain is gated only on there being no pending Changesets. That is as true of a documentation merge as of a Version Packages merge, so every commit ran version, verify, seal, and browser_e2e and then parked a deployment at the `npm-production` gate. Approving one is worse than pointless. The sealed commit no longer matches the commit named in the published packages' provenance, so the publisher answers `[REGISTRY_PROVENANCE]` and refuses the set — correctly, because it must not certify bytes it did not produce. Merging the previous fix demonstrated it: every build job passed, publish was approved, and it failed on exactly that check with the registry untouched. A gate that can only fail is a trap left standing open on `main`. `verify` now also requires something left to publish. `published.mjs` asks the registry whether every package in the fixed set already carries the working tree's version, and the workflow reads it before Changesets rewrites those manifests, so the question is about the commit that was pushed. An unreachable registry reports not-published. A registry this cannot read is one it cannot claim anything about, and the safe direction is to let the chain run: the worst case is the pending deployment this exists to avoid, whereas wrongly reporting "published" would silently skip a real release. Partial publication reports not-published for the same reason, which is what keeps the documented resume path reachable. --- .github/workflows/release.yml | 16 ++++++++- scripts/release/published.mjs | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 scripts/release/published.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2241dd..e9bae3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: pull-requests: write outputs: hasChangesets: ${{ steps.changesets.outputs.hasChangesets }} + allPublished: ${{ steps.published.outputs.allPublished }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.0.0 with: @@ -43,6 +44,16 @@ jobs: node scripts/release/seal.mjs self-test node scripts/release/publisher.mjs self-test + # Runs before Changesets, which rewrites manifests in the working tree + # for the Version Packages PR. The question is about the commit that was + # pushed, not about what a pending release would become. + - name: Report whether the pushed version is already published + id: published + run: | + result="$(node scripts/release/published.mjs)" + echo "$result" + echo "allPublished=$(node -p 'JSON.parse(process.argv[1]).allPublished' "$result")" >> "$GITHUB_OUTPUT" + - name: Open or update the fixed-set Version Packages PR id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 @@ -53,7 +64,10 @@ jobs: verify: needs: version - if: ${{ needs.version.outputs.hasChangesets == 'false' }} + # No pending Changesets AND something left to publish. The first condition + # alone is satisfied by any ordinary merge, which is how every commit to + # `main` came to park a deployment at a gate that could only fail. + if: ${{ needs.version.outputs.hasChangesets == 'false' && needs.version.outputs.allPublished == 'false' }} runs-on: ubuntu-latest permissions: contents: read diff --git a/scripts/release/published.mjs b/scripts/release/published.mjs new file mode 100644 index 0000000..6b5765c --- /dev/null +++ b/scripts/release/published.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +/** + * Report whether the working tree's version of the fixed set is already on the + * registry. + * + * `release.yml` fires on every push to `main`, and the release chain is gated + * only on there being no pending Changesets. That is true of an ordinary merge + * as well as a Version Packages merge, so every unrelated commit ran the whole + * chain and parked a deployment at the `npm-production` gate. Approving one is + * worse than pointless: the sealed commit no longer matches the commit named in + * the published packages' provenance, so `[REGISTRY_PROVENANCE]` refuses the + * set — correctly, because the publisher must not certify bytes it did not + * produce. A gate that can only fail is a trap standing open on `main`. + * + * **Unreachable is reported as not published.** A registry this cannot read is + * a registry it cannot claim anything about, and the safe direction is to let + * the release chain run: the worst case is the pending deployment this exists + * to avoid, whereas wrongly reporting "published" would silently skip a real + * release. Partial publication reports not-published too, which is what keeps + * the documented resume path reachable. + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { ROOT, loadReleaseLine } from "./config.mjs"; + +function registryVersions(name) { + try { + const output = execFileSync("npm", ["view", name, "versions", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 60_000, + }); + const parsed = JSON.parse(output); + // npm answers with a bare string when a package has exactly one version. + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return null; + } +} + +function report() { + const config = loadReleaseLine(); + const corePath = join(ROOT, config.packages[0].path, "package.json"); + const version = JSON.parse(readFileSync(corePath, "utf8")).version; + const missing = []; + for (const spec of config.packages) { + const versions = registryVersions(spec.name); + if (versions === null || !versions.includes(version)) { + missing.push(spec.name); + } + } + process.stdout.write( + `${JSON.stringify({ + version, + allPublished: missing.length === 0, + missing, + })}\n`, + ); +} + +report();