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:
parent
68c1296b7a
commit
652b0837d2
3 changed files with 204 additions and 42 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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()]));
|
||||
|
|
|
|||
Loading…
Reference in a new issue