Files
msd-core/scripts/verify-npm-publish.cjs
Tom Boucher f729101eec refactor(scripts): replace process.exit() with ExitError + runMain handler (#739) (#740)
Part 1 of 2 of the n/no-process-exit cleanup (umbrella #738): convert every
process.exit() call in standalone scripts/** CLIs to the rule-compliant pattern.

- New shared helper scripts/lib/cli-exit.cjs: ExitError(code,message) + runMain()
  which translates a thrown ExitError / returned number into process.exitCode
  (never process.exit()), flushing output and still firing process.on('exit').
- main()-based entrypoints: throw new ExitError(code) for errors, return <code>
  for verdicts; invoked via runMain(main). Child exit codes preserved via return.
- top-level-only scripts: imperative body extracted into main() so mid-flow
  aborts (throw ExitError) actually halt; pure consts/helpers stay at module scope.
- diff-touches-shipped-paths.cjs: stdin event handling restructured to an async
  read so the whole flow runs under runMain; uncaughtException/unhandledRejection
  nets replaced by an in-band catch that preserves EXIT_ERROR=2.

Exit codes verified unchanged for every converted script (success/error/help and
the 0/1/2 semantic codes in diff-touches). Rule stays warn here; flipped to error
in part 2 (#738) once gsd-core/bin/** is also clean.

Refs #739

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:13:13 -04:00

241 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* verify-npm-publish.cjs — verifies a freshly-published npm version is
* retrievable, tolerating registry/CDN propagation lag via bounded retry.
* Fixes #623. Used by both Verify-publish steps in .github/workflows/release.yml.
*/
const cp = require('node:child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// ---- Constants ---------------------------------------------------------------
const REASON = Object.freeze({
OK_VERSION_LIVE: 'ok_version_live',
FAIL_VERSION_NOT_FOUND: 'fail_version_not_found',
});
// ---- Sleep -------------------------------------------------------------------
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---- npm fetchers ------------------------------------------------------------
function defaultFetchVersion(pkg, version) {
try {
const out = cp.execFileSync('npm', ['view', `${pkg}@${version}`, 'version'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
return out || null;
} catch { return null; }
}
function defaultFetchDistTag(pkg, distTag) {
try {
const out = cp.execFileSync('npm', ['view', pkg, 'dist-tags', '--json'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const tags = JSON.parse(out);
return (tags && typeof tags === 'object' && tags[distTag]) || null;
} catch { return null; }
}
// ---- Core async function (unit-tested seam) ----------------------------------
async function verifyPublish({
pkg,
version,
distTag = null,
fetchVersion = defaultFetchVersion,
fetchDistTag = defaultFetchDistTag,
maxAttempts = 20,
intervalMs = 5000,
sleep = defaultSleep,
}) {
let attempts = 0;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const found = fetchVersion(pkg, version);
attempts++;
if (found === version) {
// Version confirmed live — optionally resolve dist-tag informally
let distTagResult = null;
if (distTag && typeof distTag === 'string' && distTag.length > 0) {
let pointsTo = null;
for (let dt = 1; dt <= maxAttempts; dt++) {
const tagVal = fetchDistTag(pkg, distTag);
if (tagVal !== null) {
pointsTo = tagVal;
break;
}
if (dt < maxAttempts) {
await sleep(intervalMs);
}
}
distTagResult = {
name: distTag,
points_to: pointsTo,
matches: pointsTo === version,
};
}
return {
ok: true,
reason: REASON.OK_VERSION_LIVE,
pkg,
version,
attempts,
distTag: distTagResult,
};
}
// Not found yet — sleep before retry (but not after the final attempt)
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
return {
ok: false,
reason: REASON.FAIL_VERSION_NOT_FOUND,
pkg,
version,
attempts,
distTag: null,
};
}
// ---- Argument parsing --------------------------------------------------------
function parseArgs(argv) {
const opts = {
pkg: null,
version: null,
distTag: null,
maxAttempts: 20,
intervalMs: 5000,
json: false,
};
const args = argv.slice();
while (args.length > 0) {
const arg = args.shift();
if (arg === '--help' || arg === '-h') {
process.stdout.write(
'Usage: node scripts/verify-npm-publish.cjs --package <pkg> --version <ver> [options]\n' +
'\n' +
'Options:\n' +
' --package <s> npm package name (required)\n' +
' --version <s> version to verify (required)\n' +
' --dist-tag <s> dist-tag to report (optional, informational only)\n' +
' --max-attempts <n> max retry attempts (default: 20)\n' +
' --interval-ms <n> ms between retries (default: 5000)\n' +
' --json emit structured JSON output\n' +
' --help, -h show this help\n'
);
throw new ExitError(0);
} else if (arg === '--package') {
const val = args.shift();
if (!val || val.startsWith('-')) {
throw new ExitError(2, 'error: --package requires a value');
}
opts.pkg = val;
} else if (arg === '--version') {
const val = args.shift();
if (!val || val.startsWith('-')) {
throw new ExitError(2, 'error: --version requires a value');
}
opts.version = val;
} else if (arg === '--dist-tag') {
const val = args.shift();
if (!val || val.startsWith('-')) {
throw new ExitError(2, 'error: --dist-tag requires a value');
}
opts.distTag = val;
} else if (arg === '--max-attempts') {
const val = args.shift();
if (!val || val.startsWith('-')) {
throw new ExitError(2, 'error: --max-attempts requires a value');
}
const n = parseInt(val, 10);
if (isNaN(n) || n < 1) {
throw new ExitError(2, 'error: --max-attempts must be a positive integer');
}
opts.maxAttempts = n;
} else if (arg === '--interval-ms') {
const val = args.shift();
if (!val || val.startsWith('-')) {
throw new ExitError(2, 'error: --interval-ms requires a value');
}
const n = parseInt(val, 10);
if (isNaN(n) || n < 0) {
throw new ExitError(2, 'error: --interval-ms must be a non-negative integer');
}
opts.intervalMs = n;
} else if (arg === '--json') {
opts.json = true;
} else {
throw new ExitError(2, `unknown argument: ${arg}`);
}
}
if (!opts.pkg) {
throw new ExitError(2, 'error: --package is required');
}
if (!opts.version) {
throw new ExitError(2, 'error: --version is required');
}
return opts;
}
// ---- Main entry point --------------------------------------------------------
async function main() {
const opts = parseArgs(process.argv.slice(2));
const result = await verifyPublish({
pkg: opts.pkg,
version: opts.version,
distTag: opts.distTag,
maxAttempts: opts.maxAttempts,
intervalMs: opts.intervalMs,
});
if (opts.json) {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
if (result.ok) {
process.stdout.write(
`✓ Verified: ${result.pkg}@${result.version} is live on npm (after ${result.attempts} attempt(s))\n`
);
if (result.distTag) {
process.stdout.write(`✓ ${result.distTag.name} tag points to: ${result.distTag.points_to}\n`);
if (!result.distTag.matches) {
process.stdout.write(
`::warning::${result.distTag.name} dist-tag points to ${result.distTag.points_to}, expected ${result.version}\n`
);
}
}
} else {
process.stdout.write(
`::error::Published version verification failed. ${result.pkg}@${result.version} not found after ${result.attempts} attempt(s)\n`
);
}
}
return result.ok ? 0 : 1;
}
// ---- Guard -------------------------------------------------------------------
if (require.main === module) {
runMain(main);
}
module.exports = { verifyPublish, parseArgs, REASON, defaultFetchVersion, defaultFetchDistTag };