The python-build step runs the same guard whose output is the dist PyPI uploads, so a NODE_OPTIONS preload in the workflow or job env could stub its checks exactly as it could on the npm Publish line. It now starts under env -u NODE_OPTIONS; the pin's exact text and semver-policy.md say so. The pin test's if: always() edit matched the npm line's old text and had become a no-op; it now matches the pinned line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1654 lines
85 KiB
JavaScript
1654 lines
85 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, posix as posixPath } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { test } from "node:test";
|
|
import {
|
|
BREAKING_HEADING,
|
|
checkDeclaration,
|
|
classifyBump,
|
|
evaluateRelease,
|
|
hasBreakingSection,
|
|
isManagedAssembly,
|
|
nextVersions,
|
|
parseVersion,
|
|
readApiCompatRun,
|
|
} from "./check-changelog-semver.mjs";
|
|
|
|
const checkerPath = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url));
|
|
|
|
function changelogFor(version, { breaking } = {}) {
|
|
return [
|
|
"# Changelog",
|
|
"",
|
|
"All notable changes to this package are documented in this file.",
|
|
"",
|
|
`## [${version}]`,
|
|
"",
|
|
"### Changed",
|
|
"",
|
|
"- something changed",
|
|
"",
|
|
...(breaking ? [BREAKING_HEADING, "", "- something public was removed", ""] : []),
|
|
].join("\n");
|
|
}
|
|
|
|
test("parseVersion rejects non-semver strings", () => {
|
|
assert.throws(() => parseVersion("1.0"), /not a MAJOR.MINOR.PATCH version/);
|
|
assert.throws(() => parseVersion("v1.0.0"), /not a MAJOR.MINOR.PATCH version/);
|
|
});
|
|
|
|
test("nextVersions enumerates exactly the three legitimate next versions", () => {
|
|
const prev = parseVersion("1.2.3");
|
|
assert.deepEqual(nextVersions(prev), {
|
|
major: { major: 2, minor: 0, patch: 0 },
|
|
minor: { major: 1, minor: 3, patch: 0 },
|
|
patch: { major: 1, minor: 2, patch: 4 },
|
|
});
|
|
});
|
|
|
|
test("classifyBump identifies each lane and rejects everything else", () => {
|
|
const prev = parseVersion("1.2.3");
|
|
assert.equal(classifyBump(prev, parseVersion("2.0.0")), "major");
|
|
assert.equal(classifyBump(prev, parseVersion("1.3.0")), "minor");
|
|
assert.equal(classifyBump(prev, parseVersion("1.2.4")), "patch");
|
|
assert.deepEqual(classifyBump(prev, parseVersion("1.2.3")), { invalid: "no-op" });
|
|
assert.deepEqual(classifyBump(prev, parseVersion("1.2.2")), { invalid: "reverse" });
|
|
assert.deepEqual(classifyBump(prev, parseVersion("1.2.5")), { invalid: "skip" });
|
|
assert.deepEqual(classifyBump(prev, parseVersion("1.4.0")), { invalid: "skip" });
|
|
assert.deepEqual(classifyBump(prev, parseVersion("3.0.0")), { invalid: "skip" });
|
|
});
|
|
|
|
test("hasBreakingSection scopes to the named version's section only", () => {
|
|
const text = [
|
|
"# Changelog",
|
|
"",
|
|
"## [1.0.60]",
|
|
"",
|
|
"### Breaking",
|
|
"",
|
|
"- removed CultInspectorAssetPath",
|
|
"",
|
|
"## [1.0.59]",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- something older, not breaking",
|
|
"",
|
|
].join("\n");
|
|
assert.equal(hasBreakingSection(text, "1.0.60"), true);
|
|
assert.equal(hasBreakingSection(text, "1.0.59"), false);
|
|
assert.throws(() => hasBreakingSection(text, "0.9.0"), /no "## \[0\.9\.0\]" entry/);
|
|
});
|
|
|
|
test("hasBreakingSection reads Keep a Changelog's dated heading beside the plain forms", () => {
|
|
const text = ["## [0.4.0] - 2026-10-03", "### Breaking", "- x", "## 0.3.1 - 2026-09-01", "### Breaking", "## [0.3.0]", "### Fixed", ""].join("\n");
|
|
assert.equal(hasBreakingSection(text, "0.4.0"), true);
|
|
assert.equal(hasBreakingSection(text, "0.3.1"), true);
|
|
assert.equal(hasBreakingSection(text, "0.3.0"), false);
|
|
// a dated section ends at the next heading, dated or not
|
|
assert.equal(hasBreakingSection(["## [0.4.0] - 2026-10-03", "### Fixed", "## [0.3.9] - 2026-09-01", "### Breaking"].join("\n"), "0.4.0"), false);
|
|
// only a date may follow the version
|
|
for (const heading of ["## [0.4.0] - soon", "## [0.4.0] 2026-10-03", "## [0.4.0] - 2026-10-03 extra", "## [0.4.0.1] - 2026-10-03"]) {
|
|
assert.throws(() => hasBreakingSection(`${heading}\n### Breaking\n`, "0.4.0"), /no "## \[0\.4\.0\]" entry/, heading);
|
|
}
|
|
});
|
|
|
|
// Table-driven per the operator's required cases (spec: breaking+major=pass,
|
|
// breaking+patch=fail, breaking+minor=fail, non-breaking+minor=pass,
|
|
// 0.y.z breaking+minor=pass, skipped version=fail, downgrade=fail).
|
|
const cases = [
|
|
{
|
|
name: "breaking + major = pass",
|
|
previousVersion: "1.0.60",
|
|
version: "2.0.0",
|
|
breaking: true,
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "breaking + patch = fail",
|
|
previousVersion: "1.0.60",
|
|
version: "1.0.61",
|
|
breaking: true,
|
|
ok: false,
|
|
},
|
|
{
|
|
name: "breaking + minor = fail",
|
|
previousVersion: "1.0.60",
|
|
version: "1.1.0",
|
|
breaking: true,
|
|
ok: false,
|
|
},
|
|
{
|
|
name: "non-breaking + minor = pass",
|
|
previousVersion: "1.0.60",
|
|
version: "1.1.0",
|
|
breaking: false,
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "0.y.z breaking + minor = pass",
|
|
previousVersion: "0.2.3",
|
|
version: "0.3.0",
|
|
breaking: true,
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "0.y.z breaking + patch = fail",
|
|
previousVersion: "0.2.3",
|
|
version: "0.2.4",
|
|
breaking: true,
|
|
ok: false,
|
|
},
|
|
{
|
|
name: "skipped version = fail",
|
|
previousVersion: "1.0.60",
|
|
version: "1.0.62",
|
|
breaking: false,
|
|
ok: false,
|
|
},
|
|
{
|
|
name: "downgrade = fail",
|
|
previousVersion: "1.0.60",
|
|
version: "1.0.59",
|
|
breaking: false,
|
|
ok: false,
|
|
},
|
|
{
|
|
name: "first release has nothing to compare against but still needs a changelog entry",
|
|
previousVersion: null,
|
|
version: "0.1.0",
|
|
breaking: false,
|
|
ok: true,
|
|
},
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
test(`evaluateRelease: ${testCase.name}`, () => {
|
|
const result = evaluateRelease({
|
|
packageName: "test-package",
|
|
changelogText: changelogFor(testCase.version, { breaking: testCase.breaking }),
|
|
version: testCase.version,
|
|
previousVersion: testCase.previousVersion,
|
|
});
|
|
assert.equal(result.ok, testCase.ok, result.reason);
|
|
});
|
|
}
|
|
|
|
test("evaluateRelease fails loudly when the version has no changelog entry at all", () => {
|
|
const result = evaluateRelease({
|
|
packageName: "test-package",
|
|
changelogText: changelogFor("1.0.0"),
|
|
version: "1.2.0",
|
|
previousVersion: "1.0.0",
|
|
});
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.reason, /no "## \[1\.2\.0\]" entry/);
|
|
});
|
|
|
|
test("evaluateRelease names package, previous version, new version, and the breaking heading on failure", () => {
|
|
const result = evaluateRelease({
|
|
packageName: "org.gamecult.cultlib",
|
|
changelogText: changelogFor("1.0.61", { breaking: true }),
|
|
version: "1.0.61",
|
|
previousVersion: "1.0.60",
|
|
});
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.reason, /org\.gamecult\.cultlib/);
|
|
assert.match(result.reason, /1\.0\.60/);
|
|
assert.match(result.reason, /1\.0\.61/);
|
|
assert.match(result.reason, new RegExp(BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
});
|
|
|
|
// --- CLI-level integration tests: exercise the declaration, the git-backed release
|
|
// resolution and the derived first release, which the pure evaluateRelease() tests
|
|
// above cannot reach. ---
|
|
|
|
const PLUGINS = "unity/widget/Runtime/Plugins";
|
|
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
const workflowFile = join(repoRoot, ".github", "workflows", "publish-packages.yml");
|
|
const releasePackages = (await import("./release-packages.mjs")).default;
|
|
|
|
// The package every fixture repository declares in its own scripts/release-packages.mjs.
|
|
const WIDGET = { tagPrefix: "widget", changelog: "CHANGELOG.md" };
|
|
|
|
function declare(dir, entries) {
|
|
mkdirSync(join(dir, "scripts"), { recursive: true });
|
|
writeFileSync(join(dir, "scripts", "release-packages.mjs"), `export default ${JSON.stringify(entries)};\n`);
|
|
}
|
|
|
|
function withTempGitRepo(fn) {
|
|
const dir = mkdtempSync(join(tmpdir(), "cultlib-semver-check-"));
|
|
try {
|
|
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
|
|
declare(dir, { widget: WIDGET });
|
|
fn(dir);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// Runs the checker on the repository at `cwd` (--cwd). The process itself starts in
|
|
// `processCwd`, by default that same repository, never the one that holds the checker:
|
|
// the release scripts do not run it from there either.
|
|
function runChecker(cwd, args, { env, script = checkerPath, processCwd = cwd } = {}) {
|
|
return execFileSync(process.execPath, [script, ...args, "--cwd", cwd], {
|
|
cwd: processCwd,
|
|
env,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
function runCheckerExpectFailure(cwd, args, { status = 1, ...options } = {}) {
|
|
try {
|
|
runChecker(cwd, args, options);
|
|
throw new Error("expected the checker to exit non-zero");
|
|
} catch (err) {
|
|
assert.equal(err.status, status);
|
|
return err.stdout + err.stderr;
|
|
}
|
|
}
|
|
|
|
const git = (dir, ...args) => execFileSync("git", args, { cwd: dir });
|
|
const commitAndTag = (dir, ...tags) => {
|
|
git(dir, "commit", "--allow-empty", "-q", "-m", `init ${tags.join(" ")}`);
|
|
for (const tag of tags) git(dir, "tag", tag);
|
|
};
|
|
const writeChangelog = (dir, version, options) => writeFileSync(join(dir, "CHANGELOG.md"), changelogFor(version, options));
|
|
const widgetArgs = (version) => ["--package", "widget", "--version", version];
|
|
|
|
test("CLI: resolves the previous version from git tags and passes a valid bump", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.1.0");
|
|
const output = runChecker(dir, widgetArgs("1.1.0"));
|
|
assert.match(output, /1\.1\.0 is a minor bump over 1\.0\.0/);
|
|
});
|
|
});
|
|
|
|
test("CLI: fails when the changelog claims breaking under too small a bump", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.0.1", { breaking: true });
|
|
const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1"));
|
|
assert.match(output, /needs a major bump/);
|
|
});
|
|
});
|
|
|
|
test("CLI: a tag for the version being released does not skip the check", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0", "widget-v1.0.1");
|
|
writeChangelog(dir, "1.0.1", { breaking: true });
|
|
const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1"));
|
|
assert.match(output, /needs a major bump/);
|
|
});
|
|
});
|
|
|
|
test("CLI: a first release is derived from the tags, and nothing is measured for it", () => {
|
|
withTempGitRepo((dir) => {
|
|
// another package's tag exists; this package has none
|
|
commitAndTag(dir, "other-v1.0.0");
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
const junk = join(dir, "junk.dll");
|
|
writeFileSync(junk, "junk");
|
|
const measured = runChecker(dir, measuredArgs(dir, "1.0.0", [junk]));
|
|
assert.match(measured, /first release \(1\.0\.0\)/);
|
|
assert.doesNotMatch(measured, /public API measured/);
|
|
declare(dir, { widget: WIDGET });
|
|
assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/);
|
|
// the version's own tag, beside another package's, is still a first release
|
|
git(dir, "tag", "widget-v1.0.0");
|
|
assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/);
|
|
});
|
|
});
|
|
|
|
test("CLI: a checkout that holds no other tag refuses, and no option rescues it", () => {
|
|
const holdsNone = /this checkout holds no tags; a first release cannot be told from a missing record/;
|
|
withTempGitRepo((dir) => {
|
|
git(dir, "commit", "--allow-empty", "-q", "-m", "init");
|
|
writeChangelog(dir, "1.0.0");
|
|
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), holdsNone);
|
|
git(dir, "tag", "widget-v1.0.0");
|
|
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), holdsNone);
|
|
// an option that used to declare the release is now an unknown option, not a rescue
|
|
runCheckerExpectFailure(dir, [...widgetArgs("1.0.0"), "--first-release"], { status: 2 });
|
|
});
|
|
});
|
|
|
|
test("CLI: tags that cannot be read refuse without git's output", () => {
|
|
const notARepo = mkdtempSync(join(tmpdir(), "cultlib-semver-norepo-"));
|
|
try {
|
|
declare(notARepo, { widget: WIDGET });
|
|
writeChangelog(notARepo, "1.0.0");
|
|
const refused = runCheckerExpectFailure(notARepo, widgetArgs("1.0.0"));
|
|
assert.match(refused, /tags of --cwd could not be read \(not a git repository\?\), so the previous release is unknown/);
|
|
assert.doesNotMatch(refused, /fatal/); // git's own complaint is not forwarded
|
|
} finally {
|
|
rmSync(notARepo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("CLI: a same-prefix tag that is not MAJOR.MINOR.PATCH refuses, never skipped", () => {
|
|
for (const malformed of ["widget-v0.9.0-rc1", "widget-v1.0", "widget-vnext"]) {
|
|
// alone
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, malformed);
|
|
writeChangelog(dir, "0.9.0");
|
|
const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0"));
|
|
assert.match(output, new RegExp(`${malformed.replace(/\./g, "\\.")} starts with widget-v but is not widget-vMAJOR\\.MINOR\\.PATCH`));
|
|
});
|
|
// beside an older valid release the version would otherwise be measured against
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v0.8.0", malformed);
|
|
writeChangelog(dir, "0.9.0");
|
|
const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0"));
|
|
assert.match(output, new RegExp(`${malformed.replace(/\./g, "\\.")} starts with`));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("CLI: a version older than every release refuses", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0", "widget-v2.0.0");
|
|
writeChangelog(dir, "0.9.0");
|
|
const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0"));
|
|
assert.match(output, /widget-v(1|2)\.0\.0 exists but no widget-v tag is older than 0\.9\.0: the version is older than every release/);
|
|
});
|
|
});
|
|
|
|
test("CLI: the previous release is the newest older tag by version order, not git's string order", () => {
|
|
withTempGitRepo((dir) => {
|
|
// git tag -l lists these as 1.0.0, 1.0.5, 1.10.0, 1.2.0, 1.9.0, 2.0.0: 1.10.0 sorts before 1.2.0
|
|
commitAndTag(dir, "widget-v1.0.0", "widget-v1.2.0", "widget-v1.10.0", "widget-v1.9.0", "widget-v2.0.0", "other-v9.9.9");
|
|
git(dir, "tag", "-a", "-m", "annotated", "widget-v1.0.5");
|
|
const previousOf = (version) => {
|
|
writeChangelog(dir, version);
|
|
return runChecker(dir, widgetArgs(version));
|
|
};
|
|
assert.match(previousOf("1.10.1"), /1\.10\.1 is a patch bump over 1\.10\.0/);
|
|
assert.match(previousOf("1.11.0"), /1\.11\.0 is a minor bump over 1\.10\.0/);
|
|
assert.match(previousOf("1.3.0"), /1\.3\.0 is a minor bump over 1\.2\.0/);
|
|
assert.match(previousOf("1.0.6"), /1\.0\.6 is a patch bump over 1\.0\.5/);
|
|
assert.match(previousOf("2.0.1"), /2\.0\.1 is a patch bump over 2\.0\.0/);
|
|
assert.match(previousOf("3.0.0"), /3\.0\.0 is a major bump over 2\.0\.0/);
|
|
});
|
|
});
|
|
|
|
test("CLI: a later backport tag on another branch does not change a rebuild's predecessor", () => {
|
|
const head = (dir) => git(dir, "rev-parse", "HEAD").toString().trim();
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v0.2.0");
|
|
const older = head(dir);
|
|
commitAndTag(dir, "widget-v0.3.0");
|
|
const rebuilt = head(dir);
|
|
writeChangelog(dir, "0.3.0");
|
|
const atRelease = runChecker(dir, widgetArgs("0.3.0"));
|
|
assert.match(atRelease, /0\.3\.0 is a minor bump over 0\.2\.0/);
|
|
// the backport is cut from the older release, later, and is never merged
|
|
git(dir, "checkout", "-q", older);
|
|
commitAndTag(dir, "widget-v0.2.5");
|
|
const backport = head(dir);
|
|
git(dir, "checkout", "-q", rebuilt);
|
|
assert.equal(runChecker(dir, widgetArgs("0.3.0")), atRelease);
|
|
// history that does contain the backport still measures against it
|
|
git(dir, "checkout", "-q", backport);
|
|
writeChangelog(dir, "0.2.6");
|
|
assert.match(runChecker(dir, widgetArgs("0.2.6")), /0\.2\.6 is a patch bump over 0\.2\.5/);
|
|
});
|
|
// older tags that are all outside the history leave the predecessor unknown
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "other-v1.0.0");
|
|
const root = head(dir);
|
|
git(dir, "checkout", "-q", "--detach");
|
|
commitAndTag(dir, "widget-v0.1.0");
|
|
git(dir, "checkout", "-q", root);
|
|
writeChangelog(dir, "0.2.0");
|
|
assert.match(runCheckerExpectFailure(dir, widgetArgs("0.2.0")), /none is an ancestor of the commit being checked/);
|
|
});
|
|
});
|
|
|
|
// A repository where each tag tracks the files it is given: tag name -> { path: contents }.
|
|
// Every file is committed under the tag that first names it, so a later tag tracks the earlier ones'.
|
|
function tagWithFiles(dir, tag, files) {
|
|
for (const [path, contents] of Object.entries(files)) {
|
|
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
writeFileSync(join(dir, path), contents);
|
|
}
|
|
git(dir, "add", "--", ...Object.keys(files));
|
|
commitAndTag(dir, tag);
|
|
}
|
|
|
|
// A PE image written byte by byte. A managed assembly's CLI header is data directory 14; a
|
|
// native DLL has none.
|
|
function peImage({ plus = true, directories = 16, cli = null } = {}) {
|
|
const pe = 0x40;
|
|
const optionalSize = (plus ? 112 : 96) + directories * 8;
|
|
const bytes = Buffer.alloc(pe + 24 + optionalSize);
|
|
bytes.writeUInt16LE(0x5a4d, 0);
|
|
bytes.writeUInt32LE(pe, 0x3c);
|
|
bytes.writeUInt32LE(0x00004550, pe);
|
|
bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, pe + 4);
|
|
bytes.writeUInt16LE(optionalSize, pe + 20);
|
|
const optional = pe + 24;
|
|
bytes.writeUInt16LE(plus ? 0x20b : 0x10b, optional);
|
|
const dirs = optional + (plus ? 112 : 96);
|
|
bytes.writeUInt32LE(directories, dirs - 4);
|
|
if (cli) {
|
|
bytes.writeUInt32LE(cli.rva, dirs + 14 * 8);
|
|
bytes.writeUInt32LE(cli.size, dirs + 14 * 8 + 4);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
const CLI = { rva: 0x2000, size: 0x48 };
|
|
|
|
test("CLI: a mistyped declared prefix of a Unity package is refused, because a tag under another prefix tracks its managed assembly", () => {
|
|
// The real entries of CultMath and cultlib, each released once under its real prefix, then
|
|
// the declaration misspelled. Nothing is measured: the refusal comes from git.
|
|
const cases = [
|
|
{ name: "org.gamecult.cultmath", released: "0.3.0", next: "0.3.1", file: "CultMath.dll" },
|
|
{ name: "org.gamecult.cultlib", released: "1.0.60", next: "1.0.61", file: "GameCult.Core.dll" },
|
|
];
|
|
for (const { name, released, next, file } of cases) {
|
|
const real = releasePackages[name];
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { [name]: real });
|
|
tagWithFiles(dir, `${real.tagPrefix}-v${released}`, { [`${real.assemblies}/${file}`]: peImage({ cli: CLI }) });
|
|
mkdirSync(dirname(join(dir, real.changelog)), { recursive: true });
|
|
writeFileSync(join(dir, real.changelog), changelogFor(next));
|
|
const junk = join(dir, "junk.dll");
|
|
writeFileSync(junk, "junk");
|
|
const args = ["--package", name, "--version", next, "--api-built", junk];
|
|
declare(dir, { [name]: { ...real, tagPrefix: real.tagPrefix.slice(0, -1) } });
|
|
const refused = runCheckerExpectFailure(dir, args);
|
|
assert.ok(refused.includes(`${real.tagPrefix}-v${released} tracks the managed assembly ${real.assemblies}/${file}`), refused);
|
|
assert.doesNotMatch(refused, /first release \(/);
|
|
});
|
|
}
|
|
});
|
|
|
|
test("CLI: only a managed assembly under the declared assemblies directory proves an earlier release", () => {
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
tagWithFiles(dir, "other-v1.0.0", { "other/file.txt": "other" });
|
|
// files under the directory that are not managed assemblies prove nothing: text, and a native DLL
|
|
tagWithFiles(dir, "other-v1.1.0", { [`${PLUGINS}/notes.txt`]: "text", [`${PLUGINS}/x86_64/native.dll`]: peImage() });
|
|
writeChangelog(dir, "1.0.0");
|
|
const junk = join(dir, "junk.dll");
|
|
writeFileSync(junk, "junk");
|
|
const args = [...widgetArgs("1.0.0"), "--api-built", junk];
|
|
assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/);
|
|
// its own tag does not stop a rebuild of that first release
|
|
tagWithFiles(dir, "widget-v1.0.0", { [`${PLUGINS}/GameCult.Widget.dll`]: peImage({ cli: CLI }) });
|
|
assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/);
|
|
// but a managed assembly under any other tag makes a release with no same-prefix baseline a refusal
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS, tagPrefix: "widgt" } });
|
|
const refused = runCheckerExpectFailure(dir, args);
|
|
assert.ok(refused.includes(`widget-v1.0.0 tracks the managed assembly ${PLUGINS}/GameCult.Widget.dll`), refused);
|
|
});
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
tagWithFiles(dir, "other-v1.0.0", { [`${PLUGINS}/bogus.dll`]: "not a PE image" });
|
|
writeChangelog(dir, "1.0.0");
|
|
const junk = join(dir, "junk.dll");
|
|
writeFileSync(junk, "junk");
|
|
assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.0.0"), "--api-built", junk]), /bogus\.dll at other-v1\.0\.0 is a \.dll but not a PE image/);
|
|
});
|
|
});
|
|
|
|
test("CLI: the first-release probe reads the repository of --cwd and a blob of any size, and reads no tree for a package without assemblies", () => {
|
|
const elsewhere = mkdtempSync(join(tmpdir(), "cultlib-semver-elsewhere-"));
|
|
try {
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
// a managed assembly larger than the default 1 MiB buffer of a child process
|
|
tagWithFiles(dir, "other-v1.0.0", { [`${PLUGINS}/Big.dll`]: Buffer.concat([peImage({ cli: CLI }), Buffer.alloc(2 << 20)]) });
|
|
writeChangelog(dir, "1.0.0");
|
|
const junk = join(dir, "junk.dll");
|
|
writeFileSync(junk, "junk");
|
|
const refused = runCheckerExpectFailure(dir, [...widgetArgs("1.0.0"), "--api-built", junk], { processCwd: elsewhere });
|
|
assert.ok(refused.includes(`other-v1.0.0 tracks the managed assembly ${PLUGINS}/Big.dll`), refused);
|
|
});
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "other-v1.0.0");
|
|
writeFileSync(join(dir, ".git", "refs", "tags", "other-v2.0.0"), "1".repeat(40) + "\n");
|
|
writeChangelog(dir, "1.0.0");
|
|
// an unreadable tag matters only to a package whose assemblies it could have shipped
|
|
assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/);
|
|
});
|
|
} finally {
|
|
rmSync(elsewhere, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("CLI: a package with no assemblies is a first release when no tag of its own prefix exists, whatever other tags track", () => {
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, changelog: "pkg/CHANGELOG.md" } });
|
|
tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" });
|
|
tagWithFiles(dir, "other-v1.1.0", { "pkg/code.py": "x" });
|
|
mkdirSync(join(dir, "pkg"), { recursive: true });
|
|
writeFileSync(join(dir, "pkg", "CHANGELOG.md"), changelogFor("1.0.0"));
|
|
assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/);
|
|
// once it has a tag of its own prefix, that tag is the baseline
|
|
git(dir, "tag", "widget-v1.0.0");
|
|
writeFileSync(join(dir, "pkg", "CHANGELOG.md"), changelogFor("1.0.1"));
|
|
assert.match(runChecker(dir, widgetArgs("1.0.1")), /patch bump over 1\.0\.0/);
|
|
});
|
|
// the real entry of cultnet-py, whose directory every other package's tag tracks
|
|
withTempGitRepo((dir) => {
|
|
const real = releasePackages["cultnet-py"];
|
|
declare(dir, { "cultnet-py": real });
|
|
tagWithFiles(dir, "caching-unity-v1.2.0", { "packages/cultnet-py/cultnet/__init__.py": "x" });
|
|
mkdirSync(dirname(join(dir, real.changelog)), { recursive: true });
|
|
writeFileSync(join(dir, real.changelog), changelogFor("0.1.0"));
|
|
assert.match(runChecker(dir, ["--package", "cultnet-py", "--version", "0.1.0"]), /first release \(0\.1\.0\)/);
|
|
});
|
|
});
|
|
|
|
test("CLI: a folder that moved is the declared folder alone: the old path does not count", () => {
|
|
const baseline = buildAssembly("GameCult.Widget", WITH_GONE);
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": baseline } }, (dir) => {
|
|
const moved = "unity/widget2/Runtime/Plugins";
|
|
const built = buildAssembly("GameCult.Widget", WITH_GONE);
|
|
// under the real prefix the moved folder holds nothing at the tag: refused as a moved directory
|
|
declare(dir, { widget: { ...WIDGET, assemblies: moved } });
|
|
writeChangelog(dir, "1.0.1");
|
|
assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.0.1"), "--api-built", built]), /tracks no managed assembly under unity\/widget2\/Runtime\/Plugins/);
|
|
// under a mistyped prefix, no tag tracks a managed assembly in the new folder, and the old one is not looked at
|
|
declare(dir, { widget: { ...WIDGET, assemblies: moved, tagPrefix: "widgt" } });
|
|
assert.match(runChecker(dir, [...widgetArgs("1.0.1"), "--api-built", built]), /first release \(1\.0\.1\)/);
|
|
});
|
|
});
|
|
|
|
test("CLI: a tag that cannot be read at its ancestry or its files refuses", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v0.8.0");
|
|
// a tag whose object is missing from the repository (a damaged checkout)
|
|
writeFileSync(join(dir, ".git", "refs", "tags", "widget-v0.9.0"), "1".repeat(40) + "\n");
|
|
writeChangelog(dir, "1.0.0");
|
|
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /whether widget-v0\.9\.0 is an ancestor of the commit being checked could not be read/);
|
|
});
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
commitAndTag(dir, "other-v1.0.0");
|
|
writeFileSync(join(dir, ".git", "refs", "tags", "other-v2.0.0"), "1".repeat(40) + "\n");
|
|
const junk = join(dir, "junk.dll");
|
|
writeFileSync(junk, "junk");
|
|
const refused = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.0", [junk]));
|
|
assert.match(refused, /could not be read, so a first release cannot be told from a mistyped prefix/);
|
|
assert.doesNotMatch(refused, /fatal|bad object|1111111/); // git's own complaint is not forwarded
|
|
});
|
|
});
|
|
|
|
test("CLI: two packages that share a tag prefix are refused, and so is an entry that is not whole", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.0.1");
|
|
declare(dir, { widget: WIDGET, gadget: { ...WIDGET } });
|
|
for (const name of ["widget", "gadget"]) {
|
|
const output = runCheckerExpectFailure(dir, ["--package", name, "--version", "1.0.1"]);
|
|
assert.match(output, /scripts\/release-packages\.mjs is malformed: widget and gadget share one tag prefix/);
|
|
}
|
|
// distinct prefixes pass
|
|
declare(dir, { widget: WIDGET, gadget: { ...WIDGET, tagPrefix: "gadget" } });
|
|
assert.match(runChecker(dir, widgetArgs("1.0.1")), /patch bump over 1\.0\.0/);
|
|
for (const broken of [
|
|
{ changelog: "CHANGELOG.md" },
|
|
{ tagPrefix: 7, changelog: "CHANGELOG.md" },
|
|
{ tagPrefix: "widget" },
|
|
{ tagPrefix: "widget", changelog: ["CHANGELOG.md"] },
|
|
null,
|
|
]) {
|
|
declare(dir, { widget: WIDGET, gadget: broken });
|
|
const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1"));
|
|
assert.match(output, /scripts\/release-packages\.mjs is malformed: gadget must declare a string tagPrefix and a string changelog/);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("the real declaration is whole and shares no tag prefix", () => {
|
|
assert.equal(checkDeclaration(releasePackages), null);
|
|
assert.equal(new Set(Object.values(releasePackages).map((entry) => entry.tagPrefix)).size, Object.keys(releasePackages).length);
|
|
});
|
|
|
|
test("CLI: the tag prefix is the declaration's, not a spelling the caller or the directory supplies", () => {
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { tagPrefix: "widget-unity", changelog: "widget/CHANGELOG.md" } });
|
|
mkdirSync(join(dir, "widget"));
|
|
const changelog = (version) => writeFileSync(join(dir, "widget", "CHANGELOG.md"), changelogFor(version));
|
|
// widget-v1.2.0 is another package's tag: were it this package's, 1.1.0 would be a reversal
|
|
commitAndTag(dir, "widget-v1.2.0", "widget-unity-v1.0.0");
|
|
changelog("1.1.0");
|
|
assert.match(runChecker(dir, widgetArgs("1.1.0")), /1\.1\.0 is a minor bump over 1\.0\.0/);
|
|
// with the declared prefix's only release deleted, 1.0.1 is a first release, not a bump over widget-v1.2.0
|
|
git(dir, "tag", "-d", "widget-unity-v1.0.0");
|
|
changelog("1.0.1");
|
|
assert.match(runChecker(dir, widgetArgs("1.0.1")), /first release \(1\.0\.1\)/);
|
|
});
|
|
});
|
|
|
|
test("CLI: an undeclared package refuses, and so does a name that only the prototype holds", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.1.0");
|
|
for (const name of ["gadget", "constructor", "__proto__"]) {
|
|
const output = runCheckerExpectFailure(dir, ["--package", name, "--version", "1.1.0"]);
|
|
assert.match(output, /not a package declared in scripts\/release-packages\.mjs/);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("CLI: a repository with no declaration refuses", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.1.0");
|
|
rmSync(join(dir, "scripts"), { recursive: true });
|
|
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.1.0")), /scripts\/release-packages\.mjs could not be loaded from --cwd/);
|
|
});
|
|
});
|
|
|
|
test("CLI: the flags the declaration replaced are usage errors, as is any unknown option", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.1.0");
|
|
// the same call without the extra option passes, so a silently ignored flag would exit 0
|
|
assert.match(runChecker(dir, widgetArgs("1.1.0")), /minor bump/);
|
|
for (const extra of [
|
|
["--first-release"],
|
|
["--first-release", "true"],
|
|
["--first-release=true"],
|
|
["--api-new", "GameCult.Widget.dll"],
|
|
["--tag-prefix", "widget"],
|
|
["--changelog", join(dir, "CHANGELOG.md")],
|
|
["--api-baseline-path", PLUGINS],
|
|
["--bogus", "1"],
|
|
]) {
|
|
assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.1.0"), ...extra], { status: 2 }), /usage: check-changelog-semver\.mjs/);
|
|
assert.match(runCheckerExpectFailure(dir, [...extra, ...widgetArgs("1.1.0")], { status: 2 }), /usage:/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// The prefix of a package that declares no assemblies is proven by no tag's bytes, so this pin is
|
|
// its only guard against a typo in the declaration. Every prefix is declared once, every workflow
|
|
// tag trigger is a declared prefix, and every assembly-less package's prefix is a workflow trigger
|
|
// or, for a package the workflow does not build, names a family of tags that are that package's own:
|
|
// each tag '<prefix>-vX.Y.Z' carries the package's manifest, named and versioned as the tag is.
|
|
const familyProblem = (name, entry, tags, readFile) => {
|
|
const own = tags.filter((tag) => new RegExp(`^${entry.tagPrefix}-v\\d+\\.\\d+\\.\\d+$`).test(tag));
|
|
if (own.length === 0) return `${name} declares no assemblies and its prefix ${entry.tagPrefix} is neither a workflow trigger nor the prefix of any tag (are the tags fetched?)`;
|
|
const manifest = posixPath.join(posixPath.dirname(entry.changelog), "package.json");
|
|
for (const tag of own) {
|
|
let found;
|
|
try {
|
|
found = JSON.parse(readFile(tag, manifest));
|
|
} catch {
|
|
return `${name}: ${tag} holds no readable ${manifest}`;
|
|
}
|
|
if (found.name !== name || `${entry.tagPrefix}-v${found.version}` !== tag) return `${name}: ${tag} is not that package's release (${manifest} there is ${found.name} ${found.version})`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
function prefixPinProblems(declared, workflow, tagNames, readFile) {
|
|
const triggers = [...workflow.match(/^\s+tags:\r?\n((?:\s+- "[^"]+"\r?\n)+)/m)[1].matchAll(/- "([^"]+)-v\*"/g)].map((match) => match[1]);
|
|
const problems = [];
|
|
const prefixes = Object.values(declared).map((entry) => entry.tagPrefix);
|
|
for (const trigger of triggers) {
|
|
if (!prefixes.includes(trigger)) problems.push(`${trigger}-v* triggers the workflow but no package declares that prefix`);
|
|
}
|
|
for (const [name, entry] of Object.entries(declared)) {
|
|
if (prefixes.filter((prefix) => prefix === entry.tagPrefix).length > 1) problems.push(`${name} declares the prefix ${entry.tagPrefix}, which another package declares too`);
|
|
if (entry.assemblies !== undefined || triggers.includes(entry.tagPrefix)) continue;
|
|
const problem = familyProblem(name, entry, tagNames, readFile);
|
|
if (problem !== null) problems.push(problem);
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
const workflowText = () => readFileSync(workflowFile, "utf8");
|
|
const repoTags = () => execFileSync("git", ["tag", "-l"], { cwd: repoRoot, encoding: "utf8" }).split("\n").filter(Boolean);
|
|
const readAtTag = (tag, path) => execFileSync("git", ["show", `refs/tags/${tag}:${path}`], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
|
|
test("workflow: the declared prefixes are pinned to the tag triggers, and every declared assemblies directory is tracked", () => {
|
|
assert.deepEqual(prefixPinProblems(releasePackages, workflowText(), repoTags(), readAtTag), []);
|
|
for (const [name, entry] of Object.entries(releasePackages)) {
|
|
if (entry.assemblies === undefined) continue;
|
|
assert.notEqual(execFileSync("git", ["ls-files", "--", entry.assemblies], { cwd: repoRoot, encoding: "utf8" }).trim(), "", `${name} declares ${entry.assemblies}, which tracks nothing`);
|
|
}
|
|
});
|
|
|
|
test("workflow: a typo in the declared prefix of any assembly-less package fails the pin, and so does a deleted trigger", () => {
|
|
const assemblyLess = Object.entries(releasePackages).filter(([, entry]) => entry.assemblies === undefined);
|
|
assert.deepEqual(assemblyLess.map(([name]) => name).sort(), ["@gamecult/cultcache-ts", "cultcache-py", "cultmesh-py", "cultnet-py", "org.gamecult.caching.unity"]);
|
|
for (const [name, entry] of assemblyLess) {
|
|
for (const tagPrefix of [entry.tagPrefix.slice(0, -1), `${entry.tagPrefix}s`, entry.tagPrefix.replace("-", "_")]) {
|
|
const typo = { ...releasePackages, [name]: { ...entry, tagPrefix } };
|
|
assert.notDeepEqual(prefixPinProblems(typo, workflowText(), repoTags(), readAtTag), [], `${name} with prefix ${tagPrefix} passed the pin`);
|
|
}
|
|
}
|
|
const withoutTrigger = workflowText().replace(/^\s+- "cultmesh-py-v\*"\r?\n/m, "");
|
|
assert.notEqual(withoutTrigger, workflowText());
|
|
assert.notDeepEqual(prefixPinProblems(releasePackages, withoutTrigger, repoTags().filter((tag) => !tag.startsWith("cultmesh-py-v")), readAtTag), []);
|
|
});
|
|
|
|
test("workflow: the Caching Unity prefix is its own tag family, not another package's prefix or a prefix some tag happens to carry", () => {
|
|
const cachingUnity = releasePackages["org.gamecult.caching.unity"];
|
|
// another package's declared prefix, and a prefix tags carry that no package declares
|
|
for (const tagPrefix of [...Object.values(releasePackages).map((entry) => entry.tagPrefix).filter((prefix) => prefix !== cachingUnity.tagPrefix), "cultmath-core-rs", "cultmath", "cultlib", "cultnet-ts"]) {
|
|
const copied = { ...releasePackages, "org.gamecult.caching.unity": { ...cachingUnity, tagPrefix } };
|
|
assert.notDeepEqual(prefixPinProblems(copied, workflowText(), repoTags(), readAtTag), [], `Caching Unity declaring ${tagPrefix} passed the pin`);
|
|
}
|
|
// a tag that merely starts with the prefix is not that package's tag
|
|
const tags = [...repoTags(), "caching-unity-v1.5.0-rc1", "caching-unity-extra-v1.0.0"];
|
|
assert.deepEqual(prefixPinProblems(releasePackages, workflowText(), tags, readAtTag), []);
|
|
// an own-looking tag whose tree is another package's release refuses
|
|
assert.notDeepEqual(prefixPinProblems(releasePackages, workflowText(), [...tags, "caching-unity-v9.9.9"], readAtTag), []);
|
|
// no tags at all (a clone without them) refuses rather than passing
|
|
assert.match(prefixPinProblems(releasePackages, workflowText(), [], readAtTag).join("\n"), /are the tags fetched\?/);
|
|
});
|
|
|
|
// The suite is what pins the declared prefixes and the checker's rules, so nothing releases without it.
|
|
// publish-packages.yml releases only through scripts/release-after-suite.mjs, which runs the tag check, the
|
|
// suite, the semver policy check and the package's tests and then the release action from one process, so no
|
|
// if: or continue-on-error on any step can release what those checks did not pass. This pin reads no
|
|
// condition. It compares each job's release steps whole, less their own if: line, with the text below, so a
|
|
// release step that does anything else is a mismatch. It stops accidental edits; a deliberate one, such as a
|
|
// workflow-level defaults or env or an extra step, is review's job (ruling suite-gate-structural-not-parsed).
|
|
// Each Unity release script runs the suite itself, before it calls the checker.
|
|
const SUITE = /node\s+--test[^\n]*check-changelog-semver\.test\.mjs/;
|
|
const CHECKER = /check-changelog-semver\.mjs/;
|
|
const code = (text) => text.split("\n").filter((line) => !line.trim().startsWith("#")).join("\n");
|
|
|
|
const RELEASE_STEPS = {
|
|
"cultcache-ts": [
|
|
["- name: Publish", " run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts \"$GITHUB_REF\"", " env:", " NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}"],
|
|
],
|
|
// Publish uploads only the dist the guarded step built, so it has nothing to upload unless the suite passed.
|
|
python: [
|
|
["- name: Build sdist and wheel", " run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }} \"$GITHUB_REF\""],
|
|
["- name: Publish", " uses: pypa/gh-action-pypi-publish@release/v1", " with:", " packages-dir: packages/${{ matrix.package }}/dist"],
|
|
],
|
|
};
|
|
|
|
// A job's steps, each as its lines relative to its "- name:" line, without comments, blank lines or the
|
|
// step's own one-line if:. A line indented less than the step stays whole, so it cannot match.
|
|
function jobSteps(job) {
|
|
const steps = [];
|
|
for (const line of job.split(/\r?\n/)) {
|
|
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
const name = line.match(/^( *)- name:/);
|
|
if (name) steps.push({ indent: name[1], lines: [] });
|
|
const step = steps.at(-1);
|
|
if (step === undefined) continue;
|
|
const relative = line.startsWith(step.indent) ? line.slice(step.indent.length) : line;
|
|
if (!/^ if: .*$/.test(relative)) step.lines.push(relative);
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
function workflowReleaseProblems(workflow) {
|
|
const [, jobs = ""] = workflow.split(/^jobs:\r?\n/m);
|
|
const starts = [...jobs.matchAll(/^ ([\w-]+):\r?\n/gm)];
|
|
const problems = [];
|
|
for (const id of Object.keys(RELEASE_STEPS)) {
|
|
if (!starts.some((start) => start[1] === id)) problems.push(`the workflow has no job ${id}`);
|
|
}
|
|
starts.forEach((start, index) => {
|
|
const label = `job ${start[1]}`;
|
|
if (!Object.hasOwn(RELEASE_STEPS, start[1])) return problems.push(`${label} is not a job this pin knows; give it its release steps in RELEASE_STEPS`);
|
|
const steps = jobSteps(jobs.slice(start.index, starts[index + 1]?.index ?? jobs.length));
|
|
let from = 0;
|
|
for (const want of RELEASE_STEPS[start[1]]) {
|
|
const named = steps.filter((step) => step.lines[0] === want[0]);
|
|
const at = steps.findIndex((step, i) => i >= from && step.lines[0] === want[0]);
|
|
if (named.length !== 1 || at < 0) {
|
|
problems.push(`${label} needs exactly one step "${want[0]}", after the release step before it`);
|
|
continue;
|
|
}
|
|
if (steps[at].lines.join("\n") !== want.join("\n")) {
|
|
problems.push(`${label} step "${want[0]}" is not exactly the release call:\n${steps[at].lines.join("\n")}`);
|
|
}
|
|
from = at + 1;
|
|
}
|
|
});
|
|
return problems;
|
|
}
|
|
|
|
function scriptSuiteProblems(script) {
|
|
const body = code(script);
|
|
if (!SUITE.test(body)) return ["does not run the suite"];
|
|
return body.search(CHECKER) < body.search(SUITE) ? ["calls the checker before the suite"] : [];
|
|
}
|
|
|
|
test("workflow: each job releases only through release-after-suite.mjs", () => {
|
|
const workflow = workflowText();
|
|
assert.deepEqual(workflowReleaseProblems(workflow), []);
|
|
const npmRun = 'run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts "$GITHUB_REF"';
|
|
const pythonRun = 'run: env -u NODE_OPTIONS node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }} "$GITHUB_REF"';
|
|
const exact = /is not exactly the release call/;
|
|
for (const run of [npmRun, pythonRun]) {
|
|
assert.equal(workflow.split(run).length, 2, run);
|
|
const edit = (replacement) => workflowReleaseProblems(workflow.replace(run, replacement)).join("\n");
|
|
// anything else on the line, on a continuation line, or in a block; another call, ref or shell
|
|
assert.match(edit(`${run} || true`), exact, run);
|
|
assert.match(edit(`${run}\n || true`), exact, run);
|
|
assert.match(edit("run: |\n node --test scripts/check-changelog-semver.test.mjs\n true"), exact, run);
|
|
assert.match(edit(run.replace("release-after-suite.mjs", "check-changelog-semver.mjs")), exact, run);
|
|
assert.match(edit(run.replace('"$GITHUB_REF"', "refs/tags/cultcache-ts-v0.14.0")), exact, run);
|
|
assert.match(edit(`${run}\n shell: bash`), exact, run);
|
|
assert.match(edit(`${run}\n continue-on-error: true`), exact, run);
|
|
}
|
|
// the guard runs every release check itself, so neither the release steps' conditions nor a job allowed
|
|
// to fail can release what a check refused
|
|
assert.deepEqual(workflowReleaseProblems(workflow.replace(/if: startsWith\(github\.ref, 'refs\/tags\/cultcache-ts-v'\)(\r?\n\s+run: env -u NODE_OPTIONS node)/, "if: always()$1")), []);
|
|
assert.deepEqual(workflowReleaseProblems(workflow.replace(/( runs-on: ubuntu-latest\r?\n)/, "$1 continue-on-error: true\n")), []);
|
|
// a release step removed, renamed or doubled, a Publish that uploads another dist, an unknown job
|
|
assert.match(workflowReleaseProblems(workflow.replace("- name: Build sdist and wheel", "- name: Build")).join("\n"), /needs exactly one step "- name: Build sdist and wheel"/);
|
|
assert.match(workflowReleaseProblems(workflow.replace("- name: Build and test", "- name: Publish\n run: npm publish\n\n - name: Build and test")).join("\n"), /needs exactly one step "- name: Publish"/);
|
|
assert.match(workflowReleaseProblems(workflow.replace("packages-dir: packages/${{ matrix.package }}/dist", "packages-dir: dist")).join("\n"), exact);
|
|
assert.match(workflowReleaseProblems(`${workflow}\n other:\n runs-on: ubuntu-latest\n`).join("\n"), /job other is not a job this pin knows/);
|
|
});
|
|
|
|
test("release-after-suite checks the tag, then runs the suite, the semver check and the package's tests, and releases only when all passed", async () => {
|
|
const { releaseAfterSuite, suite, checker } = await import("./release-after-suite.mjs");
|
|
const npmVersion = JSON.parse(readFileSync(join(repoRoot, "packages", "cultcache-ts", "package.json"), "utf8")).version;
|
|
const npmDir = join(repoRoot, "packages", "cultcache-ts");
|
|
const pyDir = join(repoRoot, "packages", "cultnet-py");
|
|
const pyVersion = readFileSync(join(pyDir, "pyproject.toml"), "utf8").match(/^version = "([^"]+)"$/m)[1];
|
|
const npmTag = `refs/tags/cultcache-ts-v${npmVersion}`;
|
|
const pyTag = `refs/tags/cultnet-py-v${pyVersion}`;
|
|
const env = { PATH: "p", NODE_AUTH_TOKEN: "t", NODE_OPTIONS: "--test-name-pattern=x", NODE_TEST_CONTEXT: "child", NODE_TEST_X: "1" };
|
|
const checked = { PATH: "p" };
|
|
let calls;
|
|
// fail: the index of the call that exits 1
|
|
const runner = (fail = -1) => (command, args, cwd, childEnv) => {
|
|
calls.push([command, args, cwd, childEnv]);
|
|
return calls.length - 1 === fail ? 1 : 0;
|
|
};
|
|
const go = (argv, fail) => {
|
|
calls = [];
|
|
return releaseAfterSuite(argv, runner(fail), env);
|
|
};
|
|
const suiteCall = [process.execPath, ["--test", suite], undefined, checked];
|
|
const checkerCall = (name, version) => [process.execPath, [checker, "--package", name, "--version", version, "--cwd", repoRoot], undefined, checked];
|
|
const npmCalls = [
|
|
suiteCall,
|
|
checkerCall("@gamecult/cultcache-ts", npmVersion),
|
|
["npm", ["test"], npmDir, checked],
|
|
["npm", ["publish", "--access", "public"], npmDir, env],
|
|
];
|
|
assert.equal(go(["npm-publish", npmDir, npmTag]), 0);
|
|
assert.deepEqual(calls, npmCalls);
|
|
// any check that fails stops the release at that check
|
|
for (const fail of [0, 1, 2]) {
|
|
assert.equal(go(["npm-publish", npmDir, npmTag], fail), 1, `check ${fail}`);
|
|
assert.deepEqual(calls, npmCalls.slice(0, fail + 1), `check ${fail}`);
|
|
}
|
|
const pyCalls = [
|
|
suiteCall,
|
|
checkerCall("cultnet-py", pyVersion),
|
|
["python", ["-m", "unittest", "discover", "-s", join(pyDir, "tests")], undefined, checked],
|
|
["python", ["-m", "build", pyDir], undefined, env],
|
|
];
|
|
assert.equal(go(["python-build", pyDir, pyTag]), 0);
|
|
assert.deepEqual(calls, pyCalls);
|
|
// a branch ref builds Python without the tag's semver check, and never publishes npm
|
|
assert.equal(go(["python-build", pyDir, "refs/heads/main"]), 0);
|
|
assert.deepEqual(calls, [pyCalls[0], pyCalls[2], pyCalls[3]]);
|
|
// a tag that is not the manifest version's, another package's tag, or no tag at all refuses before anything runs
|
|
for (const argv of [
|
|
["npm-publish", npmDir, `refs/tags/cultcache-ts-v${npmVersion}9`],
|
|
["npm-publish", npmDir, `refs/tags/cultnet-py-v${npmVersion}`],
|
|
["npm-publish", npmDir, "refs/heads/main"],
|
|
["python-build", pyDir, `refs/tags/cultcache-py-v${pyVersion}`],
|
|
["npm-publish", join(repoRoot, "packages"), npmTag],
|
|
]) {
|
|
assert.equal(go(argv), 1, argv.join(" "));
|
|
assert.deepEqual(calls, [], argv.join(" "));
|
|
}
|
|
for (const argv of [[], ["npm-publish"], ["npm-publish", npmDir], ["constructor", "x", npmTag], ["npm-publish", npmDir, npmTag, "y"]]) {
|
|
assert.equal(go(argv), 2, argv.join(" "));
|
|
assert.deepEqual(calls, []);
|
|
}
|
|
// run as a script, a usage error exits 2 before the suite runs
|
|
const script = fileURLToPath(new URL("./release-after-suite.mjs", import.meta.url));
|
|
assert.throws(() => execFileSync(process.execPath, [script], { stdio: "pipe" }), (error) => error.status === 2);
|
|
});
|
|
|
|
// Each of these makes `node --test` on a failing suite run nothing and exit 0 (Soul, asura cut-3-gate s4).
|
|
// The guard must still refuse, and must never reach the publish.
|
|
test("release-after-suite refuses when the environment would make the failing suite pass vacuously", async () => {
|
|
const { releaseAfterSuite, suite } = await import("./release-after-suite.mjs");
|
|
const dir = mkdtempSync(join(tmpdir(), "cultlib-release-guard-"));
|
|
try {
|
|
const failing = join(dir, "failing.test.mjs");
|
|
writeFileSync(failing, 'import { test } from "node:test";\ntest("fails", () => { throw new Error("the suite failed"); });\n');
|
|
const runTests = (env) => spawnSync(process.execPath, ["--test", failing], { env, stdio: "ignore" }).status ?? 1;
|
|
const base = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^NODE_(OPTIONS|TEST_)/i.test(key)));
|
|
assert.equal(runTests(base), 1, "the fixture suite fails");
|
|
const npmDir = join(repoRoot, "packages", "cultcache-ts");
|
|
const tag = `refs/tags/cultcache-ts-v${JSON.parse(readFileSync(join(npmDir, "package.json"), "utf8")).version}`;
|
|
for (const bypass of [
|
|
{ NODE_TEST_CONTEXT: "child" },
|
|
{ NODE_TEST_CONTEXT: "child-v8" },
|
|
{ NODE_OPTIONS: "--test-name-pattern=^zzz_nothing$" },
|
|
{ NODE_OPTIONS: "--test-skip-pattern=." },
|
|
]) {
|
|
const env = { ...base, ...bypass, NODE_AUTH_TOKEN: "token" };
|
|
const label = JSON.stringify(bypass);
|
|
assert.equal(runTests(env), 0, `${label} no longer makes the failing suite pass, so this test proves nothing`);
|
|
const released = [];
|
|
const run = (command, args, cwd, childEnv) => {
|
|
if (command === process.execPath && args.length === 2 && args[1] === suite) return runTests(childEnv);
|
|
released.push([command, ...args]);
|
|
return 0;
|
|
};
|
|
assert.equal(releaseAfterSuite(["npm-publish", npmDir, tag], run, env), 1, label);
|
|
assert.deepEqual(released, [], label);
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("the Unity release scripts run the suite before the checker; removing it fails this test", () => {
|
|
const scripts = ["scripts/build-unity-package.ps1", "packages/cultmath/scripts/build-unity-package.ps1", "scripts/verify-caching-unity-release.ps1"];
|
|
for (const script of scripts) {
|
|
const text = readFileSync(join(repoRoot, script), "utf8");
|
|
assert.deepEqual(scriptSuiteProblems(text), [], script);
|
|
assert.deepEqual(scriptSuiteProblems(text.replace(new RegExp(SUITE.source, "g"), "node --version")), ["does not run the suite"], script);
|
|
assert.deepEqual(scriptSuiteProblems(`${text.replace(new RegExp(SUITE.source, "g"), "")}\n& node --test scripts/check-changelog-semver.test.mjs`), ["calls the checker before the suite"], script);
|
|
}
|
|
});
|
|
|
|
test("the checker's source is text: it holds no NUL byte, so grep reads it", () => {
|
|
assert.equal(readFileSync(checkerPath).includes(0), false);
|
|
});
|
|
|
|
// --- 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. ---
|
|
|
|
// Built fixtures are cached by content under the temp directory, so reruns (and a
|
|
// mutation tool's many reruns) do not rebuild the same tiny assemblies.
|
|
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, references = []) {
|
|
const dir = join(fixtureRoot, createHash("sha256").update(`${assemblyName}
|
|
${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>` +
|
|
`<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" });
|
|
}
|
|
return dll;
|
|
}
|
|
|
|
// A repo whose tag widget-v<previous> tracks `baseline` ({path under PLUGINS: dll path}) under
|
|
// PLUGINS, and whose working tree then holds `afterTag` instead. The package declares PLUGINS
|
|
// as its assemblies directory.
|
|
function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0" }, fn) {
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
const place = (files) => {
|
|
for (const [name, path] of Object.entries(files)) {
|
|
mkdirSync(dirname(join(dir, PLUGINS, name)), { recursive: true });
|
|
copyFileSync(path, join(dir, PLUGINS, name));
|
|
}
|
|
};
|
|
place(baseline);
|
|
execFileSync("git", ["add", "."], { cwd: dir });
|
|
execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir });
|
|
execFileSync("git", ["tag", `widget-v${previous}`], { cwd: dir });
|
|
if (afterTag !== baseline) {
|
|
for (const name of Object.keys(baseline)) rmSync(join(dir, PLUGINS, name));
|
|
place(afterTag);
|
|
execFileSync("git", ["add", "-A", "."], { cwd: dir });
|
|
execFileSync("git", ["commit", "-q", "-m", "after the release"], { cwd: dir });
|
|
}
|
|
fn(dir);
|
|
});
|
|
}
|
|
|
|
function measuredArgs(dir, version, builtPaths, { breaking = false, refs = [] } = {}) {
|
|
writeChangelog(dir, version, { breaking });
|
|
return [
|
|
...widgetArgs(version),
|
|
...builtPaths.flatMap((path) => ["--api-built", path]),
|
|
...refs.flatMap((refDir) => ["--api-refs", refDir]),
|
|
];
|
|
}
|
|
|
|
const WITH_GONE = "namespace N { public class C { public void Keep() {} public void Gone() {} } }";
|
|
const WITHOUT_GONE = "namespace N { public class C { public void Keep() {} } }";
|
|
const WITH_ADDED = "namespace N { public class C { public void Keep() {} public void Gone() {} public void Added() {} } }";
|
|
|
|
const oldWidget = () => buildAssembly("GameCult.Widget", WITH_GONE);
|
|
const trimmedWidget = () => buildAssembly("GameCult.Widget", WITHOUT_GONE);
|
|
|
|
test("measured: a removed public member without ### Breaking is refused and named", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]));
|
|
assert.match(output, /Gone/);
|
|
assert.match(output, /declare them under "### Breaking"/);
|
|
assert.match(output, /breaks 1 public API member/);
|
|
});
|
|
});
|
|
|
|
test("measured: a removed member with the heading needs a major bump after 1.0 and a minor bump before it", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const tooSmall = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()], { breaking: true }));
|
|
assert.match(tooSmall, /needs a major bump/);
|
|
const ok = runChecker(dir, measuredArgs(dir, "2.0.0", [trimmedWidget()], { breaking: true }));
|
|
assert.match(ok, /2\.0\.0 is a major bump/);
|
|
});
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() }, previous: "0.4.0" }, (dir) => {
|
|
const patch = runCheckerExpectFailure(dir, measuredArgs(dir, "0.4.1", [trimmedWidget()], { breaking: true }));
|
|
assert.match(patch, /at least a minor bump/);
|
|
const ok = runChecker(dir, measuredArgs(dir, "0.5.0", [trimmedWidget()], { breaking: true }));
|
|
assert.match(ok, /0\.5\.0 is a minor bump/);
|
|
});
|
|
});
|
|
|
|
test("measured: additions alone pass under a patch bump with no heading", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const output = runChecker(dir, measuredArgs(dir, "1.0.1", [buildAssembly("GameCult.Widget", WITH_ADDED)]));
|
|
assert.match(output, /1\.0\.1 is a patch bump over 1\.0\.0, no breaking section/);
|
|
});
|
|
});
|
|
|
|
test("measured: a declared break with nothing measured still passes", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const output = runChecker(dir, measuredArgs(dir, "2.0.0", [oldWidget()], { breaking: true }));
|
|
assert.match(output, /2\.0\.0 is a major bump over 1\.0\.0 and has a "### Breaking" section, which agree/);
|
|
});
|
|
});
|
|
|
|
test("measured: a tracked assembly that is no longer built is a break, whatever its name; a built assembly with no namesake is an addition", () => {
|
|
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.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, thirdParty, brandNew]));
|
|
assert.match(ok, /1\.0\.1 is a patch bump/);
|
|
});
|
|
});
|
|
|
|
test("measured: renaming the only assembly is a break with nothing declared, and a declared break can ship", () => {
|
|
// 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]));
|
|
assert.match(renamed, /assembly CultMath\.dll exists at widget-v1\.0\.0 but is no longer built/);
|
|
const shipped = runChecker(dir, measuredArgs(dir, "2.0.0", [renamedTrimmed], { breaking: true }));
|
|
assert.match(shipped, /2\.0\.0 is a major bump over 1\.0\.0 and has a "### Breaking" section and 1 measured public API break/);
|
|
});
|
|
// with another assembly built, the unbuilt one is still the break
|
|
const other = buildAssembly("Other", "namespace O { public class D {} }");
|
|
withReleasedBaseline({ baseline: { "CultMath.dll": cultMath, "Other.dll": other } }, (both) => {
|
|
const partial = runCheckerExpectFailure(both, measuredArgs(both, "1.0.1", [cultMath, renamedTrimmed]));
|
|
assert.match(partial, /Other\.dll exists at widget-v1\.0\.0 but is no longer built/);
|
|
});
|
|
});
|
|
|
|
test("measured: an assembly tracked in a subdirectory is measured, and is a break when it is no longer built", () => {
|
|
const widget = oldWidget();
|
|
const sub = buildAssembly("GameCult.B", "namespace B { public class K { public void Keep() {} public void Gone() {} } }");
|
|
const subTrimmed = buildAssembly("GameCult.B", "namespace B { public class K { public void Keep() {} } }");
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": widget, "sub/GameCult.B.dll": sub } }, (dir) => {
|
|
const unbuilt = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [widget]));
|
|
assert.match(unbuilt, /assembly GameCult\.B\.dll exists at widget-v1\.0\.0 but is no longer built/);
|
|
const compared = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [widget, subTrimmed]));
|
|
assert.match(compared, /B\.K\.Gone/);
|
|
assert.doesNotMatch(compared, /no longer built/);
|
|
assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [widget, sub])), /1\.0\.1 is a patch bump/);
|
|
});
|
|
});
|
|
|
|
test("isManagedAssembly: a non-empty CLI header makes an assembly managed, nothing else does", () => {
|
|
assert.equal(isManagedAssembly(peImage({ cli: CLI })), true);
|
|
assert.equal(isManagedAssembly(peImage({ plus: false, cli: CLI })), true);
|
|
assert.equal(isManagedAssembly(peImage()), false);
|
|
assert.equal(isManagedAssembly(peImage({ plus: false })), false);
|
|
assert.equal(isManagedAssembly(peImage({ cli: { rva: CLI.rva, size: 0 } })), false);
|
|
assert.equal(isManagedAssembly(peImage({ cli: { rva: 0, size: CLI.size } })), false);
|
|
// too few data directories to have a CLI header, whatever the bytes after them say
|
|
assert.equal(isManagedAssembly(peImage({ directories: 14 })), false);
|
|
assert.equal(isManagedAssembly(readFileSync(oldWidget())), true);
|
|
});
|
|
|
|
test("isManagedAssembly: bytes that are not a PE image throw", () => {
|
|
const withByte = (offset, value) => {
|
|
const bytes = peImage({ cli: CLI });
|
|
bytes[offset] = value;
|
|
return bytes;
|
|
};
|
|
for (const bytes of [
|
|
Buffer.alloc(0),
|
|
Buffer.from("not a PE image, just text that is long enough to be read as a header ........."),
|
|
peImage({ cli: CLI }).subarray(0, 0x50),
|
|
peImage({ cli: CLI }).subarray(0, 0x40 + 24 + 100),
|
|
withByte(0, 0x4e),
|
|
withByte(0x40, 0x51),
|
|
withByte(0x40 + 24 + 1, 0x00),
|
|
]) {
|
|
assert.throws(() => isManagedAssembly(bytes), /not a PE image/);
|
|
}
|
|
});
|
|
|
|
test("isManagedAssembly: the shipped QUIC bridge is native and the shipped managed assemblies are managed", () => {
|
|
const plugins = join(repoRoot, "unity", "org.gamecult.cultlib", "Runtime", "Plugins");
|
|
assert.equal(isManagedAssembly(readFileSync(join(plugins, "x86_64", "gamecult_mesh_quic_native.dll"))), false);
|
|
assert.equal(isManagedAssembly(readFileSync(join(plugins, "GameCult.Caching.dll"))), true);
|
|
});
|
|
|
|
test("measured: natives are not measured, decided by their bytes; a .dll that is not a PE image refuses", () => {
|
|
const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-pe-"));
|
|
try {
|
|
const file = (name, bytes) => {
|
|
const path = join(scratch, name);
|
|
writeFileSync(path, bytes);
|
|
return path;
|
|
};
|
|
// a native in x86_64/ and one beside the managed assemblies: neither is built, neither is a break
|
|
const baseline = { "GameCult.Widget.dll": oldWidget(), "x86_64/native.dll": file("a.dll", peImage()), "Native32.dll": file("b.dll", peImage({ plus: false })) };
|
|
withReleasedBaseline({ baseline }, (dir) => {
|
|
const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()]));
|
|
assert.match(ok, /1\.0\.1 is a patch bump/);
|
|
assert.doesNotMatch(ok, /native/i);
|
|
});
|
|
for (const [name, bytes] of [["text.dll", Buffer.from("not a PE image")], ["cut.dll", peImage({ cli: CLI }).subarray(0, 0x50)]]) {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget(), "x86_64/bogus.dll": file(name, bytes) } }, (dir) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]));
|
|
assert.match(output, /unity\/widget\/Runtime\/Plugins\/x86_64\/bogus\.dll at widget-v1\.0\.0 is a \.dll but not a PE image/);
|
|
});
|
|
}
|
|
} finally {
|
|
rmSync(scratch, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("measured: two managed assemblies with one name refuse", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget(), "a/X.dll": oldWidget(), "b/X.dll": oldWidget() } }, (dir) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]));
|
|
assert.match(output, /widget-v1\.0\.0 tracks two managed assemblies named X\.dll/);
|
|
});
|
|
});
|
|
|
|
test("measured: a declared assemblies directory makes measurement mandatory, and a package without one takes no --api-built", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", []));
|
|
assert.match(output, /ships the assemblies under unity\/widget\/Runtime\/Plugins, so the release must hand them over with --api-built/);
|
|
});
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()])), /declares no assemblies directory/);
|
|
});
|
|
});
|
|
|
|
test("measured: a tool failure with no diagnostic fails closed", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const notAnAssembly = join(dir, "GameCult.Widget.dll");
|
|
writeFileSync(notAnAssembly, "not an assembly");
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [notAnAssembly]));
|
|
assert.match(output, /could not be measured/);
|
|
assert.match(output, /without measuring/);
|
|
});
|
|
});
|
|
|
|
test("readApiCompatRun: diagnostics are breaks, an empty failure is a failure, an empty success is clean", () => {
|
|
const breaks = readApiCompatRun({ status: 1, output: "API compatibility errors between a (left) and b (right):\nCP0002: Member 'void C.M()' exists on a but not on b\n" });
|
|
assert.deepEqual(breaks, { breaks: ["CP0002: Member 'void C.M()' exists on a but not on b"] });
|
|
assert.match(readApiCompatRun({ status: 1, output: "Unhandled exception: boom" }).failure, /exited 1 without measuring: Unhandled exception: boom/);
|
|
assert.deepEqual(readApiCompatRun({ status: 0, output: "" }), { breaks: [] });
|
|
assert.deepEqual(readApiCompatRun({ status: 0, output: "Could not resolve reference 'netstandard.dll'\n" }), { breaks: [] });
|
|
});
|
|
|
|
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> { } }");
|
|
const baseDropped = buildAssembly("GameCult.Widget", "namespace N { public class C { } }");
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": viaNetstandardBase } }, (dir) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [baseDropped]));
|
|
assert.match(output, /does not inherit from base type 'System.Collections.Generic.List<int>'/);
|
|
});
|
|
const inheritsInh = buildAssembly("GameCult.Widget", "namespace N { public class B { public void Inh() {} } public class C : B { } }");
|
|
const baseLostInh = buildAssembly("GameCult.Widget", "namespace N { public class B { } public class C : B { } }");
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": inheritsInh } }, (dir) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [baseLostInh]));
|
|
assert.match(output, /N.B.Inh()/);
|
|
});
|
|
});
|
|
|
|
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, "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"), 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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
test("measured: a local tag of the released version does not skip the measurement", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
execFileSync("git", ["tag", "widget-v1.1.0"], { cwd: dir });
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]));
|
|
assert.match(output, /Gone/);
|
|
});
|
|
});
|
|
|
|
test("measured: the baseline is the tag's blob, not the working tree's DLL", () => {
|
|
withReleasedBaseline(
|
|
{ baseline: { "GameCult.Widget.dll": oldWidget() }, afterTag: { "GameCult.Widget.dll": trimmedWidget() } },
|
|
(dir) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]));
|
|
assert.match(output, /Gone/);
|
|
},
|
|
);
|
|
});
|
|
|
|
test("measured: measuring nothing refuses, naming what is missing", () => {
|
|
const fail = (dir, args) => runCheckerExpectFailure(dir, args);
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
// a declared directory that is wrong or moved
|
|
declare(dir, { widget: { ...WIDGET, assemblies: "unity/moved/Runtime/Plugins" } });
|
|
assert.match(fail(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()])), /widget-v1\.0\.0 tracks no managed assembly under unity\/moved\/Runtime\/Plugins: the declared directory is missing or moved/);
|
|
});
|
|
// a previous tag that does not track the DLLs at all
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
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/);
|
|
});
|
|
// a previous tag whose only DLLs are native
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
mkdirSync(join(dir, PLUGINS), { recursive: true });
|
|
writeFileSync(join(dir, PLUGINS, "native.dll"), peImage());
|
|
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()])), /tracks no managed assembly/);
|
|
});
|
|
});
|
|
|
|
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) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } });
|
|
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 declared directory that git would take for an option
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
declare(dir, { widget: { ...WIDGET, assemblies: "--bogus" } });
|
|
assert.match(clean(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]))), /tracks no managed assembly under --bogus|git could not read/);
|
|
});
|
|
// a changelog path that is a directory
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
mkdirSync(join(dir, "CHANGELOG.md"));
|
|
const output = clean(runCheckerExpectFailure(dir, widgetArgs("1.0.1")));
|
|
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()]));
|
|
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) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.0.1");
|
|
const unmeasured = runChecker(dir, widgetArgs("1.0.1"));
|
|
assert.doesNotMatch(unmeasured, /public API measured/);
|
|
});
|
|
});
|
|
|
|
test("evaluateRelease: measured breaks count as breaking for the lane, declared or not", () => {
|
|
const evaluate = (version, previousVersion, breaking, measuredBreaks) =>
|
|
evaluateRelease({ packageName: "p", changelogText: changelogFor(version, { breaking }), version, previousVersion, measuredBreaks });
|
|
assert.equal(evaluate("1.1.0", "1.0.0", false, ["CP0002: x"]).ok, false);
|
|
assert.equal(evaluate("1.1.0", "1.0.0", true, ["CP0002: x"]).ok, false);
|
|
assert.equal(evaluate("2.0.0", "1.0.0", true, ["CP0002: x"]).ok, true);
|
|
assert.equal(evaluate("0.2.0", "0.1.0", true, ["CP0002: x"]).ok, true);
|
|
assert.equal(evaluate("0.1.1", "0.1.0", true, ["CP0002: x"]).ok, false);
|
|
assert.equal(evaluate("1.0.1", "1.0.0", false, []).ok, true);
|
|
const many = Array.from({ length: 7 }, (_, i) => `CP0002: m${i}`);
|
|
const reason = evaluate("1.1.0", "1.0.0", false, many).reason;
|
|
assert.match(reason, /breaks 7 public API/);
|
|
assert.match(reason, /m4/);
|
|
assert.doesNotMatch(reason, /m5/);
|
|
assert.match(reason, /; \.\.\./);
|
|
});
|
|
|
|
test("every missing required option is a usage error", () => {
|
|
withTempGitRepo((dir) => {
|
|
const complete = widgetArgs("1.1.0");
|
|
for (let i = 0; i < complete.length; i += 2) {
|
|
const withoutOne = complete.filter((_, index) => index !== i && index !== i + 1);
|
|
const output = runCheckerExpectFailure(dir, withoutOne, { status: 2 });
|
|
assert.match(output, /usage: check-changelog-semver\.mjs/);
|
|
assert.match(output, /--api-built/);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("CLI: a missing changelog fails and names the declared path; stray positional words are ignored", () => {
|
|
withTempGitRepo((dir) => {
|
|
declare(dir, { widget: { ...WIDGET, changelog: "docs/NOPE.md" } });
|
|
const output = runCheckerExpectFailure(dir, ["stray", ...widgetArgs("1.1.0")]);
|
|
assert.match(output, /changelog not found at .*docs.NOPE\.md/);
|
|
});
|
|
});
|
|
|
|
test("measured: reads the repository named by --cwd, not the directory the process started in", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const elsewhere = mkdtempSync(join(tmpdir(), "cultlib-semver-elsewhere-"));
|
|
try {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]), { processCwd: elsewhere });
|
|
assert.match(output, /Gone/);
|
|
} finally {
|
|
rmSync(elsewhere, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
test("measured: leaves no temporary files behind, passing or failing", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-scratch-"));
|
|
try {
|
|
const env = { ...process.env, TMPDIR: scratch, TEMP: scratch, TMP: scratch };
|
|
const leftovers = () => readdirSync(scratch).filter((name) => name.startsWith("cultlib-apicompat-"));
|
|
runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env });
|
|
assert.deepEqual(leftovers(), []);
|
|
runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]), { env });
|
|
assert.deepEqual(leftovers(), []);
|
|
} finally {
|
|
rmSync(scratch, { 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) => {
|
|
// A gate directory without the manifest, and one with a .config directory but no manifest in it.
|
|
for (const gateFiles of [{}, { ".config/other.json": "{}" }]) {
|
|
withCheckerCopy(gateFiles, (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/);
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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) => {
|
|
withFakeDotnet(null, (env) => {
|
|
const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env });
|
|
assert.match(output, /dotnet tool restore failed: .*ENOENT/);
|
|
});
|
|
});
|
|
});
|
|
|
|
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) => {
|
|
const notRestored = /the pinned netstandard reference assemblies were not restored under scripts\/api-gate\/packages/;
|
|
withFakeDotnet("exit 0", (env) => {
|
|
assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }), notRestored);
|
|
});
|
|
// The package directory arrives, but without netstandard.dll in it.
|
|
const emptyPackage = 'if [ "$1" = restore ]; then /bin/mkdir -p packages/netstandard.library.ref/2.1.0/ref/netstandard2.1; fi\nexit 0';
|
|
withFakeDotnet(emptyPackage, (env) => {
|
|
assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }), notRestored);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
test("readApiCompatRun: only line-initial diagnostics count, and a failure shows the first five trimmed lines", () => {
|
|
assert.deepEqual(readApiCompatRun({ status: 1, output: "warning CP1002: not at the start\nCP0002: Member 'x'\n" }), { breaks: ["CP0002: Member 'x'"] });
|
|
const failure = readApiCompatRun({ status: 3, output: "\n one\ntwo\nthree\nfour\nfive\nsix\n" }).failure;
|
|
assert.equal(failure, "apicompat exited 3 without measuring: one | two | three | four | five");
|
|
});
|
|
|
|
test("evaluateRelease: lists at most five measured breaks, and says nothing more for five", () => {
|
|
const evaluate = (count) =>
|
|
evaluateRelease({
|
|
packageName: "p",
|
|
changelogText: changelogFor("1.1.0"),
|
|
version: "1.1.0",
|
|
previousVersion: "1.0.0",
|
|
measuredBreaks: Array.from({ length: count }, (_, i) => `CP0002: m${i}`),
|
|
}).reason;
|
|
assert.match(evaluate(5), /\(CP0002: m0; CP0002: m1; CP0002: m2; CP0002: m3; CP0002: m4\);/);
|
|
assert.doesNotMatch(evaluate(5), /\.\.\./);
|
|
assert.match(evaluate(6), /m4; \.\.\.\);/);
|
|
const agreed = evaluateRelease({
|
|
packageName: "p",
|
|
changelogText: changelogFor("2.0.0", { breaking: true }),
|
|
version: "2.0.0",
|
|
previousVersion: "1.0.0",
|
|
measuredBreaks: ["CP0002: m0"],
|
|
}).reason;
|
|
assert.match(agreed, /a "### Breaking" section and 1 measured public API break\(s\)/);
|
|
});
|
|
|
|
test("CLI: a malformed version is refused by name, without repeating it", () => {
|
|
withTempGitRepo((dir) => {
|
|
commitAndTag(dir, "widget-v1.0.0");
|
|
writeChangelog(dir, "1.1.0");
|
|
const output = runCheckerExpectFailure(dir, widgetArgs("1.1.x-canary"));
|
|
assert.match(output, /--version is not a MAJOR\.MINOR\.PATCH version/);
|
|
assert.doesNotMatch(output, /canary/);
|
|
assert.doesNotMatch(output, /could not be read/);
|
|
});
|
|
});
|
|
|
|
test("refusals never repeat what the inputs contain", () => {
|
|
const canary = "CANARY-5f3a9c-never-echoed";
|
|
const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-canary-"));
|
|
try {
|
|
const junk = join(scratch, "GameCult.Widget.dll");
|
|
writeFileSync(junk, `MZ not an assembly ${canary}`);
|
|
const canaryChangelog = (dir, version, { breaking = false } = {}) => {
|
|
writeFileSync(join(dir, "CHANGELOG.md"), changelogFor(version, { breaking }).replace("- something changed", `- something changed ${canary}`) + `\n## [0.0.1]\n\n- ${canary}\n`);
|
|
};
|
|
const refuse = (dir, args) => {
|
|
const output = runCheckerExpectFailure(dir, args);
|
|
assert.doesNotMatch(output, new RegExp(canary), `the refusal repeated an input: ${output}`);
|
|
return output;
|
|
};
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const measured = (version, built, options) => {
|
|
const args = measuredArgs(dir, version, built, options);
|
|
canaryChangelog(dir, version, options);
|
|
return args;
|
|
};
|
|
// a measured break, with the canary in the changelog it must be declared in
|
|
assert.match(refuse(dir, measured("1.1.0", [trimmedWidget()])), /Gone/);
|
|
// the tool cannot read the built assembly
|
|
assert.match(refuse(dir, measured("1.0.1", [junk])), /could not be measured/);
|
|
// no entry for the version, a skipped version, a breaking entry under too small a bump
|
|
const noEntry = measured("1.0.1", [oldWidget()]);
|
|
noEntry[noEntry.indexOf("--version") + 1] = "1.0.2";
|
|
assert.match(refuse(dir, noEntry), /no "## \[1\.0\.2\]" entry/);
|
|
const skip = measured("1.0.9", [oldWidget()]);
|
|
assert.match(refuse(dir, skip), /skips ahead/);
|
|
assert.match(refuse(dir, measured("1.0.1", [oldWidget()], { breaking: true })), /needs a major bump/);
|
|
});
|
|
// the tag's own blob is not a PE image
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": junk } }, (dir) => {
|
|
const args = measuredArgs(dir, "1.0.1", [oldWidget()]);
|
|
canaryChangelog(dir, "1.0.1");
|
|
assert.match(refuse(dir, args), /could not be measured/);
|
|
});
|
|
} finally {
|
|
rmSync(scratch, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("measured: built and reference paths given relative to the process are read from there", () => {
|
|
withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => {
|
|
const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-relative-"));
|
|
try {
|
|
mkdirSync(join(scratch, "refs"));
|
|
copyFileSync(trimmedWidget(), join(scratch, "GameCult.Widget.dll"));
|
|
const args = measuredArgs(dir, "1.1.0", ["GameCult.Widget.dll"], { refs: ["refs"] });
|
|
// The tool is started elsewhere, so an unresolved relative path would not be found.
|
|
assert.match(runCheckerExpectFailure(dir, args, { processCwd: scratch }), /Gone/);
|
|
} finally {
|
|
rmSync(scratch, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|