#!/usr/bin/env node // // Prints the next preview version for this package, e.g. `0.73.1-preview.4`. // // The base is package.json's version with the patch incremented, so a preview always // sorts above the last release and below whatever release follows it. `-preview.N` // counts from 1 per base and restarts whenever release-please moves package.json on. // // Basing the preview on release-please's *predicted* next version would be more // truthful — it would give `0.74.0-preview.N` when a `feat:` is queued — but the // prediction moves mid-flight: a `fix:` opens a 0.73.1 release PR, a later `feat:` // moves it to 0.74.0, and N resets under previews that were already published. If the // prediction ever moved down, the next preview would sort below the previous one. // package.json only ever increases, so patch+1 is monotonic in every case: // 0.73.1-preview.9 < 0.73.2-preview.1 and < 0.74.1-preview.1, whichever way the next // release goes. The cost is that `0.73.1-preview.4` is not a promise that 0.73.1 will // ship. // // N has to be a number nobody has used, and two sources are consulted because either // alone is unsafe: // // * the npm registry, authoritative for what is taken — npm versions are immutable // and stay reserved even after `npm unpublish`, so a taken N is a hard E403 at // publish time — but served from a CDN, so it can lag a publish by minutes; // * git tags, written by the workflow after each publish and strongly consistent, // which cover that window and any unpublish. // // The workflow publishes before it tags, so a version can exist on npm without a tag // but never the other way round. That asymmetry is why the registry is the authority // and the tags are only a floor, and why a registry read failure is fatal here rather // than falling back to the tags alone. // // See docs/RELEASES.md. import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const DEFAULT_REGISTRY = "https://registry.npmjs.org"; const PREVIEW_ID = "preview"; const FETCH_ATTEMPTS = 3; const FETCH_TIMEOUT_MS = 10_000; const BACKOFF_MS = 500; /** X.Y.Z: no prerelease, no build metadata, no leading zeros. */ const RELEASE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; /** The base a preview anticipates: package.json's version with the patch bumped. */ export function bumpPatch(version) { const match = RELEASE_VERSION.exec(String(version ?? "").trim()); if (!match) { throw new Error( `expected a plain X.Y.Z release version, got ${JSON.stringify(version)}. ` + `release-please owns package.json's version; a prerelease or a range here means ` + `something else has edited it.`, ); } const [, major, minor, patch] = match; return `${major}.${minor}.${Number(patch) + 1}`; } function escapeRegExp(literal) { return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Matcher for `-preview.`. `0731-preview.1` would match a base of `0.73.1`. `n` * is a semver-legal numeric identifier, so leading zeros are rejected; npm would never * have accepted `preview.01` in the first place. */ export function previewPattern(base) { return new RegExp(`^${escapeRegExp(base)}-${PREVIEW_ID}\\.(0|[1-9]\\d*)$`); } /** The N in `-preview.N`, or null for anything else. */ export function previewNumber(candidate, base) { const match = previewPattern(base).exec(String(candidate ?? "").trim()); return match ? Number(match[1]) : null; } /** `refs/tags/v1.2.3`, `v1.2.3` and `1.2.3` all become `1.2.3`. */ export function versionFromTag(tag) { return String(tag ?? "") .trim() .replace(/^refs\/tags\//, "") .replace(/^v/, ""); } /** * Highest N among the candidates, or 0 when none match. */ export function highestPreviewNumber(candidates, base) { let highest = 0; for (const candidate of candidates) { const n = previewNumber(candidate, base); if (n !== null && n > highest) { highest = n; } } return highest; } export function nextPreviewVersion({ base, registryVersions = [], tags = [] }) { const taken = Math.max( highestPreviewNumber(registryVersions, base), highestPreviewNumber(tags.map(versionFromTag), base), ); return `${base}-${PREVIEW_ID}.${taken + 1}`; } /** Every version this package has ever published, per the registry. */ export async function fetchPublishedVersions(name, options = {}) { const { registry = DEFAULT_REGISTRY, fetchImpl = fetch, attempts = FETCH_ATTEMPTS, backoffMs = BACKOFF_MS, } = options; const url = `${registry}/${name.replace("/", "%2f")}`; let lastError; for (let attempt = 1; attempt <= attempts; attempt++) { try { const response = await fetchImpl(url, { // The abbreviated packument is a fraction of the full one — tens of kilobytes // rather than megabytes — and still carries every version key. headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (response.status === 404) { // Never published. A definite answer, not a failure. return []; } if (!response.ok) { throw new Error(`${url} answered ${response.status} ${response.statusText}`); } const packument = await response.json(); // `versions` on a packument is always an object keyed by version, so there is // none of the "a single version comes back as a bare string" shape that // `npm view versions --json` has. return Object.keys(packument.versions ?? {}); } catch (error) { lastError = error; if (attempt < attempts) { await new Promise((resolve) => setTimeout(resolve, backoffMs * 2 ** (attempt - 1))); } } } throw new Error( `could not read published versions for ${name} after ${attempts} attempts: ${lastError}. ` + `Refusing to guess a preview number: the registry is the only record of versions ` + `that were published and then unpublished, and those can never be reused.`, { cause: lastError }, ); } function localPreviewTags(base) { try { return execFileSync("git", ["tag", "--list", `v${base}-${PREVIEW_ID}.*`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }) .split("\n") .filter(Boolean); } catch { // No git, no repository, or a clone without tags. Tags are only a floor under the // registry, so carry on rather than fail. process.stderr.write("warning: could not list git tags; using the registry alone\n"); return []; } } function readFlag(argv, name) { const index = argv.indexOf(name); return index === -1 ? undefined : argv[index + 1]; } async function main(argv) { const packageJsonPath = readFlag(argv, "--package-json") ?? "package.json"; const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8")); const base = bumpPatch(manifest.version); const registryVersions = await fetchPublishedVersions(manifest.name, { registry: readFlag(argv, "--registry") ?? DEFAULT_REGISTRY, }); const tags = argv.includes("--no-git-tags") ? [] : localPreviewTags(base); // Diagnostics on stderr so stdout stays a single machine-readable line. process.stderr.write( `${manifest.name} ${manifest.version} -> base ${base}; registry high-water ` + `${highestPreviewNumber(registryVersions, base)}, tag high-water ` + `${highestPreviewNumber(tags.map(versionFromTag), base)}\n`, ); process.stdout.write(`${nextPreviewVersion({ base, registryVersions, tags })}\n`); } // Only when invoked as a program, so the helpers above can be imported by the test // without doing any I/O. if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { main(process.argv.slice(2)).catch((error) => { process.stderr.write(`${error.message}\n`); process.exit(1); }); }