Semver check: a first release is one whose package folder no tag has tracked
The changelog-heading guard missed 12 of 17 heading shapes and a first changelog entry. It and its parsing are deleted. A release with no same-prefix baseline is a first release only when no tag in the repository, under any prefix, tracks a file under the package's folder (declared assemblies dir, else the changelog's directory); otherwise it refuses naming the tag. A tag whose tree cannot be read refuses. Tests pin the mistyped prefix for CultMath 0.3.1, cultlib 1.0.61 and a Python package, a genuine first release, the moved-folder choice, and an unreadable ancestry and tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9225e8786
commit
2610434917
2 changed files with 142 additions and 38 deletions
|
|
@ -11,7 +11,7 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, posix, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
// The exact heading spelling the policy mandates for a breaking-change
|
||||
|
|
@ -173,14 +173,15 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
|
|||
// --cwd): a backport tagged later on another branch is not in this commit's history, so it
|
||||
// never changes a rebuild's predecessor. The version's own tag is ignored, so a rebuild at a
|
||||
// tagged version gets its release-time verdict. A same-prefix tag that does not parse is never
|
||||
// skipped. A first release is the absence of any older tag in a checkout that holds other tags,
|
||||
// because a checkout with none cannot tell a first release from a missing record.
|
||||
// skipped. A first release is the absence of any older tag in a checkout that holds other tags
|
||||
// (a checkout with none cannot tell a first release from a missing record), none of which, under
|
||||
// any prefix, tracks a file under the package's folder.
|
||||
function isAncestorOfHead(tag, cwd) {
|
||||
const run = spawnSync("git", ["merge-base", "--is-ancestor", `refs/tags/${tag}`, "HEAD"], { cwd, stdio: "ignore" });
|
||||
return run.status === 0 ? true : run.status === 1 ? false : null;
|
||||
}
|
||||
|
||||
function resolveRelease(tagPrefix, versionText, cwd) {
|
||||
function resolveRelease(tagPrefix, versionText, cwd, folder) {
|
||||
let tags;
|
||||
try {
|
||||
tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
||||
|
|
@ -225,20 +226,33 @@ function resolveRelease(tagPrefix, versionText, cwd) {
|
|||
if (!tags.some((tag) => tag !== own)) {
|
||||
return { failure: "this checkout holds no tags; a first release cannot be told from a missing record (fetch the tags)" };
|
||||
}
|
||||
// Under every other prefix too: a package some tag has shipped is not new, whatever the
|
||||
// declared prefix says. This is read from git, never from the changelog's prose.
|
||||
const tracked = firstTagTracking(tags.filter((tag) => tag !== own), folder, cwd);
|
||||
if (tracked.failure) return tracked;
|
||||
if (tracked.tag) {
|
||||
return { failure: `no ${prefix} tag is an earlier release, but ${tracked.tag} tracks files under ${folder}: the package has shipped before, so the declared tag prefix is wrong` };
|
||||
}
|
||||
return { previous: null };
|
||||
}
|
||||
|
||||
// The newest `## [x.y.z]` entry of the changelog older than the version, or null. A release with
|
||||
// no previous tag whose changelog already lists an earlier version is not a first release: the
|
||||
// declared tag prefix names none of the package's releases.
|
||||
function earlierChangelogVersion(changelogText, versionText) {
|
||||
const current = parseVersion(versionText);
|
||||
let newest = null;
|
||||
for (const match of changelogText.matchAll(/^##\s*\[?(\d+\.\d+\.\d+)\]?\s*$/gm)) {
|
||||
const entry = parseVersion(match[1]);
|
||||
if (compareVersions(entry, current) < 0 && (newest === null || compareVersions(entry, newest) > 0)) newest = entry;
|
||||
// The first of the tags that tracks any file under `folder`, or none. A tag that cannot be read
|
||||
// is a refusal, never a tag that tracks nothing.
|
||||
function firstTagTracking(tags, folder, cwd) {
|
||||
const pathspec = folder === "." ? [] : ["--", `${folder}/`];
|
||||
for (const tag of tags) {
|
||||
try {
|
||||
const listing = execFileSync("git", ["ls-tree", "-r", "--name-only", `refs/tags/${tag}`, ...pathspec], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (listing.trim() !== "") return { tag };
|
||||
} catch {
|
||||
return { failure: `the files ${tag} tracks under ${folder} could not be read, so a first release cannot be told from a mistyped prefix` };
|
||||
}
|
||||
}
|
||||
return newest === null ? null : formatVersion(newest);
|
||||
return {};
|
||||
}
|
||||
|
||||
// The declaration as a whole: every entry names a string tagPrefix and changelog, and no two
|
||||
|
|
@ -482,17 +496,15 @@ async function main() {
|
|||
refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`);
|
||||
return;
|
||||
}
|
||||
const release = resolveRelease(tagPrefix, version, cwd);
|
||||
// The folder whose tracking by a tag tells a shipped package from a new one: the shipped
|
||||
// assemblies, else the package's own directory (where its changelog lives).
|
||||
const folder = typeof assemblies === "string" ? assemblies : posix.dirname(entry.changelog);
|
||||
const release = resolveRelease(tagPrefix, version, cwd, folder);
|
||||
if (release.failure) {
|
||||
refuse(`${packageName}: ${release.failure}`);
|
||||
return;
|
||||
}
|
||||
const previousVersion = release.previous;
|
||||
const earlier = previousVersion == null ? earlierChangelogVersion(changelogText, version) : null;
|
||||
if (earlier !== null) {
|
||||
refuse(`${packageName}: ${version} would be a first release, but the changelog already lists ${earlier} and no ${tagPrefix}-v tag is an earlier release: the declared tag prefix is wrong`);
|
||||
return;
|
||||
}
|
||||
let measuredBreaks = [];
|
||||
if (previousVersion != null && built.length > 0) {
|
||||
const measurement = measureApiBreaks({
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { execFileSync } 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 } from "node:path";
|
||||
import { basename, dirname, join, posix as posixPath } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
|
|
@ -400,23 +400,115 @@ test("CLI: a later backport tag on another branch does not change a rebuild's pr
|
|||
});
|
||||
});
|
||||
|
||||
test("CLI: a declared prefix that names no release is not a first release when the changelog lists earlier versions", () => {
|
||||
// CultMath's shape: 0.3.0 is released and tracks the DLL; 0.3.1 removes a member.
|
||||
const cultMath = buildAssembly("CultMath", WITH_GONE);
|
||||
const trimmed = buildAssembly("CultMath", WITHOUT_GONE);
|
||||
withReleasedBaseline({ baseline: { "CultMath.dll": cultMath }, previous: "0.3.0" }, (dir) => {
|
||||
const entry = { ...WIDGET, assemblies: PLUGINS };
|
||||
const args = measuredArgs(dir, "0.3.1", [trimmed]);
|
||||
writeFileSync(join(dir, "CHANGELOG.md"), `${changelogFor("0.3.1")}\n## [0.3.0]\n\n- the release\n`);
|
||||
assert.match(runCheckerExpectFailure(dir, args), /Gone/);
|
||||
// one mistyped character in the declaration
|
||||
declare(dir, { widget: { ...entry, tagPrefix: "widgt" } });
|
||||
const typo = runCheckerExpectFailure(dir, args);
|
||||
assert.match(typo, /0\.3\.1 would be a first release, but the changelog already lists 0\.3\.0/);
|
||||
assert.doesNotMatch(typo, /first release \(/);
|
||||
// a release the changelog lists nothing before is still a first release
|
||||
writeChangelog(dir, "0.3.1");
|
||||
assert.match(runChecker(dir, args), /first release \(0\.3\.1\)/);
|
||||
// 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);
|
||||
}
|
||||
|
||||
test("CLI: a mistyped declared prefix is refused, because a tag under another prefix tracks the package's files", () => {
|
||||
// The real entries of CultMath, cultlib and a Python package, 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" },
|
||||
{ name: "cultcache-py", released: "0.1.0", next: "0.1.1", file: "cultcache/__init__.py" },
|
||||
];
|
||||
for (const { name, released, next, file } of cases) {
|
||||
const real = releasePackages[name];
|
||||
const folder = real.assemblies ?? posixPath.dirname(real.changelog);
|
||||
withTempGitRepo((dir) => {
|
||||
declare(dir, { [name]: real });
|
||||
tagWithFiles(dir, `${real.tagPrefix}-v${released}`, { [`${folder}/${file}`]: "released" });
|
||||
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, ...(real.assemblies ? ["--api-built", junk] : [])];
|
||||
declare(dir, { [name]: { ...real, tagPrefix: real.tagPrefix.slice(0, -1) } });
|
||||
const refused = runCheckerExpectFailure(dir, args);
|
||||
assert.match(refused, new RegExp(`${real.tagPrefix}-v${released} tracks files under `));
|
||||
assert.doesNotMatch(refused, /first release \(/);
|
||||
// the same release under the real prefix is a bump over its predecessor, not a first release
|
||||
if (!real.assemblies) {
|
||||
declare(dir, { [name]: real });
|
||||
assert.match(runChecker(dir, args), /patch bump over/);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI: no tag tracks the package's folder, so a release is a first release however many other tags exist", () => {
|
||||
withTempGitRepo((dir) => {
|
||||
declare(dir, { widget: { ...WIDGET, changelog: "widget/CHANGELOG.md", assemblies: PLUGINS } });
|
||||
tagWithFiles(dir, "other-v1.0.0", { "other/file.txt": "other" });
|
||||
tagWithFiles(dir, "other-v1.1.0", { "other/more.txt": "more" });
|
||||
// the package's folder arrives after every tag, and its files are not tracked by any of them
|
||||
mkdirSync(join(dir, "widget"), { recursive: true });
|
||||
writeFileSync(join(dir, "widget", "CHANGELOG.md"), changelogFor("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`]: "shipped" });
|
||||
assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/);
|
||||
// but the same files under any other tag make a later release with no same-prefix baseline a refusal
|
||||
declare(dir, { widget: { ...WIDGET, changelog: "widget/CHANGELOG.md", assemblies: PLUGINS, tagPrefix: "widgt" } });
|
||||
assert.match(runCheckerExpectFailure(dir, args), /widget-v1\.0\.0 tracks files under unity\/widget\/Runtime\/Plugins/);
|
||||
});
|
||||
});
|
||||
|
||||
test("CLI: a package without assemblies is told from a new one by its own directory, and a changelog at the root by the whole tree", () => {
|
||||
withTempGitRepo((dir) => {
|
||||
declare(dir, { widget: { ...WIDGET, changelog: "pkg/CHANGELOG.md" } });
|
||||
tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" });
|
||||
mkdirSync(join(dir, "pkg"));
|
||||
writeFileSync(join(dir, "pkg", "CHANGELOG.md"), changelogFor("1.0.0"));
|
||||
assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/);
|
||||
tagWithFiles(dir, "other-v1.1.0", { "pkg/code.py": "x" });
|
||||
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /other-v1\.1\.0 tracks files under pkg/);
|
||||
});
|
||||
withTempGitRepo((dir) => {
|
||||
tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" });
|
||||
writeChangelog(dir, "1.0.0");
|
||||
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /other-v1.0.0 tracks files under ./);
|
||||
});
|
||||
});
|
||||
|
||||
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 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) => {
|
||||
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");
|
||||
assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /could not be read, so a first release cannot be told from a mistyped prefix/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue