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();