Semver check derives what callers used to declare: first release, tag prefix, changelog and measured set

The checker reads each package's tag prefix, changelog and assemblies directory
from scripts/release-packages.mjs and derives the release record from git tags.
A first release is the absence of any older tag in a checkout that holds other
tags; a malformed same-prefix tag, a checkout without tags and an unreadable
repository refuse. The measured set is every managed assembly (CLI header in
the PE bytes) tracked at the previous tag at any depth; an unbuilt one is a
break, a built assembly with no namesake is an addition.

Deleted with their branches, refusals and tests: --first-release, --api-new,
--tag-prefix, --changelog, --api-baseline-path and the workflow first-release
decision line. Any unknown option is now a usage error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 02:36:54 +02:00
commit 23d7beac20
7 changed files with 514 additions and 322 deletions

View file

@ -75,17 +75,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
run: |
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
# The package's first release carries --first-release. The checker believes it only
# when no other cultcache-ts tag exists, so this test only avoids refusing the
# releases that follow; it does not decide anything.
first=""
if [ -z "$(git tag -l 'cultcache-ts-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi
node scripts/check-changelog-semver.mjs \
--package "@gamecult/cultcache-ts" \
--changelog packages/cultcache-ts/CHANGELOG.md \
--version "$tagged" \
--tag-prefix cultcache-ts \
$first
node scripts/check-changelog-semver.mjs --package "@gamecult/cultcache-ts" --version "$tagged"
- name: Build and test
working-directory: packages/cultcache-ts
@ -165,15 +155,7 @@ jobs:
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
# See the cultcache-ts job: the checker, not this test, decides whether a first release is real.
first=""
if [ -z "$(git tag -l '${{ matrix.package }}-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi
node scripts/check-changelog-semver.mjs \
--package "${{ matrix.package }}" \
--changelog packages/${{ matrix.package }}/CHANGELOG.md \
--version "$tagged" \
--tag-prefix "${{ matrix.package }}" \
$first
node scripts/check-changelog-semver.mjs --package "${{ matrix.package }}" --version "$tagged"
# Lower layers install from source so a release of one package is tested
# against the sibling revision it was cut with, not whatever PyPI has.

View file

@ -50,11 +50,8 @@ $cultLibRoot = Split-Path -Parent (Split-Path -Parent $repoRoot)
$cultmathVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultmath" `
--changelog (Join-Path $templateRoot "CHANGELOG.md") `
--version $cultmathVersion `
--tag-prefix "cultmath-unity" `
--cwd $cultLibRoot `
--api-baseline-path "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins" `
--api-built $freshAssembly
if ($LASTEXITCODE -ne 0) {
throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)."

View file

@ -176,6 +176,8 @@ foreach ($assemblyName in $expectedAssemblies) {
# the public API of every shipped assembly against the DLLs the previous cultlib-unity tag
# tracked (read from git), so it runs here, once the assemblies under judgement exist. Every
# tracked assembly the build does not hand over counts as removed, so all of them are handed over.
# The package's tag prefix, changelog and assemblies directory are declared in
# scripts/release-packages.mjs; this call passes only the version and the built assemblies.
$measuredArguments = @()
foreach ($assemblyName in $expectedAssemblies) {
$measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName)
@ -185,11 +187,8 @@ foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) {
}
& node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultlib" `
--changelog (Join-Path $templateRoot "CHANGELOG.md") `
--version $unityPackageVersion `
--tag-prefix "cultlib-unity" `
--cwd $repoRoot `
--api-baseline-path "unity/org.gamecult.cultlib/Runtime/Plugins" `
@measuredArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)."

View file

@ -5,8 +5,8 @@
// 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 changelog file and asks git for the previous
// published tag, then calls `evaluateRelease`.
// 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";
@ -164,40 +164,53 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and has ${evidence}, which agree` };
}
// --- CLI wrapper: resolves the previous published tag via git and reads the
// changelog off disk, then delegates to the pure functions above. ---
// --- CLI wrapper: reads the package's declaration, the release record (git tags) and the
// changelog, then delegates to the pure functions above. ---
// The previous release is the newest same-prefix tag strictly older than the version.
// `other` is any same-prefix tag that is not the version itself, older or newer: a
// package with one has been released before, whatever the version arithmetic says.
function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
// A directory that cannot list tags (not a git repository, git missing) throws: that
// is not evidence that there was no previous release.
const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
// 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; 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, because a checkout with none cannot tell a first release
// from a missing record.
function resolveRelease(tagPrefix, versionText, cwd) {
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(currentVersion);
const current = parseVersion(versionText);
const own = `${prefix}${formatVersion(current)}`;
let best = null;
let other = null;
let newer = null;
for (const tag of tags) {
if (!tag.startsWith(prefix)) continue;
const versionText = tag.slice(prefix.length);
let candidate;
try {
candidate = parseVersion(versionText);
} catch {
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` };
}
if (sameVersion(candidate, current)) continue; // the tag being released itself
other ??= tag;
if (compareVersions(candidate, current) >= 0) continue; // ignore anything not older
if (best === null || compareVersions(candidate, best) > 0) {
const candidate = parseVersion(text);
const order = compareVersions(candidate, current);
if (order === 0) continue; // the tag being released itself
if (order > 0) {
newer ??= tag;
} else if (best === null || compareVersions(candidate, best) > 0) {
best = candidate;
}
}
return { previous: best ? formatVersion(best) : null, other };
if (best !== null) return { previous: formatVersion(best) };
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)" };
}
return { previous: null };
}
// --- Measured public API: the built DLLs are compared with the DLLs the
@ -252,57 +265,83 @@ function prepareGate() {
return { refs };
}
// The DLLs the previous tag tracked under `baselinePath`, as git blobs: no
// worktree, no network, and unaffected by what the working tree holds now. A git
// failure is a refusal that names the step, never git's own output.
function extractBaseline({ tag, baselinePath, into, cwd }) {
const dir = baselinePath.replace(/\\/g, "/").replace(/\/+$/, "");
// 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 listing = git(["ls-tree", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" });
const names = listing.split("\n").map((line) => basename(line.trim())).filter((name) => name.toLowerCase().endsWith(".dll"));
for (const name of names) {
writeFileSync(join(into, name), git(["show", `${tag}:${dir}/${name}`], { maxBuffer: 1 << 28 }));
}
return { dir, names };
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 baseline path must hold DLLs at the tag, every
// built assembly must have a namesake there unless the caller declares it new
// (`declaredNew`), and at least one comparison must run. An assembly the tag
// tracked and the build no longer produces is itself a break, whatever its name.
export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, declaredNew = [], cwd }) {
// 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, baselinePath, into: left, cwd });
const baseline = extractBaseline({ tag, dir: assemblies, into: left, cwd });
if (baseline.failure) return baseline;
const { dir, names: baselineNames } = baseline;
const baselineNames = baseline.names;
if (baselineNames.length === 0) {
return { failure: `${tag} tracks no assembly under ${dir}: the baseline path is missing or moved, or that tag does not track the DLLs` };
}
const builtNames = new Set(built.map((path) => basename(path)));
for (const name of declaredNew) {
if (baselineNames.includes(name)) return { failure: `${name} is declared new but ${tag} already tracks it under ${dir}` };
}
for (const name of builtNames) {
if (!baselineNames.includes(name) && !declaredNew.includes(name)) {
return { failure: `${name} is built but ${tag} does not track it under ${dir}; if it is a new assembly, declare it with --api-new ${name}` };
}
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 unbuilt = baselineNames.filter((name) => !builtNames.has(name));
if (unbuilt.length === baselineNames.length) {
return { failure: `no built assembly has a namesake tracked at ${tag}, so nothing was compared (${tag} tracks ${baselineNames.join(", ")})` };
}
const breaks = unbuilt.map((name) => `assembly ${name} exists at ${tag} but is no longer built`);
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;
@ -318,9 +357,9 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui
}
}
// Repeatable options collect into arrays; flags take no value.
const REPEATABLE = new Set(["api-built", "api-refs", "api-new"]);
const FLAGS = new Set(["first-release"]);
// 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 = {};
@ -328,10 +367,7 @@ function parseArgs(argv) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
if (FLAGS.has(key)) {
args[key] = true;
continue;
}
if (!KNOWN.has(key)) return { unknown: arg };
if (REPEATABLE.has(key)) {
(args[key] ??= []).push(argv[i + 1]);
} else {
@ -350,15 +386,14 @@ function refuse(message) {
const UNMEASURED_NOTICE =
'public API measured; changes ApiCompat cannot see (see "Unmeasured changes" in docs/semver-policy.md) must still be declared under "### Breaking"';
function main() {
async function main() {
const args = parseArgs(process.argv.slice(2));
const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args;
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 (!packageName || !changelog || !version || !tagPrefix || (built.length > 0) !== Boolean(args["api-baseline-path"])) {
if (args.unknown || !packageName || !version) {
console.error(
"usage: check-changelog-semver.mjs --package <name> --changelog <path> --version <x.y.z> --tag-prefix <prefix> [--cwd <dir>] [--first-release]\n" +
" [--api-baseline-path <repo-relative dir of tracked DLLs> --api-built <dll>... [--api-refs <dir>...] [--api-new <dll name>...]]",
"usage: check-changelog-semver.mjs --package <name> --version <x.y.z> [--cwd <dir>] [--api-built <dll>... [--api-refs <dir>...]]",
);
process.exitCode = 2;
return;
@ -369,6 +404,21 @@ function main() {
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 entry = Object.hasOwn(declared ?? {}, packageName) ? declared[packageName] : null;
if (entry == null || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") {
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");
@ -376,44 +426,28 @@ function main() {
refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`);
return;
}
const firstRelease = args["first-release"] === true;
let previousVersion;
let otherTag = null;
try {
({ previous: previousVersion, other: otherTag } = resolvePreviousVersion(tagPrefix, version, cwd));
} catch {
if (!firstRelease) {
refuse(`${packageName}: the tags of --cwd could not be read (not a git repository?), so the previous release is unknown; ` +
"if this is the package's first release, declare it with --first-release");
return;
}
previousVersion = null;
}
if (previousVersion == null && !firstRelease) {
refuse(`${packageName}: no ${tagPrefix}-v tag older than ${version} exists, so there is nothing to compare against; ` +
"if this is the package's first release, declare it with --first-release");
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 (firstRelease && otherTag != null) {
refuse(`${packageName}: --first-release was declared but ${otherTag} already exists`);
if (typeof assemblies !== "string" && built.length > 0) {
refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`);
return;
}
// A package's tag prefix is its directory under packages/ (the workflow's tag triggers and
// job matrix spell it the same way), so a first release is believed only for the prefix
// its changelog's directory names.
if (firstRelease && basename(dirname(resolve(changelog))) !== tagPrefix) {
refuse(`${packageName}: --first-release is accepted only when --tag-prefix names the directory that holds the changelog`);
const release = resolveRelease(tagPrefix, version, cwd);
if (release.failure) {
refuse(`${packageName}: ${release.failure}`);
return;
}
const previousVersion = release.previous;
let measuredBreaks = [];
if (built.length > 0 && previousVersion != null) {
if (previousVersion != null && built.length > 0) {
const measurement = measureApiBreaks({
tagPrefix,
previousVersion,
baselinePath: args["api-baseline-path"],
assemblies,
built,
refs: (args["api-refs"] ?? []).map((path) => resolve(path)),
declaredNew: args["api-new"] ?? [],
cwd,
});
if (measurement.failure) {
@ -435,5 +469,5 @@ function main() {
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
main();
await main();
}

View file

@ -11,6 +11,7 @@ import {
classifyBump,
evaluateRelease,
hasBreakingSection,
isManagedAssembly,
nextVersions,
parseVersion,
readApiCompatRun,
@ -188,9 +189,22 @@ test("evaluateRelease names package, previous version, new version, and the brea
assert.match(result.reason, new RegExp(BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
// --- CLI-level integration tests: exercise the git-backed previous-version
// resolution and the "don't re-litigate an already-tagged release" skip,
// which the pure evaluateRelease() tests above cannot reach. ---
// --- CLI-level integration tests: exercise the declaration, the git-backed release
// resolution and the derived first release, which the pure evaluateRelease() tests
// above cannot reach. ---
const PLUGINS = "unity/widget/Runtime/Plugins";
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const workflowFile = join(repoRoot, ".github", "workflows", "publish-packages.yml");
const releasePackages = (await import("./release-packages.mjs")).default;
// The package every fixture repository declares in its own scripts/release-packages.mjs.
const WIDGET = { tagPrefix: "widget", changelog: "CHANGELOG.md" };
function declare(dir, entries) {
mkdirSync(join(dir, "scripts"), { recursive: true });
writeFileSync(join(dir, "scripts", "release-packages.mjs"), `export default ${JSON.stringify(entries)};\n`);
}
function withTempGitRepo(fn) {
const dir = mkdtempSync(join(tmpdir(), "cultlib-semver-check-"));
@ -198,6 +212,7 @@ function withTempGitRepo(fn) {
execFileSync("git", ["init", "-q"], { cwd: dir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
declare(dir, { widget: WIDGET });
fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
@ -226,143 +241,180 @@ function runCheckerExpectFailure(cwd, args, { status = 1, ...options } = {}) {
}
}
const git = (dir, ...args) => execFileSync("git", args, { cwd: dir });
const commitAndTag = (dir, ...tags) => {
git(dir, "commit", "--allow-empty", "-q", "-m", "init");
for (const tag of tags) git(dir, "tag", tag);
};
const writeChangelog = (dir, version, options) => writeFileSync(join(dir, "CHANGELOG.md"), changelogFor(version, options));
const widgetArgs = (version) => ["--package", "widget", "--version", version];
test("CLI: resolves the previous version from git tags and passes a valid bump", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.1.0", { breaking: false }));
const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget"]);
commitAndTag(dir, "widget-v1.0.0");
writeChangelog(dir, "1.1.0");
const output = runChecker(dir, widgetArgs("1.1.0"));
assert.match(output, /1\.1\.0 is a minor bump over 1\.0\.0/);
});
});
test("CLI: fails when the changelog claims breaking under too small a bump", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true }));
const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]);
commitAndTag(dir, "widget-v1.0.0");
writeChangelog(dir, "1.0.1", { breaking: true });
const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1"));
assert.match(output, /needs a major bump/);
});
});
test("CLI: a tag for the version being released does not skip the check", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.1"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true }));
const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]);
commitAndTag(dir, "widget-v1.0.0", "widget-v1.0.1");
writeChangelog(dir, "1.0.1", { breaking: true });
const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1"));
assert.match(output, /needs a major bump/);
});
});
// A package's changelog lives in a directory named like its tag prefix (packages/<prefix>/).
function packageChangelog(dir, version, prefix = "widget") {
mkdirSync(join(dir, prefix), { recursive: true });
const path = join(dir, prefix, "CHANGELOG.md");
writeFileSync(path, changelogFor(version));
return path;
}
test("CLI: no previous tag, or tags that cannot be read, refuse unless the first release is declared", () => {
test("CLI: a first release is derived from the tags, and nothing is measured for it", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "other-v1.0.0"], { cwd: dir });
const changelogPath = packageChangelog(dir, "1.0.0");
const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"];
assert.match(runCheckerExpectFailure(dir, args), /nothing to compare against; if this is the package's first release, declare it with --first-release/);
assert.match(runChecker(dir, [...args, "--first-release"]), /first release \(1\.0\.0\)/);
// A flag takes no value: the option after it is still read.
assert.match(runChecker(dir, ["--first-release", ...args]), /first release/);
// another package's tag exists; this package has none
commitAndTag(dir, "other-v1.0.0");
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
const junk = join(dir, "junk.dll");
writeFileSync(junk, "junk");
const measured = runChecker(dir, measuredArgs(dir, "1.0.0", [junk]));
assert.match(measured, /first release \(1\.0\.0\)/);
assert.doesNotMatch(measured, /public API measured/);
declare(dir, { widget: WIDGET });
assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/);
// the version's own tag, beside another package's, is still a first release
git(dir, "tag", "widget-v1.0.0");
assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/);
});
});
test("CLI: a checkout that holds no other tag refuses, and no option rescues it", () => {
const holdsNone = /this checkout holds no tags; a first release cannot be told from a missing record/;
withTempGitRepo((dir) => {
git(dir, "commit", "--allow-empty", "-q", "-m", "init");
writeChangelog(dir, "1.0.0");
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), holdsNone);
git(dir, "tag", "widget-v1.0.0");
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), holdsNone);
// an option that used to declare the release is now an unknown option, not a rescue
runCheckerExpectFailure(dir, [...widgetArgs("1.0.0"), "--first-release"], { status: 2 });
});
});
test("CLI: tags that cannot be read refuse without git's output", () => {
const notARepo = mkdtempSync(join(tmpdir(), "cultlib-semver-norepo-"));
try {
const changelogPath = packageChangelog(notARepo, "1.0.0");
const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"];
const refused = runCheckerExpectFailure(notARepo, args);
assert.match(refused, /tags of --cwd could not be read .*; if this is the package's first release, declare it with --first-release/);
declare(notARepo, { widget: WIDGET });
writeChangelog(notARepo, "1.0.0");
const refused = runCheckerExpectFailure(notARepo, widgetArgs("1.0.0"));
assert.match(refused, /tags of --cwd could not be read \(not a git repository\?\), so the previous release is unknown/);
assert.doesNotMatch(refused, /fatal/); // git's own complaint is not forwarded
assert.match(runChecker(notARepo, [...args, "--first-release"]), /first release/);
} finally {
rmSync(notARepo, { recursive: true, force: true });
}
});
test("CLI: declaring a first release while an earlier tag exists is refused", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.1.0"));
const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget", "--first-release"]);
assert.match(output, /--first-release was declared but widget-v1\.0\.0 already exists/);
});
});
test("CLI: --first-release is believed only with no other same-prefix tag and the package's own prefix", () => {
const declared = (dir, version, prefix = "widget") => [
"--package", "widget", "--changelog", packageChangelog(dir, version), "--version", version, "--tag-prefix", prefix, "--first-release",
];
// a newer tag of the prefix: not a first release (the unflagged run refuses it too)
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
execFileSync("git", ["tag", "widget-v2.0.0"], { cwd: dir });
assert.match(runCheckerExpectFailure(dir, declared(dir, "0.9.0")), /--first-release was declared but widget-v(1|2)\.0\.0 already exists/);
});
// a mistyped prefix, with the real package's tags in the repository
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const args = declared(dir, "1.0.1", "widgit");
assert.match(runCheckerExpectFailure(dir, args), /--first-release is accepted only when --tag-prefix names the directory that holds the changelog/);
// the right prefix names the tag that exists, so it is refused for that
assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1")), /already exists/);
});
// with the predecessor tag deleted, a mistyped prefix is still refused by the prefix check
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
execFileSync("git", ["tag", "-d", "widget-v1.0.0"], { cwd: dir });
assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1", "widgit")), /names the directory that holds the changelog/);
});
// the version's own tag is not another tag: a re-run of a package's only release
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
assert.match(runChecker(dir, declared(dir, "1.0.0")), /first release \(1\.0\.0\)/);
});
});
// The publish workflow's own first-release decision, run as the workflow runs it, then handed
// to the checker: a package's first release passes, and the release after it does too.
const workflowFile = fileURLToPath(new URL("../.github/workflows/publish-packages.yml", import.meta.url));
test("workflow: the --first-release line passes a package's first release and its second", { skip: process.platform === "win32" }, () => {
const workflow = readFileSync(workflowFile, "utf8");
const lines = [...workflow.matchAll(/^ *if \[ -z "\$\(git tag -l '([^']*)-v\*' [^\n]*first="--first-release"; fi\r?$/gm)];
assert.equal(lines.length, 2, "the cultcache-ts job and the python matrix each decide it once");
for (const [line, prefix] of lines) {
const decision = line.trim().replace("${{ matrix.package }}", "widget");
const first = (dir, tag) => execFileSync("bash", ["-c", `${decision}; printf '%s' "$first"`], { cwd: dir, encoding: "utf8", env: { ...process.env, GITHUB_REF_NAME: tag } });
test("CLI: a same-prefix tag that is not MAJOR.MINOR.PATCH refuses, never skipped", () => {
for (const malformed of ["widget-v0.9.0-rc1", "widget-v1.0", "widget-vnext"]) {
// alone
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
const name = prefix.includes("matrix") ? "widget" : prefix;
execFileSync("git", ["tag", `${name}-v1.0.0`], { cwd: dir });
assert.equal(first(dir, `${name}-v1.0.0`), "--first-release");
execFileSync("git", ["tag", `${name}-v1.1.0`], { cwd: dir });
assert.equal(first(dir, `${name}-v1.1.0`), "");
// another package's tags are not this package's releases
execFileSync("git", ["tag", "unrelated-v9.0.0"], { cwd: dir });
execFileSync("git", ["tag", "-d", `${name}-v1.0.0`], { cwd: dir });
execFileSync("git", ["tag", "-d", `${name}-v1.1.0`], { cwd: dir });
execFileSync("git", ["tag", `${name}-v0.1.0`], { cwd: dir });
assert.equal(first(dir, `${name}-v0.1.0`), "--first-release");
commitAndTag(dir, malformed);
writeChangelog(dir, "0.9.0");
const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0"));
assert.match(output, new RegExp(`${malformed.replace(/\./g, "\\.")} starts with widget-v but is not widget-vMAJOR\\.MINOR\\.PATCH`));
});
// beside an older valid release the version would otherwise be measured against
withTempGitRepo((dir) => {
commitAndTag(dir, "widget-v0.8.0", malformed);
writeChangelog(dir, "0.9.0");
const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0"));
assert.match(output, new RegExp(`${malformed.replace(/\./g, "\\.")} starts with`));
});
}
});
test("CLI: a version older than every release refuses", () => {
withTempGitRepo((dir) => {
commitAndTag(dir, "widget-v1.0.0", "widget-v2.0.0");
writeChangelog(dir, "0.9.0");
const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0"));
assert.match(output, /widget-v(1|2)\.0\.0 exists but no widget-v tag is older than 0\.9\.0: the version is older than every release/);
});
});
test("CLI: the tag prefix is the declaration's, not a spelling the caller or the directory supplies", () => {
withTempGitRepo((dir) => {
declare(dir, { widget: { tagPrefix: "widget-unity", changelog: "widget/CHANGELOG.md" } });
mkdirSync(join(dir, "widget"));
const changelog = (version) => writeFileSync(join(dir, "widget", "CHANGELOG.md"), changelogFor(version));
// widget-v1.2.0 is another package's tag: were it this package's, 1.1.0 would be a reversal
commitAndTag(dir, "widget-v1.2.0", "widget-unity-v1.0.0");
changelog("1.1.0");
assert.match(runChecker(dir, widgetArgs("1.1.0")), /1\.1\.0 is a minor bump over 1\.0\.0/);
// with the declared prefix's only release deleted, 1.0.1 is a first release, not a bump over widget-v1.2.0
git(dir, "tag", "-d", "widget-unity-v1.0.0");
changelog("1.0.1");
assert.match(runChecker(dir, widgetArgs("1.0.1")), /first release \(1\.0\.1\)/);
});
});
test("CLI: an undeclared package refuses, and so does a name that only the prototype holds", () => {
withTempGitRepo((dir) => {
commitAndTag(dir, "widget-v1.0.0");
writeChangelog(dir, "1.1.0");
for (const name of ["gadget", "constructor", "__proto__"]) {
const output = runCheckerExpectFailure(dir, ["--package", name, "--version", "1.1.0"]);
assert.match(output, /not a package declared in scripts\/release-packages\.mjs/);
}
});
});
test("CLI: a repository with no declaration refuses", () => {
withTempGitRepo((dir) => {
commitAndTag(dir, "widget-v1.0.0");
writeChangelog(dir, "1.1.0");
rmSync(join(dir, "scripts"), { recursive: true });
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.1.0")), /scripts\/release-packages\.mjs could not be loaded from --cwd/);
});
});
test("CLI: the flags the declaration replaced are usage errors, as is any unknown option", () => {
withTempGitRepo((dir) => {
commitAndTag(dir, "widget-v1.0.0");
writeChangelog(dir, "1.1.0");
// the same call without the extra option passes, so a silently ignored flag would exit 0
assert.match(runChecker(dir, widgetArgs("1.1.0")), /minor bump/);
for (const extra of [
["--first-release"],
["--first-release", "true"],
["--first-release=true"],
["--api-new", "GameCult.Widget.dll"],
["--tag-prefix", "widget"],
["--changelog", join(dir, "CHANGELOG.md")],
["--api-baseline-path", PLUGINS],
["--bogus", "1"],
]) {
assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.1.0"), ...extra], { status: 2 }), /usage: check-changelog-semver\.mjs/);
assert.match(runCheckerExpectFailure(dir, [...extra, ...widgetArgs("1.1.0")], { status: 2 }), /usage:/);
}
});
});
test("workflow: every tag trigger is a declared prefix, and every declared assemblies directory is tracked", () => {
const workflow = readFileSync(workflowFile, "utf8");
const triggers = [...workflow.match(/^\s+tags:\r?\n((?:\s+- "[^"]+"\r?\n)+)/m)[1].matchAll(/- "([^"]+)-v\*"/g)].map((match) => match[1]);
assert.ok(triggers.length >= 4, "the workflow triggers on the npm and PyPI packages' tags");
const prefixes = Object.values(releasePackages).map((entry) => entry.tagPrefix);
for (const trigger of triggers) assert.ok(prefixes.includes(trigger), `${trigger}-v* triggers the workflow but no package declares that prefix`);
for (const [name, entry] of Object.entries(releasePackages)) {
if (entry.assemblies === undefined) continue;
assert.notEqual(execFileSync("git", ["ls-files", "--", entry.assemblies], { cwd: repoRoot, encoding: "utf8" }).trim(), "", `${name} declares ${entry.assemblies}, which tracks nothing`);
}
});
@ -395,20 +447,25 @@ ${references.join("|")}`).digest("hex").slice(0, 16));
return dll;
}
const PLUGINS = "unity/widget/Runtime/Plugins";
// A repo whose tag widget-v<previous> tracks `baseline` ({file name: dll path})
// under PLUGINS, and whose working tree then holds `afterTag` instead.
// A repo whose tag widget-v<previous> tracks `baseline` ({path under PLUGINS: dll path}) under
// PLUGINS, and whose working tree then holds `afterTag` instead. The package declares PLUGINS
// as its assemblies directory.
function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0" }, fn) {
withTempGitRepo((dir) => {
mkdirSync(join(dir, PLUGINS), { recursive: true });
for (const [name, path] of Object.entries(baseline)) copyFileSync(path, join(dir, PLUGINS, name));
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
const place = (files) => {
for (const [name, path] of Object.entries(files)) {
mkdirSync(dirname(join(dir, PLUGINS, name)), { recursive: true });
copyFileSync(path, join(dir, PLUGINS, name));
}
};
place(baseline);
execFileSync("git", ["add", "."], { cwd: dir });
execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir });
execFileSync("git", ["tag", `widget-v${previous}`], { cwd: dir });
if (afterTag !== baseline) {
for (const name of Object.keys(baseline)) rmSync(join(dir, PLUGINS, name));
for (const [name, path] of Object.entries(afterTag)) copyFileSync(path, join(dir, PLUGINS, name));
place(afterTag);
execFileSync("git", ["add", "-A", "."], { cwd: dir });
execFileSync("git", ["commit", "-q", "-m", "after the release"], { cwd: dir });
}
@ -416,13 +473,12 @@ function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0
});
}
function measuredArgs(dir, version, builtPaths, { breaking = false, news = [], refs = [] } = {}) {
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor(version, { breaking }));
function measuredArgs(dir, version, builtPaths, { breaking = false, refs = [] } = {}) {
writeChangelog(dir, version, { breaking });
return [
"--package", "widget", "--changelog", changelogPath, "--version", version, "--tag-prefix", "widget",
"--api-baseline-path", PLUGINS, ...builtPaths.flatMap((path) => ["--api-built", path]),
...news.flatMap((name) => ["--api-new", name]), ...refs.flatMap((refDir) => ["--api-refs", refDir]),
...widgetArgs(version),
...builtPaths.flatMap((path) => ["--api-built", path]),
...refs.flatMap((refDir) => ["--api-refs", refDir]),
];
}
@ -471,7 +527,7 @@ test("measured: a declared break with nothing measured still passes", () => {
});
});
test("measured: a tracked assembly that is no longer built is a break, whatever its name; a declared new assembly is not", () => {
test("measured: a tracked assembly that is no longer built is a break, whatever its name; a built assembly with no namesake is an addition", () => {
const other = buildAssembly("GameCult.Other", "namespace O { public class D {} }");
const thirdParty = buildAssembly("Vendor.Lib", "namespace V { public class E {} }");
const baseline = { "GameCult.Widget.dll": oldWidget(), "GameCult.Other.dll": other, "Vendor.Lib.dll": thirdParty };
@ -481,26 +537,147 @@ test("measured: a tracked assembly that is no longer built is a break, whatever
assert.match(refused, /Vendor\.Lib\.dll exists at widget-v1\.0\.0 but is no longer built/);
assert.match(refused, /breaks 2 public API member/);
const brandNew = buildAssembly("GameCult.Brand", "namespace B { public class F {} }");
const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, thirdParty, brandNew], { news: ["GameCult.Brand.dll"] }));
const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, thirdParty, brandNew]));
assert.match(ok, /1\.0\.1 is a patch bump/);
});
});
test("measured: renaming the only assembly and declaring it new compares nothing and is refused", () => {
test("measured: renaming the only assembly is a break with nothing declared, and a declared break can ship", () => {
// CultMath's shape: one assembly, and its name is not GameCult.*.
const cultMath = buildAssembly("CultMath", WITH_GONE);
const renamedTrimmed = buildAssembly("CultMath.Next", WITHOUT_GONE);
withReleasedBaseline({ baseline: { "CultMath.dll": cultMath } }, (dir) => {
const own = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [buildAssembly("CultMath", WITHOUT_GONE)]));
assert.match(own, /Gone/);
const renamed = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [renamedTrimmed], { news: ["CultMath.Next.dll"] }));
assert.match(renamed, /no built assembly has a namesake tracked at widget-v1\.0\.0, so nothing was compared/);
assert.match(renamed, /CultMath\.dll/);
// with a comparison that ran, the unbuilt tracked assembly is itself the break
withReleasedBaseline({ baseline: { "CultMath.dll": cultMath, "Other.dll": buildAssembly("Other", "namespace O { public class D {} }") } }, (both) => {
const partial = runCheckerExpectFailure(both, measuredArgs(both, "1.0.1", [cultMath, renamedTrimmed], { news: ["CultMath.Next.dll"] }));
assert.match(partial, /Other\.dll exists at widget-v1\.0\.0 but is no longer built/);
const renamed = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [renamedTrimmed]));
assert.match(renamed, /assembly CultMath\.dll exists at widget-v1\.0\.0 but is no longer built/);
const shipped = runChecker(dir, measuredArgs(dir, "2.0.0", [renamedTrimmed], { breaking: true }));
assert.match(shipped, /2\.0\.0 is a major bump over 1\.0\.0 and has a "### Breaking" section and 1 measured public API break/);
});
// with another assembly built, the unbuilt one is still the break
const other = buildAssembly("Other", "namespace O { public class D {} }");
withReleasedBaseline({ baseline: { "CultMath.dll": cultMath, "Other.dll": other } }, (both) => {
const partial = runCheckerExpectFailure(both, measuredArgs(both, "1.0.1", [cultMath, renamedTrimmed]));
assert.match(partial, /Other\.dll exists at widget-v1\.0\.0 but is no longer built/);
});
});
test("measured: an assembly tracked in a subdirectory is measured, and is a break when it is no longer built", () => {
const widget = oldWidget();
const sub = buildAssembly("GameCult.B", "namespace B { public class K { public void Keep() {} public void Gone() {} } }");
const subTrimmed = buildAssembly("GameCult.B", "namespace B { public class K { public void Keep() {} } }");
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": widget, "sub/GameCult.B.dll": sub } }, (dir) => {
const unbuilt = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [widget]));
assert.match(unbuilt, /assembly GameCult\.B\.dll exists at widget-v1\.0\.0 but is no longer built/);
const compared = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [widget, subTrimmed]));
assert.match(compared, /B\.K\.Gone/);
assert.doesNotMatch(compared, /no longer built/);
assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [widget, sub])), /1\.0\.1 is a patch bump/);
});
});
// A PE image written byte by byte. A managed assembly's CLI header is data directory 14; a
// native DLL has none.
function peImage({ plus = true, directories = 16, cli = null } = {}) {
const pe = 0x40;
const optionalSize = (plus ? 112 : 96) + directories * 8;
const bytes = Buffer.alloc(pe + 24 + optionalSize);
bytes.writeUInt16LE(0x5a4d, 0);
bytes.writeUInt32LE(pe, 0x3c);
bytes.writeUInt32LE(0x00004550, pe);
bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, pe + 4);
bytes.writeUInt16LE(optionalSize, pe + 20);
const optional = pe + 24;
bytes.writeUInt16LE(plus ? 0x20b : 0x10b, optional);
const dirs = optional + (plus ? 112 : 96);
bytes.writeUInt32LE(directories, dirs - 4);
if (cli) {
bytes.writeUInt32LE(cli.rva, dirs + 14 * 8);
bytes.writeUInt32LE(cli.size, dirs + 14 * 8 + 4);
}
return bytes;
}
const CLI = { rva: 0x2000, size: 0x48 };
test("isManagedAssembly: a non-empty CLI header makes an assembly managed, nothing else does", () => {
assert.equal(isManagedAssembly(peImage({ cli: CLI })), true);
assert.equal(isManagedAssembly(peImage({ plus: false, cli: CLI })), true);
assert.equal(isManagedAssembly(peImage()), false);
assert.equal(isManagedAssembly(peImage({ plus: false })), false);
assert.equal(isManagedAssembly(peImage({ cli: { rva: CLI.rva, size: 0 } })), false);
assert.equal(isManagedAssembly(peImage({ cli: { rva: 0, size: CLI.size } })), false);
// too few data directories to have a CLI header, whatever the bytes after them say
assert.equal(isManagedAssembly(peImage({ directories: 14 })), false);
assert.equal(isManagedAssembly(readFileSync(oldWidget())), true);
});
test("isManagedAssembly: bytes that are not a PE image throw", () => {
const withByte = (offset, value) => {
const bytes = peImage({ cli: CLI });
bytes[offset] = value;
return bytes;
};
for (const bytes of [
Buffer.alloc(0),
Buffer.from("not a PE image, just text that is long enough to be read as a header ........."),
peImage({ cli: CLI }).subarray(0, 0x50),
peImage({ cli: CLI }).subarray(0, 0x40 + 24 + 100),
withByte(0, 0x4e),
withByte(0x40, 0x51),
withByte(0x40 + 24 + 1, 0x00),
]) {
assert.throws(() => isManagedAssembly(bytes), /not a PE image/);
}
});
test("isManagedAssembly: the shipped QUIC bridge is native and the shipped managed assemblies are managed", () => {
const plugins = join(repoRoot, "unity", "org.gamecult.cultlib", "Runtime", "Plugins");
assert.equal(isManagedAssembly(readFileSync(join(plugins, "x86_64", "gamecult_mesh_quic_native.dll"))), false);
assert.equal(isManagedAssembly(readFileSync(join(plugins, "GameCult.Caching.dll"))), true);
});
test("measured: natives are not measured, decided by their bytes; a .dll that is not a PE image refuses", () => {
const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-pe-"));
try {
const file = (name, bytes) => {
const path = join(scratch, name);
writeFileSync(path, bytes);
return path;
};
// a native in x86_64/ and one beside the managed assemblies: neither is built, neither is a break
const baseline = { "GameCult.Widget.dll": oldWidget(), "x86_64/native.dll": file("a.dll", peImage()), "Native32.dll": file("b.dll", peImage({ plus: false })) };
withReleasedBaseline({ baseline }, (dir) => {
const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()]));
assert.match(ok, /1\.0\.1 is a patch bump/);
assert.doesNotMatch(ok, /native/i);
});
for (const [name, bytes] of [["text.dll", Buffer.from("not a PE image")], ["cut.dll", peImage({ cli: CLI }).subarray(0, 0x50)]]) {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget(), "x86_64/bogus.dll": file(name, bytes) } }, (dir) => {
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]));
assert.match(output, /unity\/widget\/Runtime\/Plugins\/x86_64\/bogus\.dll at widget-v1\.0\.0 is a \.dll but not a PE image/);
});
}
} finally {
rmSync(scratch, { recursive: true, force: true });
}
});
test("measured: two managed assemblies with one name refuse", () => {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget(), "a/X.dll": oldWidget(), "b/X.dll": oldWidget() } }, (dir) => {
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]));
assert.match(output, /widget-v1\.0\.0 tracks two managed assemblies named X\.dll/);
});
});
test("measured: a declared assemblies directory makes measurement mandatory, and a package without one takes no --api-built", () => {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", []));
assert.match(output, /ships the assemblies under unity\/widget\/Runtime\/Plugins, so the release must hand them over with --api-built/);
});
withTempGitRepo((dir) => {
commitAndTag(dir, "widget-v1.0.0");
assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()])), /declares no assemblies directory/);
});
});
@ -618,36 +795,16 @@ test("measured: the baseline is the tag's blob, not the working tree's DLL", ()
);
});
test("measured: a declared first release measures nothing", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
const junk = join(dir, "junk.dll");
writeFileSync(junk, "junk");
const args = measuredArgs(dir, "1.0.0", [junk]);
args[args.indexOf("--changelog") + 1] = packageChangelog(dir, "1.0.0");
const output = runChecker(dir, [...args, "--first-release"]);
assert.match(output, /first release/);
assert.doesNotMatch(output, /public API measured/);
});
});
test("measured: measuring nothing refuses, naming what is missing", () => {
const fail = (dir, args) => runCheckerExpectFailure(dir, args);
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
// a baseline path that is wrong or moved
const moved = measuredArgs(dir, "1.1.0", [trimmedWidget()]);
moved[moved.indexOf("--api-baseline-path") + 1] = "unity/moved/Runtime/Plugins";
assert.match(fail(dir, moved), /widget-v1\.0\.0 tracks no assembly under unity\/moved\/Runtime\/Plugins: the baseline path is missing or moved/);
// a shipped DLL the tag does not track
const fresh = buildAssembly("GameCult.Fresh", "namespace F { public class G {} }");
const output = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget(), fresh]));
assert.match(output, /GameCult\.Fresh\.dll is built but widget-v1\.0\.0 does not track it under .*; if it is a new assembly, declare it with --api-new GameCult\.Fresh\.dll/);
// declaring an assembly new that the tag already tracks is a wrong declaration
const wrong = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()], { news: ["GameCult.Widget.dll"] }));
assert.match(wrong, /GameCult\.Widget\.dll is declared new but widget-v1\.0\.0 already tracks it/);
// a declared directory that is wrong or moved
declare(dir, { widget: { ...WIDGET, assemblies: "unity/moved/Runtime/Plugins" } });
assert.match(fail(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()])), /widget-v1\.0\.0 tracks no managed assembly under unity\/moved\/Runtime\/Plugins: the declared directory is missing or moved/);
});
// a previous tag that does not track the DLLs at all
withTempGitRepo((dir) => {
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
mkdirSync(join(dir, PLUGINS), { recursive: true });
writeFileSync(join(dir, PLUGINS, "README.txt"), "no dlls yet");
execFileSync("git", ["add", "."], { cwd: dir });
@ -655,6 +812,16 @@ test("measured: measuring nothing refuses, naming what is missing", () => {
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
assert.match(fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()])), /does not track the DLLs/);
});
// a previous tag whose only DLLs are native
withTempGitRepo((dir) => {
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
mkdirSync(join(dir, PLUGINS), { recursive: true });
writeFileSync(join(dir, PLUGINS, "native.dll"), peImage());
execFileSync("git", ["add", "."], { cwd: dir });
execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
assert.match(fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()])), /tracks no managed assembly/);
});
});
test("measured: a git failure while reading the baseline, and a changelog that is not a file, are refusals without a stack or git's output", () => {
@ -664,6 +831,7 @@ test("measured: a git failure while reading the baseline, and a changelog that i
};
// the tag tracks a gitlink named like a DLL: ls-tree lists it, git show cannot read it
withTempGitRepo((dir) => {
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
mkdirSync(join(dir, PLUGINS), { recursive: true });
copyFileSync(oldWidget(), join(dir, PLUGINS, "GameCult.Widget.dll"));
execFileSync("git", ["add", "."], { cwd: dir });
@ -673,17 +841,16 @@ test("measured: a git failure while reading the baseline, and a changelog that i
const output = clean(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()])));
assert.match(output, /git could not read the assemblies tracked under unity\/widget\/Runtime\/Plugins at widget-v1\.0\.0/);
});
// a baseline path that git would take for an option
// a declared directory that git would take for an option
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
const args = measuredArgs(dir, "1.0.1", [oldWidget()]);
args[args.indexOf("--api-baseline-path") + 1] = "--bogus";
assert.match(clean(runCheckerExpectFailure(dir, args)), /tracks no assembly under --bogus|git could not read/);
declare(dir, { widget: { ...WIDGET, assemblies: "--bogus" } });
assert.match(clean(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]))), /tracks no managed assembly under --bogus|git could not read/);
});
// a changelog path that is a directory
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
commitAndTag(dir, "widget-v1.0.0");
mkdirSync(join(dir, "CHANGELOG.md"));
const output = clean(runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.0.0", "--tag-prefix", "widget"]));
const output = clean(runCheckerExpectFailure(dir, widgetArgs("1.0.1")));
assert.match(output, /changelog cannot be read at /);
});
});
@ -694,11 +861,9 @@ test("measured: a passing release says that unmeasured changes still need declar
assert.match(output, /public API measured; changes ApiCompat cannot see \(see "Unmeasured changes" in docs\/semver-policy\.md\) must still be declared under "### Breaking"/);
});
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.0.1"));
const unmeasured = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]);
commitAndTag(dir, "widget-v1.0.0");
writeChangelog(dir, "1.0.1");
const unmeasured = runChecker(dir, widgetArgs("1.0.1"));
assert.doesNotMatch(unmeasured, /public API measured/);
});
});
@ -720,37 +885,23 @@ test("evaluateRelease: measured breaks count as breaking for the lane, declared
assert.match(reason, /; \.\.\./);
});
test("measured: every missing required option is a usage error", () => {
test("every missing required option is a usage error", () => {
withTempGitRepo((dir) => {
const complete = ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.1.0", "--tag-prefix", "widget"];
const complete = widgetArgs("1.1.0");
for (let i = 0; i < complete.length; i += 2) {
const withoutOne = complete.filter((_, index) => index !== i && index !== i + 1);
const output = runCheckerExpectFailure(dir, withoutOne, { status: 2 });
assert.match(output, /usage: check-changelog-semver\.mjs/);
assert.match(output, /--api-baseline-path/);
assert.match(output, /--api-built/);
}
const builtWithoutBaseline = runCheckerExpectFailure(dir, [...complete, "--api-built", "x.dll"], { status: 2 });
assert.match(builtWithoutBaseline, /usage:/);
const baselineWithoutBuilt = runCheckerExpectFailure(dir, [...complete, "--api-baseline-path", PLUGINS], { status: 2 });
assert.match(baselineWithoutBuilt, /usage:/);
});
});
test("CLI: a missing changelog fails and names the path; stray positional words are ignored", () => {
test("CLI: a missing changelog fails and names the declared path; stray positional words are ignored", () => {
withTempGitRepo((dir) => {
const missing = join(dir, "NOPE.md");
const output = runCheckerExpectFailure(dir, ["stray", "--package", "widget", "--changelog", missing, "--version", "1.1.0", "--tag-prefix", "widget"]);
assert.match(output, /changelog not found at .*NOPE.md/);
});
});
test("measured: the baseline path may use backslashes or a trailing slash", () => {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
for (const path of [`${PLUGINS}/`, `${PLUGINS}//`, PLUGINS.replace(/\//g, "\\")]) {
const args = measuredArgs(dir, "1.1.0", [trimmedWidget()]);
args[args.indexOf("--api-baseline-path") + 1] = path;
assert.match(runCheckerExpectFailure(dir, args), /Gone/);
}
declare(dir, { widget: { ...WIDGET, changelog: "docs/NOPE.md" } });
const output = runCheckerExpectFailure(dir, ["stray", ...widgetArgs("1.1.0")]);
assert.match(output, /changelog not found at .*docs.NOPE\.md/);
});
});
@ -903,18 +1054,14 @@ test("evaluateRelease: lists at most five measured breaks, and says nothing more
assert.match(agreed, /a "### Breaking" section and 1 measured public API break\(s\)/);
});
test("CLI: a malformed version is refused by name, without repeating it, with or without --first-release", () => {
test("CLI: a malformed version is refused by name, without repeating it", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.1.0"));
for (const extra of [[], ["--first-release"]]) {
const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.x-canary", "--tag-prefix", "widget", ...extra]);
assert.match(output, /--version is not a MAJOR\.MINOR\.PATCH version/);
assert.doesNotMatch(output, /canary/);
assert.doesNotMatch(output, /could not be read/);
}
commitAndTag(dir, "widget-v1.0.0");
writeChangelog(dir, "1.1.0");
const output = runCheckerExpectFailure(dir, widgetArgs("1.1.x-canary"));
assert.match(output, /--version is not a MAJOR\.MINOR\.PATCH version/);
assert.doesNotMatch(output, /canary/);
assert.doesNotMatch(output, /could not be read/);
});
});
@ -925,9 +1072,7 @@ test("refusals never repeat what the inputs contain", () => {
const junk = join(scratch, "GameCult.Widget.dll");
writeFileSync(junk, `MZ not an assembly ${canary}`);
const canaryChangelog = (dir, version, { breaking = false } = {}) => {
const path = join(dir, "CHANGELOG.md");
writeFileSync(path, changelogFor(version, { breaking }).replace("- something changed", `- something changed ${canary}`) + `\n## [0.0.1]\n\n- ${canary}\n`);
return path;
writeFileSync(join(dir, "CHANGELOG.md"), changelogFor(version, { breaking }).replace("- something changed", `- something changed ${canary}`) + `\n## [0.0.1]\n\n- ${canary}\n`);
};
const refuse = (dir, args) => {
const output = runCheckerExpectFailure(dir, args);
@ -937,7 +1082,7 @@ test("refusals never repeat what the inputs contain", () => {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
const measured = (version, built, options) => {
const args = measuredArgs(dir, version, built, options);
args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, version, options);
canaryChangelog(dir, version, options);
return args;
};
// a measured break, with the canary in the changelog it must be declared in
@ -952,10 +1097,10 @@ test("refusals never repeat what the inputs contain", () => {
assert.match(refuse(dir, skip), /skips ahead/);
assert.match(refuse(dir, measured("1.0.1", [oldWidget()], { breaking: true })), /needs a major bump/);
});
// the tag's own blob is unreadable
// the tag's own blob is not a PE image
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": junk } }, (dir) => {
const args = measuredArgs(dir, "1.0.1", [oldWidget()]);
args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, "1.0.1");
canaryChangelog(dir, "1.0.1");
assert.match(refuse(dir, args), /could not be measured/);
});
} finally {

View file

@ -0,0 +1,38 @@
// The one declaration of what each released package is, read by
// scripts/check-changelog-semver.mjs (docs/semver-policy.md) and by nothing a caller types.
// Keyed by the --package name. Paths are repo-relative.
// tagPrefix releases are tags named '<tagPrefix>-vMAJOR.MINOR.PATCH'
// changelog the package's CHANGELOG.md
// assemblies the directory whose tracked DLLs the package ships; absent when it ships none
export default {
"org.gamecult.cultlib": {
tagPrefix: "cultlib-unity",
changelog: "unity/org.gamecult.cultlib/CHANGELOG.md",
assemblies: "unity/org.gamecult.cultlib/Runtime/Plugins",
},
"org.gamecult.cultmath": {
tagPrefix: "cultmath-unity",
changelog: "packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md",
assemblies: "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins",
},
"org.gamecult.caching.unity": {
tagPrefix: "caching-unity",
changelog: "src/GameCult.Unity/Assets/Caching/CHANGELOG.md",
},
"@gamecult/cultcache-ts": {
tagPrefix: "cultcache-ts",
changelog: "packages/cultcache-ts/CHANGELOG.md",
},
"cultcache-py": {
tagPrefix: "cultcache-py",
changelog: "packages/cultcache-py/CHANGELOG.md",
},
"cultnet-py": {
tagPrefix: "cultnet-py",
changelog: "packages/cultnet-py/CHANGELOG.md",
},
"cultmesh-py": {
tagPrefix: "cultmesh-py",
changelog: "packages/cultmesh-py/CHANGELOG.md",
},
};

View file

@ -11,15 +11,12 @@ $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$packageRoot = Join-Path $repoRoot "src\GameCult.Unity\Assets\Caching"
$manifestPath = Join-Path $packageRoot "package.json"
$changelogPath = Join-Path $packageRoot "CHANGELOG.md"
$version = (Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json).version
& node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.caching.unity" `
--changelog $changelogPath `
--version $version `
--tag-prefix "caching-unity" `
--cwd $repoRoot
if ($LASTEXITCODE -ne 0) {
throw "org.gamecult.caching.unity failed the semver policy check (see docs/semver-policy.md)."