| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { execFileSync } from "node:child_process"; |
| 4 | import { setTimeout as delay } from "node:timers/promises"; |
| 5 | |
| 6 | const version = process.argv[2]; |
| 7 | const expectedSha = process.env.EXPECTED_SHA || ""; |
| 8 | const verifyDelayMs = Number.parseInt(process.env.NPM_TAG_VERIFY_DELAY_MS || "2000", 10); |
| 9 | const verifyAttempts = Number.parseInt(process.env.NPM_TAG_VERIFY_ATTEMPTS || "15", 10); |
| 10 | if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version || "")) { |
| 11 | throw new Error("usage: finalize-npm-official-release.mjs MAJOR.MINOR.PATCH"); |
| 12 | } |
| 13 | if (expectedSha && !/^[0-9a-f]{40}$/.test(expectedSha)) { |
| 14 | throw new Error("EXPECTED_SHA must be a full lowercase commit SHA"); |
| 15 | } |
| 16 | if (!Number.isSafeInteger(verifyDelayMs) || verifyDelayMs < 0) { |
| 17 | throw new Error("NPM_TAG_VERIFY_DELAY_MS must be a non-negative integer"); |
| 18 | } |
| 19 | if (!Number.isSafeInteger(verifyAttempts) || verifyAttempts < 1) { |
| 20 | throw new Error("NPM_TAG_VERIFY_ATTEMPTS must be a positive integer"); |
| 21 | } |
| 22 | |
| 23 | const packages = [ |
| 24 | "reasonix", |
| 25 | "@reasonix/cli-darwin-arm64", "@reasonix/cli-darwin-x64", |
| 26 | "@reasonix/cli-linux-arm64", "@reasonix/cli-linux-x64", |
| 27 | "@reasonix/cli-win32-arm64", "@reasonix/cli-win32-x64", |
| 28 | ]; |
| 29 | |
| 30 | const OFFICIAL_ALIASES = ["latest", "canary", "next"]; |
| 31 | |
| 32 | // The publisher stages a stable release under "<dist-tag>-staging" and removes |
| 33 | // it once the official aliases land (npm/publish.mjs). This cleanup used to name |
| 34 | // "official-staging", which nothing creates, so it never removed anything and a |
| 35 | // staging alias left behind by a failed publish stayed on the registry. |
| 36 | const stagingTag = "latest-staging"; |
| 37 | |
| 38 | function metadata(name) { |
| 39 | const output = execFileSync( |
| 40 | "npm", |
| 41 | ["view", `${name}@${version}`, "name", "version", "gitHead", "reasonixCandidateSha", "--json"], |
| 42 | { encoding: "utf8" }, |
| 43 | ); |
| 44 | return JSON.parse(output); |
| 45 | } |
| 46 | |
| 47 | function distTags(name) { |
| 48 | return JSON.parse(execFileSync("npm", ["view", name, "dist-tags", "--json"], { encoding: "utf8" })); |
| 49 | } |
| 50 | |
| 51 | function removeStagingTag(name) { |
| 52 | try { |
| 53 | execFileSync("npm", ["dist-tag", "rm", name, stagingTag], { encoding: "utf8" }); |
| 54 | } catch (error) { |
| 55 | const detail = [error?.message, error?.stdout, error?.stderr] |
| 56 | .filter(Boolean) |
| 57 | .join("\n"); |
| 58 | if (!/\bE403\b|\b403 Forbidden\b/.test(detail)) throw error; |
| 59 | console.warn( |
| 60 | `npm refused cleanup of ${name} dist-tag ${stagingTag} with E403; official aliases are already verified`, |
| 61 | ); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | async function waitForOfficialAliases(name) { |
| 66 | let tags = {}; |
| 67 | for (let attempt = 1; attempt <= verifyAttempts; attempt += 1) { |
| 68 | tags = distTags(name); |
| 69 | if (OFFICIAL_ALIASES.every((tag) => tags[tag] === version)) return tags; |
| 70 | if (attempt < verifyAttempts) await delay(verifyDelayMs); |
| 71 | } |
| 72 | const actual = OFFICIAL_ALIASES |
| 73 | .map((tag) => `${tag}=${tags[tag] || "<missing>"}`) |
| 74 | .join(", "); |
| 75 | throw new Error(`${name} aliases did not converge to ${version}: ${actual}`); |
| 76 | } |
| 77 | |
| 78 | for (const name of packages) { |
| 79 | const actual = metadata(name); |
| 80 | if (actual.name !== name || actual.version !== version) { |
| 81 | throw new Error(`${name}@${version} is unavailable or resolved to another package`); |
| 82 | } |
| 83 | if (expectedSha && actual.gitHead !== expectedSha) { |
| 84 | throw new Error(`${name}@${version} gitHead ${actual.gitHead || "<missing>"} does not match ${expectedSha}`); |
| 85 | } |
| 86 | if (expectedSha && actual.reasonixCandidateSha !== expectedSha) { |
| 87 | throw new Error(`${name}@${version} reasonixCandidateSha ${actual.reasonixCandidateSha || "<missing>"} does not match ${expectedSha}`); |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // Recovery reruns this after an alias write already succeeded — including after |
| 92 | // someone realigned the aliases by hand because the automation could not. Every |
| 93 | // dist-tag write is a registry mutation that can be refused (npm treats them as |
| 94 | // sensitive operations, so an automation token gets a 403 where an interactive |
| 95 | // session does not), and refusing a write we did not need would fail a rerun |
| 96 | // that had nothing left to do. So read first and only write what is missing. |
| 97 | for (const name of packages) { |
| 98 | const before = distTags(name); |
| 99 | for (const tag of OFFICIAL_ALIASES) { |
| 100 | if (before[tag] === version) continue; |
| 101 | execFileSync("npm", ["dist-tag", "add", `${name}@${version}`, tag], { stdio: "inherit" }); |
| 102 | } |
| 103 | const tags = await waitForOfficialAliases(name); |
| 104 | if (tags[stagingTag] === version) { |
| 105 | removeStagingTag(name); |
| 106 | } |
| 107 | } |
| 108 |