From 652b0837d236e57a526cd763d03988c402ca5ddd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 00:58:33 +0200 Subject: [PATCH] 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 --- scripts/build-unity-package.ps1 | 9 +- scripts/check-changelog-semver.mjs | 67 ++++++---- scripts/check-changelog-semver.test.mjs | 170 ++++++++++++++++++++++-- 3 files changed, 204 insertions(+), 42 deletions(-) diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 0539863a..c508bc8d 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -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) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 93a9d53e..e1be5ab9 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -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 = []; diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 3d11a3db..ad7e58a3 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -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//). +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()]));