diff --git a/dotnet-tools.json b/dotnet-tools.json
index b22db21d..6da5949f 100644
--- a/dotnet-tools.json
+++ b/dotnet-tools.json
@@ -8,13 +8,6 @@
"dotnet-stryker"
],
"rollForward": false
- },
- "microsoft.dotnet.apicompat.tool": {
- "version": "10.0.202",
- "commands": [
- "apicompat"
- ],
- "rollForward": false
}
}
}
diff --git a/scripts/api-gate/.config/dotnet-tools.json b/scripts/api-gate/.config/dotnet-tools.json
new file mode 100644
index 00000000..fc58fff7
--- /dev/null
+++ b/scripts/api-gate/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "microsoft.dotnet.apicompat.tool": {
+ "version": "10.0.202",
+ "commands": [
+ "apicompat"
+ ],
+ "rollForward": false
+ }
+ }
+}
diff --git a/scripts/api-gate/.gitignore b/scripts/api-gate/.gitignore
new file mode 100644
index 00000000..23053de0
--- /dev/null
+++ b/scripts/api-gate/.gitignore
@@ -0,0 +1 @@
+packages/
diff --git a/scripts/api-gate/ApiRefs.csproj b/scripts/api-gate/ApiRefs.csproj
new file mode 100644
index 00000000..68c63f07
--- /dev/null
+++ b/scripts/api-gate/ApiRefs.csproj
@@ -0,0 +1,11 @@
+
+
+
+ netstandard2.1
+ $(MSBuildThisFileDirectory)packages
+
+
+
+
+
diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs
index 17c918c6..5cca0c4d 100644
--- a/scripts/check-changelog-semver.mjs
+++ b/scripts/check-changelog-semver.mjs
@@ -9,7 +9,7 @@
// published tag, then calls `evaluateRelease`.
import { execFileSync, spawnSync } from "node:child_process";
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -168,15 +168,12 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
// changelog off disk, then delegates to the pure functions above. ---
function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
- let tags;
- try {
- tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" })
- .split("\n")
- .map((line) => line.trim())
- .filter(Boolean);
- } catch {
- return null;
- }
+ // 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);
const prefix = `${tagPrefix}-v`;
const current = parseVersion(currentVersion);
let best = null;
@@ -198,24 +195,13 @@ function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
return best ? formatVersion(best) : null;
}
-// True when `-v` already exists. The check gates a NEW
-// release; rebuilding an already-tagged, already-immutable version (routine
-// local iteration, CI re-runs) is not a release action and should not keep
-// re-litigating history on every build.
-function tagAlreadyPublished(tagPrefix, version, cwd) {
- try {
- const output = execFileSync("git", ["tag", "-l", `${tagPrefix}-v${version}`], { cwd, encoding: "utf8" });
- return output.trim().length > 0;
- } catch {
- return false;
- }
-}
-
// --- Measured public API: the built DLLs are compared with the DLLs the
-// previous tag tracked, by Microsoft.DotNet.ApiCompat.Tool (pinned in
-// dotnet-tools.json). Nothing here compares members itself. ---
+// 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 repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
+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
@@ -231,10 +217,36 @@ export function readApiCompatRun({ status, output }) {
}
function runApiCompat(args) {
- const run = spawnSync("dotnet", ["tool", "run", "apicompat", ...args], { cwd: repoRoot, encoding: "utf8" });
+ 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 };
+}
+
// The DLLs the previous tag tracked under `baselinePath`, as git blobs: no
// worktree, no network, and unaffected by what the working tree holds now.
function extractBaseline({ tag, baselinePath, into, cwd }) {
@@ -245,25 +257,35 @@ function extractBaseline({ tag, baselinePath, into, cwd }) {
const blob = execFileSync("git", ["show", `${tag}:${dir}/${name}`], { cwd, maxBuffer: 1 << 28 });
writeFileSync(join(into, name), blob);
}
- return names;
+ return { dir, names };
}
-// Compares each built assembly with its namesake at the previous tag. A built
-// assembly with no namesake is new and measures nothing; a GameCult.*
-// assembly the tag tracked and the build no longer produces is itself a break.
-export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, cwd }) {
+// 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, and every
+// built assembly must have a namesake there unless the caller declares it new
+// (`declaredNew`). A GameCult.* assembly the tag tracked and the build no longer
+// produces is itself a break.
+export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, declaredNew = [], cwd }) {
const tag = `${tagPrefix}-v${previousVersion}`;
const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-"));
try {
const left = join(work, "left");
mkdirSync(left);
- const baselineNames = extractBaseline({ tag, baselinePath, into: left, cwd });
- const restore = spawnSync("dotnet", ["tool", "restore"], { cwd: repoRoot, encoding: "utf8" });
- if (restore.status !== 0) {
- const detail = restore.error ? restore.error.message : `${restore.stdout}${restore.stderr}`.trim().split(/\r?\n/).slice(-3).join(" | ");
- return { failure: `dotnet tool restore failed: ${detail}` };
+ const { dir, names: baselineNames } = extractBaseline({ tag, baselinePath, into: left, cwd });
+ 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}` };
+ }
+ }
+ const gate = prepareGate();
+ if (gate.failure) return gate;
const breaks = [];
for (const name of baselineNames) {
if (name.startsWith("GameCult.") && !builtNames.has(name)) {
@@ -273,8 +295,9 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui
for (const path of built) {
const name = basename(path);
if (!baselineNames.includes(name)) continue;
- const rightRefs = [...new Set([dirname(path), ...refs])].join(",");
- const run = runApiCompat(["--left-assembly", join(left, name), "--right-assembly", path, "--left-assembly-references", left, "--right-assembly-references", rightRefs]);
+ 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);
}
@@ -284,8 +307,9 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui
}
}
-// Repeatable options (--api-built, --api-refs) collect into arrays.
-const REPEATABLE = new Set(["api-built", "api-refs"]);
+// 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"]);
function parseArgs(argv) {
const args = {};
@@ -293,6 +317,10 @@ 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 (REPEATABLE.has(key)) {
(args[key] ??= []).push(argv[i + 1]);
} else {
@@ -303,30 +331,53 @@ function parseArgs(argv) {
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"';
+
function main() {
const args = parseArgs(process.argv.slice(2));
const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args;
- if (!packageName || !changelog || !version || !tagPrefix || (args["api-built"] && !args["api-baseline-path"])) {
+ const built = args["api-built"] ?? [];
+ if (!packageName || !changelog || !version || !tagPrefix || (built.length > 0) !== Boolean(args["api-baseline-path"])) {
console.error(
- "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ]\n" +
- " [--api-baseline-path --api-built ... [--api-refs ...]]",
+ "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ] [--first-release]\n" +
+ " [--api-baseline-path --api-built ... [--api-refs ...] [--api-new ...]]",
);
process.exitCode = 2;
return;
}
- if (tagAlreadyPublished(tagPrefix, version, cwd)) {
- console.log(`${packageName}: ${version} is already published as ${tagPrefix}-v${version}; not re-checking a rebuild of immutable history`);
- return;
- }
if (!existsSync(changelog)) {
- console.error(`${packageName}: changelog not found at ${changelog}`);
- process.exitCode = 1;
+ refuse(`${packageName}: changelog not found at ${changelog}`);
return;
}
const changelogText = readFileSync(changelog, "utf8");
- const previousVersion = resolvePreviousVersion(tagPrefix, version, cwd);
+ const firstRelease = args["first-release"] === true;
+ let previousVersion;
+ try {
+ previousVersion = 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");
+ return;
+ }
+ if (previousVersion != null && firstRelease) {
+ refuse(`${packageName}: --first-release was declared but ${tagPrefix}-v${previousVersion} already exists`);
+ return;
+ }
let measuredBreaks = [];
- const built = args["api-built"] ?? [];
if (built.length > 0 && previousVersion != null) {
const measurement = measureApiBreaks({
tagPrefix,
@@ -334,11 +385,11 @@ function main() {
baselinePath: args["api-baseline-path"],
built,
refs: args["api-refs"] ?? [],
+ declaredNew: args["api-new"] ?? [],
cwd,
});
if (measurement.failure) {
- console.error(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`);
- process.exitCode = 1;
+ refuse(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`);
return;
}
measuredBreaks = measurement.breaks;
@@ -347,6 +398,10 @@ function main() {
console.log(result.reason);
if (!result.ok) {
process.exitCode = 1;
+ return;
+ }
+ if (previousVersion != null && built.length > 0) {
+ console.log(UNMEASURED_NOTICE);
}
}
diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs
index 68e36f57..141e29c3 100644
--- a/scripts/check-changelog-semver.test.mjs
+++ b/scripts/check-changelog-semver.test.mjs
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
+import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import {
@@ -248,18 +248,50 @@ test("CLI: fails when the changelog claims breaking under too small a bump", ()
});
});
-test("CLI: does not re-litigate a version already published as a tag", () => {
+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 });
- // 1.0.0 itself is a scar: its own changelog claims breaking under a
- // patch-looking first release. Rebuilding it locally (no new version)
- // must not fail just because history already shipped that way.
- execFileSync("git", ["tag", "widget-v0.9.0"], { cwd: dir });
+ execFileSync("git", ["tag", "widget-v1.0.1"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
- writeFileSync(changelogPath, changelogFor("1.0.0", { breaking: true }));
- const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]);
- assert.match(output, /already published/);
+ writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true }));
+ const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]);
+ assert.match(output, /needs a major bump/);
+ });
+});
+
+test("CLI: no previous tag, or tags that cannot be read, refuse unless the first release is declared", () => {
+ withTempGitRepo((dir) => {
+ execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
+ execFileSync("git", ["tag", "other-v1.0.0"], { cwd: dir });
+ const changelogPath = join(dir, "CHANGELOG.md");
+ writeFileSync(changelogPath, changelogFor("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/);
+ });
+ const notARepo = mkdtempSync(join(tmpdir(), "cultlib-semver-norepo-"));
+ try {
+ const changelogPath = join(notARepo, "CHANGELOG.md");
+ writeFileSync(changelogPath, changelogFor("1.0.0"));
+ const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"];
+ assert.match(runCheckerExpectFailure(notARepo, args), /tags of --cwd could not be read/);
+ 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/);
});
});
@@ -273,16 +305,18 @@ const fixtureRoot = join(tmpdir(), "cultlib-semver-fixtures");
// Builds `source` into .dll (netstandard2.1, like the shipped
// assemblies) once per distinct (name, source) and returns the dll's path.
-function buildAssembly(assemblyName, source) {
+function buildAssembly(assemblyName, source, references = []) {
const dir = join(fixtureRoot, createHash("sha256").update(`${assemblyName}
-${source}`).digest("hex").slice(0, 16));
+${source}
+${references.join("|")}`).digest("hex").slice(0, 16));
const dll = join(dir, "out", `${assemblyName}.dll`);
if (!existsSync(dll)) {
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "Fixture.csproj"),
`netstandard2.1` +
- `${assemblyName}true`,
+ `${assemblyName}true` +
+ `${references.map((path) => `${path}`).join("")}`,
);
writeFileSync(join(dir, "Source.cs"), source);
execFileSync("dotnet", ["build", "-c", "Release", "-o", "out", "--nologo", "-v", "q"], { cwd: dir, stdio: "pipe" });
@@ -311,12 +345,13 @@ function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0
});
}
-function measuredArgs(dir, version, builtPaths, { breaking = false } = {}) {
+function measuredArgs(dir, version, builtPaths, { breaking = false, news = [], refs = [] } = {}) {
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor(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]),
];
}
@@ -365,7 +400,7 @@ test("measured: a declared break with nothing measured still passes", () => {
});
});
-test("measured: a removed GameCult.* assembly is a break, a new assembly and a removed third-party one are not", () => {
+test("measured: a removed GameCult.* assembly is a break, a declared new assembly and a removed third-party one are not", () => {
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 };
@@ -375,7 +410,7 @@ test("measured: a removed GameCult.* assembly is a break, a new assembly and a r
assert.doesNotMatch(refused, /Vendor\.Lib/);
assert.match(refused, /breaks 1 public API member/);
const brandNew = buildAssembly("GameCult.Brand", "namespace B { public class F {} }");
- const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, brandNew]));
+ const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, brandNew], { news: ["GameCult.Brand.dll"] }));
assert.match(ok, /1\.0\.1 is a patch bump/);
});
});
@@ -398,7 +433,7 @@ test("readApiCompatRun: diagnostics are breaks, an empty failure is a failure, a
assert.deepEqual(readApiCompatRun({ status: 0, output: "Could not resolve reference 'netstandard.dll'\n" }), { breaks: [] });
});
-test("measured: removed inherited members are caught although netstandard cannot be resolved", () => {
+test("measured: removed inherited members are caught", () => {
// The shipped assemblies are netstandard2.1 and ApiCompat cannot resolve netstandard.dll
// for them; both ways an inherited member can disappear are still reported.
const viaNetstandardBase = buildAssembly("GameCult.Widget", "namespace N { public class C : System.Collections.Generic.List { } }");
@@ -415,12 +450,50 @@ test("measured: removed inherited members are caught although netstandard cannot
});
});
-test("measured: an already-published version is not measured", () => {
+test("measured: netstandard reference assemblies keep non-breaking changes to netstandard-derived types clean", () => {
+ const pass = (baseline, built) =>
+ withReleasedBaseline({ baseline: { "GameCult.Widget.dll": baseline } }, (dir) => {
+ assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [built])), /1\.0\.1 is a patch bump/);
+ });
+ // Inserting a class between the type and its netstandard base keeps every old base in the chain.
+ pass(
+ buildAssembly("GameCult.Widget", "namespace N { public class C : System.Exception { } }"),
+ buildAssembly("GameCult.Widget", "namespace N { public class C : System.ArgumentException { } }"),
+ );
+ // Dropping a member that hid a netstandard member leaves the inherited one visible.
+ pass(
+ buildAssembly("GameCult.Widget", 'namespace N { public class C : System.Exception { public new string Message => "m"; } }'),
+ buildAssembly("GameCult.Widget", "namespace N { public class C : System.Exception { } }"),
+ );
+});
+
+test("measured: a base type moved to a subclass in another assembly resolves through --api-refs and the baseline directory", () => {
+ const oldA = buildAssembly("Vendor.A", "namespace A { public class Base { public void Inh() {} } }");
+ const newA = buildAssembly("Vendor.A", "namespace A { public class Base { public void Inh() {} } public class Mid : Base { } }");
+ const oldB = buildAssembly("GameCult.Widget", "namespace B { public class D : A.Base { } }", [oldA]);
+ const newB = buildAssembly("GameCult.Widget", "namespace B { public class D : A.Mid { } }", [newA]);
+ withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldB, "Vendor.A.dll": oldA } }, (dir) => {
+ const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-refs-"));
+ try {
+ mkdirSync(join(scratch, "built"));
+ mkdirSync(join(scratch, "refs"));
+ copyFileSync(newB, join(scratch, "built", "GameCult.Widget.dll"));
+ copyFileSync(newA, join(scratch, "refs", "Vendor.A.dll"));
+ const built = join(scratch, "built", "GameCult.Widget.dll");
+ // The new base assembly is reachable only through --api-refs: the checker must pass it on.
+ assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [built], { refs: [join(scratch, "refs")] })), /1\.0\.1 is a patch bump/);
+ assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [built])), /CP0007/);
+ } finally {
+ rmSync(scratch, { recursive: true, force: true });
+ }
+ });
+});
+
+test("measured: a local tag of the released version does not skip the measurement", () => {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
- const junk = join(dir, "junk.dll");
- writeFileSync(junk, "junk");
- const output = runChecker(dir, measuredArgs(dir, "1.0.0", [junk], { breaking: true }));
- assert.match(output, /already published/);
+ execFileSync("git", ["tag", "widget-v1.1.0"], { cwd: dir });
+ const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]));
+ assert.match(output, /Gone/);
});
});
@@ -434,13 +507,55 @@ test("measured: the baseline is the tag's blob, not the working tree's DLL", ()
);
});
-test("measured: a first release measures nothing", () => {
+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 output = runChecker(dir, measuredArgs(dir, "1.0.0", [junk]));
+ const output = runChecker(dir, [...measuredArgs(dir, "1.0.0", [junk]), "--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 previous tag that does not track the DLLs at all
+ withTempGitRepo((dir) => {
+ mkdirSync(join(dir, PLUGINS), { recursive: true });
+ writeFileSync(join(dir, PLUGINS, "README.txt"), "no dlls yet");
+ 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()])), /does not track the DLLs/);
+ });
+});
+
+test("measured: a passing release says that unmeasured changes still need declaring", () => {
+ withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
+ const output = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()]));
+ 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"]);
+ assert.doesNotMatch(unmeasured, /public API measured/);
});
});
@@ -472,6 +587,8 @@ test("measured: every missing required option is a usage error", () => {
}
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:/);
});
});
@@ -521,38 +638,88 @@ test("measured: leaves no temporary files behind, passing or failing", () => {
});
});
-test("measured: a missing or unrestorable tool fails closed and says why", () => {
- withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
- // The checker finds its tool manifest next to its own scripts directory.
- const bare = mkdtempSync(join(tmpdir(), "cultlib-semver-bare-"));
- try {
- mkdirSync(join(bare, "scripts"));
- const script = join(bare, "scripts", "check-changelog-semver.mjs");
- copyFileSync(checkerPath, script);
- const noManifest = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script });
- assert.match(noManifest, /could not be measured/);
- assert.match(noManifest, /without measuring: Cannot find a tool in the manifest/);
- writeFileSync(join(bare, "dotnet-tools.json"), "{ this is not a manifest");
- const brokenManifest = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script });
- assert.match(brokenManifest, /could not be measured/);
- assert.match(brokenManifest, /dotnet tool restore failed: Json parsing error/);
- } finally {
- rmSync(bare, { recursive: true, force: true });
+// A copy of the checker with its own scripts/api-gate directory, so a test can break the
+// gate without touching the repository's.
+function withCheckerCopy(gateFiles, fn) {
+ const bare = mkdtempSync(join(tmpdir(), "cultlib-semver-bare-"));
+ try {
+ mkdirSync(join(bare, "scripts"));
+ const script = join(bare, "scripts", "check-changelog-semver.mjs");
+ copyFileSync(checkerPath, script);
+ for (const [path, text] of Object.entries(gateFiles)) {
+ mkdirSync(dirname(join(bare, "scripts", "api-gate", path)), { recursive: true });
+ writeFileSync(join(bare, "scripts", "api-gate", path), text);
}
+ fn(script);
+ } finally {
+ rmSync(bare, { recursive: true, force: true });
+ }
+}
+
+test("measured: a missing or broken tool manifest fails closed and says why", () => {
+ withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
+ withCheckerCopy({}, (script) => {
+ const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script });
+ assert.match(output, /could not be measured/);
+ assert.match(output, /the api-gate tool manifest \(scripts\/api-gate\/\.config\/dotnet-tools\.json\) is missing/);
+ });
+ withCheckerCopy({ ".config/dotnet-tools.json": "{ this is not a manifest" }, (script) => {
+ const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script });
+ assert.match(output, /could not be measured/);
+ assert.match(output, /dotnet tool restore failed: .*Json parsing error/);
+ });
});
});
-test("measured: dotnet missing from the PATH fails closed", { skip: process.platform === "win32" }, () => {
+// A directory holding git and a stand-in `dotnet` shell script, as the whole PATH.
+function withFakeDotnet(dotnetScript, fn) {
+ const bin = mkdtempSync(join(tmpdir(), "cultlib-semver-path-"));
+ try {
+ const git = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
+ symlinkSync(git, join(bin, "git"));
+ if (dotnetScript != null) writeFileSync(join(bin, "dotnet"), `#!/bin/sh\n${dotnetScript}\n`, { mode: 0o755 });
+ fn({ ...process.env, PATH: bin });
+ } finally {
+ rmSync(bin, { recursive: true, force: true });
+ }
+}
+
+const posix = { skip: process.platform === "win32" };
+
+test("measured: dotnet missing from the PATH fails closed", posix, () => {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
- const gitOnly = mkdtempSync(join(tmpdir(), "cultlib-semver-path-"));
- try {
- const git = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
- symlinkSync(git, join(gitOnly, "git"));
- const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env: { ...process.env, PATH: gitOnly } });
+ withFakeDotnet(null, (env) => {
+ const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env });
assert.match(output, /dotnet tool restore failed: .*ENOENT/);
- } finally {
- rmSync(gitOnly, { recursive: true, force: true });
- }
+ });
+ });
+});
+
+test("measured: a restore failure shows the last three lines of the tool's own output, in order", posix, () => {
+ withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
+ const args = measuredArgs(dir, "1.0.1", [oldWidget()]);
+ // stdout then stderr, CRLF and LF line ends, trailing blank lines: only the last three real lines remain.
+ const toolRestore = "if [ \"$1\" = tool ] && [ \"$2\" = restore ]; then printf 'out1\\nout2\\n'; printf 'err1\\r\\nerr2\\n\\n' >&2; exit 1; fi\nexit 0";
+ withFakeDotnet(toolRestore, (env) => {
+ const output = runCheckerExpectFailure(dir, args, { env });
+ assert.match(output, /could not be measured against widget-v1\.0\.0: dotnet tool restore failed: out2 \| err1 \| err2\s*$/);
+ });
+ const packageRestore = "if [ \"$1\" = restore ]; then printf 'a\\nb\\nc\\nd\\n' >&2; exit 1; fi\nexit 0";
+ withFakeDotnet(packageRestore, (env) => {
+ const output = runCheckerExpectFailure(dir, args, { env });
+ assert.match(output, /: dotnet restore failed: b \| c \| d\s*$/);
+ });
+ });
+});
+
+test("measured: restores that succeed without delivering the reference assemblies fail closed", posix, () => {
+ withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
+ withCheckerCopy({ ".config/dotnet-tools.json": "{}" }, (script) => {
+ withFakeDotnet("exit 0", (env) => {
+ const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env });
+ assert.match(output, /the pinned netstandard reference assemblies were not restored under scripts\/api-gate\/packages/);
+ });
+ });
});
});