Semver check: measuring nothing refuses; reference assemblies; tool restored alone
- A missing or moved baseline path, a previous tag that does not track the DLLs, and a built DLL with no namesake at the tag (unless declared with --api-new) refuse, naming what is missing. - First release is declared with --first-release, never inferred: no previous tag, or tags that cannot be read, refuse without it. - The "already published" skip is removed; a local tag of the released version exempts nothing. - ApiCompat and the pinned NETStandard.Library.Ref 2.1.0 live in scripts/api-gate and are restored there, so the root manifest (dotnet-stryker) is no longer needed by a release. Both sides resolve netstandard through the reference assemblies. - Tests: refusals, netstandard false breaks, cross-assembly references, restore diagnostics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
32f5700b2a
commit
83e3f81263
6 changed files with 350 additions and 110 deletions
|
|
@ -8,13 +8,6 @@
|
|||
"dotnet-stryker"
|
||||
],
|
||||
"rollForward": false
|
||||
},
|
||||
"microsoft.dotnet.apicompat.tool": {
|
||||
"version": "10.0.202",
|
||||
"commands": [
|
||||
"apicompat"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
scripts/api-gate/.config/dotnet-tools.json
Normal file
13
scripts/api-gate/.config/dotnet-tools.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"microsoft.dotnet.apicompat.tool": {
|
||||
"version": "10.0.202",
|
||||
"commands": [
|
||||
"apicompat"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
1
scripts/api-gate/.gitignore
vendored
Normal file
1
scripts/api-gate/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
packages/
|
||||
11
scripts/api-gate/ApiRefs.csproj
Normal file
11
scripts/api-gate/ApiRefs.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Restores the netstandard2.1 reference assemblies for scripts/check-changelog-semver.mjs.
|
||||
Not built: PackageDownload only fetches the package into ./packages. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<RestorePackagesPath>$(MSBuildThisFileDirectory)packages</RestorePackagesPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageDownload Include="NETStandard.Library.Ref" Version="[2.1.0]" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -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 `<tagPrefix>-v<version>` 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 <name> --changelog <path> --version <x.y.z> --tag-prefix <prefix> [--cwd <dir>]\n" +
|
||||
" [--api-baseline-path <repo-relative dir of tracked DLLs> --api-built <dll>... [--api-refs <dir>...]]",
|
||||
"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>...]]",
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <assemblyName>.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"),
|
||||
`<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>netstandard2.1</TargetFramework>` +
|
||||
`<AssemblyName>${assemblyName}</AssemblyName><Deterministic>true</Deterministic></PropertyGroup></Project>`,
|
||||
`<AssemblyName>${assemblyName}</AssemblyName><Deterministic>true</Deterministic></PropertyGroup>` +
|
||||
`<ItemGroup>${references.map((path) => `<Reference Include="${basename(path, ".dll")}"><HintPath>${path}</HintPath></Reference>`).join("")}</ItemGroup></Project>`,
|
||||
);
|
||||
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<int> { } }");
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue