Files
vercel__workflow/scripts/check-published.mjs
2026-09-09 14:44:37 -07:00

172 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Fails when a publishable package version on this commit is not on npm.
*
* `changeset publish` runs one `pnpm publish` per package, concurrently, and
* reports the release as a whole. When one of those publishes fails the
* release is left half-shipped: some versions are live, others are not, and
* `workflow` can point at an `@workflow/core` that does not exist yet. The
* 5.0.0-beta.48 release shipped 7 of 21 packages on its first attempt, and
* the failure that stopped it (an E401 whose JSON had no `detail` field)
* crashed the publish loop before it printed which packages were affected.
*
* This script is the check the Release job runs after publishing, whatever
* the publish step's outcome: every non-private package that changesets does
* not ignore must have its manifest version in the registry's version list,
* and the branch's dist-tag (the pre-release tag from `.changeset/pre.json`
* while in pre mode, `latest` otherwise) must point at it. The invariant
* holds on every commit of a release branch, not only right after a publish,
* so a gap keeps failing the job until it is closed.
*
* npm does not commit a publish synchronously: `pnpm publish` exits 0 once the
* version is staged, and the version list and dist-tag catch up afterwards.
* The lag is minutes, not seconds, and it scales with the tarball, so the same
* few packages are always last. Measured from the publish step to the version
* appearing in the registry: 5.0.0-beta.50 took 1m29s for `@workflow/astro`,
* 3m03s for `@workflow/core` (9.4MB) and 4m11s for `@workflow/web` (11.3MB);
* 5.0.0-beta.49 took just over 5m for `@workflow/web`. The retry budget below
* therefore has to be tens of minutes' worth, not one minute's: a budget that
* expires during an ordinary commit lag reports a healthy release as broken,
* which is the exact signal this script exists to keep trustworthy.
*
* A gap that outlives the budget is real and needs a person. 5.0.0-beta.48 left
* `@workflow/web` staged for about a day, which no retry budget can wait out.
*
* Usage: node scripts/check-published.mjs [--tag <dist-tag>]
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const REGISTRY = 'https://registry.npmjs.org';
// 20 attempts, 30s apart: ~9.5 minutes of waiting, which covers every commit
// lag observed so far with room to spare. Costs nothing when the registry is
// already caught up, since the loop exits as soon as nothing is pending.
const ATTEMPTS = Number(process.env.CHECK_PUBLISHED_ATTEMPTS ?? 20);
const DELAY_MS = Number(process.env.CHECK_PUBLISHED_DELAY_MS ?? 30_000);
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
function distTagForBranch() {
const argIndex = process.argv.indexOf('--tag');
if (argIndex !== -1 && process.argv[argIndex + 1]) {
return process.argv[argIndex + 1];
}
const prePath = join(root, '.changeset', 'pre.json');
if (existsSync(prePath)) {
const pre = readJson(prePath);
if (pre.mode === 'pre' && pre.tag) return pre.tag;
}
return 'latest';
}
function publishablePackages() {
const { ignore = [] } = readJson(join(root, '.changeset', 'config.json'));
const ignored = new Set(ignore.filter((name) => !name.includes('*')));
const ignoredPatterns = ignore
.filter((name) => name.includes('*'))
.map(
(glob) => new RegExp(`^${glob.split('*').map(escapeRegExp).join('.*')}$`)
);
const packagesDir = join(root, 'packages');
const result = [];
for (const dir of readdirSync(packagesDir)) {
const manifestPath = join(packagesDir, dir, 'package.json');
if (!existsSync(manifestPath)) continue;
const manifest = readJson(manifestPath);
if (!manifest.name || !manifest.version || manifest.private) continue;
if (ignored.has(manifest.name)) continue;
if (ignoredPatterns.some((re) => re.test(manifest.name))) continue;
result.push({ name: manifest.name, version: manifest.version });
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function registryState(name) {
const url = `${REGISTRY}/${name.replace('/', '%2F')}`;
const response = await fetch(url, {
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
});
if (response.status === 404) return { versions: {}, distTags: {} };
if (!response.ok) {
throw new Error(
`GET ${url} failed: ${response.status} ${response.statusText}`
);
}
const doc = await response.json();
return { versions: doc.versions ?? {}, distTags: doc['dist-tags'] ?? {} };
}
async function check(pkg, tag) {
const { versions, distTags } = await registryState(pkg.name);
const visible = Object.hasOwn(versions, pkg.version);
const tagged = distTags[tag] === pkg.version;
return { ...pkg, visible, tagged, actualTag: distTags[tag] };
}
const tag = distTagForBranch();
const packages = publishablePackages();
let results = await Promise.all(packages.map((pkg) => check(pkg, tag)));
for (let attempt = 1; attempt < ATTEMPTS; attempt++) {
const pending = results.filter((r) => !(r.visible && r.tagged));
if (pending.length === 0) break;
console.log(
`${pending.length} package(s) not yet visible with the "${tag}" tag; re-checking in ${DELAY_MS / 1000}s (attempt ${attempt + 1}/${ATTEMPTS})...`
);
await new Promise((resolveDelay) => setTimeout(resolveDelay, DELAY_MS));
const rechecked = await Promise.all(pending.map((r) => check(r, tag)));
results = results.map((r) => rechecked.find((n) => n.name === r.name) ?? r);
}
const width = Math.max(...results.map((r) => r.name.length));
for (const r of results) {
const status = r.visible
? r.tagged
? 'ok'
: `visible, but "${tag}" is ${r.actualTag ?? 'unset'}`
: 'NOT ON NPM';
console.log(`${r.name.padEnd(width)} ${r.version.padEnd(14)} ${status}`);
}
const missing = results.filter((r) => !r.visible);
const untagged = results.filter((r) => r.visible && !r.tagged);
if (missing.length === 0 && untagged.length === 0) {
console.log(
`\n✓ all ${results.length} publishable packages are on npm under the "${tag}" tag.`
);
process.exit(0);
}
console.error('');
if (missing.length > 0) {
console.error(
`${missing.length} package version(s) on this commit are not on npm:`
);
for (const r of missing) console.error(` ${r.name}@${r.version}`);
console.error(
'\n Read the publish step above for each one. If `pnpm publish` reported the\n' +
' version as "previously staged", it is waiting for a maintainer to approve it in\n' +
" the package's Staged Packages tab on npmjs.com. Otherwise re-run the Release\n" +
' workflow (workflow_dispatch); `changeset publish` skips versions already live.'
);
}
if (untagged.length > 0) {
console.error(
`${untagged.length} package(s) are on npm but the "${tag}" dist-tag does not point at them:`
);
for (const r of untagged)
console.error(
` ${r.name}@${r.version} (tag is ${r.actualTag ?? 'unset'})`
);
}
process.exit(1);