From 23d7beac206a3d5dee6b94b0aa533f998fc8a650 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 02:36:54 +0200 Subject: [PATCH] Semver check derives what callers used to declare: first release, tag prefix, changelog and measured set The checker reads each package's tag prefix, changelog and assemblies directory from scripts/release-packages.mjs and derives the release record from git tags. A first release is the absence of any older tag in a checkout that holds other tags; a malformed same-prefix tag, a checkout without tags and an unreadable repository refuse. The measured set is every managed assembly (CLI header in the PE bytes) tracked at the previous tag at any depth; an unbuilt one is a break, a built assembly with no namesake is an addition. Deleted with their branches, refusals and tests: --first-release, --api-new, --tag-prefix, --changelog, --api-baseline-path and the workflow first-release decision line. Any unknown option is now a usage error. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/publish-packages.yml | 22 +- .../cultmath/scripts/build-unity-package.ps1 | 3 - scripts/build-unity-package.ps1 | 5 +- scripts/check-changelog-semver.mjs | 234 ++++---- scripts/check-changelog-semver.test.mjs | 557 +++++++++++------- scripts/release-packages.mjs | 38 ++ scripts/verify-caching-unity-release.ps1 | 3 - 7 files changed, 527 insertions(+), 335 deletions(-) create mode 100644 scripts/release-packages.mjs diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 5e4129ed..5c9b361a 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -75,17 +75,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') run: | tagged="${GITHUB_REF_NAME#cultcache-ts-v}" - # The package's first release carries --first-release. The checker believes it only - # when no other cultcache-ts tag exists, so this test only avoids refusing the - # releases that follow; it does not decide anything. - first="" - if [ -z "$(git tag -l 'cultcache-ts-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi - node scripts/check-changelog-semver.mjs \ - --package "@gamecult/cultcache-ts" \ - --changelog packages/cultcache-ts/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix cultcache-ts \ - $first + node scripts/check-changelog-semver.mjs --package "@gamecult/cultcache-ts" --version "$tagged" - name: Build and test working-directory: packages/cultcache-ts @@ -165,15 +155,7 @@ jobs: if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') run: | tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}" - # See the cultcache-ts job: the checker, not this test, decides whether a first release is real. - first="" - if [ -z "$(git tag -l '${{ matrix.package }}-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi - node scripts/check-changelog-semver.mjs \ - --package "${{ matrix.package }}" \ - --changelog packages/${{ matrix.package }}/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix "${{ matrix.package }}" \ - $first + node scripts/check-changelog-semver.mjs --package "${{ matrix.package }}" --version "$tagged" # Lower layers install from source so a release of one package is tested # against the sibling revision it was cut with, not whatever PyPI has. diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index 390f523f..0e14cad6 100644 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -50,11 +50,8 @@ $cultLibRoot = Split-Path -Parent (Split-Path -Parent $repoRoot) $cultmathVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version & node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") ` --package "org.gamecult.cultmath" ` - --changelog (Join-Path $templateRoot "CHANGELOG.md") ` --version $cultmathVersion ` - --tag-prefix "cultmath-unity" ` --cwd $cultLibRoot ` - --api-baseline-path "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins" ` --api-built $freshAssembly if ($LASTEXITCODE -ne 0) { throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)." diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index c508bc8d..6605ed93 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -176,6 +176,8 @@ foreach ($assemblyName in $expectedAssemblies) { # the public API of every shipped assembly against the DLLs the previous cultlib-unity tag # tracked (read from git), so it runs here, once the assemblies under judgement exist. Every # tracked assembly the build does not hand over counts as removed, so all of them are handed over. +# The package's tag prefix, changelog and assemblies directory are declared in +# scripts/release-packages.mjs; this call passes only the version and the built assemblies. $measuredArguments = @() foreach ($assemblyName in $expectedAssemblies) { $measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName) @@ -185,11 +187,8 @@ foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) { } & node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") ` --package "org.gamecult.cultlib" ` - --changelog (Join-Path $templateRoot "CHANGELOG.md") ` --version $unityPackageVersion ` - --tag-prefix "cultlib-unity" ` --cwd $repoRoot ` - --api-baseline-path "unity/org.gamecult.cultlib/Runtime/Plugins" ` @measuredArguments if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)." diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index e1be5ab9..fce29b9f 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -5,8 +5,8 @@ // enforces; this script is only the mechanical check. // // Pure, testable logic lives in the exported functions below. `main()` is the -// CLI wrapper that reads the changelog file and asks git for the previous -// published tag, then calls `evaluateRelease`. +// CLI wrapper that reads the package's declaration (scripts/release-packages.mjs), +// its changelog and the repository's tags, then calls `evaluateRelease`. import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -164,40 +164,53 @@ export function evaluateRelease({ packageName, changelogText, version, previousV return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and has ${evidence}, which agree` }; } -// --- CLI wrapper: resolves the previous published tag via git and reads the -// changelog off disk, then delegates to the pure functions above. --- +// --- CLI wrapper: reads the package's declaration, the release record (git tags) and the +// changelog, then delegates to the pure functions above. --- -// The previous release is the newest same-prefix tag strictly older than the version. -// `other` is any same-prefix tag that is not the version itself, older or newer: a -// package with one has been released before, whatever the version arithmetic says. -function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { - // A directory that cannot list tags (not a git repository, git missing) throws: that - // is not evidence that there was no previous release. - const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); +// The release record, derived from `git tag -l` in --cwd. The previous release is the +// newest '-vMAJOR.MINOR.PATCH' tag strictly older than the version; 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. +function resolveRelease(tagPrefix, versionText, cwd) { + let tags; + try { + tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + } catch { + return { failure: "the tags of --cwd could not be read (not a git repository?), so the previous release is unknown" }; + } const prefix = `${tagPrefix}-v`; - const current = parseVersion(currentVersion); + const current = parseVersion(versionText); + const own = `${prefix}${formatVersion(current)}`; let best = null; - let other = null; + let newer = null; for (const tag of tags) { if (!tag.startsWith(prefix)) continue; - const versionText = tag.slice(prefix.length); - let candidate; - try { - candidate = parseVersion(versionText); - } catch { - continue; + const text = tag.slice(prefix.length); + if (!VERSION_PATTERN.test(text)) { + return { failure: `${tag} starts with ${prefix} but is not ${prefix}MAJOR.MINOR.PATCH, so it cannot be ordered among the releases` }; } - if (sameVersion(candidate, current)) continue; // the tag being released itself - other ??= tag; - if (compareVersions(candidate, current) >= 0) continue; // ignore anything not older - if (best === null || compareVersions(candidate, best) > 0) { + const candidate = parseVersion(text); + const order = compareVersions(candidate, current); + if (order === 0) continue; // the tag being released itself + if (order > 0) { + newer ??= tag; + } else if (best === null || compareVersions(candidate, best) > 0) { best = candidate; } } - return { previous: best ? formatVersion(best) : null, other }; + if (best !== null) return { previous: formatVersion(best) }; + if (newer !== null) { + return { failure: `${newer} exists but no ${prefix} tag is older than ${formatVersion(current)}: the version is older than every release` }; + } + 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)" }; + } + return { previous: null }; } // --- Measured public API: the built DLLs are compared with the DLLs the @@ -252,57 +265,83 @@ function prepareGate() { return { refs }; } -// The DLLs the previous tag tracked under `baselinePath`, as git blobs: no -// worktree, no network, and unaffected by what the working tree holds now. A git -// failure is a refusal that names the step, never git's own output. -function extractBaseline({ tag, baselinePath, into, cwd }) { - const dir = baselinePath.replace(/\\/g, "/").replace(/\/+$/, ""); +// Whether a PE image is a managed assembly: its optional header's data directory 14 (the +// CLI header) is non-empty. A PE without one is native and ApiCompat cannot read it. A buffer +// that is not a PE image, or is cut short, throws: decided from the bytes, never from the +// file's name or place. +export function isManagedAssembly(bytes) { + const need = (end) => { + if (end > bytes.length) throw new RangeError("not a PE image"); + }; + need(0x40); + if (bytes.readUInt16LE(0) !== 0x5a4d) throw new RangeError("not a PE image"); + const pe = bytes.readUInt32LE(0x3c); + need(pe + 24); + if (bytes.readUInt32LE(pe) !== 0x00004550) throw new RangeError("not a PE image"); + const optionalSize = bytes.readUInt16LE(pe + 20); + const optional = pe + 24; + const magic = bytes.readUInt16LE(optional); + if (magic !== 0x10b && magic !== 0x20b) throw new RangeError("not a PE image"); + const directories = optional + (magic === 0x10b ? 96 : 112); + need(directories); + if (bytes.readUInt32LE(directories - 4) <= 14 || optionalSize < directories + 15 * 8 - optional) return false; + need(directories + 15 * 8); + return bytes.readUInt32LE(directories + 14 * 8) !== 0 && bytes.readUInt32LE(directories + 14 * 8 + 4) !== 0; +} + +// The managed assemblies the previous tag tracked anywhere under `dir`, as git blobs: no +// worktree, no network, and unaffected by what the working tree holds now. Each is written to +// `into` by file name. A git failure is a refusal that names the step, never git's own output. +function extractBaseline({ tag, dir, into, cwd }) { const git = (args, options) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], ...options }); + const blobs = []; try { - const listing = git(["ls-tree", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" }); - const names = listing.split("\n").map((line) => basename(line.trim())).filter((name) => name.toLowerCase().endsWith(".dll")); - for (const name of names) { - writeFileSync(join(into, name), git(["show", `${tag}:${dir}/${name}`], { maxBuffer: 1 << 28 })); - } - return { dir, names }; + const paths = git(["ls-tree", "-r", "-z", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" }) + .split("\0") + .filter((path) => path.toLowerCase().endsWith(".dll")); + for (const path of paths) blobs.push({ path, bytes: git(["show", `${tag}:${path}`], { maxBuffer: 1 << 28 }) }); } catch { return { failure: `git could not read the assemblies tracked under ${dir} at ${tag}` }; } + const names = []; + for (const { path, bytes } of blobs) { + let managed; + try { + managed = isManagedAssembly(bytes); + } catch { + return { failure: `${path} at ${tag} is a .dll but not a PE image` }; + } + if (!managed) continue; + const name = basename(path); + if (names.includes(name)) return { failure: `${tag} tracks two managed assemblies named ${name} under ${dir}` }; + writeFileSync(join(into, name), bytes); + names.push(name); + } + return { names }; } -// Compares each built assembly with its namesake at the previous tag. Measuring -// nothing is a failure: the baseline path must hold DLLs at the tag, every -// built assembly must have a namesake there unless the caller declares it new -// (`declaredNew`), and at least one comparison must run. An assembly the tag -// tracked and the build no longer produces is itself a break, whatever its name. -export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, declaredNew = [], cwd }) { +// Compares each built assembly with its namesake at the previous tag. Measuring nothing is a +// failure: the declared directory must hold a managed assembly at the tag. A managed assembly +// the tag tracked and the build no longer hands over is itself a break, whatever its name or +// place; a built assembly with no namesake is an addition and is not examined. +export function measureApiBreaks({ tagPrefix, previousVersion, assemblies, built, refs, cwd }) { const tag = `${tagPrefix}-v${previousVersion}`; const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-")); try { const left = join(work, "left"); mkdirSync(left); - const baseline = extractBaseline({ tag, baselinePath, into: left, cwd }); + const baseline = extractBaseline({ tag, dir: assemblies, into: left, cwd }); if (baseline.failure) return baseline; - const { dir, names: baselineNames } = baseline; + const baselineNames = baseline.names; if (baselineNames.length === 0) { - return { failure: `${tag} tracks no assembly under ${dir}: the baseline path is missing or moved, or that tag does not track the DLLs` }; - } - const builtNames = new Set(built.map((path) => basename(path))); - for (const name of declaredNew) { - if (baselineNames.includes(name)) return { failure: `${name} is declared new but ${tag} already tracks it under ${dir}` }; - } - for (const name of builtNames) { - if (!baselineNames.includes(name) && !declaredNew.includes(name)) { - return { failure: `${name} is built but ${tag} does not track it under ${dir}; if it is a new assembly, declare it with --api-new ${name}` }; - } + return { failure: `${tag} tracks no managed assembly under ${assemblies}: the declared directory is missing or moved, or that tag does not track the DLLs` }; } const gate = prepareGate(); if (gate.failure) return gate; - const unbuilt = baselineNames.filter((name) => !builtNames.has(name)); - if (unbuilt.length === baselineNames.length) { - return { failure: `no built assembly has a namesake tracked at ${tag}, so nothing was compared (${tag} tracks ${baselineNames.join(", ")})` }; - } - const breaks = unbuilt.map((name) => `assembly ${name} exists at ${tag} but is no longer built`); + const builtNames = new Set(built.map((path) => basename(path))); + const breaks = baselineNames + .filter((name) => !builtNames.has(name)) + .map((name) => `assembly ${name} exists at ${tag} but is no longer built`); for (const path of built) { const name = basename(path); if (!baselineNames.includes(name)) continue; @@ -318,9 +357,9 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui } } -// Repeatable options collect into arrays; flags take no value. -const REPEATABLE = new Set(["api-built", "api-refs", "api-new"]); -const FLAGS = new Set(["first-release"]); +// Repeatable options collect into arrays. Any other option is a usage error. +const KNOWN = new Set(["package", "version", "cwd", "api-built", "api-refs"]); +const REPEATABLE = new Set(["api-built", "api-refs"]); function parseArgs(argv) { const args = {}; @@ -328,10 +367,7 @@ function parseArgs(argv) { const arg = argv[i]; if (!arg.startsWith("--")) continue; const key = arg.slice(2); - if (FLAGS.has(key)) { - args[key] = true; - continue; - } + if (!KNOWN.has(key)) return { unknown: arg }; if (REPEATABLE.has(key)) { (args[key] ??= []).push(argv[i + 1]); } else { @@ -350,15 +386,14 @@ function refuse(message) { const UNMEASURED_NOTICE = 'public API measured; changes ApiCompat cannot see (see "Unmeasured changes" in docs/semver-policy.md) must still be declared under "### Breaking"'; -function main() { +async function main() { const args = parseArgs(process.argv.slice(2)); - const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args; + const { package: packageName, version, cwd = process.cwd() } = args; // The tool runs from scripts/api-gate, so a path given relative to this process is made absolute first. const built = (args["api-built"] ?? []).map((path) => resolve(path)); - if (!packageName || !changelog || !version || !tagPrefix || (built.length > 0) !== Boolean(args["api-baseline-path"])) { + if (args.unknown || !packageName || !version) { console.error( - "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ] [--first-release]\n" + - " [--api-baseline-path --api-built ... [--api-refs ...] [--api-new ...]]", + "usage: check-changelog-semver.mjs --package --version [--cwd ] [--api-built ... [--api-refs ...]]", ); process.exitCode = 2; return; @@ -369,6 +404,21 @@ function main() { refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`); return; } + // The package's release identity is declared once, in the repository being released. + let declared; + try { + declared = (await import(pathToFileURL(resolve(cwd, "scripts", "release-packages.mjs")).href)).default; + } catch { + refuse(`${packageName}: scripts/release-packages.mjs could not be loaded from --cwd`); + return; + } + const entry = Object.hasOwn(declared ?? {}, packageName) ? declared[packageName] : null; + if (entry == null || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") { + refuse(`${packageName}: not a package declared in scripts/release-packages.mjs`); + return; + } + const { tagPrefix, assemblies } = entry; + const changelog = resolve(cwd, entry.changelog); let changelogText; try { changelogText = readFileSync(changelog, "utf8"); @@ -376,44 +426,28 @@ function main() { refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`); return; } - const firstRelease = args["first-release"] === true; - let previousVersion; - let otherTag = null; - try { - ({ previous: previousVersion, other: otherTag } = resolvePreviousVersion(tagPrefix, version, cwd)); - } catch { - if (!firstRelease) { - refuse(`${packageName}: the tags of --cwd could not be read (not a git repository?), so the previous release is unknown; ` + - "if this is the package's first release, declare it with --first-release"); - return; - } - previousVersion = null; - } - if (previousVersion == null && !firstRelease) { - refuse(`${packageName}: no ${tagPrefix}-v tag older than ${version} exists, so there is nothing to compare against; ` + - "if this is the package's first release, declare it with --first-release"); + if (typeof assemblies === "string" && built.length === 0) { + refuse(`${packageName}: ships the assemblies under ${assemblies}, so the release must hand them over with --api-built`); return; } - if (firstRelease && otherTag != null) { - refuse(`${packageName}: --first-release was declared but ${otherTag} already exists`); + if (typeof assemblies !== "string" && built.length > 0) { + refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`); return; } - // A package's tag prefix is its directory under packages/ (the workflow's tag triggers and - // job matrix spell it the same way), so a first release is believed only for the prefix - // its changelog's directory names. - if (firstRelease && basename(dirname(resolve(changelog))) !== tagPrefix) { - refuse(`${packageName}: --first-release is accepted only when --tag-prefix names the directory that holds the changelog`); + const release = resolveRelease(tagPrefix, version, cwd); + if (release.failure) { + refuse(`${packageName}: ${release.failure}`); return; } + const previousVersion = release.previous; let measuredBreaks = []; - if (built.length > 0 && previousVersion != null) { + if (previousVersion != null && built.length > 0) { const measurement = measureApiBreaks({ tagPrefix, previousVersion, - baselinePath: args["api-baseline-path"], + assemblies, built, refs: (args["api-refs"] ?? []).map((path) => resolve(path)), - declaredNew: args["api-new"] ?? [], cwd, }); if (measurement.failure) { @@ -435,5 +469,5 @@ function main() { const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - main(); + await main(); } diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index ad7e58a3..1d2516dc 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -11,6 +11,7 @@ import { classifyBump, evaluateRelease, hasBreakingSection, + isManagedAssembly, nextVersions, parseVersion, readApiCompatRun, @@ -188,9 +189,22 @@ test("evaluateRelease names package, previous version, new version, and the brea assert.match(result.reason, new RegExp(BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); -// --- CLI-level integration tests: exercise the git-backed previous-version -// resolution and the "don't re-litigate an already-tagged release" skip, -// which the pure evaluateRelease() tests above cannot reach. --- +// --- 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-")); @@ -198,6 +212,7 @@ function withTempGitRepo(fn) { 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 }); @@ -226,143 +241,180 @@ function runCheckerExpectFailure(cwd, args, { status = 1, ...options } = {}) { } } +const git = (dir, ...args) => execFileSync("git", args, { cwd: dir }); +const commitAndTag = (dir, ...tags) => { + git(dir, "commit", "--allow-empty", "-q", "-m", "init"); + 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) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.1.0", { breaking: false })); - const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget"]); + 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) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true })); - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + 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) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.1"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true })); - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + 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/); }); }); -// A package's changelog lives in a directory named like its tag prefix (packages//). -function packageChangelog(dir, version, prefix = "widget") { - mkdirSync(join(dir, prefix), { recursive: true }); - const path = join(dir, prefix, "CHANGELOG.md"); - writeFileSync(path, changelogFor(version)); - return path; -} - -test("CLI: no previous tag, or tags that cannot be read, refuse unless the first release is declared", () => { +test("CLI: a first release is derived from the tags, and nothing is measured for it", () => { withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "other-v1.0.0"], { cwd: dir }); - const changelogPath = packageChangelog(dir, "1.0.0"); - const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; - assert.match(runCheckerExpectFailure(dir, args), /nothing to compare against; if this is the package's first release, declare it with --first-release/); - assert.match(runChecker(dir, [...args, "--first-release"]), /first release \(1\.0\.0\)/); - // A flag takes no value: the option after it is still read. - assert.match(runChecker(dir, ["--first-release", ...args]), /first release/); + // 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 { - const changelogPath = packageChangelog(notARepo, "1.0.0"); - const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; - const refused = runCheckerExpectFailure(notARepo, args); - assert.match(refused, /tags of --cwd could not be read .*; if this is the package's first release, declare it with --first-release/); + 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 - assert.match(runChecker(notARepo, [...args, "--first-release"]), /first release/); } finally { rmSync(notARepo, { recursive: true, force: true }); } }); -test("CLI: declaring a first release while an earlier tag exists is refused", () => { - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.1.0")); - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget", "--first-release"]); - assert.match(output, /--first-release was declared but widget-v1\.0\.0 already exists/); - }); -}); - -test("CLI: --first-release is believed only with no other same-prefix tag and the package's own prefix", () => { - const declared = (dir, version, prefix = "widget") => [ - "--package", "widget", "--changelog", packageChangelog(dir, version), "--version", version, "--tag-prefix", prefix, "--first-release", - ]; - // a newer tag of the prefix: not a first release (the unflagged run refuses it too) - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v2.0.0"], { cwd: dir }); - assert.match(runCheckerExpectFailure(dir, declared(dir, "0.9.0")), /--first-release was declared but widget-v(1|2)\.0\.0 already exists/); - }); - // a mistyped prefix, with the real package's tags in the repository - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const args = declared(dir, "1.0.1", "widgit"); - assert.match(runCheckerExpectFailure(dir, args), /--first-release is accepted only when --tag-prefix names the directory that holds the changelog/); - // the right prefix names the tag that exists, so it is refused for that - assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1")), /already exists/); - }); - // with the predecessor tag deleted, a mistyped prefix is still refused by the prefix check - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "-d", "widget-v1.0.0"], { cwd: dir }); - assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1", "widgit")), /names the directory that holds the changelog/); - }); - // the version's own tag is not another tag: a re-run of a package's only release - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - assert.match(runChecker(dir, declared(dir, "1.0.0")), /first release \(1\.0\.0\)/); - }); -}); - -// The publish workflow's own first-release decision, run as the workflow runs it, then handed -// to the checker: a package's first release passes, and the release after it does too. -const workflowFile = fileURLToPath(new URL("../.github/workflows/publish-packages.yml", import.meta.url)); - -test("workflow: the --first-release line passes a package's first release and its second", { skip: process.platform === "win32" }, () => { - const workflow = readFileSync(workflowFile, "utf8"); - const lines = [...workflow.matchAll(/^ *if \[ -z "\$\(git tag -l '([^']*)-v\*' [^\n]*first="--first-release"; fi\r?$/gm)]; - assert.equal(lines.length, 2, "the cultcache-ts job and the python matrix each decide it once"); - for (const [line, prefix] of lines) { - const decision = line.trim().replace("${{ matrix.package }}", "widget"); - const first = (dir, tag) => execFileSync("bash", ["-c", `${decision}; printf '%s' "$first"`], { cwd: dir, encoding: "utf8", env: { ...process.env, GITHUB_REF_NAME: tag } }); +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) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - const name = prefix.includes("matrix") ? "widget" : prefix; - execFileSync("git", ["tag", `${name}-v1.0.0`], { cwd: dir }); - assert.equal(first(dir, `${name}-v1.0.0`), "--first-release"); - execFileSync("git", ["tag", `${name}-v1.1.0`], { cwd: dir }); - assert.equal(first(dir, `${name}-v1.1.0`), ""); - // another package's tags are not this package's releases - execFileSync("git", ["tag", "unrelated-v9.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "-d", `${name}-v1.0.0`], { cwd: dir }); - execFileSync("git", ["tag", "-d", `${name}-v1.1.0`], { cwd: dir }); - execFileSync("git", ["tag", `${name}-v0.1.0`], { cwd: dir }); - assert.equal(first(dir, `${name}-v0.1.0`), "--first-release"); + 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 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:/); + } + }); +}); + +test("workflow: every tag trigger is a declared prefix, and every declared assemblies directory is tracked", () => { + const workflow = readFileSync(workflowFile, "utf8"); + const triggers = [...workflow.match(/^\s+tags:\r?\n((?:\s+- "[^"]+"\r?\n)+)/m)[1].matchAll(/- "([^"]+)-v\*"/g)].map((match) => match[1]); + assert.ok(triggers.length >= 4, "the workflow triggers on the npm and PyPI packages' tags"); + const prefixes = Object.values(releasePackages).map((entry) => entry.tagPrefix); + for (const trigger of triggers) assert.ok(prefixes.includes(trigger), `${trigger}-v* triggers the workflow but no package declares that prefix`); + 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`); } }); @@ -395,20 +447,25 @@ ${references.join("|")}`).digest("hex").slice(0, 16)); return dll; } -const PLUGINS = "unity/widget/Runtime/Plugins"; - -// A repo whose tag widget-v tracks `baseline` ({file name: dll path}) -// under PLUGINS, and whose working tree then holds `afterTag` instead. +// A repo whose tag widget-v 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) => { - mkdirSync(join(dir, PLUGINS), { recursive: true }); - for (const [name, path] of Object.entries(baseline)) copyFileSync(path, join(dir, PLUGINS, name)); + 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)); - for (const [name, path] of Object.entries(afterTag)) copyFileSync(path, join(dir, PLUGINS, name)); + place(afterTag); execFileSync("git", ["add", "-A", "."], { cwd: dir }); execFileSync("git", ["commit", "-q", "-m", "after the release"], { cwd: dir }); } @@ -416,13 +473,12 @@ function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0 }); } -function measuredArgs(dir, version, builtPaths, { breaking = false, news = [], refs = [] } = {}) { - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor(version, { breaking })); +function measuredArgs(dir, version, builtPaths, { breaking = false, refs = [] } = {}) { + writeChangelog(dir, version, { breaking }); return [ - "--package", "widget", "--changelog", changelogPath, "--version", version, "--tag-prefix", "widget", - "--api-baseline-path", PLUGINS, ...builtPaths.flatMap((path) => ["--api-built", path]), - ...news.flatMap((name) => ["--api-new", name]), ...refs.flatMap((refDir) => ["--api-refs", refDir]), + ...widgetArgs(version), + ...builtPaths.flatMap((path) => ["--api-built", path]), + ...refs.flatMap((refDir) => ["--api-refs", refDir]), ]; } @@ -471,7 +527,7 @@ test("measured: a declared break with nothing measured still passes", () => { }); }); -test("measured: a tracked assembly that is no longer built is a break, whatever its name; a declared new assembly is not", () => { +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 }; @@ -481,26 +537,147 @@ test("measured: a tracked assembly that is no longer built is a break, whatever 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], { news: ["GameCult.Brand.dll"] })); + 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 and declaring it new compares nothing and is refused", () => { +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], { news: ["CultMath.Next.dll"] })); - assert.match(renamed, /no built assembly has a namesake tracked at widget-v1\.0\.0, so nothing was compared/); - assert.match(renamed, /CultMath\.dll/); - // with a comparison that ran, the unbuilt tracked assembly is itself the break - withReleasedBaseline({ baseline: { "CultMath.dll": cultMath, "Other.dll": buildAssembly("Other", "namespace O { public class D {} }") } }, (both) => { - const partial = runCheckerExpectFailure(both, measuredArgs(both, "1.0.1", [cultMath, renamedTrimmed], { news: ["CultMath.Next.dll"] })); - assert.match(partial, /Other\.dll exists at widget-v1\.0\.0 but is no longer built/); + 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/); + }); +}); + +// 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("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/); }); }); @@ -618,36 +795,16 @@ test("measured: the baseline is the tag's blob, not the working tree's DLL", () ); }); -test("measured: a declared first release measures nothing", () => { - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - const junk = join(dir, "junk.dll"); - writeFileSync(junk, "junk"); - const args = measuredArgs(dir, "1.0.0", [junk]); - args[args.indexOf("--changelog") + 1] = packageChangelog(dir, "1.0.0"); - const output = runChecker(dir, [...args, "--first-release"]); - assert.match(output, /first release/); - assert.doesNotMatch(output, /public API measured/); - }); -}); - test("measured: measuring nothing refuses, naming what is missing", () => { const fail = (dir, args) => runCheckerExpectFailure(dir, args); withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - // a baseline path that is wrong or moved - const moved = measuredArgs(dir, "1.1.0", [trimmedWidget()]); - moved[moved.indexOf("--api-baseline-path") + 1] = "unity/moved/Runtime/Plugins"; - assert.match(fail(dir, moved), /widget-v1\.0\.0 tracks no assembly under unity\/moved\/Runtime\/Plugins: the baseline path is missing or moved/); - // a shipped DLL the tag does not track - const fresh = buildAssembly("GameCult.Fresh", "namespace F { public class G {} }"); - const output = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget(), fresh])); - assert.match(output, /GameCult\.Fresh\.dll is built but widget-v1\.0\.0 does not track it under .*; if it is a new assembly, declare it with --api-new GameCult\.Fresh\.dll/); - // declaring an assembly new that the tag already tracks is a wrong declaration - const wrong = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()], { news: ["GameCult.Widget.dll"] })); - assert.match(wrong, /GameCult\.Widget\.dll is declared new but widget-v1\.0\.0 already tracks it/); + // 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 }); @@ -655,6 +812,16 @@ test("measured: measuring nothing refuses, naming what is missing", () => { 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", () => { @@ -664,6 +831,7 @@ test("measured: a git failure while reading the baseline, and a changelog that i }; // 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 }); @@ -673,17 +841,16 @@ test("measured: a git failure while reading the baseline, and a changelog that i const output = clean(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]))); assert.match(output, /git could not read the assemblies tracked under unity\/widget\/Runtime\/Plugins at widget-v1\.0\.0/); }); - // a baseline path that git would take for an option + // a declared directory that git would take for an option withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - const args = measuredArgs(dir, "1.0.1", [oldWidget()]); - args[args.indexOf("--api-baseline-path") + 1] = "--bogus"; - assert.match(clean(runCheckerExpectFailure(dir, args)), /tracks no assembly under --bogus|git could not read/); + 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) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + commitAndTag(dir, "widget-v1.0.0"); mkdirSync(join(dir, "CHANGELOG.md")); - const output = clean(runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.0.0", "--tag-prefix", "widget"])); + const output = clean(runCheckerExpectFailure(dir, widgetArgs("1.0.1"))); assert.match(output, /changelog cannot be read at /); }); }); @@ -694,11 +861,9 @@ test("measured: a passing release says that unmeasured changes still need declar 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) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.1")); - const unmeasured = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + 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/); }); }); @@ -720,37 +885,23 @@ test("evaluateRelease: measured breaks count as breaking for the lane, declared assert.match(reason, /; \.\.\./); }); -test("measured: every missing required option is a usage error", () => { +test("every missing required option is a usage error", () => { withTempGitRepo((dir) => { - const complete = ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.1.0", "--tag-prefix", "widget"]; + 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-baseline-path/); + assert.match(output, /--api-built/); } - const builtWithoutBaseline = runCheckerExpectFailure(dir, [...complete, "--api-built", "x.dll"], { status: 2 }); - assert.match(builtWithoutBaseline, /usage:/); - const baselineWithoutBuilt = runCheckerExpectFailure(dir, [...complete, "--api-baseline-path", PLUGINS], { status: 2 }); - assert.match(baselineWithoutBuilt, /usage:/); }); }); -test("CLI: a missing changelog fails and names the path; stray positional words are ignored", () => { +test("CLI: a missing changelog fails and names the declared path; stray positional words are ignored", () => { withTempGitRepo((dir) => { - const missing = join(dir, "NOPE.md"); - const output = runCheckerExpectFailure(dir, ["stray", "--package", "widget", "--changelog", missing, "--version", "1.1.0", "--tag-prefix", "widget"]); - assert.match(output, /changelog not found at .*NOPE.md/); - }); -}); - -test("measured: the baseline path may use backslashes or a trailing slash", () => { - withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - for (const path of [`${PLUGINS}/`, `${PLUGINS}//`, PLUGINS.replace(/\//g, "\\")]) { - const args = measuredArgs(dir, "1.1.0", [trimmedWidget()]); - args[args.indexOf("--api-baseline-path") + 1] = path; - assert.match(runCheckerExpectFailure(dir, args), /Gone/); - } + 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/); }); }); @@ -903,18 +1054,14 @@ test("evaluateRelease: lists at most five measured breaks, and says nothing more 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, with or without --first-release", () => { +test("CLI: a malformed version is refused by name, without repeating it", () => { withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.1.0")); - for (const extra of [[], ["--first-release"]]) { - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.x-canary", "--tag-prefix", "widget", ...extra]); - assert.match(output, /--version is not a MAJOR\.MINOR\.PATCH version/); - assert.doesNotMatch(output, /canary/); - assert.doesNotMatch(output, /could not be read/); - } + 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/); }); }); @@ -925,9 +1072,7 @@ test("refusals never repeat what the inputs contain", () => { const junk = join(scratch, "GameCult.Widget.dll"); writeFileSync(junk, `MZ not an assembly ${canary}`); const canaryChangelog = (dir, version, { breaking = false } = {}) => { - const path = join(dir, "CHANGELOG.md"); - writeFileSync(path, changelogFor(version, { breaking }).replace("- something changed", `- something changed ${canary}`) + `\n## [0.0.1]\n\n- ${canary}\n`); - return path; + 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); @@ -937,7 +1082,7 @@ test("refusals never repeat what the inputs contain", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { const measured = (version, built, options) => { const args = measuredArgs(dir, version, built, options); - args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, version, options); + canaryChangelog(dir, version, options); return args; }; // a measured break, with the canary in the changelog it must be declared in @@ -952,10 +1097,10 @@ test("refusals never repeat what the inputs contain", () => { 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 unreadable + // 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()]); - args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, "1.0.1"); + canaryChangelog(dir, "1.0.1"); assert.match(refuse(dir, args), /could not be measured/); }); } finally { diff --git a/scripts/release-packages.mjs b/scripts/release-packages.mjs new file mode 100644 index 00000000..d70e0026 --- /dev/null +++ b/scripts/release-packages.mjs @@ -0,0 +1,38 @@ +// The one declaration of what each released package is, read by +// scripts/check-changelog-semver.mjs (docs/semver-policy.md) and by nothing a caller types. +// Keyed by the --package name. Paths are repo-relative. +// tagPrefix releases are tags named '-vMAJOR.MINOR.PATCH' +// changelog the package's CHANGELOG.md +// assemblies the directory whose tracked DLLs the package ships; absent when it ships none +export default { + "org.gamecult.cultlib": { + tagPrefix: "cultlib-unity", + changelog: "unity/org.gamecult.cultlib/CHANGELOG.md", + assemblies: "unity/org.gamecult.cultlib/Runtime/Plugins", + }, + "org.gamecult.cultmath": { + tagPrefix: "cultmath-unity", + changelog: "packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md", + assemblies: "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins", + }, + "org.gamecult.caching.unity": { + tagPrefix: "caching-unity", + changelog: "src/GameCult.Unity/Assets/Caching/CHANGELOG.md", + }, + "@gamecult/cultcache-ts": { + tagPrefix: "cultcache-ts", + changelog: "packages/cultcache-ts/CHANGELOG.md", + }, + "cultcache-py": { + tagPrefix: "cultcache-py", + changelog: "packages/cultcache-py/CHANGELOG.md", + }, + "cultnet-py": { + tagPrefix: "cultnet-py", + changelog: "packages/cultnet-py/CHANGELOG.md", + }, + "cultmesh-py": { + tagPrefix: "cultmesh-py", + changelog: "packages/cultmesh-py/CHANGELOG.md", + }, +}; diff --git a/scripts/verify-caching-unity-release.ps1 b/scripts/verify-caching-unity-release.ps1 index 7e28ef6c..7bf55b68 100644 --- a/scripts/verify-caching-unity-release.ps1 +++ b/scripts/verify-caching-unity-release.ps1 @@ -11,15 +11,12 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot $packageRoot = Join-Path $repoRoot "src\GameCult.Unity\Assets\Caching" $manifestPath = Join-Path $packageRoot "package.json" -$changelogPath = Join-Path $packageRoot "CHANGELOG.md" $version = (Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json).version & node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") ` --package "org.gamecult.caching.unity" ` - --changelog $changelogPath ` --version $version ` - --tag-prefix "caching-unity" ` --cwd $repoRoot if ($LASTEXITCODE -ne 0) { throw "org.gamecult.caching.unity failed the semver policy check (see docs/semver-policy.md)."