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:
Meta Vens 2026-10-03 08:01:30 +02:00
commit 2610434917
2 changed files with 142 additions and 38 deletions

View file

@ -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({

View file

@ -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/);
});
});