CultLib/scripts/check-changelog-semver.mjs
Meta Vens 49a5526ede Release guard tidy: threat model stated, preload route pinned shut, dated changelog headings read
- The npm Publish step starts the guard under `env -u NODE_OPTIONS`, and the
  pin's exact text carries it, so a workflow or job NODE_OPTIONS preload
  cannot run inside the guard (preload-fakes-guard).
- The pin's shell/defaults ban and its test are gone: it defended only a
  deliberate edit, which is review's job (suite-gate-structural-not-parsed),
  and refused a harmless defaults.run.working-directory. A step-level shell
  still fails the exact comparison (shell-ban-deliberate-only).
- docs/semver-policy.md states that the guard and pin stop accidental edits,
  that the cleaned environment removes known variables rather than proving no
  inherited one matters (env-denylist-overclaim), recommends a
  required-reviewer environment for the npm job, and gives the first-release
  note with the valid next versions.
- check-changelog-semver accepts `## [x.y.z] - YYYY-MM-DD` and
  `## x.y.z - YYYY-MM-DD` beside the plain forms (changelog-first-entry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:02:55 +02:00

557 lines
24 KiB
JavaScript

#!/usr/bin/env node
// Refuses a release whose CHANGELOG.md entry claims a breaking change under a
// bump too small to say so, or whose version skips or reverses relative to
// the previous published tag. See docs/semver-policy.md for the policy this
// enforces; this script is only the mechanical check.
//
// Pure, testable logic lives in the exported functions below. `main()` is the
// CLI wrapper that reads the package's declaration (scripts/release-packages.mjs),
// its changelog and the repository's tags, then calls `evaluateRelease`.
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
// The exact heading spelling the policy mandates for a breaking-change
// section. `unity/org.gamecult.cultlib/CHANGELOG.md`'s 1.0.60 entry set this
// precedent; this check is what makes that heading load-bearing instead of
// decorative.
export const BREAKING_HEADING = "### Breaking";
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
export function parseVersion(version) {
const match = VERSION_PATTERN.exec(version.trim());
if (!match) {
throw new Error(`not a MAJOR.MINOR.PATCH version: "${version}"`);
}
const [, major, minor, patch] = match;
return { major: Number(major), minor: Number(minor), patch: Number(patch) };
}
function formatVersion(v) {
return `${v.major}.${v.minor}.${v.patch}`;
}
// The three (and only three) versions that legitimately follow `prev`: one
// component incremented, everything to its right reset to zero. Anything
// else is a skip, a reversal, a no-op, or a multi-step jump — all refused by
// the same rule, since none of them is "the next version".
export function nextVersions(prev) {
return {
major: { major: prev.major + 1, minor: 0, patch: 0 },
minor: { major: prev.major, minor: prev.minor + 1, patch: 0 },
patch: { major: prev.major, minor: prev.minor, patch: prev.patch + 1 },
};
}
function sameVersion(a, b) {
return a.major === b.major && a.minor === b.minor && a.patch === b.patch;
}
function compareVersions(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
// Classifies `next` relative to `prev`. Returns one of "major" | "minor" |
// "patch" when `next` is exactly the next version in that lane, or an
// { invalid: "skip" | "reverse" | "no-op" } descriptor otherwise.
export function classifyBump(prev, next) {
if (sameVersion(prev, next)) {
return { invalid: "no-op" };
}
const candidates = nextVersions(prev);
for (const [lane, candidate] of Object.entries(candidates)) {
if (sameVersion(candidate, next)) {
return lane;
}
}
return { invalid: compareVersions(next, prev) < 0 ? "reverse" : "skip" };
}
// Extracts the `## [<version>]` section of a changelog (also `## <version>`,
// and either with Keep a Changelog's ` - YYYY-MM-DD` date) and reports whether
// it carries a BREAKING_HEADING subsection. Throws if the version has no entry
// at all — an undocumented release is refused the same as a mislabelled one.
export function hasBreakingSection(changelogText, version) {
const sectionHeading = new RegExp(
`^##\\s*\\[?${version.replace(/\./g, "\\.")}\\]?(\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`,
"m",
);
const startMatch = sectionHeading.exec(changelogText);
if (!startMatch) {
throw new Error(`no "## [${version}]" entry found in changelog`);
}
const start = startMatch.index + startMatch[0].length;
const rest = changelogText.slice(start);
const nextSectionMatch = /^##\s/m.exec(rest);
const section = nextSectionMatch ? rest.slice(0, nextSectionMatch.index) : rest;
const breakingHeading = new RegExp(`^${BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
return breakingHeading.test(section);
}
// The pre-1.0 lane a package is releasing FROM. `0.y.z` treats a minor bump
// as the breaking lane (semver's own 0.y.z convention: MAJOR is pinned at 0,
// so MINOR carries what MAJOR would mean once the package reaches 1.0.0).
function isPreOneZero(prev) {
return prev.major === 0;
}
// Evaluates one release. `previousVersion` may be null for a package's first
// ever release (nothing to compare against, so the bump rule does not apply,
// but a changelog entry is still required). `measuredBreaks` lists the public
// API diagnostics measured against the previous tag's assemblies; a non-empty
// list makes the release breaking whether or not the changelog says so, and
// the changelog must then declare it.
export function evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks = [] }) {
const next = parseVersion(version);
let declared;
try {
declared = hasBreakingSection(changelogText, version);
} catch (err) {
return { ok: false, reason: `${packageName}: ${err.message}` };
}
if (previousVersion == null) {
return { ok: true, reason: `${packageName}: first release (${version}); no prior version to compare against` };
}
const prev = parseVersion(previousVersion);
const bump = classifyBump(prev, next);
if (typeof bump !== "string") {
const verb = bump.invalid === "no-op" ? "repeats" : bump.invalid === "reverse" ? "reverses" : "skips ahead of";
return {
ok: false,
reason: `${packageName}: version ${version} ${verb} the previous published version ${previousVersion}; ` +
`the next version must be exactly one of ${formatVersion(nextVersions(prev).major)}, ` +
`${formatVersion(nextVersions(prev).minor)}, or ${formatVersion(nextVersions(prev).patch)}`,
};
}
const measured = measuredBreaks.length > 0;
if (measured && !declared) {
const shown = measuredBreaks.slice(0, 5).join("; ") + (measuredBreaks.length > 5 ? "; ..." : "");
return {
ok: false,
reason: `${packageName}: ${version} breaks ${measuredBreaks.length} public API member(s) measured against ` +
`${previousVersion} (${shown}); declare them under "${BREAKING_HEADING}" in the changelog and bump accordingly`,
};
}
if (!declared) {
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion}, no breaking section` };
}
const evidence = measured
? `a "${BREAKING_HEADING}" section and ${measuredBreaks.length} measured public API break(s)`
: `a "${BREAKING_HEADING}" section`;
const pre1 = isPreOneZero(prev);
const requiredLanes = pre1 ? ["major", "minor"] : ["major"];
if (!requiredLanes.includes(bump)) {
const requirement = pre1
? `at least a minor bump (the 0.y.z convention's breaking lane)`
: `a major bump`;
return {
ok: false,
reason: `${packageName}: ${version} has ${evidence} but is only a ${bump} bump ` +
`over ${previousVersion}; a breaking release needs ${requirement}`,
};
}
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and has ${evidence}, which agree` };
}
// --- CLI wrapper: reads the package's declaration, the release record (git tags) and the
// changelog, then delegates to the pure functions above. ---
// The release record, derived from `git tag -l` in --cwd. The previous release is the
// newest '<prefix>-vMAJOR.MINOR.PATCH' tag strictly older than the version, by version order
// (never git's string order), that is also an ancestor of the commit being checked (HEAD of
// --cwd): a backport tagged later on another branch is not in this commit's history, so it
// never changes a rebuild's predecessor. The version's own tag is ignored, so a rebuild at a
// tagged version gets its release-time verdict. A same-prefix tag that does not parse is never
// skipped. A first release is the absence of any older tag in a checkout that holds other tags (a
// checkout with none cannot tell a first release from a missing record), none of which, under
// any prefix, tracks a managed assembly under the package's declared assemblies directory. A
// package that declares none has no such proof: the prefix of an assembly-less package is
// pinned by the suite test that ties every declared prefix to its tag trigger.
function isAncestorOfHead(tag, cwd) {
const run = spawnSync("git", ["merge-base", "--is-ancestor", `refs/tags/${tag}`, "HEAD"], { cwd, stdio: "ignore" });
return run.status === 0 ? true : run.status === 1 ? false : null;
}
function resolveRelease(tagPrefix, versionText, cwd, assemblies) {
let tags;
try {
tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
} catch {
return { failure: "the tags of --cwd could not be read (not a git repository?), so the previous release is unknown" };
}
const prefix = `${tagPrefix}-v`;
const current = parseVersion(versionText);
const own = `${prefix}${formatVersion(current)}`;
const older = [];
let newer = null;
for (const tag of tags) {
if (!tag.startsWith(prefix)) continue;
const text = tag.slice(prefix.length);
if (!VERSION_PATTERN.test(text)) {
return { failure: `${tag} starts with ${prefix} but is not ${prefix}MAJOR.MINOR.PATCH, so it cannot be ordered among the releases` };
}
const candidate = parseVersion(text);
const order = compareVersions(candidate, current);
if (order === 0) continue; // the tag being released itself
if (order > 0) {
newer ??= tag;
} else {
older.push({ tag, version: candidate });
}
}
older.sort((a, b) => compareVersions(b.version, a.version));
for (const { tag, version } of older) {
const ancestor = isAncestorOfHead(tag, cwd);
if (ancestor === null) return { failure: `whether ${tag} is an ancestor of the commit being checked could not be read` };
if (ancestor) return { previous: formatVersion(version) };
}
if (older.length > 0) {
return { failure: `${older.length} older ${prefix} tag(s) exist but none is an ancestor of the commit being checked, so the previous release is unknown` };
}
if (newer !== null) {
return { failure: `${newer} exists but no ${prefix} tag is older than ${formatVersion(current)}: the version is older than every release` };
}
if (!tags.some((tag) => tag !== own)) {
return { failure: "this checkout holds no tags; a first release cannot be told from a missing record (fetch the tags)" };
}
if (typeof assemblies === "string") {
// Under every other prefix too: a package whose assemblies some tag has shipped is not new,
// whatever the declared prefix says. This is read from git, never from the changelog's prose.
const tracked = firstTagTrackingManaged(tags.filter((tag) => tag !== own), assemblies, cwd);
if (tracked.failure) return tracked;
if (tracked.tag) {
return { failure: `no ${prefix} tag is an earlier release, but ${tracked.tag} tracks the managed assembly ${tracked.path}: the package has shipped before, so the declared tag prefix is wrong` };
}
}
return { previous: null };
}
// The first of the tags that tracks a managed assembly anywhere under `dir`, or none. Whether a
// blob is managed is decided by its bytes once per blob id, however many tags track it. A tag
// that cannot be read, or a .dll that is not a PE image, is a refusal, never a tag that tracks
// nothing.
function firstTagTrackingManaged(tags, dir, cwd) {
const managedBlobs = new Map();
for (const tag of tags) {
try {
const entries = execFileSync("git", ["ls-tree", "-r", "-z", `refs/tags/${tag}`, "--", `${dir}/`], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
.split("\0")
.filter((entry) => entry.toLowerCase().endsWith(".dll"));
for (const entry of entries) {
const tab = entry.indexOf("\t");
const id = entry.slice(0, tab).split(" ")[2];
const path = entry.slice(tab + 1);
if (!managedBlobs.has(id)) {
const bytes = execFileSync("git", ["cat-file", "blob", id], { cwd, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 28 });
try {
managedBlobs.set(id, isManagedAssembly(bytes));
} catch {
return { failure: `${path} at ${tag} is a .dll but not a PE image, so a first release cannot be told from a mistyped prefix` };
}
}
if (managedBlobs.get(id)) return { tag, path };
}
} catch {
return { failure: `the assemblies ${tag} tracks under ${dir} could not be read, so a first release cannot be told from a mistyped prefix` };
}
}
return {};
}
// The declaration as a whole: every entry names a string tagPrefix and changelog, and no two
// packages share a prefix (each would be measured against the other's releases). Returns a
// refusal text, or null.
export function checkDeclaration(declared) {
const seen = new Map();
for (const [name, entry] of Object.entries(declared ?? {})) {
if (entry == null || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") {
return `${name} must declare a string tagPrefix and a string changelog`;
}
if (seen.has(entry.tagPrefix)) return `${seen.get(entry.tagPrefix)} and ${name} share one tag prefix`;
seen.set(entry.tagPrefix, name);
}
return null;
}
// --- Measured public API: the built DLLs are compared with the DLLs the
// previous tag tracked, by Microsoft.DotNet.ApiCompat.Tool. The tool, and the
// netstandard reference assemblies it needs to resolve inherited members, are
// pinned in scripts/api-gate/ and restored there; nothing here compares
// members itself. ---
const gateRoot = join(dirname(fileURLToPath(import.meta.url)), "api-gate");
const DIAGNOSTIC_LINE = /^CP\d{4}: /;
// One ApiCompat run's verdict. A diagnostic line is a measured break. A
// non-zero exit with no diagnostic line means the tool did not measure
// anything (missing tool, unreadable input, crash): that is a failure, never a
// pass.
export function readApiCompatRun({ status, output }) {
const breaks = output.split(/\r?\n/).filter((line) => DIAGNOSTIC_LINE.test(line));
if (breaks.length === 0 && status !== 0) {
return { failure: `apicompat exited ${status} without measuring: ${output.trim().split(/\r?\n/).slice(0, 5).join(" | ")}` };
}
return { breaks };
}
function runApiCompat(args) {
const run = spawnSync("dotnet", ["tool", "run", "apicompat", ...args], { cwd: gateRoot, encoding: "utf8" });
return readApiCompatRun({ status: run.status, output: `${run.stdout}${run.stderr}` });
}
function lastLines(text) {
return text.trim().split(/\r?\n/).slice(-3).join(" | ");
}
// Restores the pinned tool and the pinned netstandard reference package, and
// returns the reference assemblies' directory. Both come from scripts/api-gate/
// and need NuGet only until they are cached there.
function prepareGate() {
if (!existsSync(join(gateRoot, ".config", "dotnet-tools.json"))) {
return { failure: "the api-gate tool manifest (scripts/api-gate/.config/dotnet-tools.json) is missing" };
}
for (const [what, args] of [["dotnet tool restore", ["tool", "restore"]], ["dotnet restore", ["restore", "ApiRefs.csproj", "--nologo", "-v", "q"]]]) {
const run = spawnSync("dotnet", args, { cwd: gateRoot, encoding: "utf8" });
if (run.status !== 0) {
return { failure: `${what} failed: ${run.error ? run.error.message : lastLines(`${run.stdout}${run.stderr}`)}` };
}
}
const packageDir = join(gateRoot, "packages", "netstandard.library.ref");
const versions = existsSync(packageDir) ? readdirSync(packageDir).sort() : [];
const refs = versions.length > 0 ? join(packageDir, versions[0], "ref", "netstandard2.1") : null;
if (refs == null || !existsSync(join(refs, "netstandard.dll"))) {
return { failure: "the pinned netstandard reference assemblies were not restored under scripts/api-gate/packages" };
}
return { refs };
}
// Whether a PE image is a managed assembly: its optional header's data directory 14 (the
// CLI header) is non-empty. A PE without one is native and ApiCompat cannot read it. A buffer
// that is not a PE image, or is cut short, throws: decided from the bytes, never from the
// file's name or place.
export function isManagedAssembly(bytes) {
const need = (end) => {
if (end > bytes.length) throw new RangeError("not a PE image");
};
need(0x40);
if (bytes.readUInt16LE(0) !== 0x5a4d) throw new RangeError("not a PE image");
const pe = bytes.readUInt32LE(0x3c);
need(pe + 24);
if (bytes.readUInt32LE(pe) !== 0x00004550) throw new RangeError("not a PE image");
const optionalSize = bytes.readUInt16LE(pe + 20);
const optional = pe + 24;
const magic = bytes.readUInt16LE(optional);
if (magic !== 0x10b && magic !== 0x20b) throw new RangeError("not a PE image");
const directories = optional + (magic === 0x10b ? 96 : 112);
need(directories);
if (bytes.readUInt32LE(directories - 4) <= 14 || optionalSize < directories + 15 * 8 - optional) return false;
need(directories + 15 * 8);
return bytes.readUInt32LE(directories + 14 * 8) !== 0 && bytes.readUInt32LE(directories + 14 * 8 + 4) !== 0;
}
// The managed assemblies the previous tag tracked anywhere under `dir`, as git blobs: no
// worktree, no network, and unaffected by what the working tree holds now. Each is written to
// `into` by file name. A git failure is a refusal that names the step, never git's own output.
function extractBaseline({ tag, dir, into, cwd }) {
const git = (args, options) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], ...options });
const blobs = [];
try {
const paths = git(["ls-tree", "-r", "-z", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" })
.split("\0")
.filter((path) => path.toLowerCase().endsWith(".dll"));
for (const path of paths) blobs.push({ path, bytes: git(["show", `${tag}:${path}`], { maxBuffer: 1 << 28 }) });
} catch {
return { failure: `git could not read the assemblies tracked under ${dir} at ${tag}` };
}
const names = [];
for (const { path, bytes } of blobs) {
let managed;
try {
managed = isManagedAssembly(bytes);
} catch {
return { failure: `${path} at ${tag} is a .dll but not a PE image` };
}
if (!managed) continue;
const name = basename(path);
if (names.includes(name)) return { failure: `${tag} tracks two managed assemblies named ${name} under ${dir}` };
writeFileSync(join(into, name), bytes);
names.push(name);
}
return { names };
}
// Compares each built assembly with its namesake at the previous tag. Measuring nothing is a
// failure: the declared directory must hold a managed assembly at the tag. A managed assembly
// the tag tracked and the build no longer hands over is itself a break, whatever its name or
// place; a built assembly with no namesake is an addition and is not examined.
export function measureApiBreaks({ tagPrefix, previousVersion, assemblies, built, refs, cwd }) {
const tag = `${tagPrefix}-v${previousVersion}`;
const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-"));
try {
const left = join(work, "left");
mkdirSync(left);
const baseline = extractBaseline({ tag, dir: assemblies, into: left, cwd });
if (baseline.failure) return baseline;
const baselineNames = baseline.names;
if (baselineNames.length === 0) {
return { failure: `${tag} tracks no managed assembly under ${assemblies}: the declared directory is missing or moved, or that tag does not track the DLLs` };
}
const gate = prepareGate();
if (gate.failure) return gate;
const builtNames = new Set(built.map((path) => basename(path)));
const breaks = baselineNames
.filter((name) => !builtNames.has(name))
.map((name) => `assembly ${name} exists at ${tag} but is no longer built`);
for (const path of built) {
const name = basename(path);
if (!baselineNames.includes(name)) continue;
const leftRefs = [left, gate.refs].join(",");
const rightRefs = [...new Set([dirname(path), ...refs, gate.refs])].join(",");
const run = runApiCompat(["--left-assembly", join(left, name), "--right-assembly", path, "--left-assembly-references", leftRefs, "--right-assembly-references", rightRefs]);
if (run.failure) return { failure: `${name}: ${run.failure}` };
breaks.push(...run.breaks);
}
return { breaks };
} finally {
rmSync(work, { recursive: true, force: true });
}
}
// Repeatable options collect into arrays. Any other option is a usage error.
const KNOWN = new Set(["package", "version", "cwd", "api-built", "api-refs"]);
const REPEATABLE = new Set(["api-built", "api-refs"]);
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
if (!KNOWN.has(key)) return { unknown: arg };
if (REPEATABLE.has(key)) {
(args[key] ??= []).push(argv[i + 1]);
} else {
args[key] = argv[i + 1];
}
i += 1;
}
return args;
}
function refuse(message) {
console.error(message);
process.exitCode = 1;
}
const UNMEASURED_NOTICE =
'public API measured; changes ApiCompat cannot see (see "Unmeasured changes" in docs/semver-policy.md) must still be declared under "### Breaking"';
async function main() {
const args = parseArgs(process.argv.slice(2));
const { package: packageName, version, cwd = process.cwd() } = args;
// The tool runs from scripts/api-gate, so a path given relative to this process is made absolute first.
const built = (args["api-built"] ?? []).map((path) => resolve(path));
if (args.unknown || !packageName || !version) {
console.error(
"usage: check-changelog-semver.mjs --package <name> --version <x.y.z> [--cwd <dir>] [--api-built <dll>... [--api-refs <dir>...]]",
);
process.exitCode = 2;
return;
}
// Checked before anything reads tags: a malformed version is its own refusal, and the
// value is not repeated back.
if (!VERSION_PATTERN.test(version.trim())) {
refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`);
return;
}
// The package's release identity is declared once, in the repository being released.
let declared;
try {
declared = (await import(pathToFileURL(resolve(cwd, "scripts", "release-packages.mjs")).href)).default;
} catch {
refuse(`${packageName}: scripts/release-packages.mjs could not be loaded from --cwd`);
return;
}
const malformed = checkDeclaration(declared);
if (malformed) {
refuse(`${packageName}: scripts/release-packages.mjs is malformed: ${malformed}`);
return;
}
const entry = Object.hasOwn(declared ?? {}, packageName) ? declared[packageName] : null;
if (entry == null) {
refuse(`${packageName}: not a package declared in scripts/release-packages.mjs`);
return;
}
const { tagPrefix, assemblies } = entry;
const changelog = resolve(cwd, entry.changelog);
let changelogText;
try {
changelogText = readFileSync(changelog, "utf8");
} catch (err) {
refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`);
return;
}
if (typeof assemblies === "string" && built.length === 0) {
refuse(`${packageName}: ships the assemblies under ${assemblies}, so the release must hand them over with --api-built`);
return;
}
if (typeof assemblies !== "string" && built.length > 0) {
refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`);
return;
}
const release = resolveRelease(tagPrefix, version, cwd, assemblies);
if (release.failure) {
refuse(`${packageName}: ${release.failure}`);
return;
}
const previousVersion = release.previous;
let measuredBreaks = [];
if (previousVersion != null && built.length > 0) {
const measurement = measureApiBreaks({
tagPrefix,
previousVersion,
assemblies,
built,
refs: (args["api-refs"] ?? []).map((path) => resolve(path)),
cwd,
});
if (measurement.failure) {
refuse(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`);
return;
}
measuredBreaks = measurement.breaks;
}
const result = evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks });
console.log(result.reason);
if (!result.ok) {
process.exitCode = 1;
return;
}
if (previousVersion != null && built.length > 0) {
console.log(UNMEASURED_NOTICE);
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
await main();
}