Semver check: one rule for an unbuilt tracked assembly, comparisons must run, git failures are refusals, a first release is cross-checked

Every assembly the previous tag tracks that the build does not hand over is a break, whatever its name; a run that compared nothing is refused (the --api-new rename of a lone assembly). The cultlib build now hands over every shipped assembly. --first-release is believed only with no other same-prefix tag and the prefix its changelog directory names. git ls-tree/show failures and an unreadable changelog become refusals. Tests pin the CultMath-shaped rename, the left-reference scenario (a dependency's netstandard base chain changes), the first-release probes and the workflow's own first-release line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 00:58:33 +02:00
commit 652b0837d2
3 changed files with 204 additions and 42 deletions

View file

@ -173,13 +173,12 @@ foreach ($assemblyName in $expectedAssemblies) {
}
# docs/semver-policy.md: refuse a release whose version does not tell the truth. The check measures
# the public API of every shipped GameCult.* assembly against the DLLs the previous cultlib-unity tag
# tracked (read from git), so it runs here, once the assemblies under judgement exist.
# 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.
$measuredArguments = @()
foreach ($assemblyName in $expectedAssemblies) {
if ($assemblyName.StartsWith("GameCult.")) {
$measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName)
}
$measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName)
}
foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) {
$measuredArguments += @("--api-refs", $refRoot)

View file

@ -167,6 +167,9 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
// --- CLI wrapper: resolves the previous published tag via git and reads the
// changelog off disk, 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.
@ -177,6 +180,7 @@ function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
const prefix = `${tagPrefix}-v`;
const current = parseVersion(currentVersion);
let best = null;
let other = null;
for (const tag of tags) {
if (!tag.startsWith(prefix)) continue;
const versionText = tag.slice(prefix.length);
@ -187,12 +191,13 @@ function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
continue;
}
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) {
best = candidate;
}
}
return best ? formatVersion(best) : null;
return { previous: best ? formatVersion(best) : null, other };
}
// --- Measured public API: the built DLLs are compared with the DLLs the
@ -248,30 +253,37 @@ function prepareGate() {
}
// The DLLs the previous tag tracked under `baselinePath`, as git blobs: no
// worktree, no network, and unaffected by what the working tree holds now.
// 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(/\/+$/, "");
const listing = execFileSync("git", ["ls-tree", "--name-only", tag, `${dir}/`], { cwd, encoding: "utf8" });
const names = listing.split("\n").map((line) => basename(line.trim())).filter((name) => name.toLowerCase().endsWith(".dll"));
for (const name of names) {
const blob = execFileSync("git", ["show", `${tag}:${dir}/${name}`], { cwd, maxBuffer: 1 << 28 });
writeFileSync(join(into, name), blob);
const git = (args, options) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], ...options });
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 };
} catch {
return { failure: `git could not read the assemblies tracked under ${dir} at ${tag}` };
}
return { dir, 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, and every
// 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`). A GameCult.* assembly the tag tracked and the build no longer
// produces is itself a break.
// (`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 }) {
const tag = `${tagPrefix}-v${previousVersion}`;
const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-"));
try {
const left = join(work, "left");
mkdirSync(left);
const { dir, names: baselineNames } = extractBaseline({ tag, baselinePath, into: left, cwd });
const baseline = extractBaseline({ tag, baselinePath, into: left, cwd });
if (baseline.failure) return baseline;
const { dir, names: baselineNames } = baseline;
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` };
}
@ -286,12 +298,11 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui
}
const gate = prepareGate();
if (gate.failure) return gate;
const breaks = [];
for (const name of baselineNames) {
if (name.startsWith("GameCult.") && !builtNames.has(name)) {
breaks.push(`assembly ${name} exists at ${tag} but is no longer built`);
}
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`);
for (const path of built) {
const name = basename(path);
if (!baselineNames.includes(name)) continue;
@ -358,15 +369,18 @@ function main() {
refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`);
return;
}
if (!existsSync(changelog)) {
refuse(`${packageName}: changelog not found at ${changelog}`);
let changelogText;
try {
changelogText = readFileSync(changelog, "utf8");
} catch (err) {
refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`);
return;
}
const changelogText = readFileSync(changelog, "utf8");
const firstRelease = args["first-release"] === true;
let previousVersion;
let otherTag = null;
try {
previousVersion = resolvePreviousVersion(tagPrefix, version, cwd);
({ 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; ` +
@ -380,8 +394,15 @@ function main() {
"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`);
if (firstRelease && otherTag != null) {
refuse(`${packageName}: --first-release was declared but ${otherTag} already exists`);
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`);
return;
}
let measuredBreaks = [];

View file

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -260,12 +260,19 @@ test("CLI: a tag for the version being released does not skip the check", () =>
});
});
// 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", () => {
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 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\)/);
@ -274,8 +281,7 @@ test("CLI: no previous tag, or tags that cannot be read, refuse unless the first
});
const notARepo = mkdtempSync(join(tmpdir(), "cultlib-semver-norepo-"));
try {
const changelogPath = join(notARepo, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.0.0"));
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/);
@ -297,6 +303,69 @@ test("CLI: declaring a first release while an earlier tag exists is refused", ()
});
});
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 } });
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");
});
}
});
// --- Measured public API. The fixtures are real assemblies, built here with
// the SDK and compared by the pinned ApiCompat tool through the CLI, so every
// rule is observed where the release scripts observe it. ---
@ -402,21 +471,39 @@ test("measured: a declared break with nothing measured still passes", () => {
});
});
test("measured: a removed GameCult.* assembly is a break, a declared new assembly and a removed third-party one are not", () => {
test("measured: a tracked assembly that is no longer built is a break, whatever its name; a declared new assembly is 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 };
withReleasedBaseline({ baseline }, (dir) => {
const refused = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [oldWidget()]));
assert.match(refused, /GameCult\.Other\.dll exists at widget-v1\.0\.0 but is no longer built/);
assert.doesNotMatch(refused, /Vendor\.Lib/);
assert.match(refused, /breaks 1 public API member/);
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, brandNew], { news: ["GameCult.Brand.dll"] }));
const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, thirdParty, brandNew], { news: ["GameCult.Brand.dll"] }));
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", () => {
// 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/);
});
});
});
test("measured: a tool failure with no diagnostic fails closed", () => {
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
const notAnAssembly = join(dir, "GameCult.Widget.dll");
@ -478,13 +565,35 @@ test("measured: a base type moved to a subclass in another assembly resolves thr
const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-refs-"));
try {
mkdirSync(join(scratch, "built"));
mkdirSync(join(scratch, "builtA"));
mkdirSync(join(scratch, "refs"));
copyFileSync(newB, join(scratch, "built", "GameCult.Widget.dll"));
copyFileSync(newA, join(scratch, "builtA", "Vendor.A.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/);
const built = [join(scratch, "built", "GameCult.Widget.dll"), join(scratch, "builtA", "Vendor.A.dll")];
// The new base assembly is reachable from the widget's directory 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 base chain changed in a dependency is seen on the GameCult type through the left references", () => {
// Old Vendor.A: Mid derives from Exception. New Vendor.A: Mid has no base. GameCult's D derives from Mid in both.
// ApiCompat reports the lost ISerializable on D only when the left side can resolve netstandard and Vendor.A.
const oldA = buildAssembly("Vendor.A", "namespace A { public class Mid : System.Exception { } }");
const newA = buildAssembly("Vendor.A", "namespace A { public class Mid { } }");
const oldB = buildAssembly("GameCult.Widget", "namespace B { public class D : A.Mid { } }", [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-left-"));
try {
copyFileSync(newB, join(scratch, "GameCult.Widget.dll"));
copyFileSync(newA, join(scratch, "Vendor.A.dll"));
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [join(scratch, "GameCult.Widget.dll"), join(scratch, "Vendor.A.dll")]));
assert.match(output, /Type 'B\.D' does not implement interface 'System\.Runtime\.Serialization\.ISerializable'/);
} finally {
rmSync(scratch, { recursive: true, force: true });
}
@ -514,7 +623,9 @@ test("measured: a declared first release measures nothing", () => {
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]), "--first-release"]);
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/);
});
@ -546,6 +657,37 @@ test("measured: measuring nothing refuses, naming what is missing", () => {
});
});
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", () => {
const clean = (output) => {
assert.doesNotMatch(output, /fatal|Command failed|\n\s+at |usage: git|unknown option/);
return output;
};
// the tag tracks a gitlink named like a DLL: ls-tree lists it, git show cannot read it
withTempGitRepo((dir) => {
mkdirSync(join(dir, PLUGINS), { recursive: true });
copyFileSync(oldWidget(), join(dir, PLUGINS, "GameCult.Widget.dll"));
execFileSync("git", ["add", "."], { cwd: dir });
execFileSync("git", ["update-index", "--add", "--cacheinfo", "160000,1111111111111111111111111111111111111111,unity/widget/Runtime/Plugins/Gitlink.dll"], { cwd: dir });
execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
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
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/);
});
// a changelog path that is a directory
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
mkdirSync(join(dir, "CHANGELOG.md"));
const output = clean(runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.0.0", "--tag-prefix", "widget"]));
assert.match(output, /changelog cannot be read at /);
});
});
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()]));