diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41325bdf2..46f520063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: - name: Type-check run: yarn typecheck + - name: Type-check bin + run: yarn typecheck:bin + - name: Check type-check programs use live source run: yarn tsx bin/check-typecheck-sources.ts diff --git a/bin/check-changelog.test.ts b/bin/check-changelog.test.ts index 45659d062..a9264f582 100644 --- a/bin/check-changelog.test.ts +++ b/bin/check-changelog.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from "vitest"; import { changelogMessage, - formatProblemsMessage, - impactLineProblems, missingPackageChangelogs, needsChangelog, - sectionProblems, } from "./check-changelog.js"; const packagePrefixes = ["packages/format", "packages/pointers"]; @@ -231,145 +228,3 @@ describe("changelogMessage", () => { expect(message).toContain("packages/pointers/CHANGELOG.md"); }); }); - -describe("impactLineProblems", () => { - const entry = (producers: string, consumers: string): string => - [ - "## Unreleased", - "", - "### Changed", - "", - "- A summary of the change ([#1]).", - " - Schemas: **ethdebug/format/pointer**", - ` - Producers: ${producers}`, - ` - Consumers: ${consumers}`, - "", - ].join("\n"); - - it("accepts the no change needed. prefix", () => { - expect( - impactLineProblems( - entry("no change needed.", "no change needed. One short reason."), - ), - ).toEqual([]); - }); - - it("accepts the optional: prefix", () => { - expect( - impactLineProblems( - entry("optional: a producer may emit it.", "optional: may read it."), - ), - ).toEqual([]); - }); - - it("accepts the required: prefix", () => { - expect( - impactLineProblems( - entry("required: `minimum` forbids it.", "required: must read it."), - ), - ).toEqual([]); - }); - - it("accepts a prefix on the line below a bare label", () => { - const text = [ - "- A summary.", - " - Producers:", - " optional: a producer may emit it.", - "", - ].join("\n"); - expect(impactLineProblems(text)).toEqual([]); - }); - - it("reports a bare imperative with its line number", () => { - const problems = impactLineProblems( - entry("emit the new field.", "no change needed."), - ); - expect(problems).toHaveLength(1); - expect(problems[0]).toContain("line 7:"); - expect(problems[0]).toContain('"Producers:"'); - expect(problems[0]).toContain('"no change needed."'); - expect(problems[0]).toContain('"optional:"'); - expect(problems[0]).toContain('"required:"'); - }); - - it("reports each offending sub-item", () => { - const problems = impactLineProblems( - entry("emit the new field.", "Required: read the new field."), - ); - expect(problems).toHaveLength(2); - expect(problems[0]).toContain("line 7:"); - expect(problems[1]).toContain("line 8:"); - expect(problems[1]).toContain('"Consumers:"'); - }); - - it("reports a prefix that runs into the text after it", () => { - expect( - impactLineProblems(entry("optional:emit it.", "no change needed.")), - ).toHaveLength(1); - }); - - it("reports a bare label with no prefix below it", () => { - const text = ["- A summary.", " - Consumers:", " read it.", ""].join( - "\n", - ); - expect(impactLineProblems(text)).toEqual([ - expect.stringContaining("line 2:"), - ]); - }); - - it("ignores the intro bullets that describe the sub-items", () => { - const text = [ - "# Changelog", - "", - "- `Schemas:` the schema(s) the change touches.", - "- `Producers:` what the change means for an emitter of data (a", - " compiler such as solc or bugc).", - "- `Consumers:` what the change means for a reader.", - "", - "Each `Producers:` and `Consumers:` sub-item starts with a prefix:", - "", - "- `no change needed.` Nothing changes.", - "", - ].join("\n"); - expect(impactLineProblems(text)).toEqual([]); - }); - - it("accepts a file with no entries", () => { - expect(impactLineProblems("# Changelog\n\n## Unreleased\n")).toEqual([]); - expect(impactLineProblems("")).toEqual([]); - }); -}); - -describe("formatProblemsMessage", () => { - it("is empty when there are no problems", () => { - expect(formatProblemsMessage([])).toBe(""); - }); - - it("names the file and lists each problem", () => { - const message = formatProblemsMessage(["line 7: first", "line 9: second"]); - expect(message).toContain("CHANGELOG.md"); - expect(message).toContain(" line 7: first"); - expect(message).toContain(" line 9: second"); - }); -}); - -describe("sectionProblems", () => { - it("accepts Added and Changed sections", () => { - const text = "## Unreleased\n\n### Added\n\n### Changed\n"; - expect(sectionProblems(text)).toEqual([]); - }); - - it("rejects any other section with its line number", () => { - const text = "## Unreleased\n\n### Breaking\n"; - const problems = sectionProblems(text); - expect(problems).toHaveLength(1); - expect(problems[0]).toContain("line 3"); - expect(problems[0]).toContain('"### Added"'); - expect(problems[0]).toContain('"### Changed"'); - }); - - it("ignores version headings and deeper headings", () => { - expect(sectionProblems("# Changelog\n\n## 0.1.0-1\n")).toEqual([]); - expect(sectionProblems("#### Breaking\n")).toEqual([]); - }); -}); diff --git a/bin/check-changelog.ts b/bin/check-changelog.ts index 6df4554ef..fffe5a169 100644 --- a/bin/check-changelog.ts +++ b/bin/check-changelog.ts @@ -1,8 +1,13 @@ -import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { readWorkspaces } from "./publish-tagged.js"; +import { + formatProblemsMessage, + impactLineProblems, + sectionProblems, +} from "./release/changelog.js"; +import * as git from "./release/git.js"; +import { readWorkspaces } from "./release/workspaces.js"; const defaultBase = "origin/main"; @@ -89,89 +94,19 @@ export function changelogMessage( return lines.join("\n"); } -const impactPrefixes = ["no change needed.", "optional:", "required:"]; - -// a real sub-item is indented two spaces and has a bare label; the intro -// bullets that describe the sub-items start at column 0 with a code span -const impactLabel = /^ {2}- (Producers|Consumers):(.*)$/; - -function startsWithImpactPrefix(text: string): boolean { - return impactPrefixes.some( - (prefix) => - text.startsWith(prefix) && - (text.length === prefix.length || /\s/.test(text[prefix.length])), - ); -} - -export function impactLineProblems(text: string): string[] { - const lines = text.split("\n"); - const allowed = impactPrefixes.map((prefix) => `"${prefix}"`).join(", "); - return lines.flatMap((line, index) => { - const match = impactLabel.exec(line); - if (!match) { - return []; - } - const [, label, rest] = match; - // the text starts on the label line after one space, or, when the - // label stands alone, on the continuation line below it - const conforms = - rest.trim().length > 0 - ? rest.startsWith(" ") && startsWithImpactPrefix(rest.slice(1)) - : startsWithImpactPrefix((lines[index + 1] ?? "").trimStart()); - return conforms - ? [] - : [`line ${index + 1}: "${label}:" must start with one of: ${allowed}`]; - }); -} - -const sectionNames = ["Added", "Changed"]; - -// the prefixes carry the obligations, so a section only says whether a -// change adds something new or alters something that exists -export function sectionProblems(text: string): string[] { - const allowed = sectionNames.map((name) => `"### ${name}"`).join(", "); - return text.split("\n").flatMap((line, index) => { - if (!line.startsWith("### ")) { - return []; - } - return sectionNames.includes(line.slice(4).trim()) - ? [] - : [`line ${index + 1}: section heading must be one of: ${allowed}`]; - }); -} - -export function formatProblemsMessage(problems: string[]): string { - if (problems.length === 0) { - return ""; - } - return [ - "CHANGELOG.md does not follow the entry format:", - ...problems.map((problem) => ` ${problem}`), - ].join("\n"); -} - function resolvesToCommit(root: string, ref: string): boolean { - try { - execFileSync( - "git", - ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], - { - cwd: root, - stdio: "ignore", - }, - ); - return true; - } catch { - return false; - } + return ( + git.status(root, [ + "rev-parse", + "--verify", + "--quiet", + `${ref}^{commit}`, + ]) === 0 + ); } function changedPaths(root: string, base: string): string[] { - const stdout = execFileSync( - "git", - ["diff", "--name-only", `${base}...HEAD`], - { cwd: root, encoding: "utf8" }, - ); + const stdout = git.run(root, ["diff", "--name-only", `${base}...HEAD`]); return stdout .split("\n") .map((line) => line.trim()) diff --git a/bin/check-tarballs.ts b/bin/check-tarballs.ts index 053ad1f1d..9e3fc337b 100644 --- a/bin/check-tarballs.ts +++ b/bin/check-tarballs.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; -import { checkPackList, packList } from "./packlist.js"; -import { readWorkspaces } from "./publish-tagged.js"; +import { checkPackList, packList } from "./release/packlist.js"; +import { readWorkspaces } from "./release/workspaces.js"; const root = fileURLToPath(new URL("..", import.meta.url)); diff --git a/bin/check-typecheck-sources.ts b/bin/check-typecheck-sources.ts index fccc41bf7..d19c1b63d 100644 --- a/bin/check-typecheck-sources.ts +++ b/bin/check-typecheck-sources.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { readWorkspaces } from "./publish-tagged.js"; +import { readWorkspaces } from "./release/workspaces.js"; const built = [/packages\/[^/]+\/dist\//, /node_modules\/@ethdebug\//]; diff --git a/bin/publish-tagged.test.ts b/bin/publish-tagged.test.ts index e9d75f4d4..bf3c50eb1 100644 --- a/bin/publish-tagged.test.ts +++ b/bin/publish-tagged.test.ts @@ -1,22 +1,15 @@ import type { SpawnSyncReturns } from "node:child_process"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { classifyView, - distTag, npmEnv, - parseTags, publishArgs, - readWorkspaces, registry, selectPackages, - topoSort, viewVersions, - type Workspace, } from "./publish-tagged.js"; +import type { Workspace } from "./release/workspaces.js"; vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), @@ -33,15 +26,14 @@ const ws = ( version, dir: `/repo/packages/${name.replace("@ethdebug/", "")}`, private: isPrivate, + text: "{}", + json: {}, + runtime: deps, + peer: [], + dev: [], + optional: [], dependencies: deps, -}); - -describe("parseTags", () => { - it("keeps only @ethdebug package tags", () => { - expect( - parseTags(["@ethdebug/format@0.1.0-1", "v1", "@other/x@1.0.0", ""]), - ).toEqual([{ name: "@ethdebug/format", version: "0.1.0-1" }]); - }); + all: deps, }); describe("selectPackages", () => { @@ -85,21 +77,6 @@ describe("selectPackages", () => { }); }); -describe("topoSort", () => { - it("orders dependencies before dependents, including peers", () => { - const sorted = topoSort([ - ws("@ethdebug/evm", "1", ["@ethdebug/pointers"]), - ws("@ethdebug/pointers", "1", ["@ethdebug/format"]), - ws("@ethdebug/format", "1"), - ]).map((w) => w.name); - expect(sorted).toEqual([ - "@ethdebug/format", - "@ethdebug/pointers", - "@ethdebug/evm", - ]); - }); -}); - describe("classifyView", () => { it("treats E404 as unpublished", () => { expect( @@ -197,63 +174,6 @@ describe("viewVersions", () => { }); }); -describe("readWorkspaces", () => { - let root: string | undefined; - - afterEach(() => { - if (root) { - rmSync(root, { recursive: true, force: true }); - root = undefined; - } - }); - - it("merges dependencies and peerDependencies, filtered", () => { - root = mkdtempSync(join(tmpdir(), "ws-")); - const packagesDir = join(root, "packages"); - mkdirSync(join(packagesDir, "a"), { recursive: true }); - mkdirSync(join(packagesDir, "b"), { recursive: true }); - mkdirSync(join(packagesDir, "c"), { recursive: true }); - writeFileSync( - join(packagesDir, "a", "package.json"), - JSON.stringify({ name: "@ethdebug/a", version: "1.0.0" }), - ); - writeFileSync( - join(packagesDir, "b", "package.json"), - JSON.stringify({ - name: "@ethdebug/b", - version: "1.0.0", - dependencies: { "@ethdebug/a": "^1.0.0", lodash: "^4" }, - peerDependencies: { "@ethdebug/c": "^1.0.0" }, - }), - ); - writeFileSync( - join(packagesDir, "c", "package.json"), - JSON.stringify({ - name: "@ethdebug/c", - version: "1.0.0", - private: true, - }), - ); - writeFileSync(join(packagesDir, ".DS_Store"), ""); - - const workspaces = readWorkspaces(root); - expect(workspaces).toHaveLength(3); - expect(workspaces.some((w) => w.dir.endsWith(".DS_Store"))).toBe(false); - - const byName = new Map(workspaces.map((w) => [w.name, w])); - expect(byName.get("@ethdebug/a")?.dependencies).toEqual([]); - expect(byName.get("@ethdebug/b")?.dependencies).toEqual([ - "@ethdebug/a", - "@ethdebug/c", - ]); - expect(byName.get("@ethdebug/c")?.private).toBe(true); - for (const name of ["a", "b", "c"]) { - const dir = byName.get(`@ethdebug/${name}`)?.dir ?? ""; - expect(dir.endsWith(join("packages", name))).toBe(true); - } - }); -}); - describe("publishArgs", () => { it("tags a publish as latest, on registry.npmjs.org", () => { expect(publishArgs(false, {}, "latest")).toEqual([ @@ -321,33 +241,3 @@ describe("publishArgs", () => { ); }); }); - -describe("distTag", () => { - it( - "publishes a prerelease under latest while no stable version " + "exists", - () => { - expect(distTag("0.1.0-draft.0", ["0.1.0-0", "0.1.0-1", "0.1.0-2"])).toBe( - "latest", - ); - expect(distTag("0.1.0-preview.0", [])).toBe("latest"); - }, - ); - - it("publishes a prerelease under its identifier once a stable exists", () => { - expect(distTag("0.2.0-draft.0", ["0.1.0-draft.3", "0.1.0"])).toBe("draft"); - expect(distTag("0.2.0-preview.1", ["0.1.0"])).toBe("preview"); - }); - - it("publishes the highest stable version under latest", () => { - expect(distTag("0.1.0", ["0.1.0-draft.4"])).toBe("latest"); - expect(distTag("0.2.1", ["0.1.0", "0.2.0"])).toBe("latest"); - }); - - it("keeps latest from moving backwards on a back-port", () => { - expect(distTag("0.1.1", ["0.1.0", "0.2.0"])).toBe("release-0.1"); - }); - - it("rejects an identifier that is not a valid tag name", () => { - expect(() => distTag("0.1.0-3", ["0.1.0"])).toThrow(/dist-tag/); - }); -}); diff --git a/bin/publish-tagged.ts b/bin/publish-tagged.ts index 63a57c268..0be73316e 100644 --- a/bin/publish-tagged.ts +++ b/bin/publish-tagged.ts @@ -1,9 +1,13 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; -import semver from "semver"; -import { checkPackList, packList } from "./packlist.js"; +import * as git from "./release/git.js"; +import { checkPackList, packList } from "./release/packlist.js"; +import { distTag, parseReleaseTag, releaseVersions } from "./release/policy.js"; +import { + readWorkspaces, + topoSort, + type Workspace, +} from "./release/workspaces.js"; export const registry = "https://registry.npmjs.org"; @@ -17,59 +21,11 @@ export function npmEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return clean; } -export interface Workspace { - name: string; - version: string; - dir: string; - private: boolean; - dependencies: string[]; -} - export interface Tag { name: string; version: string; } -export function parseTags(lines: string[]): Tag[] { - const tags: Tag[] = []; - for (const line of lines) { - const match = /^(@ethdebug\/[^@]+)@(.+)$/.exec(line.trim()); - if (match) { - tags.push({ name: match[1], version: match[2] }); - } - } - return tags; -} - -export function readWorkspaces(root: string): Workspace[] { - const packagesDir = join(root, "packages"); - return readdirSync(packagesDir) - .filter((entry) => existsSync(join(packagesDir, entry, "package.json"))) - .map((entry) => { - const dir = join(packagesDir, entry); - const manifest = JSON.parse( - readFileSync(join(dir, "package.json"), "utf8"), - ) as { - name: string; - version: string; - private?: boolean; - dependencies?: Record; - peerDependencies?: Record; - }; - const dependencies = Object.keys({ - ...manifest.dependencies, - ...manifest.peerDependencies, - }).filter((dep) => dep.startsWith("@ethdebug/")); - return { - name: manifest.name, - version: manifest.version, - dir, - private: manifest.private === true, - dependencies, - }; - }); -} - export function selectPackages( tags: Tag[], workspaces: Workspace[], @@ -95,32 +51,6 @@ export function selectPackages( return selected; } -export function topoSort(workspaces: Workspace[]): Workspace[] { - const byName = new Map(workspaces.map((w) => [w.name, w])); - const done = new Set(); - const sorted: Workspace[] = []; - const visit = (workspace: Workspace, trail: string[]) => { - if (done.has(workspace.name)) { - return; - } - if (trail.includes(workspace.name)) { - throw new Error(`dependency cycle: ${trail.join(" -> ")}`); - } - for (const dep of workspace.dependencies) { - const target = byName.get(dep); - if (target) { - visit(target, [...trail, workspace.name]); - } - } - done.add(workspace.name); - sorted.push(workspace); - }; - for (const workspace of workspaces) { - visit(workspace, []); - } - return sorted; -} - export type ViewResult = "published" | "unpublished"; export interface View { @@ -173,50 +103,6 @@ export function viewVersions(name: string, version: string): View { } } -// versions that this repository has already tagged for the package; -// the registry document can lag minutes behind a publish, tags do not -export function localTagVersions(root: string, name: string): string[] { - return execFileSync("git", ["tag", "--list", `${name}@*`], { - cwd: root, - encoding: "utf8", - }) - .split("\n") - .map((line) => line.trim().slice(name.length + 1)) - .filter((version) => semver.valid(version) !== null); -} - -// A stable version is `latest` when nothing stable is higher; a -// prerelease is `latest` only while the package has no stable version, -// and its identifier (`draft`, `preview`) after that. CI can set one -// tag per publish, so this is the only tag a version gets. -export function distTag(version: string, knownVersions: string[]): string { - const stable = knownVersions.filter( - (known) => - semver.valid(known) !== null && semver.prerelease(known) === null, - ); - const prerelease = semver.prerelease(version); - if (prerelease === null) { - const highest = [version, ...stable].sort(semver.rcompare)[0]; - return highest === version - ? "latest" - : `release-${semver.major(version)}.${semver.minor(version)}`; - } - if (stable.length === 0) { - return "latest"; - } - const identifier = prerelease[0]; - if ( - typeof identifier !== "string" || - semver.validRange(identifier) !== null - ) { - throw new Error( - `${version}: prerelease identifier "${identifier}" is not a ` + - "valid dist-tag", - ); - } - return identifier; -} - export function publishArgs( dryRun: boolean, env: NodeJS.ProcessEnv, @@ -255,15 +141,16 @@ function publish(workspace: Workspace, dryRun: boolean, tag: string): void { export function main(argv: string[]): number { const dryRun = argv.includes("--dry-run"); const root = fileURLToPath(new URL("..", import.meta.url)); - const tagLines = execFileSync("git", ["tag", "--points-at", "HEAD"], { - cwd: root, - encoding: "utf8", - }).split("\n"); - const tags = parseTags(tagLines); + const tags = git + .run(root, ["tag", "--points-at", "HEAD"]) + .split("\n") + .flatMap((line) => parseReleaseTag(line) ?? []); if (tags.length === 0) { console.log("no @ethdebug package tags at HEAD; nothing to publish"); return 0; } + // the registry document can lag minutes behind a publish, tags do not + const allTags = git.run(root, ["tag", "--list"]).split("\n"); const selected = topoSort(selectPackages(tags, readWorkspaces(root))); const published: string[] = []; const skipped: string[] = []; @@ -287,7 +174,7 @@ export function main(argv: string[]): number { } const tag = distTag(workspace.version, [ ...view.versions, - ...localTagVersions(root, workspace.name), + ...releaseVersions(allTags, workspace.name), ]); console.log( `${label}: publishing under ${tag}${dryRun ? " (dry run)" : ""}`, diff --git a/bin/release/apply.test.ts b/bin/release/apply.test.ts new file mode 100644 index 000000000..8cb831ed5 --- /dev/null +++ b/bin/release/apply.test.ts @@ -0,0 +1,148 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { ApplyFailure, applyRelease, undoAdvice } from "./apply.js"; +import type { Move, Plan } from "./plan.js"; + +const dirs: string[] = []; + +// git hooks and `rebase --exec` export GIT_DIR and friends; git in the +// temp repos would then act on the real repository, so clear them +const saved: Record = {}; + +beforeAll(() => { + for (const key of Object.keys(process.env)) { + if (key.startsWith("GIT_")) { + saved[key] = process.env[key] as string; + delete process.env[key]; + } + } +}); + +afterAll(() => { + Object.assign(process.env, saved); +}); + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function git(dir: string, ...args: string[]): string { + return execFileSync("git", args, { cwd: dir, encoding: "utf8" }).trim(); +} + +// a real repository holding one committed file per name +function repo(files: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "apply-")); + dirs.push(dir); + git(dir, "init", "--quiet"); + git(dir, "config", "user.email", "test@example.com"); + git(dir, "config", "user.name", "test"); + for (const file of files) { + writeFileSync(join(dir, file), "{}\n"); + } + git(dir, "add", ...files); + git(dir, "commit", "--quiet", "-m", "first"); + return dir; +} + +function move(name: string, to: string): Move { + return { name, from: "0.1.0", to, reason: "changed", firstRelease: false }; +} + +describe("applyRelease", () => { + it("writes the files, commits Publish and creates the tags", () => { + const dir = repo(["a.json"]); + const plan: Plan = { + moves: [move("@ethdebug/a", "0.2.0")], + manifests: [{ path: "a.json", text: '{"version":"0.2.0"}\n' }], + schemas: [], + }; + + const applied = applyRelease(dir, plan); + + expect(readFileSync(join(dir, "a.json"), "utf8")).toContain("0.2.0"); + expect(applied.written).toEqual(["a.json"]); + expect(applied.committed).toBe(true); + expect(applied.created).toEqual(["@ethdebug/a@0.2.0"]); + expect(git(dir, "log", "-1", "--format=%s")).toBe("Publish"); + }); + + it("tags HEAD without committing on a first-release-only plan", () => { + const dir = repo(["a.json"]); + const head = git(dir, "rev-parse", "HEAD"); + const plan: Plan = { + moves: [{ ...move("@ethdebug/a", "0.1.0"), firstRelease: true }], + manifests: [], + schemas: [], + }; + + const applied = applyRelease(dir, plan); + + expect(applied.committed).toBe(false); + expect(applied.created).toEqual(["@ethdebug/a@0.1.0"]); + expect(git(dir, "tag", "--points-at", "HEAD")).toBe("@ethdebug/a@0.1.0"); + expect(git(dir, "rev-parse", "HEAD")).toBe(head); + expect(git(dir, "rev-list", "--count", "HEAD")).toBe("1"); + }); + + it("reports the tags it created when tagging fails part-way", () => { + const dir = repo(["a.json", "b.json"]); + const plan: Plan = { + moves: [move("@ethdebug/a", "0.2.0"), move("@ethdebug/b", "0.2.0")], + manifests: [ + { path: "a.json", text: '{"version":"0.2.0"}\n' }, + { path: "b.json", text: '{"version":"0.2.0"}\n' }, + ], + schemas: [], + }; + // the second tag exists, so `git tag -a` throws on the second turn + git(dir, "tag", "-a", "@ethdebug/b@0.2.0", "-m", "x"); + + let failure: unknown; + try { + applyRelease(dir, plan); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(ApplyFailure); + const error = failure as ApplyFailure; + expect(error.created).toEqual(["@ethdebug/a@0.2.0"]); + expect(error.committed).toBe(true); + const advice = undoAdvice(error.created, error.committed); + expect(advice).toContain("git tag -d @ethdebug/a@0.2.0"); + expect(advice).toContain("git reset --hard HEAD~1"); + }); +}); + +describe("undoAdvice", () => { + it("removes the tags and the commit when this run committed", () => { + expect(undoAdvice(["@ethdebug/evm@1.0.0"], true)).toBe( + "undo: git tag -d @ethdebug/evm@1.0.0 && git reset --hard HEAD~1", + ); + }); + + it("removes the commit alone when it failed before any tag", () => { + expect(undoAdvice([], true)).toBe("undo: git reset --hard HEAD~1"); + }); + + // a first-release-only plan tags HEAD without committing + it("removes the tags alone when no commit was made", () => { + expect( + undoAdvice(["@ethdebug/evm@1.0.0", "@ethdebug/bugc@1.0.0"], false), + ).toBe("undo: git tag -d @ethdebug/evm@1.0.0 @ethdebug/bugc@1.0.0"); + }); + + it("restores the manifests when nothing was committed or tagged", () => { + expect(undoAdvice([], false)).toBe( + "undo: git checkout HEAD -- packages/*/package.json schemas/", + ); + }); +}); diff --git a/bin/release/apply.ts b/bin/release/apply.ts new file mode 100644 index 000000000..5ca0be3e5 --- /dev/null +++ b/bin/release/apply.ts @@ -0,0 +1,77 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import * as git from "./git.js"; +import type { Move, Plan } from "./plan.js"; +import { releaseTag } from "./policy.js"; + +export interface Applied { + written: string[]; + created: string[]; + committed: boolean; +} + +export class ApplyFailure extends Error { + constructor( + readonly created: string[], + readonly committed: boolean, + readonly cause: unknown, + ) { + super(cause instanceof Error ? cause.message : String(cause)); + } +} + +// appends every tag it creates to `created`, so a failure partway +// leaves the caller with the exact list to undo +function commitAndTag( + root: string, + files: string[], + plan: Move[], + created: string[], +): void { + if (files.length > 0) { + git.run(root, ["add", "--", ...files]); + git.run(root, ["commit", "--no-verify", "--quiet", "-m", "Publish"]); + } else { + // a plan of first releases only: each manifest already carries the + // version it is tagged at, so there is nothing to commit + console.log("no manifest changed; tagging HEAD"); + } + for (const move of plan) { + const tag = releaseTag(move.name, move.to); + git.run(root, ["tag", "-a", tag, "-m", tag]); + created.push(tag); + } +} + +// what a failed bump left behind, and how to remove it +export function undoAdvice(created: string[], committed: boolean): string { + const tags = created.length > 0 ? `git tag -d ${created.join(" ")}` : ""; + if (committed) { + const prefix = tags.length > 0 ? `${tags} && ` : ""; + return `undo: ${prefix}git reset --hard HEAD~1`; + } + if (tags.length > 0) { + return `undo: ${tags}`; + } + return "undo: git checkout HEAD -- packages/*/package.json schemas/"; +} + +export function applyRelease(root: string, plan: Plan): Applied { + // HEAD before the writes: the only reliable sign of whether this run + // committed, since HEAD already is a Publish commit after a release + const headBefore = git.run(root, ["rev-parse", "HEAD"]); + const created: string[] = []; + const written: string[] = []; + try { + for (const { path, text } of [...plan.manifests, ...plan.schemas]) { + writeFileSync(join(root, path), text); + written.push(path); + } + commitAndTag(root, written, plan.moves, created); + } catch (error) { + const committed = git.run(root, ["rev-parse", "HEAD"]) !== headBefore; + throw new ApplyFailure(created, committed, error); + } + return { written, created, committed: written.length > 0 }; +} diff --git a/bin/release/changelog.test.ts b/bin/release/changelog.test.ts new file mode 100644 index 000000000..9b5d3da5a --- /dev/null +++ b/bin/release/changelog.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { + changelogProblems, + formatProblemsMessage, + hasReleaseSection, + hasUnreleasedEntries, + impactLineProblems, + sectionProblems, +} from "./changelog.js"; + +const cut = [ + "# Changelog", + "", + "## Unreleased", + "", + "## 0.1.0-draft.0 — 2026-09-18", + "", + "### Changed", + "", + "- Something changed ([#310]).", + "", + "## 0.1.0-2 — 2026-09-17", + "", + "No changes to the specification.", + "", + "[#310]: https://github.com/ethdebug/format/pull/310", +].join("\n"); + +describe("hasReleaseSection", () => { + it("finds a dated section that has an entry", () => { + expect(hasReleaseSection(cut, "0.1.0-draft.0")).toBe(true); + }); + + it("accepts a section that holds one sentence", () => { + expect(hasReleaseSection(cut, "0.1.0-2")).toBe(true); + }); + + it("does not match a longer version with the same prefix", () => { + expect(hasReleaseSection(cut, "0.1.0")).toBe(false); + expect(hasReleaseSection(cut, "0.1.0-draft.0.1")).toBe(false); + }); + + it( + "is false for a section with only sub-headings or link " + "definitions", + () => { + expect( + hasReleaseSection("## 0.1.0\n\n### Changed\n\n## 0.0.1\n", "0.1.0"), + ).toBe(false); + expect( + hasReleaseSection("## 0.1.0\n\n[#1]: https://example.com\n", "0.1.0"), + ).toBe(false); + }, + ); + + it("reads CRLF line endings", () => { + const text = + "## Unreleased\r\n\r\n- Left.\r\n\r\n## 0.1.0\r\n\r\n- Entry.\r\n"; + expect(hasUnreleasedEntries(text)).toBe(true); + expect(hasReleaseSection(text, "0.1.0")).toBe(true); + }); +}); + +describe("changelogProblems", () => { + it("is empty for a changelog that was cut", () => { + expect( + changelogProblems([ + { path: "CHANGELOG.md", version: "0.1.0-draft.0", text: cut }, + ]), + ).toEqual([]); + }); + + it("reports a missing section, leftovers, and a missing file", () => { + const leftover = "## Unreleased\n\n- Left behind.\n"; + expect( + changelogProblems([ + { path: "a/CHANGELOG.md", version: "0.1.0-preview.1", text: leftover }, + { path: "b/CHANGELOG.md", version: "0.1.0-preview.1", text: undefined }, + ]), + ).toEqual([ + 'a/CHANGELOG.md: no "## 0.1.0-preview.1" section with an entry', + 'a/CHANGELOG.md: entries remain under "## Unreleased"', + "b/CHANGELOG.md: file is missing", + ]); + }); +}); + +describe("impactLineProblems", () => { + const entry = (producers: string, consumers: string): string => + [ + "## Unreleased", + "", + "### Changed", + "", + "- A summary of the change ([#1]).", + " - Schemas: **ethdebug/format/pointer**", + ` - Producers: ${producers}`, + ` - Consumers: ${consumers}`, + "", + ].join("\n"); + + it("accepts the no change needed. prefix", () => { + expect( + impactLineProblems( + entry("no change needed.", "no change needed. One short reason."), + ), + ).toEqual([]); + }); + + it("accepts the optional: prefix", () => { + expect( + impactLineProblems( + entry("optional: a producer may emit it.", "optional: may read it."), + ), + ).toEqual([]); + }); + + it("accepts the required: prefix", () => { + expect( + impactLineProblems( + entry("required: `minimum` forbids it.", "required: must read it."), + ), + ).toEqual([]); + }); + + it("accepts a prefix on the line below a bare label", () => { + const text = [ + "- A summary.", + " - Producers:", + " optional: a producer may emit it.", + "", + ].join("\n"); + expect(impactLineProblems(text)).toEqual([]); + }); + + it("reports a bare imperative with its line number", () => { + const problems = impactLineProblems( + entry("emit the new field.", "no change needed."), + ); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain("line 7:"); + expect(problems[0]).toContain('"Producers:"'); + expect(problems[0]).toContain('"no change needed."'); + expect(problems[0]).toContain('"optional:"'); + expect(problems[0]).toContain('"required:"'); + }); + + it("reports each offending sub-item", () => { + const problems = impactLineProblems( + entry("emit the new field.", "Required: read the new field."), + ); + expect(problems).toHaveLength(2); + expect(problems[0]).toContain("line 7:"); + expect(problems[1]).toContain("line 8:"); + expect(problems[1]).toContain('"Consumers:"'); + }); + + it("reports a prefix that runs into the text after it", () => { + expect( + impactLineProblems(entry("optional:emit it.", "no change needed.")), + ).toHaveLength(1); + }); + + it("reports a bare label with no prefix below it", () => { + const text = ["- A summary.", " - Consumers:", " read it.", ""].join( + "\n", + ); + expect(impactLineProblems(text)).toEqual([ + expect.stringContaining("line 2:"), + ]); + }); + + it("ignores the intro bullets that describe the sub-items", () => { + const text = [ + "# Changelog", + "", + "- `Schemas:` the schema(s) the change touches.", + "- `Producers:` what the change means for an emitter of data (a", + " compiler such as solc or bugc).", + "- `Consumers:` what the change means for a reader.", + "", + "Each `Producers:` and `Consumers:` sub-item starts with a prefix:", + "", + "- `no change needed.` Nothing changes.", + "", + ].join("\n"); + expect(impactLineProblems(text)).toEqual([]); + }); + + it("accepts a file with no entries", () => { + expect(impactLineProblems("# Changelog\n\n## Unreleased\n")).toEqual([]); + expect(impactLineProblems("")).toEqual([]); + }); +}); + +describe("formatProblemsMessage", () => { + it("is empty when there are no problems", () => { + expect(formatProblemsMessage([])).toBe(""); + }); + + it("names the file and lists each problem", () => { + const message = formatProblemsMessage(["line 7: first", "line 9: second"]); + expect(message).toContain("CHANGELOG.md"); + expect(message).toContain(" line 7: first"); + expect(message).toContain(" line 9: second"); + }); +}); + +describe("sectionProblems", () => { + it("accepts Added and Changed sections", () => { + const text = "## Unreleased\n\n### Added\n\n### Changed\n"; + expect(sectionProblems(text)).toEqual([]); + }); + + it("rejects any other section with its line number", () => { + const text = "## Unreleased\n\n### Breaking\n"; + const problems = sectionProblems(text); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain("line 3"); + expect(problems[0]).toContain('"### Added"'); + expect(problems[0]).toContain('"### Changed"'); + }); + + it("ignores version headings and deeper headings", () => { + expect(sectionProblems("# Changelog\n\n## 0.1.0-1\n")).toEqual([]); + expect(sectionProblems("#### Breaking\n")).toEqual([]); + }); +}); diff --git a/bin/release/changelog.ts b/bin/release/changelog.ts new file mode 100644 index 000000000..b1b5f1458 --- /dev/null +++ b/bin/release/changelog.ts @@ -0,0 +1,107 @@ +// true when the changelog has a section for the version with at least +// one entry or sentence in it +export function hasReleaseSection(text: string, version: string): boolean { + const lines = text.split(/\r?\n/); + const start = lines.findIndex( + (line) => line === `## ${version}` || line.startsWith(`## ${version} `), + ); + if (start === -1) { + return false; + } + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => line.startsWith("## ")); + const body = end === -1 ? rest : rest.slice(0, end); + return body.some( + (line) => + line.trim().length > 0 && + !line.startsWith("#") && + !/^\[[^\]]+\]: /.test(line), + ); +} + +export function hasUnreleasedEntries(text: string): boolean { + return hasReleaseSection(text, "Unreleased"); +} + +export interface ChangelogFile { + path: string; + version: string; + text: string | undefined; +} + +export function changelogProblems(files: ChangelogFile[]): string[] { + return files.flatMap(({ path, version, text }) => { + if (text === undefined) { + return [`${path}: file is missing`]; + } + return [ + ...(hasReleaseSection(text, version) + ? [] + : [`${path}: no "## ${version}" section with an entry`]), + ...(hasUnreleasedEntries(text) + ? [`${path}: entries remain under "## Unreleased"`] + : []), + ]; + }); +} + +const impactPrefixes = ["no change needed.", "optional:", "required:"]; + +// a real sub-item is indented two spaces and has a bare label; the intro +// bullets that describe the sub-items start at column 0 with a code span +const impactLabel = /^ {2}- (Producers|Consumers):(.*)$/; + +function startsWithImpactPrefix(text: string): boolean { + return impactPrefixes.some( + (prefix) => + text.startsWith(prefix) && + (text.length === prefix.length || /\s/.test(text[prefix.length])), + ); +} + +export function impactLineProblems(text: string): string[] { + const lines = text.split("\n"); + const allowed = impactPrefixes.map((prefix) => `"${prefix}"`).join(", "); + return lines.flatMap((line, index) => { + const match = impactLabel.exec(line); + if (!match) { + return []; + } + const [, label, rest] = match; + // the text starts on the label line after one space, or, when the + // label stands alone, on the continuation line below it + const conforms = + rest.trim().length > 0 + ? rest.startsWith(" ") && startsWithImpactPrefix(rest.slice(1)) + : startsWithImpactPrefix((lines[index + 1] ?? "").trimStart()); + return conforms + ? [] + : [`line ${index + 1}: "${label}:" must start with one of: ${allowed}`]; + }); +} + +const sectionNames = ["Added", "Changed"]; + +// the prefixes carry the obligations, so a section only says whether a +// change adds something new or alters something that exists +export function sectionProblems(text: string): string[] { + const allowed = sectionNames.map((name) => `"### ${name}"`).join(", "); + return text.split("\n").flatMap((line, index) => { + if (!line.startsWith("### ")) { + return []; + } + return sectionNames.includes(line.slice(4).trim()) + ? [] + : [`line ${index + 1}: section heading must be one of: ${allowed}`]; + }); +} + +export function formatProblemsMessage(problems: string[]): string { + if (problems.length === 0) { + return ""; + } + return [ + "CHANGELOG.md does not follow the entry format:", + ...problems.map((problem) => ` ${problem}`), + ].join("\n"); +} diff --git a/bin/release/git.ts b/bin/release/git.ts new file mode 100644 index 000000000..8aa43975c --- /dev/null +++ b/bin/release/git.ts @@ -0,0 +1,18 @@ +import { execFileSync, spawnSync } from "node:child_process"; + +export function run(root: string, args: string[]): string { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + +// for a command whose exit status is the answer, not a failure +export function status(root: string, args: string[]): number { + return spawnSync("git", args, { cwd: root, stdio: "pipe" }).status ?? 128; +} + +export function tryRun( + root: string, + args: string[], +): { ok: boolean; stdout: string } { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); + return { ok: result.status === 0, stdout: (result.stdout ?? "").trim() }; +} diff --git a/bin/release/lerna.test.ts b/bin/release/lerna.test.ts new file mode 100644 index 000000000..4bd216d8f --- /dev/null +++ b/bin/release/lerna.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { parseChanged } from "./lerna.js"; + +describe("parseChanged", () => { + it("reads names from the JSON that follows any log noise", () => { + const stdout = 'lerna notice\n[\n { "name": "@ethdebug/evm" }\n]\n'; + expect(parseChanged(stdout, "", 0)).toEqual(["@ethdebug/evm"]); + }); + + it("is empty when Lerna says nothing changed", () => { + expect(parseChanged("", "lerna info No changed packages found", 1)).toEqual( + [], + ); + }); + + it("throws for any other failure", () => { + expect(() => parseChanged("", "lerna ERR! boom", 1)).toThrow(/boom/); + expect(() => parseChanged("", "", null)).toThrow(/lerna changed failed/); + }); +}); diff --git a/bin/release/lerna.ts b/bin/release/lerna.ts new file mode 100644 index 000000000..5ab0add8f --- /dev/null +++ b/bin/release/lerna.ts @@ -0,0 +1,36 @@ +import { spawnSync } from "node:child_process"; + +import { ignoredChanges } from "./policy.js"; + +// `lerna changed` exits non-zero both when nothing changed and when it +// fails; only the first is an empty list +export function parseChanged( + stdout: string, + stderr: string, + status: number | null, +): string[] { + if (status !== 0) { + if (/No changed packages/i.test(stderr)) { + return []; + } + throw new Error(`lerna changed failed:\n${stderr.trim()}`); + } + const start = stdout.indexOf("["); + if (start === -1) { + return []; + } + const listed = JSON.parse(stdout.slice(start)) as { name: string }[]; + return listed.map(({ name }) => name); +} + +export function lernaChanged(root: string, forced: string[]): string[] { + const args = ["-s", "lerna", "changed", "--all", "--json"]; + for (const glob of ignoredChanges) { + args.push("--ignore-changes", glob); + } + if (forced.length > 0) { + args.push(`--force-publish=${forced.join(",")}`); + } + const result = spawnSync("yarn", args, { cwd: root, encoding: "utf8" }); + return parseChanged(result.stdout ?? "", result.stderr ?? "", result.status); +} diff --git a/bin/packlist.test.ts b/bin/release/packlist.test.ts similarity index 100% rename from bin/packlist.test.ts rename to bin/release/packlist.test.ts diff --git a/bin/packlist.ts b/bin/release/packlist.ts similarity index 100% rename from bin/packlist.ts rename to bin/release/packlist.ts diff --git a/bin/release/plan.test.ts b/bin/release/plan.test.ts new file mode 100644 index 000000000..7146c2778 --- /dev/null +++ b/bin/release/plan.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, it } from "vitest"; + +import { + forcedNames, + keywordProblems, + type Move, + nextVersion, + planMoves, + planProblems, + requiredChangelogs, +} from "./plan.js"; +import { identifierFor } from "./policy.js"; +import { rewriteManifest, type Workspace } from "./workspaces.js"; + +function manifest( + name: string, + version: string, + dependencies: string[] = [], + isPrivate = false, +): Workspace { + return { + name, + version, + dir: `/repo/packages/${name.replace("@ethdebug/", "")}`, + private: isPrivate, + text: "{}", + json: { name, version }, + runtime: dependencies, + peer: [], + dev: [], + optional: [], + dependencies, + all: dependencies, + }; +} + +describe("identifierFor", () => { + it("gives draft to the spec package and preview to the rest", () => { + expect(identifierFor("@ethdebug/format")).toBe("draft"); + expect(identifierFor("@ethdebug/bugc")).toBe("preview"); + expect(identifierFor("@ethdebug/format-web")).toBe("preview"); + }); +}); + +describe("keywordProblems", () => { + const stable = [ + manifest("@ethdebug/format", "0.1.0"), + manifest("@ethdebug/bugc", "0.1.3"), + ]; + const drafts = [ + manifest("@ethdebug/format", "0.1.0-draft.7"), + manifest("@ethdebug/bugc", "0.1.0-preview.2"), + ]; + + it("accepts prerelease and patch without flags in any state", () => { + expect(keywordProblems("prerelease", false, drafts)).toEqual([]); + expect(keywordProblems("patch", false, drafts)).toEqual([]); + expect(keywordProblems("prerelease", false, stable)).toEqual([]); + }); + + it("requires --all for a series start", () => { + for (const keyword of ["preminor", "premajor", "minor", "major"]) { + expect(keywordProblems(keyword, false, stable)).toEqual([ + `${keyword} starts a series for every workspace: pass --all`, + ]); + expect(keywordProblems(keyword, true, stable)).toEqual([]); + } + }); + + it("rejects a series start while a prerelease exists", () => { + const mixed = [ + manifest("@ethdebug/format", "0.1.0"), + manifest("@ethdebug/format-web", "0.1.1-preview.0", [], true), + ]; + expect(keywordProblems("preminor", true, mixed)).toEqual([ + "cannot start a series while @ethdebug/format-web is a " + + "prerelease; run `patch` first", + ]); + }); + + it("allows a series start when all are prereleases of one version", () => { + expect(keywordProblems("preminor", true, drafts)).toEqual([]); + expect(keywordProblems("premajor", true, drafts)).toEqual([]); + const split = [ + manifest("@ethdebug/format", "0.1.0-draft.7"), + manifest("@ethdebug/bugc", "0.1.1-preview.0"), + ]; + expect(keywordProblems("preminor", true, split)).toHaveLength(1); + }); + + // minor/major on a prerelease graduate in place, so the exception + // for one whole draft series must not admit them + it("rejects minor and major while any prerelease exists", () => { + const rejected = [ + "cannot start a series while @ethdebug/format, @ethdebug/bugc " + + "are prereleases; run `patch` first", + ]; + expect(keywordProblems("minor", true, drafts)).toEqual(rejected); + expect(keywordProblems("major", true, drafts)).toEqual(rejected); + }); +}); + +describe("nextVersion", () => { + it("switches a numeric prerelease to the named identifier", () => { + expect(nextVersion("0.1.0-2", "prerelease", "@ethdebug/format", true)).toBe( + "0.1.0-draft.0", + ); + expect(nextVersion("0.1.0-2", "prerelease", "@ethdebug/bugc", true)).toBe( + "0.1.0-preview.0", + ); + }); + + it("counts up, graduates, and starts series", () => { + const n = (v: string, k: string) => + nextVersion(v, k, "@ethdebug/bugc", true); + expect(n("0.1.0-preview.9", "prerelease")).toBe("0.1.0-preview.10"); + expect(n("0.1.0", "prerelease")).toBe("0.1.1-preview.0"); + expect(n("0.1.0-preview.4", "patch")).toBe("0.1.0"); + expect(n("0.1.0", "patch")).toBe("0.1.1"); + expect(n("0.1.3", "preminor")).toBe("0.2.0-preview.0"); + expect(n("0.1.3", "premajor")).toBe("1.0.0-preview.0"); + expect(n("0.1.7", "minor")).toBe("0.2.0"); + expect(n("0.1.7", "major")).toBe("1.0.0"); + }); + + it( + "keeps the manifest version for a workspace that was never " + "released", + () => { + expect( + nextVersion("0.1.0-preview.0", "prerelease", "@ethdebug/codec", false), + ).toBe("0.1.0-preview.0"); + }, + ); + + it("throws when semver cannot increment", () => { + expect(() => + nextVersion("banana", "patch", "@ethdebug/bugc", true), + ).toThrow(/banana/); + }); +}); + +describe("forcedNames", () => { + const ms = [ + manifest("@ethdebug/format", "0.1.0-draft.1"), + manifest("@ethdebug/bugc", "0.1.0"), + manifest("@ethdebug/evm", "0.1.1-preview.0"), + ]; + + it("forces the spec package when schemas changed", () => { + expect(forcedNames(ms, "prerelease", true)).toEqual(["@ethdebug/format"]); + expect(forcedNames(ms, "prerelease", false)).toEqual([]); + }); + + it("forces every prerelease workspace under patch", () => { + expect(forcedNames(ms, "patch", false)).toEqual([ + "@ethdebug/format", + "@ethdebug/evm", + ]); + }); +}); + +describe("planMoves", () => { + const ms = [ + manifest("@ethdebug/format", "0.1.0-draft.1"), + manifest("@ethdebug/pointers", "0.1.0-preview.3", ["@ethdebug/format"]), + manifest("@ethdebug/bugc", "0.1.0-preview.5", ["@ethdebug/pointers"]), + manifest( + "@ethdebug/format-web", + "0.1.0-preview.2", + ["@ethdebug/bugc"], + true, + ), + ]; + const released = ms.map((m) => m.name); + + it("labels direct changes, dependents, and schema-driven moves", () => { + const plan = planMoves({ + manifests: ms, + listed: [ + "@ethdebug/format", + "@ethdebug/pointers", + "@ethdebug/bugc", + "@ethdebug/format-web", + ], + directlyChanged: ["@ethdebug/bugc"], + released, + schemasChanged: true, + keyword: "prerelease", + all: false, + }); + expect(plan).toEqual([ + { + name: "@ethdebug/format", + from: "0.1.0-draft.1", + to: "0.1.0-draft.2", + reason: "schemas", + firstRelease: false, + }, + { + name: "@ethdebug/pointers", + from: "0.1.0-preview.3", + to: "0.1.0-preview.4", + reason: "dependent", + firstRelease: false, + }, + { + name: "@ethdebug/bugc", + from: "0.1.0-preview.5", + to: "0.1.0-preview.6", + reason: "changed", + firstRelease: false, + }, + { + name: "@ethdebug/format-web", + from: "0.1.0-preview.2", + to: "0.1.0-preview.3", + reason: "dependent", + firstRelease: false, + }, + ]); + }); + + it("moves only listed workspaces without --all", () => { + const plan = planMoves({ + manifests: ms, + listed: ["@ethdebug/bugc", "@ethdebug/format-web"], + directlyChanged: ["@ethdebug/bugc"], + released, + schemasChanged: false, + keyword: "prerelease", + all: false, + }); + expect(plan.map((m) => m.name)).toEqual([ + "@ethdebug/bugc", + "@ethdebug/format-web", + ]); + }); + + it("labels graduations and --all moves", () => { + const plan = planMoves({ + manifests: ms, + listed: [ + "@ethdebug/format", + "@ethdebug/pointers", + "@ethdebug/bugc", + "@ethdebug/format-web", + ], + directlyChanged: [], + released, + schemasChanged: false, + keyword: "patch", + all: false, + }); + expect(plan.map((m) => [m.to, m.reason])).toEqual([ + ["0.1.0", "graduates"], + ["0.1.0", "graduates"], + ["0.1.0", "graduates"], + ["0.1.0", "graduates"], + ]); + const all = planMoves({ + manifests: ms, + listed: [], + directlyChanged: [], + released, + schemasChanged: false, + keyword: "prerelease", + all: true, + }); + expect(all.every((m) => m.reason === "all")).toBe(true); + }); + + it("keeps the manifest version of a never-released workspace", () => { + const withNew = [ + ...ms, + manifest("@ethdebug/codec", "0.1.0-preview.0", ["@ethdebug/format"]), + ]; + const plan = planMoves({ + manifests: withNew, + listed: ["@ethdebug/codec"], + directlyChanged: ["@ethdebug/codec"], + released, + schemasChanged: false, + keyword: "prerelease", + all: false, + }); + expect(plan).toEqual([ + { + name: "@ethdebug/codec", + from: "0.1.0-preview.0", + to: "0.1.0-preview.0", + reason: "changed", + firstRelease: true, + }, + ]); + }); +}); + +describe("planProblems", () => { + const ms = [ + manifest("@ethdebug/format", "0.1.0-draft.1"), + manifest("@ethdebug/bugc", "0.1.0", ["@ethdebug/format"]), + ]; + const move = ( + name: string, + from: string, + to: string, + reason: Move["reason"] = "changed", + ): Move => ({ name, from, to, reason, firstRelease: false }); + + it("is empty for a sane plan", () => { + expect( + planProblems( + [move("@ethdebug/format", "0.1.0-draft.1", "0.1.0-draft.2")], + ms, + "prerelease", + ), + ).toEqual([]); + }); + + it("rejects a version that does not move forward", () => { + expect( + planProblems( + [move("@ethdebug/bugc", "0.1.0-preview.3", "0.1.0-draft.0")], + ms, + "prerelease", + ), + ).toEqual([ + "@ethdebug/bugc: 0.1.0-draft.0 does not sort after 0.1.0-preview.3", + ]); + }); + + it("rejects a stable workspace that depends on a prerelease", () => { + expect( + planProblems( + [move("@ethdebug/bugc", "0.1.0-preview.3", "0.1.0", "graduates")], + ms, + "patch", + ), + ).toEqual([ + "@ethdebug/bugc: stable 0.1.0 would depend on " + + "@ethdebug/format 0.1.0-draft.1", + ]); + }); + + it("requires equal versions for a series start", () => { + const plan = [ + move("@ethdebug/format", "0.1.0", "0.2.0-draft.0", "all"), + move("@ethdebug/bugc", "0.1.7", "0.2.0-preview.0", "all"), + ]; + expect(planProblems(plan, ms, "preminor")).toEqual([]); + const split = [ + move("@ethdebug/format", "0.1.0", "0.2.0-draft.0", "all"), + move("@ethdebug/bugc", "0.2.0", "0.3.0-preview.0", "all"), + ]; + expect(planProblems(split, ms, "preminor")).toEqual([ + "a series start must give every workspace the same " + + "major.minor.patch; got 0.2.0, 0.3.0", + ]); + }); +}); + +describe("requiredChangelogs", () => { + const ms = [ + manifest("@ethdebug/format", "0.1.0-draft.1"), + manifest("@ethdebug/evm", "0.1.0-preview.3"), + manifest("@ethdebug/format-web", "0.1.0-preview.2", [], true), + ]; + + it("lists the root file when the spec moves and each public package", () => { + const plan: Move[] = [ + { + name: "@ethdebug/format", + from: "0.1.0-draft.1", + to: "0.1.0-draft.2", + reason: "schemas", + firstRelease: false, + }, + { + name: "@ethdebug/evm", + from: "0.1.0-preview.3", + to: "0.1.0-preview.4", + reason: "dependent", + firstRelease: false, + }, + { + name: "@ethdebug/format-web", + from: "0.1.0-preview.2", + to: "0.1.0-preview.3", + reason: "dependent", + firstRelease: false, + }, + ]; + expect(requiredChangelogs(plan, ms, "/repo")).toEqual([ + { path: "CHANGELOG.md", version: "0.1.0-draft.2" }, + { path: "packages/format/CHANGELOG.md", version: "0.1.0-draft.2" }, + { path: "packages/evm/CHANGELOG.md", version: "0.1.0-preview.4" }, + ]); + }); + + it("omits the root file when the spec does not move", () => { + const plan: Move[] = [ + { + name: "@ethdebug/evm", + from: "0.1.0-preview.3", + to: "0.1.0-preview.4", + reason: "changed", + firstRelease: false, + }, + ]; + expect(requiredChangelogs(plan, ms, "/repo")).toEqual([ + { path: "packages/evm/CHANGELOG.md", version: "0.1.0-preview.4" }, + ]); + }); +}); + +describe("rewriteManifest", () => { + const text = + JSON.stringify( + { + name: "@ethdebug/bugc", + version: "0.1.0-preview.5", + dependencies: { "@ethdebug/evm": "^0.1.0-preview.3", lodash: "^4.0.0" }, + devDependencies: { "@ethdebug/format": "^0.1.0-draft.1" }, + peerDependencies: { "@ethdebug/pointers": "^0.1.0-preview.3" }, + }, + null, + 2, + ) + "\n"; + const versions = new Map([ + ["@ethdebug/bugc", "0.1.0-preview.6"], + ["@ethdebug/format", "0.1.0-draft.2"], + ["@ethdebug/pointers", "0.1.0-preview.4"], + ]); + + it("rewrites the version and every internal range, keeping the rest", () => { + const out = JSON.parse(rewriteManifest(text, versions)); + expect(out.version).toBe("0.1.0-preview.6"); + expect(out.dependencies["@ethdebug/evm"]).toBe("^0.1.0-preview.3"); + expect(out.dependencies.lodash).toBe("^4.0.0"); + expect(out.devDependencies["@ethdebug/format"]).toBe("^0.1.0-draft.2"); + expect(out.peerDependencies["@ethdebug/pointers"]).toBe("^0.1.0-preview.4"); + }); + + it( + "preserves key order, two-space indentation and the trailing " + "newline", + () => { + const out = rewriteManifest(text, versions); + expect(out.endsWith("}\n")).toBe(true); + expect(out.indexOf('"name"')).toBeLessThan(out.indexOf('"version"')); + expect(out.split("\n")[1]).toBe(' "name": "@ethdebug/bugc",'); + }, + ); + + it( + "leaves a manifest of a workspace that does not move untouched " + + "except ranges", + () => { + const out = JSON.parse( + rewriteManifest(text, new Map([["@ethdebug/format", "0.1.0-draft.2"]])), + ); + expect(out.version).toBe("0.1.0-preview.5"); + expect(out.devDependencies["@ethdebug/format"]).toBe("^0.1.0-draft.2"); + }, + ); +}); + +// the premise of the first-release-only branch of commitAndTag: such a +// plan changes no manifest text, so there is nothing to commit +describe("a plan of first releases only", () => { + it("rewrites no manifest, because each version already matches", () => { + const text = + JSON.stringify( + { + name: "@ethdebug/newcomer", + version: "0.1.0", + dependencies: { "@ethdebug/other": "^0.1.0" }, + }, + null, + 2, + ) + "\n"; + const plan: Move[] = [ + { + name: "@ethdebug/newcomer", + from: "0.1.0", + to: "0.1.0", + reason: "changed", + firstRelease: true, + }, + { + name: "@ethdebug/other", + from: "0.1.0", + to: "0.1.0", + reason: "changed", + firstRelease: true, + }, + ]; + const versions = new Map(plan.map((move) => [move.name, move.to])); + expect(rewriteManifest(text, versions)).toBe(text); + }); +}); diff --git a/bin/release/plan.ts b/bin/release/plan.ts new file mode 100644 index 000000000..b830b7d32 --- /dev/null +++ b/bin/release/plan.ts @@ -0,0 +1,213 @@ +import { join, relative } from "node:path"; + +import semver from "semver"; + +import { + identifierFor, + isPrerelease, + seriesStartKeywords, + specPackage, + tuple, +} from "./policy.js"; +import type { Workspace } from "./workspaces.js"; + +// the guards of the series-start keywords +export function keywordProblems( + keyword: string, + all: boolean, + manifests: Workspace[], +): string[] { + if (!(seriesStartKeywords as string[]).includes(keyword)) { + return []; + } + const problems: string[] = []; + if (!all) { + problems.push(`${keyword} starts a series for every workspace: pass --all`); + } + const prereleases = manifests.filter((m) => isPrerelease(m.version)); + const tuples = new Set(manifests.map((m) => tuple(m.version))); + // a whole-repository draft series can be abandoned for the next draft + // series without a stable release, so `preminor` / `premajor` may run + // while every workspace is a prerelease of one and the same X.Y.Z. + // `minor` / `major` must NOT take that exception: on a prerelease + // they graduate in place (0.1.0-draft.3 + minor -> 0.1.0), which is + // an unannounced stable release, not a series start. + const graduatesInPlace = keyword === "minor" || keyword === "major"; + const wholeSeries = + !graduatesInPlace && + prereleases.length === manifests.length && + tuples.size === 1; + if (prereleases.length > 0 && !wholeSeries) { + const names = prereleases.map((m) => m.name).join(", "); + problems.push( + `cannot start a series while ${names} ${ + prereleases.length === 1 ? "is a prerelease" : "are prereleases" + }; run \`patch\` first`, + ); + } + return problems; +} + +// a workspace that was never released keeps the version its manifest +// carries: that is its first version, and it is tagged at it +export function nextVersion( + current: string, + keyword: string, + name: string, + released: boolean, +): string { + if (!released) { + return current; + } + const next = semver.inc( + current, + keyword as semver.ReleaseType, + identifierFor(name), + ); + if (next === null) { + throw new Error(`cannot apply ${keyword} to ${name}@${current}`); + } + return next; +} + +export type Reason = "changed" | "schemas" | "graduates" | "dependent" | "all"; + +export interface Move { + name: string; + from: string; + to: string; + reason: Reason; + firstRelease: boolean; +} + +// names to pass to `lerna changed --force-publish`; Lerna then adds +// their transitive dependents +export function forcedNames( + manifests: Workspace[], + keyword: string, + schemasChanged: boolean, +): string[] { + const forced = schemasChanged ? [specPackage] : []; + if (keyword === "patch") { + for (const manifest of manifests) { + if (isPrerelease(manifest.version) && !forced.includes(manifest.name)) { + forced.push(manifest.name); + } + } + } + return forced; +} + +export interface PlanInput { + manifests: Workspace[]; + // names from `lerna changed`, forced names and dependents included + listed: string[]; + // names whose own directory differs from their tag, ignores applied + directlyChanged: string[]; + // names that have at least one release tag + released: string[]; + schemasChanged: boolean; + keyword: string; + all: boolean; +} + +export function planMoves(input: PlanInput): Move[] { + const { manifests, listed, directlyChanged, released, keyword } = input; + return manifests + .filter((m) => input.all || listed.includes(m.name)) + .map((m) => { + const firstRelease = !released.includes(m.name); + let reason: Reason; + if (directlyChanged.includes(m.name) || firstRelease) { + reason = "changed"; + } else if (m.name === specPackage && input.schemasChanged) { + reason = "schemas"; + } else if (keyword === "patch" && isPrerelease(m.version)) { + reason = "graduates"; + } else if (listed.includes(m.name)) { + reason = "dependent"; + } else { + reason = "all"; + } + return { + name: m.name, + from: m.version, + to: nextVersion(m.version, keyword, m.name, !firstRelease), + reason, + firstRelease, + }; + }); +} + +export function planProblems( + plan: Move[], + manifests: Workspace[], + keyword: string, +): string[] { + const problems: string[] = []; + const after = new Map(manifests.map((m) => [m.name, m.version])); + for (const move of plan) { + after.set(move.name, move.to); + } + for (const move of plan) { + if (!move.firstRelease && !semver.gt(move.to, move.from)) { + problems.push( + `${move.name}: ${move.to} does not sort after ${move.from}`, + ); + } + if (!isPrerelease(move.to)) { + const manifest = manifests.find((m) => m.name === move.name); + for (const dep of manifest?.all ?? []) { + const version = after.get(dep); + if (version !== undefined && isPrerelease(version)) { + problems.push( + `${move.name}: stable ${move.to} would depend on ${dep} ${version}`, + ); + } + } + } + } + if ((seriesStartKeywords as string[]).includes(keyword)) { + // identifiers differ (draft / preview); the tuple must not + const tuples = [...new Set(plan.map((move) => tuple(move.to)))]; + if (tuples.length > 1) { + problems.push( + "a series start must give every workspace the same " + + `major.minor.patch; got ${tuples.join(", ")}`, + ); + } + } + return problems; +} + +// the changelogs the release must have cut, each with the version its +// heading must carry +export function requiredChangelogs( + plan: Move[], + manifests: Workspace[], + root: string, +): { path: string; version: string }[] { + const spec = plan.find((move) => move.name === specPackage); + const root_ = spec ? [{ path: "CHANGELOG.md", version: spec.to }] : []; + const packages = plan.flatMap((move) => { + const manifest = manifests.find((m) => m.name === move.name); + if (!manifest || manifest.private) { + return []; + } + return [ + { + path: join(relative(root, manifest.dir), "CHANGELOG.md"), + version: move.to, + }, + ]; + }); + return [...root_, ...packages]; +} + +export interface Plan { + moves: Move[]; + // rewritten package.json and schema yaml, computed purely, so + // --dry-run holds the exact bytes a real run would write + manifests: { path: string; text: string }[]; + schemas: { path: string; text: string }[]; +} diff --git a/bin/release/policy.test.ts b/bin/release/policy.test.ts new file mode 100644 index 000000000..fe371de04 --- /dev/null +++ b/bin/release/policy.test.ts @@ -0,0 +1,113 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + distTag, + isReleaseTag, + parseReleaseTag, + releaseTag, + releaseVersions, +} from "./policy.js"; + +describe("releaseTag and parseReleaseTag", () => { + it("round-trip a name and version", () => { + const tag = releaseTag("@ethdebug/format", "0.1.0-draft.0"); + expect(tag).toBe("@ethdebug/format@0.1.0-draft.0"); + expect(parseReleaseTag(tag)).toEqual({ + name: "@ethdebug/format", + version: "0.1.0-draft.0", + }); + }); + + it("rejects tags that are not @ethdebug release tags", () => { + for (const tag of ["v1", "@other/x@1.0.0", "@ethdebug/format", ""]) { + expect(parseReleaseTag(tag)).toBeUndefined(); + expect(isReleaseTag(tag)).toBe(false); + } + }); +}); + +describe("releaseVersions", () => { + it("returns the versions of one workspace only", () => { + expect( + releaseVersions( + [ + "@ethdebug/format@0.1.0-1", + "@ethdebug/format-web@0.1.0-1", + "@ethdebug/format@0.1.0-draft.0", + "v1", + ], + "@ethdebug/format", + ), + ).toEqual(["0.1.0-1", "0.1.0-draft.0"]); + }); + + it("drops versions that are not valid semver", () => { + expect( + releaseVersions( + ["@ethdebug/format@0.1.0", "@ethdebug/format@nightly"], + "@ethdebug/format", + ), + ).toEqual(["0.1.0"]); + }); +}); + +// Vacuous on a shallow clone with no tags, by design: CI fetches none. +// The fixed examples above carry the predicate there. +describe("the repository's real tags", () => { + const root = fileURLToPath(new URL("../..", import.meta.url)); + const list = (args: string[]): string[] => + execFileSync("git", ["tag", "--list", ...args], { + cwd: root, + encoding: "utf8", + }) + .split("\n") + .filter((tag) => tag.length > 0); + const all = list([]); + // the glob the scripts use today + const globbed = list(["@ethdebug/*@*"]); + + it("treat exactly the tags the release glob matches as release tags", () => { + expect(all.filter(isReleaseTag).sort()).toEqual([...globbed].sort()); + }); + + it("round-trip every release tag through releaseTag", () => { + for (const tag of all.filter(isReleaseTag)) { + const parsed = parseReleaseTag(tag)!; + expect(releaseTag(parsed.name, parsed.version), tag).toBe(tag); + } + }); +}); + +describe("distTag", () => { + it( + "publishes a prerelease under latest while no stable version " + "exists", + () => { + expect(distTag("0.1.0-draft.0", ["0.1.0-0", "0.1.0-1", "0.1.0-2"])).toBe( + "latest", + ); + expect(distTag("0.1.0-preview.0", [])).toBe("latest"); + }, + ); + + it("publishes a prerelease under its identifier once a stable exists", () => { + expect(distTag("0.2.0-draft.0", ["0.1.0"])).toBe("draft"); + expect(distTag("0.2.0-draft.0", ["0.1.0-draft.3", "0.1.0"])).toBe("draft"); + expect(distTag("0.2.0-preview.1", ["0.1.0"])).toBe("preview"); + }); + + it("publishes the highest stable version under latest", () => { + expect(distTag("0.1.0", ["0.1.0-draft.4"])).toBe("latest"); + expect(distTag("0.2.1", ["0.1.0", "0.2.0"])).toBe("latest"); + }); + + it("keeps latest from moving backwards on a back-port", () => { + expect(distTag("0.1.1", ["0.1.0", "0.2.0"])).toBe("release-0.1"); + }); + + it("rejects an identifier that is not a valid tag name", () => { + expect(() => distTag("0.1.0-3", ["0.1.0"])).toThrow(/dist-tag/); + }); +}); diff --git a/bin/release/policy.ts b/bin/release/policy.ts new file mode 100644 index 000000000..474df5cb0 --- /dev/null +++ b/bin/release/policy.ts @@ -0,0 +1,104 @@ +import semver from "semver"; + +// the schemas ship inside this package, so its version is the version +// of the specification +export const specPackage = "@ethdebug/format"; + +export const scope = "@ethdebug/"; + +export const keywords = [ + "prerelease", + "patch", + "preminor", + "premajor", + "minor", + "major", +] as const; +export type Keyword = (typeof keywords)[number]; + +// a series start moves every workspace to the same major.minor +export const seriesStartKeywords: Keyword[] = [ + "preminor", + "premajor", + "minor", + "major", +]; + +// files whose change does not call for a release; the same list goes +// to `lerna changed` and to the direct-change diff, so the two agree +export const ignoredChanges = [ + "**/CHANGELOG.md", + "**/*.test.ts", + "**/*.test.tsx", +]; + +export function identifierFor(name: string): "draft" | "preview" { + return name === specPackage ? "draft" : "preview"; +} + +export function isPrerelease(version: string): boolean { + return (semver.prerelease(version) ?? []).length > 0; +} + +export function tuple(version: string): string { + const parsed = semver.parse(version); + return parsed ? `${parsed.major}.${parsed.minor}.${parsed.patch}` : ""; +} + +// A stable version is `latest` when nothing stable is higher; a +// prerelease is `latest` only while the package has no stable version, +// and its identifier (`draft`, `preview`) after that. CI can set one +// tag per publish, so this is the only tag a version gets. +export function distTag(version: string, knownVersions: string[]): string { + const stable = knownVersions.filter( + (known) => + semver.valid(known) !== null && semver.prerelease(known) === null, + ); + const prerelease = semver.prerelease(version); + if (prerelease === null) { + const highest = [version, ...stable].sort(semver.rcompare)[0]; + return highest === version + ? "latest" + : `release-${semver.major(version)}.${semver.minor(version)}`; + } + if (stable.length === 0) { + return "latest"; + } + const identifier = prerelease[0]; + if ( + typeof identifier !== "string" || + semver.validRange(identifier) !== null + ) { + throw new Error( + `${version}: prerelease identifier "${identifier}" is not a ` + + "valid dist-tag", + ); + } + return identifier; +} + +export function releaseTag(name: string, version: string): string { + return `${name}@${version}`; +} + +export function parseReleaseTag( + tag: string, +): { name: string; version: string } | undefined { + const match = /^(@ethdebug\/[^@]+)@(.+)$/.exec(tag.trim()); + return match ? { name: match[1], version: match[2] } : undefined; +} + +export function isReleaseTag(tag: string): boolean { + return parseReleaseTag(tag) !== undefined; +} + +// the versions a workspace has been released at, from the full tag +// list; replaces one `git tag --list @*` call per workspace. +// Only valid semver versions count, as `localTagVersions` always did. +export function releaseVersions(tags: string[], name: string): string[] { + return tags + .map(parseReleaseTag) + .filter((parsed) => parsed?.name === name) + .map((parsed) => parsed!.version) + .filter((version) => semver.valid(version) !== null); +} diff --git a/bin/release/workspaces.test.ts b/bin/release/workspaces.test.ts new file mode 100644 index 000000000..cf03d23ba --- /dev/null +++ b/bin/release/workspaces.test.ts @@ -0,0 +1,171 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { readWorkspaces, topoSort, type Workspace } from "./workspaces.js"; + +const ws = (name: string, over: Partial = {}): Workspace => ({ + name, + version: "0.1.0-preview.0", + dir: `/repo/packages/${name.replace("@ethdebug/", "")}`, + private: false, + text: "{}", + json: {}, + runtime: [], + peer: [], + dev: [], + optional: [], + dependencies: [], + all: [], + ...over, +}); + +describe("readWorkspaces", () => { + let root: string | undefined; + + afterEach(() => { + if (root) { + rmSync(root, { recursive: true, force: true }); + root = undefined; + } + }); + + function fixture(manifests: Record): Workspace[] { + root = mkdtempSync(join(tmpdir(), "ws-")); + for (const [dir, manifest] of Object.entries(manifests)) { + mkdirSync(join(root, "packages", dir), { recursive: true }); + writeFileSync( + join(root, "packages", dir, "package.json"), + JSON.stringify(manifest), + ); + } + writeFileSync(join(root, "packages", ".DS_Store"), ""); + return readWorkspaces(root); + } + + it("derives `dependencies` as runtime + peer, first one winning", () => { + const [w] = fixture({ + a: { + name: "@ethdebug/a", + version: "1.0.0", + dependencies: { + "@ethdebug/c": "^1", + lodash: "^4", + "@ethdebug/b": "^1", + }, + peerDependencies: { "@ethdebug/b": "^1", "@ethdebug/d": "^1" }, + devDependencies: { "@ethdebug/e": "^1" }, + }, + }); + expect(w.runtime).toEqual(["@ethdebug/c", "@ethdebug/b"]); + expect(w.peer).toEqual(["@ethdebug/b", "@ethdebug/d"]); + expect(w.dev).toEqual(["@ethdebug/e"]); + expect(w.dependencies).toEqual([ + "@ethdebug/c", + "@ethdebug/b", + "@ethdebug/d", + ]); + }); + + it("derives `all` from the four kinds in order, deduplicated", () => { + const [w] = fixture({ + a: { + name: "@ethdebug/a", + version: "1.0.0", + // key order in the manifest must not matter; kind order does + optionalDependencies: { "@ethdebug/o": "^1", "@ethdebug/r": "^1" }, + peerDependencies: { "@ethdebug/p": "^1", "@ethdebug/d": "^1" }, + devDependencies: { "@ethdebug/d": "^1", "@ethdebug/v": "^1" }, + dependencies: { "@ethdebug/r": "^1", other: "^1" }, + }, + }); + expect(w.optional).toEqual(["@ethdebug/o", "@ethdebug/r"]); + expect(w.all).toEqual([ + "@ethdebug/r", + "@ethdebug/d", + "@ethdebug/v", + "@ethdebug/p", + "@ethdebug/o", + ]); + }); + + it("reads private, text, version and dir, and skips non-packages", () => { + const workspaces = fixture({ + a: { name: "@ethdebug/a", version: "1.0.0" }, + b: { name: "@ethdebug/b", version: "2.0.0", private: true }, + }); + expect(workspaces).toHaveLength(2); + const byName = new Map(workspaces.map((w) => [w.name, w])); + const a = byName.get("@ethdebug/a")!; + const b = byName.get("@ethdebug/b")!; + expect(a.private).toBe(false); + expect(b.private).toBe(true); + expect(b.version).toBe("2.0.0"); + expect(a.text).toBe( + JSON.stringify({ name: "@ethdebug/a", version: "1.0.0" }), + ); + expect(a.json.name).toBe("@ethdebug/a"); + expect(a.dir.endsWith(join("packages", "a"))).toBe(true); + expect(a.dependencies).toEqual([]); + expect(a.all).toEqual([]); + }); + + it("merges dependencies and peerDependencies, filtered", () => { + const workspaces = fixture({ + a: { name: "@ethdebug/a", version: "1.0.0" }, + b: { + name: "@ethdebug/b", + version: "1.0.0", + dependencies: { "@ethdebug/a": "^1.0.0", lodash: "^4" }, + peerDependencies: { "@ethdebug/c": "^1.0.0" }, + }, + c: { name: "@ethdebug/c", version: "1.0.0", private: true }, + }); + expect(workspaces).toHaveLength(3); + expect(workspaces.some((w) => w.dir.endsWith(".DS_Store"))).toBe(false); + + const byName = new Map(workspaces.map((w) => [w.name, w])); + expect(byName.get("@ethdebug/a")?.dependencies).toEqual([]); + expect(byName.get("@ethdebug/b")?.dependencies).toEqual([ + "@ethdebug/a", + "@ethdebug/c", + ]); + expect(byName.get("@ethdebug/c")?.private).toBe(true); + for (const name of ["a", "b", "c"]) { + const dir = byName.get(`@ethdebug/${name}`)?.dir ?? ""; + expect(dir.endsWith(join("packages", name))).toBe(true); + } + }); +}); + +describe("topoSort", () => { + it("puts a dependency before its dependent", () => { + const a = ws("@ethdebug/a", { dependencies: ["@ethdebug/b"] }); + const b = ws("@ethdebug/b"); + expect(topoSort([a, b]).map((w) => w.name)).toEqual([ + "@ethdebug/b", + "@ethdebug/a", + ]); + }); + + it("orders dependencies before dependents, including peers", () => { + const sorted = topoSort([ + ws("@ethdebug/evm", { dependencies: ["@ethdebug/pointers"] }), + ws("@ethdebug/pointers", { dependencies: ["@ethdebug/format"] }), + ws("@ethdebug/format"), + ]).map((w) => w.name); + expect(sorted).toEqual([ + "@ethdebug/format", + "@ethdebug/pointers", + "@ethdebug/evm", + ]); + }); + + it("throws on a cycle", () => { + const a = ws("@ethdebug/a", { dependencies: ["@ethdebug/b"] }); + const b = ws("@ethdebug/b", { dependencies: ["@ethdebug/a"] }); + expect(() => topoSort([a, b])).toThrow(/dependency cycle/); + }); +}); diff --git a/bin/release/workspaces.ts b/bin/release/workspaces.ts new file mode 100644 index 000000000..edbcb64a0 --- /dev/null +++ b/bin/release/workspaces.ts @@ -0,0 +1,120 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { scope } from "./policy.js"; + +export interface Workspace { + name: string; + version: string; + dir: string; + private: boolean; + // the raw manifest, because `decide` computes the rewritten text + text: string; + json: Record; + // @ethdebug/* names, per kind + runtime: string[]; + peer: string[]; + dev: string[]; + optional: string[]; + // runtime + peer, deduplicated: exactly what publish ordering and + // the tarball smoke test have always meant by "dependencies" + dependencies: string[]; + // all four kinds in manifest order, for the range rewriting and the + // stable-depends-on-prerelease check + all: string[]; +} + +const kinds = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +function names( + json: Record, + kind: (typeof kinds)[number], +): string[] { + const ranges = json[kind] as Record | undefined; + return Object.keys(ranges ?? {}).filter((dep) => dep.startsWith(scope)); +} + +export function readWorkspaces(root: string): Workspace[] { + const packagesDir = join(root, "packages"); + return readdirSync(packagesDir) + .filter((entry) => existsSync(join(packagesDir, entry, "package.json"))) + .map((entry) => { + const dir = join(packagesDir, entry); + const text = readFileSync(join(dir, "package.json"), "utf8"); + const json = JSON.parse(text) as Record; + const runtime = names(json, "dependencies"); + const dev = names(json, "devDependencies"); + const peer = names(json, "peerDependencies"); + const optional = names(json, "optionalDependencies"); + return { + name: json.name as string, + version: json.version as string, + dir, + private: json.private === true, + text, + json, + runtime, + peer, + dev, + optional, + dependencies: [...new Set([...runtime, ...peer])], + all: [...new Set([...runtime, ...dev, ...peer, ...optional])], + }; + }); +} + +// sets the version when the manifest's own workspace moves, and +// points every internal range at the new version of a moving workspace +export function rewriteManifest( + text: string, + versions: Map, +): string { + const json = JSON.parse(text) as Record; + const own = versions.get(json.name as string); + if (own !== undefined) { + json.version = own; + } + for (const kind of kinds) { + const ranges = json[kind] as Record | undefined; + if (!ranges) { + continue; + } + for (const [dep, version] of versions) { + if (dep in ranges) { + ranges[dep] = `^${version}`; + } + } + } + return `${JSON.stringify(json, null, 2)}\n`; +} + +export function topoSort(workspaces: Workspace[]): Workspace[] { + const byName = new Map(workspaces.map((w) => [w.name, w])); + const done = new Set(); + const sorted: Workspace[] = []; + const visit = (workspace: Workspace, trail: string[]) => { + if (done.has(workspace.name)) { + return; + } + if (trail.includes(workspace.name)) { + throw new Error(`dependency cycle: ${trail.join(" -> ")}`); + } + for (const dep of workspace.dependencies) { + const target = byName.get(dep); + if (target) { + visit(target, [...trail, workspace.name]); + } + } + done.add(workspace.name); + sorted.push(workspace); + }; + for (const workspace of workspaces) { + visit(workspace, []); + } + return sorted; +} diff --git a/bin/smoke-tarballs.test.ts b/bin/smoke-tarballs.test.ts index 41ae3b68b..217a6ccbf 100644 --- a/bin/smoke-tarballs.test.ts +++ b/bin/smoke-tarballs.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it } from "vitest"; import { siblingTarballs } from "./smoke-tarballs.js"; -import type { Workspace } from "./publish-tagged.js"; +import type { Workspace } from "./release/workspaces.js"; const ws = (dependencies: string[]): Workspace => ({ name: "@ethdebug/x", version: "1.0.0", dir: "/repo/packages/x", private: false, + text: "{}", + json: {}, + runtime: dependencies, + peer: [], + dev: [], + optional: [], dependencies, + all: dependencies, }); describe("siblingTarballs", () => { diff --git a/bin/smoke-tarballs.ts b/bin/smoke-tarballs.ts index 3a8f36f51..772748795 100644 --- a/bin/smoke-tarballs.ts +++ b/bin/smoke-tarballs.ts @@ -3,7 +3,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { readWorkspaces, topoSort, type Workspace } from "./publish-tagged.js"; +import { + readWorkspaces, + topoSort, + type Workspace, +} from "./release/workspaces.js"; export function siblingTarballs( workspace: Workspace, diff --git a/bin/version.test.ts b/bin/version.test.ts index 522405822..19429cf4c 100644 --- a/bin/version.test.ts +++ b/bin/version.test.ts @@ -1,39 +1,16 @@ import { describe, expect, it } from "vitest"; + +import type { Plan } from "./release/plan.js"; +import { versionSites } from "./release/schema-versions.js"; +import type { Workspace } from "./release/workspaces.js"; import { - changelogProblems, - forcedNames, - hasReleaseSection, - hasUnreleasedEntries, - identifierFor, - keywordProblems, - type Manifest, - type Move, - nextVersion, + check, + decide, + type Options, parseArgs, - parseChanged, - planMoves, - planProblems, - requiredChangelogs, - rewriteManifest, - undoAdvice, + type Survey, } from "./version.js"; -function manifest( - name: string, - version: string, - dependencies: string[] = [], - isPrivate = false, -): Manifest { - return { - name, - version, - dir: `/repo/packages/${name.replace("@ethdebug/", "")}`, - private: isPrivate, - dependencies, - json: { name, version }, - }; -} - describe("parseArgs", () => { it("defaults to prerelease", () => { expect(parseArgs([])).toEqual({ @@ -64,586 +41,281 @@ describe("parseArgs", () => { }); }); -describe("identifierFor", () => { - it("gives draft to the spec package and preview to the rest", () => { - expect(identifierFor("@ethdebug/format")).toBe("draft"); - expect(identifierFor("@ethdebug/bugc")).toBe("preview"); - expect(identifierFor("@ethdebug/format-web")).toBe("preview"); - }); +const repo: Survey["repo"] = { + branch: "main", + dirty: false, + tagsAtHead: [], + nearestAnnotated: { name: "@ethdebug/format@0.1.0-draft.0", commit: "a" }, + nearestRelease: { name: "@ethdebug/format@0.1.0-draft.0", commit: "a" }, +}; + +const surveyOf = (over: Partial = {}): Survey => ({ + root: "/repo", + workspaces: [], + tags: [], + directlyChanged: [], + listed: [], + specTag: undefined, + schemasChanged: false, + schemas: [], + changelogs: [], + repo, + ...over, }); -describe("keywordProblems", () => { - const stable = [ - manifest("@ethdebug/format", "0.1.0"), - manifest("@ethdebug/bugc", "0.1.3"), - ]; - const drafts = [ - manifest("@ethdebug/format", "0.1.0-draft.7"), - manifest("@ethdebug/bugc", "0.1.0-preview.2"), - ]; +function workspace(name: string, version: string): Workspace { + const json = { name, version }; + return { + name, + version, + dir: `/repo/packages/${name.replace("@ethdebug/", "")}`, + private: false, + text: `${JSON.stringify(json, null, 2)}\n`, + json, + runtime: [], + peer: [], + dev: [], + optional: [], + dependencies: [], + all: [], + }; +} - it("accepts prerelease and patch without flags in any state", () => { - expect(keywordProblems("prerelease", false, drafts)).toEqual([]); - expect(keywordProblems("patch", false, drafts)).toEqual([]); - expect(keywordProblems("prerelease", false, stable)).toEqual([]); - }); +const empty: Plan = { moves: [], manifests: [], schemas: [] }; +const options: Options = { keyword: "prerelease", all: false, dryRun: true }; - it("requires --all for a series start", () => { - for (const keyword of ["preminor", "premajor", "minor", "major"]) { - expect(keywordProblems(keyword, false, stable)).toEqual([ - `${keyword} starts a series for every workspace: pass --all`, - ]); - expect(keywordProblems(keyword, true, stable)).toEqual([]); - } +describe("check findings", () => { + it("is silent on a clean repo on main", () => { + expect(check(surveyOf(), empty, options).findings).toEqual([]); }); - it("rejects a series start while a prerelease exists", () => { - const mixed = [ - manifest("@ethdebug/format", "0.1.0"), - manifest("@ethdebug/format-web", "0.1.1-preview.0", [], true), - ]; - expect(keywordProblems("preminor", true, mixed)).toEqual([ - "cannot start a series while @ethdebug/format-web is a " + - "prerelease; run `patch` first", + it("reports a branch that is not main", () => { + const survey = surveyOf({ repo: { ...repo, branch: "feature" } }); + expect(check(survey, empty, options).findings).toEqual([ + "on branch feature, not main", ]); }); - it("allows a series start when all are prereleases of one version", () => { - expect(keywordProblems("preminor", true, drafts)).toEqual([]); - expect(keywordProblems("premajor", true, drafts)).toEqual([]); - const split = [ - manifest("@ethdebug/format", "0.1.0-draft.7"), - manifest("@ethdebug/bugc", "0.1.1-preview.0"), - ]; - expect(keywordProblems("preminor", true, split)).toHaveLength(1); - }); - - // minor/major on a prerelease graduate in place, so the exception - // for one whole draft series must not admit them - it("rejects minor and major while any prerelease exists", () => { - const rejected = [ - "cannot start a series while @ethdebug/format, @ethdebug/bugc " + - "are prereleases; run `patch` first", - ]; - expect(keywordProblems("minor", true, drafts)).toEqual(rejected); - expect(keywordProblems("major", true, drafts)).toEqual(rejected); - }); -}); - -describe("nextVersion", () => { - it("switches a numeric prerelease to the named identifier", () => { - expect(nextVersion("0.1.0-2", "prerelease", "@ethdebug/format", true)).toBe( - "0.1.0-draft.0", - ); - expect(nextVersion("0.1.0-2", "prerelease", "@ethdebug/bugc", true)).toBe( - "0.1.0-preview.0", - ); - }); - - it("counts up, graduates, and starts series", () => { - const n = (v: string, k: string) => - nextVersion(v, k, "@ethdebug/bugc", true); - expect(n("0.1.0-preview.9", "prerelease")).toBe("0.1.0-preview.10"); - expect(n("0.1.0", "prerelease")).toBe("0.1.1-preview.0"); - expect(n("0.1.0-preview.4", "patch")).toBe("0.1.0"); - expect(n("0.1.0", "patch")).toBe("0.1.1"); - expect(n("0.1.3", "preminor")).toBe("0.2.0-preview.0"); - expect(n("0.1.3", "premajor")).toBe("1.0.0-preview.0"); - expect(n("0.1.7", "minor")).toBe("0.2.0"); - expect(n("0.1.7", "major")).toBe("1.0.0"); - }); - - it( - "keeps the manifest version for a workspace that was never " + "released", - () => { - expect( - nextVersion("0.1.0-preview.0", "prerelease", "@ethdebug/codec", false), - ).toBe("0.1.0-preview.0"); - }, - ); - - it("throws when semver cannot increment", () => { - expect(() => - nextVersion("banana", "patch", "@ethdebug/bugc", true), - ).toThrow(/banana/); - }); -}); - -const cut = [ - "# Changelog", - "", - "## Unreleased", - "", - "## 0.1.0-draft.0 — 2026-09-18", - "", - "### Changed", - "", - "- Something changed ([#310]).", - "", - "## 0.1.0-2 — 2026-09-17", - "", - "No changes to the specification.", - "", - "[#310]: https://github.com/ethdebug/format/pull/310", -].join("\n"); - -describe("hasReleaseSection", () => { - it("finds a dated section that has an entry", () => { - expect(hasReleaseSection(cut, "0.1.0-draft.0")).toBe(true); - }); - - it("accepts a section that holds one sentence", () => { - expect(hasReleaseSection(cut, "0.1.0-2")).toBe(true); - }); - - it("does not match a longer version with the same prefix", () => { - expect(hasReleaseSection(cut, "0.1.0")).toBe(false); - expect(hasReleaseSection(cut, "0.1.0-draft.0.1")).toBe(false); - }); - - it( - "is false for a section with only sub-headings or link " + "definitions", - () => { - expect( - hasReleaseSection("## 0.1.0\n\n### Changed\n\n## 0.0.1\n", "0.1.0"), - ).toBe(false); - expect( - hasReleaseSection("## 0.1.0\n\n[#1]: https://example.com\n", "0.1.0"), - ).toBe(false); - }, - ); - - it("reads CRLF line endings", () => { - const text = - "## Unreleased\r\n\r\n- Left.\r\n\r\n## 0.1.0\r\n\r\n- Entry.\r\n"; - expect(hasUnreleasedEntries(text)).toBe(true); - expect(hasReleaseSection(text, "0.1.0")).toBe(true); - }); -}); - -describe("changelogProblems", () => { - it("is empty for a changelog that was cut", () => { - expect( - changelogProblems([ - { path: "CHANGELOG.md", version: "0.1.0-draft.0", text: cut }, - ]), - ).toEqual([]); + it("reports a dirty working tree", () => { + const survey = surveyOf({ repo: { ...repo, dirty: true } }); + expect(check(survey, empty, options).findings).toEqual([ + "the working tree has uncommitted changes", + ]); }); - it("reports a missing section, leftovers, and a missing file", () => { - const leftover = "## Unreleased\n\n- Left behind.\n"; - expect( - changelogProblems([ - { path: "a/CHANGELOG.md", version: "0.1.0-preview.1", text: leftover }, - { path: "b/CHANGELOG.md", version: "0.1.0-preview.1", text: undefined }, - ]), - ).toEqual([ - 'a/CHANGELOG.md: no "## 0.1.0-preview.1" section with an entry', - 'a/CHANGELOG.md: entries remain under "## Unreleased"', - "b/CHANGELOG.md: file is missing", + it("reports a foreign annotated tag nearer than the release tag", () => { + const survey = surveyOf({ + repo: { ...repo, nearestAnnotated: { name: "v9", commit: "b" } }, + }); + expect(check(survey, empty, options).findings).toEqual([ + "the nearest annotated tag v9 is not the nearest release tag " + + "@ethdebug/format@0.1.0-draft.0; Lerna would miss changes " + + "(a foreign tag, or a release tag that is not annotated)", ]); }); -}); -describe("parseChanged", () => { - it("reads names from the JSON that follows any log noise", () => { - const stdout = 'lerna notice\n[\n { "name": "@ethdebug/evm" }\n]\n'; - expect(parseChanged(stdout, "", 0)).toEqual(["@ethdebug/evm"]); + it("reports a missing annotated release tag", () => { + const survey = surveyOf({ + repo: { ...repo, nearestAnnotated: undefined }, + }); + expect(check(survey, empty, options).findings).toEqual([ + "no annotated release tag is reachable from HEAD", + ]); }); - it("is empty when Lerna says nothing changed", () => { - expect(parseChanged("", "lerna info No changed packages found", 1)).toEqual( + it("reports release tags at HEAD only when something changed", () => { + const atHead = { ...repo, tagsAtHead: ["@ethdebug/a@0.1.0"] }; + expect(check(surveyOf({ repo: atHead }), empty, options).findings).toEqual( [], ); - }); - - it("throws for any other failure", () => { - expect(() => parseChanged("", "lerna ERR! boom", 1)).toThrow(/boom/); - expect(() => parseChanged("", "", null)).toThrow(/lerna changed failed/); - }); -}); -describe("forcedNames", () => { - const ms = [ - manifest("@ethdebug/format", "0.1.0-draft.1"), - manifest("@ethdebug/bugc", "0.1.0"), - manifest("@ethdebug/evm", "0.1.1-preview.0"), - ]; - - it("forces the spec package when schemas changed", () => { - expect(forcedNames(ms, "prerelease", true)).toEqual(["@ethdebug/format"]); - expect(forcedNames(ms, "prerelease", false)).toEqual([]); - }); - - it("forces every prerelease workspace under patch", () => { - expect(forcedNames(ms, "patch", false)).toEqual([ - "@ethdebug/format", - "@ethdebug/evm", + const loud = surveyOf({ repo: atHead, directlyChanged: ["@ethdebug/a"] }); + expect(check(loud, empty, options).findings).toEqual([ + "HEAD already carries release tags: @ethdebug/a@0.1.0; " + + "Lerna skips change detection here", ]); }); -}); - -describe("planMoves", () => { - const ms = [ - manifest("@ethdebug/format", "0.1.0-draft.1"), - manifest("@ethdebug/pointers", "0.1.0-preview.3", ["@ethdebug/format"]), - manifest("@ethdebug/bugc", "0.1.0-preview.5", ["@ethdebug/pointers"]), - manifest( - "@ethdebug/format-web", - "0.1.0-preview.2", - ["@ethdebug/bugc"], - true, - ), - ]; - const released = ms.map((m) => m.name); - it("labels direct changes, dependents, and schema-driven moves", () => { - const plan = planMoves({ - manifests: ms, - listed: [ - "@ethdebug/format", - "@ethdebug/pointers", - "@ethdebug/bugc", - "@ethdebug/format-web", + it("reports the planned tags that already exist", () => { + const survey = surveyOf({ tags: ["@ethdebug/a@0.1.1", "v9"] }); + const plan: Plan = { + ...empty, + moves: [ + { + name: "@ethdebug/a", + from: "0.1.0", + to: "0.1.1", + reason: "changed", + firstRelease: false, + }, ], - directlyChanged: ["@ethdebug/bugc"], - released, - schemasChanged: true, - keyword: "prerelease", - all: false, - }); - expect(plan).toEqual([ - { - name: "@ethdebug/format", - from: "0.1.0-draft.1", - to: "0.1.0-draft.2", - reason: "schemas", - firstRelease: false, - }, - { - name: "@ethdebug/pointers", - from: "0.1.0-preview.3", - to: "0.1.0-preview.4", - reason: "dependent", - firstRelease: false, - }, - { - name: "@ethdebug/bugc", - from: "0.1.0-preview.5", - to: "0.1.0-preview.6", - reason: "changed", - firstRelease: false, - }, - { - name: "@ethdebug/format-web", - from: "0.1.0-preview.2", - to: "0.1.0-preview.3", - reason: "dependent", - firstRelease: false, - }, + }; + expect(check(survey, plan, options).findings).toEqual([ + "tags already exist: @ethdebug/a@0.1.1", ]); }); - it("moves only listed workspaces without --all", () => { - const plan = planMoves({ - manifests: ms, - listed: ["@ethdebug/bugc", "@ethdebug/format-web"], - directlyChanged: ["@ethdebug/bugc"], - released, - schemasChanged: false, - keyword: "prerelease", - all: false, + it("emits the findings in preflight's order", () => { + const survey = surveyOf({ + directlyChanged: ["@ethdebug/a"], + tags: ["@ethdebug/a@0.1.1"], + repo: { + branch: "feature", + dirty: true, + tagsAtHead: ["@ethdebug/a@0.1.0"], + nearestAnnotated: { name: "v9", commit: "b" }, + nearestRelease: { name: "@ethdebug/a@0.1.0", commit: "a" }, + }, }); - expect(plan.map((m) => m.name)).toEqual([ - "@ethdebug/bugc", - "@ethdebug/format-web", - ]); - }); - - it("labels graduations and --all moves", () => { - const plan = planMoves({ - manifests: ms, - listed: [ - "@ethdebug/format", - "@ethdebug/pointers", - "@ethdebug/bugc", - "@ethdebug/format-web", + const plan: Plan = { + ...empty, + moves: [ + { + name: "@ethdebug/a", + from: "0.1.0", + to: "0.1.1", + reason: "changed", + firstRelease: false, + }, ], - directlyChanged: [], - released, - schemasChanged: false, - keyword: "patch", - all: false, - }); - expect(plan.map((m) => [m.to, m.reason])).toEqual([ - ["0.1.0", "graduates"], - ["0.1.0", "graduates"], - ["0.1.0", "graduates"], - ["0.1.0", "graduates"], - ]); - const all = planMoves({ - manifests: ms, - listed: [], - directlyChanged: [], - released, - schemasChanged: false, - keyword: "prerelease", - all: true, - }); - expect(all.every((m) => m.reason === "all")).toBe(true); - }); - - it("keeps the manifest version of a never-released workspace", () => { - const withNew = [ - ...ms, - manifest("@ethdebug/codec", "0.1.0-preview.0", ["@ethdebug/format"]), - ]; - const plan = planMoves({ - manifests: withNew, - listed: ["@ethdebug/codec"], - directlyChanged: ["@ethdebug/codec"], - released, - schemasChanged: false, - keyword: "prerelease", - all: false, - }); - expect(plan).toEqual([ - { - name: "@ethdebug/codec", - from: "0.1.0-preview.0", - to: "0.1.0-preview.0", - reason: "changed", - firstRelease: true, - }, - ]); + }; + const { findings } = check(survey, plan, options); + expect(findings).toHaveLength(5); + expect(findings[0]).toContain("on branch"); + expect(findings[1]).toContain("uncommitted changes"); + expect(findings[2]).toContain("nearest annotated tag"); + expect(findings[3]).toContain("HEAD already carries"); + expect(findings[4]).toContain("tags already exist"); }); }); -describe("planProblems", () => { - const ms = [ - manifest("@ethdebug/format", "0.1.0-draft.1"), - manifest("@ethdebug/bugc", "0.1.0", ["@ethdebug/format"]), - ]; - const move = ( - name: string, - from: string, - to: string, - reason: Move["reason"] = "changed", - ): Move => ({ name, from, to, reason, firstRelease: false }); - - it("is empty for a sane plan", () => { - expect( - planProblems( - [move("@ethdebug/format", "0.1.0-draft.1", "0.1.0-draft.2")], - ms, - "prerelease", - ), - ).toEqual([]); - }); - - it("rejects a version that does not move forward", () => { - expect( - planProblems( - [move("@ethdebug/bugc", "0.1.0-preview.3", "0.1.0-draft.0")], - ms, - "prerelease", - ), - ).toEqual([ - "@ethdebug/bugc: 0.1.0-draft.0 does not sort after 0.1.0-preview.3", - ]); - }); - - it("rejects a stable workspace that depends on a prerelease", () => { - expect( - planProblems( - [move("@ethdebug/bugc", "0.1.0-preview.3", "0.1.0", "graduates")], - ms, - "patch", - ), - ).toEqual([ - "@ethdebug/bugc: stable 0.1.0 would depend on " + - "@ethdebug/format 0.1.0-draft.1", - ]); - }); +const schemaText = [ + "examples:", + " - ethdebug:", + ' schema: "ethdebug/format/info"', + ' version: "0.1.0-draft.0"', + "", +].join("\n"); - it("requires equal versions for a series start", () => { - const plan = [ - move("@ethdebug/format", "0.1.0", "0.2.0-draft.0", "all"), - move("@ethdebug/bugc", "0.1.7", "0.2.0-preview.0", "all"), - ]; - expect(planProblems(plan, ms, "preminor")).toEqual([]); - const split = [ - move("@ethdebug/format", "0.1.0", "0.2.0-draft.0", "all"), - move("@ethdebug/bugc", "0.2.0", "0.3.0-preview.0", "all"), - ]; - expect(planProblems(split, ms, "preminor")).toEqual([ - "a series start must give every workspace the same " + - "major.minor.patch; got 0.2.0, 0.3.0", - ]); +const specSurvey = (version: string) => + surveyOf({ + workspaces: [workspace("@ethdebug/format", "0.1.0-draft.0")], + tags: ["@ethdebug/format@0.1.0-draft.0"], + listed: ["@ethdebug/format"], + specTag: "@ethdebug/format@0.1.0-draft.0", + schemasChanged: true, + schemas: [ + { + path: "schemas/info.schema.yaml", + text: schemaText.replace("0.1.0-draft.0", version), + sites: versionSites(schemaText.replace("0.1.0-draft.0", version)), + }, + ], }); -}); - -describe("requiredChangelogs", () => { - const ms = [ - manifest("@ethdebug/format", "0.1.0-draft.1"), - manifest("@ethdebug/evm", "0.1.0-preview.3"), - manifest("@ethdebug/format-web", "0.1.0-preview.2", [], true), - ]; - it("lists the root file when the spec moves and each public package", () => { - const plan: Move[] = [ +describe("decide", () => { + it("rewrites the manifest and schema texts to the target version", () => { + const plan = decide(specSurvey("0.1.0-draft.0"), options); + expect(plan.moves).toEqual([ { name: "@ethdebug/format", - from: "0.1.0-draft.1", - to: "0.1.0-draft.2", + from: "0.1.0-draft.0", + to: "0.1.0-draft.1", reason: "schemas", firstRelease: false, }, + ]); + expect(plan.manifests).toEqual([ { - name: "@ethdebug/evm", - from: "0.1.0-preview.3", - to: "0.1.0-preview.4", - reason: "dependent", - firstRelease: false, - }, - { - name: "@ethdebug/format-web", - from: "0.1.0-preview.2", - to: "0.1.0-preview.3", - reason: "dependent", - firstRelease: false, + path: "packages/format/package.json", + text: + JSON.stringify( + { name: "@ethdebug/format", version: "0.1.0-draft.1" }, + null, + 2, + ) + "\n", }, - ]; - expect(requiredChangelogs(plan, ms, "/repo")).toEqual([ - { path: "CHANGELOG.md", version: "0.1.0-draft.2" }, - { path: "packages/format/CHANGELOG.md", version: "0.1.0-draft.2" }, - { path: "packages/evm/CHANGELOG.md", version: "0.1.0-preview.4" }, ]); - }); - - it("omits the root file when the spec does not move", () => { - const plan: Move[] = [ + expect(plan.schemas).toEqual([ { - name: "@ethdebug/evm", - from: "0.1.0-preview.3", - to: "0.1.0-preview.4", - reason: "changed", - firstRelease: false, + path: "schemas/info.schema.yaml", + text: schemaText.replace("0.1.0-draft.0", "0.1.0-draft.1"), }, - ]; - expect(requiredChangelogs(plan, ms, "/repo")).toEqual([ - { path: "packages/evm/CHANGELOG.md", version: "0.1.0-preview.4" }, ]); }); -}); - -describe("rewriteManifest", () => { - const text = - JSON.stringify( - { - name: "@ethdebug/bugc", - version: "0.1.0-preview.5", - dependencies: { "@ethdebug/evm": "^0.1.0-preview.3", lodash: "^4.0.0" }, - devDependencies: { "@ethdebug/format": "^0.1.0-draft.1" }, - peerDependencies: { "@ethdebug/pointers": "^0.1.0-preview.3" }, - }, - null, - 2, - ) + "\n"; - const versions = new Map([ - ["@ethdebug/bugc", "0.1.0-preview.6"], - ["@ethdebug/format", "0.1.0-draft.2"], - ["@ethdebug/pointers", "0.1.0-preview.4"], - ]); - it("rewrites the version and every internal range, keeping the rest", () => { - const out = JSON.parse(rewriteManifest(text, versions)); - expect(out.version).toBe("0.1.0-preview.6"); - expect(out.dependencies["@ethdebug/evm"]).toBe("^0.1.0-preview.3"); - expect(out.dependencies.lodash).toBe("^4.0.0"); - expect(out.devDependencies["@ethdebug/format"]).toBe("^0.1.0-draft.2"); - expect(out.peerDependencies["@ethdebug/pointers"]).toBe("^0.1.0-preview.4"); + it("counts any tag named for the workspace as released", () => { + const survey = surveyOf({ + workspaces: [workspace("@ethdebug/a", "0.1.0")], + tags: ["@ethdebug/a@not-semver"], + listed: ["@ethdebug/a"], + }); + expect(decide(survey, options).moves[0].firstRelease).toBe(false); }); - - it( - "preserves key order, two-space indentation and the trailing " + "newline", - () => { - const out = rewriteManifest(text, versions); - expect(out.endsWith("}\n")).toBe(true); - expect(out.indexOf('"name"')).toBeLessThan(out.indexOf('"version"')); - expect(out.split("\n")[1]).toBe(' "name": "@ethdebug/bugc",'); - }, - ); - - it( - "leaves a manifest of a workspace that does not move untouched " + - "except ranges", - () => { - const out = JSON.parse( - rewriteManifest(text, new Map([["@ethdebug/format", "0.1.0-draft.2"]])), - ); - expect(out.version).toBe("0.1.0-preview.5"); - expect(out.devDependencies["@ethdebug/format"]).toBe("^0.1.0-draft.2"); - }, - ); }); -describe("undoAdvice", () => { - it("removes the tags and the commit when this run committed", () => { - expect(undoAdvice(["@ethdebug/evm@1.0.0"], true)).toBe( - "undo: git tag -d @ethdebug/evm@1.0.0 && git reset --hard HEAD~1", - ); - }); - - it("removes the commit alone when it failed before any tag", () => { - expect(undoAdvice([], true)).toBe("undo: git reset --hard HEAD~1"); +describe("check errors", () => { + it("reports a drifted schema site when the spec package moves", () => { + const survey = specSurvey("0.0.9"); + const plan = decide(survey, options); + expect(check(survey, plan, options).errors).toEqual([ + "schemas/info.schema.yaml:4: examples/0/ethdebug names 0.0.9, " + + "expected 0.1.0-draft.0", + ]); }); - // a first-release-only plan tags HEAD without committing - it("removes the tags alone when no commit was made", () => { - expect( - undoAdvice(["@ethdebug/evm@1.0.0", "@ethdebug/bugc@1.0.0"], false), - ).toBe("undo: git tag -d @ethdebug/evm@1.0.0 @ethdebug/bugc@1.0.0"); + it("reports zero sites when the spec package moves", () => { + const survey = surveyOf({ + ...specSurvey("0.1.0-draft.0"), + schemas: [{ path: "schemas/x.schema.yaml", text: "", sites: [] }], + }); + const plan = decide(survey, options); + expect(check(survey, plan, options).errors).toEqual([ + "schemas/: no example names the specification version; " + + "the release would rewrite nothing", + ]); }); - it("restores the manifests when nothing was committed or tagged", () => { - expect(undoAdvice([], false)).toBe( - "undo: git checkout HEAD -- packages/*/package.json schemas/", - ); + it("puts the keyword guards before the plan problems", () => { + const survey = surveyOf({ + workspaces: [workspace("@ethdebug/a", "0.1.0-preview.1")], + }); + const backwards: Plan = { + ...empty, + moves: [ + { + name: "@ethdebug/a", + from: "0.1.0-preview.1", + to: "0.1.0-preview.0", + reason: "changed", + firstRelease: false, + }, + ], + }; + const minor: Options = { keyword: "minor", all: false, dryRun: true }; + const errors = check(survey, backwards, minor).errors; + expect(errors).toEqual([ + "minor starts a series for every workspace: pass --all", + "cannot start a series while @ethdebug/a is a prerelease; " + + "run `patch` first", + "@ethdebug/a: 0.1.0-preview.0 does not sort after 0.1.0-preview.1", + ]); }); }); -// the premise of the first-release-only branch of commitAndTag: such a -// plan changes no manifest text, so there is nothing to commit -describe("a plan of first releases only", () => { - it("rewrites no manifest, because each version already matches", () => { - const text = - JSON.stringify( - { - name: "@ethdebug/newcomer", - version: "0.1.0", - dependencies: { "@ethdebug/other": "^0.1.0" }, - }, - null, - 2, - ) + "\n"; - const plan: Move[] = [ - { - name: "@ethdebug/newcomer", - from: "0.1.0", - to: "0.1.0", - reason: "changed", - firstRelease: true, - }, - { - name: "@ethdebug/other", - from: "0.1.0", - to: "0.1.0", - reason: "changed", - firstRelease: true, - }, - ]; - const versions = new Map(plan.map((move) => [move.name, move.to])); - expect(rewriteManifest(text, versions)).toBe(text); +describe("check changelogs", () => { + it("reads the planned changelogs from the survey", () => { + const survey = surveyOf({ + ...specSurvey("0.1.0-draft.0"), + changelogs: [ + { path: "CHANGELOG.md", text: "## 0.1.0-draft.1\n\n- entry\n" }, + { path: "packages/format/CHANGELOG.md", text: undefined }, + { path: "packages/other/CHANGELOG.md", text: undefined }, + ], + }); + const plan = decide(survey, options); + expect(check(survey, plan, options).changelogs).toEqual([ + "packages/format/CHANGELOG.md: file is missing", + ]); }); }); diff --git a/bin/version.ts b/bin/version.ts index 7abef7925..128664dd3 100644 --- a/bin/version.ts +++ b/bin/version.ts @@ -1,45 +1,45 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import semver from "semver"; - +import { + type Applied, + ApplyFailure, + applyRelease, + undoAdvice, +} from "./release/apply.js"; +import { type ChangelogFile, changelogProblems } from "./release/changelog.js"; +import * as git from "./release/git.js"; +import { lernaChanged } from "./release/lerna.js"; +import { + forcedNames, + keywordProblems, + type Plan, + planMoves, + planProblems, + requiredChangelogs, +} from "./release/plan.js"; +import { + ignoredChanges, + keywords, + type Keyword, + releaseTag, + parseReleaseTag, + isReleaseTag, + scope, + specPackage, +} from "./release/policy.js"; import { checkVersions, readSchemas, + type SchemaFile, setVersions, } from "./release/schema-versions.js"; - -// the schemas ship inside this package, so its version is the version -// of the specification -export const specPackage = "@ethdebug/format"; - -export const keywords = [ - "prerelease", - "patch", - "preminor", - "premajor", - "minor", - "major", -] as const; -export type Keyword = (typeof keywords)[number]; - -// a series start moves every workspace to the same major.minor -export const seriesStartKeywords: Keyword[] = [ - "preminor", - "premajor", - "minor", - "major", -]; - -// files whose change does not call for a release; the same list goes -// to `lerna changed` and to the direct-change diff, so the two agree -export const ignoredChanges = [ - "**/CHANGELOG.md", - "**/*.test.ts", - "**/*.test.tsx", -]; +import { + readWorkspaces, + rewriteManifest, + type Workspace, +} from "./release/workspaces.js"; export interface Options { keyword: Keyword; @@ -60,391 +60,63 @@ export function parseArgs(argv: string[]): Options { if (unknown.length > 0) { throw new Error(`unknown option: ${unknown.join(", ")}`); } + const keyword = positional[0]; // an empty positional is a usage error too, not a missing keyword - if (positional.length > 1 || positional.some((arg) => !isKeyword(arg))) { + if (positional.length > 1 || (keyword !== undefined && !isKeyword(keyword))) { throw new Error( "usage: tsx bin/version.ts [keyword] [--all] [--dry-run]\n" + ` keyword is one of: ${keywords.join(", ")} (default prerelease)`, ); } return { - keyword: positional[0] ?? "prerelease", + keyword: keyword ?? "prerelease", all: options.includes("--all"), dryRun: options.includes("--dry-run"), }; } -export function identifierFor(name: string): "draft" | "preview" { - return name === specPackage ? "draft" : "preview"; -} - -export interface Manifest { - name: string; - version: string; - dir: string; - private: boolean; - // @ethdebug/* names over all four dependency kinds - dependencies: string[]; - json: Record; -} - -function isPrerelease(version: string): boolean { - return (semver.prerelease(version) ?? []).length > 0; -} - -function tuple(version: string): string { - const parsed = semver.parse(version); - return parsed ? `${parsed.major}.${parsed.minor}.${parsed.patch}` : ""; -} - -// the guards of the series-start keywords -export function keywordProblems( - keyword: string, - all: boolean, - manifests: Manifest[], -): string[] { - if (!(seriesStartKeywords as string[]).includes(keyword)) { - return []; - } - const problems: string[] = []; - if (!all) { - problems.push(`${keyword} starts a series for every workspace: pass --all`); - } - const prereleases = manifests.filter((m) => isPrerelease(m.version)); - const tuples = new Set(manifests.map((m) => tuple(m.version))); - // a whole-repository draft series can be abandoned for the next draft - // series without a stable release, so `preminor` / `premajor` may run - // while every workspace is a prerelease of one and the same X.Y.Z. - // `minor` / `major` must NOT take that exception: on a prerelease - // they graduate in place (0.1.0-draft.3 + minor -> 0.1.0), which is - // an unannounced stable release, not a series start. - const graduatesInPlace = keyword === "minor" || keyword === "major"; - const wholeSeries = - !graduatesInPlace && - prereleases.length === manifests.length && - tuples.size === 1; - if (prereleases.length > 0 && !wholeSeries) { - const names = prereleases.map((m) => m.name).join(", "); - problems.push( - `cannot start a series while ${names} ${ - prereleases.length === 1 ? "is a prerelease" : "are prereleases" - }; run \`patch\` first`, - ); - } - return problems; -} - -// a workspace that was never released keeps the version its manifest -// carries: that is its first version, and it is tagged at it -export function nextVersion( - current: string, - keyword: string, - name: string, - released: boolean, -): string { - if (!released) { - return current; - } - const next = semver.inc( - current, - keyword as semver.ReleaseType, - identifierFor(name), - ); - if (next === null) { - throw new Error(`cannot apply ${keyword} to ${name}@${current}`); - } - return next; -} - -// `lerna changed` exits non-zero both when nothing changed and when it -// fails; only the first is an empty list -export function parseChanged( - stdout: string, - stderr: string, - status: number | null, -): string[] { - if (status !== 0) { - if (/No changed packages/i.test(stderr)) { - return []; - } - throw new Error(`lerna changed failed:\n${stderr.trim()}`); - } - const start = stdout.indexOf("["); - if (start === -1) { - return []; - } - const listed = JSON.parse(stdout.slice(start)) as { name: string }[]; - return listed.map(({ name }) => name); -} - -// true when the changelog has a section for the version with at least -// one entry or sentence in it -export function hasReleaseSection(text: string, version: string): boolean { - const lines = text.split(/\r?\n/); - const start = lines.findIndex( - (line) => line === `## ${version}` || line.startsWith(`## ${version} `), - ); - if (start === -1) { - return false; - } - const rest = lines.slice(start + 1); - const end = rest.findIndex((line) => line.startsWith("## ")); - const body = end === -1 ? rest : rest.slice(0, end); - return body.some( - (line) => - line.trim().length > 0 && - !line.startsWith("#") && - !/^\[[^\]]+\]: /.test(line), - ); -} - -export function hasUnreleasedEntries(text: string): boolean { - return hasReleaseSection(text, "Unreleased"); -} - -export interface ChangelogFile { - path: string; - version: string; - text: string | undefined; -} - -export function changelogProblems(files: ChangelogFile[]): string[] { - return files.flatMap(({ path, version, text }) => { - if (text === undefined) { - return [`${path}: file is missing`]; - } - return [ - ...(hasReleaseSection(text, version) - ? [] - : [`${path}: no "## ${version}" section with an entry`]), - ...(hasUnreleasedEntries(text) - ? [`${path}: entries remain under "## Unreleased"`] - : []), - ]; - }); -} - -const dependencyKinds = [ - "dependencies", - "devDependencies", - "peerDependencies", - "optionalDependencies", -] as const; - -export function readManifests(root: string): Manifest[] { - const packagesDir = join(root, "packages"); - return readdirSync(packagesDir) - .filter((entry) => existsSync(join(packagesDir, entry, "package.json"))) - .map((entry) => { - const dir = join(packagesDir, entry); - const json = JSON.parse( - readFileSync(join(dir, "package.json"), "utf8"), - ) as Record; - const dependencies = dependencyKinds.flatMap((kind) => - Object.keys((json[kind] as Record | undefined) ?? {}), - ); - return { - name: json.name as string, - version: json.version as string, - dir, - private: json.private === true, - dependencies: [...new Set(dependencies)].filter((dep) => - dep.startsWith("@ethdebug/"), - ), - json, - }; - }); -} - -export type Reason = "changed" | "schemas" | "graduates" | "dependent" | "all"; - -export interface Move { - name: string; - from: string; - to: string; - reason: Reason; - firstRelease: boolean; -} - -// names to pass to `lerna changed --force-publish`; Lerna then adds -// their transitive dependents -export function forcedNames( - manifests: Manifest[], - keyword: string, - schemasChanged: boolean, -): string[] { - const forced = schemasChanged ? [specPackage] : []; - if (keyword === "patch") { - for (const manifest of manifests) { - if (isPrerelease(manifest.version) && !forced.includes(manifest.name)) { - forced.push(manifest.name); - } - } - } - return forced; -} - -export interface PlanInput { - manifests: Manifest[]; - // names from `lerna changed`, forced names and dependents included - listed: string[]; +export interface Survey { + root: string; + workspaces: Workspace[]; + // every tag in the repository, unfiltered; policy decides which are + // releases, so a glob and a regex cannot disagree + tags: string[]; // names whose own directory differs from their tag, ignores applied directlyChanged: string[]; - // names that have at least one release tag - released: string[]; + // `lerna changed`, forced names and their dependents included + listed: string[]; + specTag: string | undefined; schemasChanged: boolean; - keyword: string; - all: boolean; -} - -export function planMoves(input: PlanInput): Move[] { - const { manifests, listed, directlyChanged, released, keyword } = input; - return manifests - .filter((m) => input.all || listed.includes(m.name)) - .map((m) => { - const firstRelease = !released.includes(m.name); - let reason: Reason; - if (directlyChanged.includes(m.name) || firstRelease) { - reason = "changed"; - } else if (m.name === specPackage && input.schemasChanged) { - reason = "schemas"; - } else if (keyword === "patch" && isPrerelease(m.version)) { - reason = "graduates"; - } else if (listed.includes(m.name)) { - reason = "dependent"; - } else { - reason = "all"; - } - return { - name: m.name, - from: m.version, - to: nextVersion(m.version, keyword, m.name, !firstRelease), - reason, - firstRelease, - }; - }); -} - -export function planProblems( - plan: Move[], - manifests: Manifest[], - keyword: string, -): string[] { - const problems: string[] = []; - const after = new Map(manifests.map((m) => [m.name, m.version])); - for (const move of plan) { - after.set(move.name, move.to); - } - for (const move of plan) { - if (!move.firstRelease && !semver.gt(move.to, move.from)) { - problems.push( - `${move.name}: ${move.to} does not sort after ${move.from}`, - ); - } - if (!isPrerelease(move.to)) { - const manifest = manifests.find((m) => m.name === move.name); - for (const dep of manifest?.dependencies ?? []) { - const version = after.get(dep); - if (version !== undefined && isPrerelease(version)) { - problems.push( - `${move.name}: stable ${move.to} would depend on ${dep} ${version}`, - ); - } - } - } - } - if ((seriesStartKeywords as string[]).includes(keyword)) { - // identifiers differ (draft / preview); the tuple must not - const tuples = [...new Set(plan.map((move) => tuple(move.to)))]; - if (tuples.length > 1) { - problems.push( - "a series start must give every workspace the same " + - `major.minor.patch; got ${tuples.join(", ")}`, - ); - } - } - return problems; -} - -// the changelogs the release must have cut, each with the version its -// heading must carry -export function requiredChangelogs( - plan: Move[], - manifests: Manifest[], - root: string, -): { path: string; version: string }[] { - const spec = plan.find((move) => move.name === specPackage); - const root_ = spec ? [{ path: "CHANGELOG.md", version: spec.to }] : []; - const packages = plan.flatMap((move) => { - const manifest = manifests.find((m) => m.name === move.name); - if (!manifest || manifest.private) { - return []; - } - return [ - { - path: join(relative(root, manifest.dir), "CHANGELOG.md"), - version: move.to, - }, - ]; - }); - return [...root_, ...packages]; -} - -// sets the version when the manifest's own workspace moves, and -// points every internal range at the new version of a moving workspace -export function rewriteManifest( - text: string, - versions: Map, -): string { - const json = JSON.parse(text) as Record; - const own = versions.get(json.name as string); - if (own !== undefined) { - json.version = own; - } - for (const kind of dependencyKinds) { - const ranges = json[kind] as Record | undefined; - if (!ranges) { - continue; - } - for (const [dep, version] of versions) { - if (dep in ranges) { - ranges[dep] = `^${version}`; - } - } - } - return `${JSON.stringify(json, null, 2)}\n`; -} - -function git(root: string, args: string[]): string { - return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); -} - -// exit status of a git command that uses its status as a result -function gitStatus(root: string, args: string[]): number { - const result = spawnSync("git", args, { cwd: root, stdio: "pipe" }); - return result.status ?? 128; + schemas: SchemaFile[]; + // the root file and every public package's, read whether or not the + // plan needs them, so `check` can filter in memory + changelogs: Omit[]; + repo: { + branch: string; + dirty: boolean; + tagsAtHead: string[]; + nearestAnnotated: { name: string; commit: string } | undefined; + nearestRelease: { name: string; commit: string } | undefined; + }; } -function lastSpecTag(root: string): string | undefined { - const result = spawnSync( - "git", - ["describe", "--tags", "--abbrev=0", "--match", `${specPackage}@*`], - { cwd: root, encoding: "utf8" }, - ); - return result.status === 0 ? result.stdout.trim() : undefined; +export interface Problems { + // guards and plan problems: exit 1, even under --dry-run + errors: string[]; + // repo state: blocks a real run + findings: string[]; + // uncut changelogs: block a real run, printed after the report + changelogs: string[]; } -function schemasChangedSince(root: string, tag: string | undefined): boolean { - if (tag === undefined) { - return true; - } - const status = gitStatus(root, [ +function changedSince(root: string, tag: string, paths: string[]): boolean { + const status = git.status(root, [ "diff", "--quiet", tag, "HEAD", "--", - "schemas/", + ...paths, ]); if (status !== 0 && status !== 1) { throw new Error(`git diff against ${tag} failed`); @@ -452,121 +124,182 @@ function schemasChangedSince(root: string, tag: string | undefined): boolean { return status === 1; } -function releasedNames(root: string, manifests: Manifest[]): string[] { - return manifests - .filter((m) => git(root, ["tag", "--list", `${m.name}@*`]).length > 0) - .map((m) => m.name); -} - // pathspecs for one workspace directory with the ignored files removed -function workspacePathspecs(root: string, manifest: Manifest): string[] { - const dir = relative(root, manifest.dir); +function workspacePathspecs(root: string, workspace: Workspace): string[] { + const dir = relative(root, workspace.dir); return [ `:(top)${dir}`, ...ignoredChanges.map((glob) => `:(top,exclude,glob)${dir}/${glob}`), ]; } -function directlyChangedNames(root: string, manifests: Manifest[]): string[] { - return manifests - .filter((m) => { - const tag = `${m.name}@${m.version}`; - if (git(root, ["tag", "--list", tag]).length === 0) { - return true; - } - const status = gitStatus(root, [ - "diff", - "--quiet", - tag, - "HEAD", - "--", - ...workspacePathspecs(root, m), - ]); - if (status !== 0 && status !== 1) { - throw new Error(`git diff against ${tag} failed`); - } - return status === 1; - }) - .map((m) => m.name); +// the nearest tag `git describe` finds, and the commit it names +function nearest(root: string, args: string[]) { + const found = git.tryRun(root, [ + "describe", + "--first-parent", + "--abbrev=0", + ...args, + ]); + if (!found.ok) { + return undefined; + } + const commit = git.run(root, ["rev-list", "-n", "1", found.stdout]); + return { name: found.stdout, commit }; } -function lernaChanged(root: string, forced: string[]): string[] { - const args = ["-s", "lerna", "changed", "--all", "--json"]; - for (const glob of ignoredChanges) { - args.push("--ignore-changes", glob); - } - if (forced.length > 0) { - args.push(`--force-publish=${forced.join(",")}`); - } - const result = spawnSync("yarn", args, { cwd: root, encoding: "utf8" }); - return parseChanged(result.stdout ?? "", result.stderr ?? "", result.status); +function readText(root: string, path: string): string | undefined { + const full = join(root, path); + return existsSync(full) ? readFileSync(full, "utf8") : undefined; } -// Lerna finds the last release with a plain `git describe`, annotated -// tags only and no name filter. The nearest annotated tag must -// therefore be the nearest release tag, lightweight ones included. -function tagCheck(root: string): string | undefined { - const annotated = spawnSync( - "git", - ["describe", "--first-parent", "--abbrev=0"], - { cwd: root, encoding: "utf8" }, - ); - const release = spawnSync( - "git", - [ - "describe", - "--tags", - "--first-parent", - "--abbrev=0", - "--match", - "@ethdebug/*@*", - ], - { cwd: root, encoding: "utf8" }, - ); - if (annotated.status !== 0 || release.status !== 0) { - return "no annotated release tag is reachable from HEAD"; - } - const a = git(root, ["rev-list", "-n", "1", annotated.stdout.trim()]); - const r = git(root, ["rev-list", "-n", "1", release.stdout.trim()]); - if (a !== r) { - return ( - `the nearest annotated tag ${annotated.stdout.trim()} is not the ` + - `nearest release tag ${release.stdout.trim()}; Lerna would miss ` + - "changes (a foreign tag, or a release tag that is not annotated)" - ); - } - return undefined; +// the only read: everything this run needs from git, Lerna and disk +export function surveyRelease( + root: string, + workspaces: Workspace[], + options: Options, +): Survey { + const tags = git.run(root, ["tag", "--list"]).split("\n").filter(Boolean); + const described = git.tryRun(root, [ + "describe", + "--tags", + "--abbrev=0", + "--match", + `${specPackage}@*`, + ]); + const specTag = described.ok ? described.stdout : undefined; + const schemasChanged = + specTag === undefined || changedSince(root, specTag, ["schemas/"]); + // the one decision inside the read: --force-publish needs the names + const forced = forcedNames(workspaces, options.keyword, schemasChanged); + const directlyChanged = workspaces + .filter((w) => { + const tag = releaseTag(w.name, w.version); + return ( + !tags.includes(tag) || + changedSince(root, tag, workspacePathspecs(root, w)) + ); + }) + .map((w) => w.name); + const changelogPaths = [ + "CHANGELOG.md", + ...workspaces + .filter((w) => !w.private) + .map((w) => join(relative(root, w.dir), "CHANGELOG.md")), + ]; + const releaseGlob = `${scope}*@*`; + return { + root, + workspaces, + tags, + directlyChanged, + listed: lernaChanged(root, forced), + specTag, + schemasChanged, + schemas: readSchemas(root), + changelogs: changelogPaths.map((path) => ({ + path, + text: readText(root, path), + })), + repo: { + branch: git.run(root, ["rev-parse", "--abbrev-ref", "HEAD"]), + dirty: + git.run(root, ["status", "--porcelain", "--untracked-files=no"]) + .length > 0, + tagsAtHead: git + .run(root, ["tag", "--points-at", "HEAD"]) + .split("\n") + .filter(isReleaseTag), + nearestAnnotated: nearest(root, []), + nearestRelease: nearest(root, ["--tags", "--match", releaseGlob]), + }, + }; } -function existingTags(root: string, plan: Move[]): string[] { - return plan - .map((move) => `${move.name}@${move.to}`) - .filter((tag) => git(root, ["tag", "--list", tag]).length > 0); +function specMove(plan: Plan) { + return plan.moves.find((move) => move.name === specPackage); } -function releaseTagsAtHead(root: string): string[] { - return git(root, ["tag", "--points-at", "HEAD", "--list", "@ethdebug/*@*"]) - .split("\n") - .filter((tag) => tag.length > 0); +// pure: the moves, and the exact bytes a real run would write +export function decide(survey: Survey, options: Options): Plan { + const { root, workspaces, tags } = survey; + const moves = planMoves({ + manifests: workspaces, + listed: survey.listed, + directlyChanged: survey.directlyChanged, + released: workspaces + .filter((w) => tags.some((t) => parseReleaseTag(t)?.name === w.name)) + .map((w) => w.name), + schemasChanged: survey.schemasChanged, + keyword: options.keyword, + all: options.all, + }); + const versions = new Map(moves.map((move) => [move.name, move.to])); + const manifests = workspaces.map((w) => ({ + path: relative(root, join(w.dir, "package.json")), + before: w.text, + text: rewriteManifest(w.text, versions), + })); + // the schemas ship inside @ethdebug/format, so their examples name + // the version it moves to; when it stays put they are left alone + const spec = moves.find((move) => move.name === specPackage); + const schemas = survey.schemas.map((file) => ({ + path: file.path, + before: file.text, + text: spec ? setVersions(file.text, spec.to) : file.text, + })); + const changed = (files: typeof manifests) => + files + .filter((file) => file.text !== file.before) + .map(({ path, text }) => ({ path, text })); + return { moves, manifests: changed(manifests), schemas: changed(schemas) }; } -function preflight( - root: string, - plan: Move[], - directlyChanged: string[], -): string[] { +// Lerna finds the last release with a plain `git describe`, so the +// nearest annotated tag must be the nearest release tag, lightweight +// ones included +function tagCheck(repo: Survey["repo"]): string[] { + const { nearestAnnotated: annotated, nearestRelease: release } = repo; + if (annotated === undefined || release === undefined) { + return ["no annotated release tag is reachable from HEAD"]; + } + if (annotated.commit !== release.commit) { + return [ + `the nearest annotated tag ${annotated.name} is not the ` + + `nearest release tag ${release.name}; Lerna would miss ` + + "changes (a foreign tag, or a release tag that is not annotated)", + ]; + } + return []; +} + +// pure; the order of each list is the order it prints in +export function check(survey: Survey, plan: Plan, options: Options): Problems { + const { workspaces, repo } = survey; + const spec = specMove(plan); + const sites = survey.schemas.flatMap((file) => file.sites); + const errors = [ + ...keywordProblems(options.keyword, options.all, workspaces), + ...planProblems(plan.moves, workspaces, options.keyword), + ...(spec !== undefined && sites.length === 0 + ? [ + "schemas/: no example names the specification version; " + + "the release would rewrite nothing", + ] + : []), + ...(spec === undefined + ? [] + : survey.schemas.flatMap((file) => checkVersions(file, spec.from))), + ]; + const findings: string[] = []; - const branch = git(root, ["rev-parse", "--abbrev-ref", "HEAD"]); - if (branch !== "main") { - findings.push(`on branch ${branch}, not main`); + if (repo.branch !== "main") { + findings.push(`on branch ${repo.branch}, not main`); } - if (git(root, ["status", "--porcelain", "--untracked-files=no"]).length > 0) { + if (repo.dirty) { findings.push("the working tree has uncommitted changes"); } - const tags = tagCheck(root); - if (tags !== undefined) { - findings.push(tags); - } + findings.push(...tagCheck(repo)); // with a release tag at HEAD, `lerna changed` reports "Current HEAD // is already released" and lists nothing, so the listing hides every // change. Release tags at HEAD are legitimate right after a @@ -574,233 +307,138 @@ function preflight( // is then nothing left to release and the next run must still work. // The finding therefore fires only when a direct change exists, that // is when the empty listing really is hiding something. - if (directlyChanged.length > 0) { - const atHead = releaseTagsAtHead(root); - if (atHead.length > 0) { - findings.push( - `HEAD already carries release tags: ${atHead.join(", ")}; ` + - "Lerna skips change detection here", - ); - } + if (survey.directlyChanged.length > 0 && repo.tagsAtHead.length > 0) { + findings.push( + `HEAD already carries release tags: ${repo.tagsAtHead.join(", ")}; ` + + "Lerna skips change detection here", + ); } - const taken = existingTags(root, plan); + const taken = plan.moves + .map((move) => releaseTag(move.name, move.to)) + .filter((tag) => survey.tags.includes(tag)); if (taken.length > 0) { findings.push(`tags already exist: ${taken.join(", ")}`); } - return findings; -} -function writeManifests( - root: string, - manifests: Manifest[], - plan: Move[], -): string[] { - const versions = new Map(plan.map((move) => [move.name, move.to])); - const written: string[] = []; - for (const manifest of manifests) { - const path = join(manifest.dir, "package.json"); - const before = readFileSync(path, "utf8"); - const after = rewriteManifest(before, versions); - if (after !== before) { - writeFileSync(path, after); - written.push(relative(root, path)); - } - } - return written; + const texts = new Map( + survey.changelogs.map((file) => [file.path, file.text]), + ); + const changelogs = changelogProblems( + requiredChangelogs(plan.moves, workspaces, survey.root).map((file) => ({ + ...file, + text: texts.get(file.path), + })), + ); + return { errors, findings, changelogs }; } -// appends every tag it creates to `created`, so a failure partway -// leaves the caller with the exact list to undo -function commitAndTag( - root: string, - files: string[], - plan: Move[], - created: string[], +// prints the findings, then the moves and the changelog problems +export function report( + survey: Survey, + plan: Plan, + problems: Problems, + options: Options, ): void { - if (files.length > 0) { - git(root, ["add", "--", ...files]); - git(root, ["commit", "--no-verify", "--quiet", "-m", "Publish"]); - } else { - // a plan of first releases only: each manifest already carries the - // version it is tagged at, so there is nothing to commit - console.log("no manifest changed; tagging HEAD"); - } - for (const move of plan) { - const tag = `${move.name}@${move.to}`; - git(root, ["tag", "-a", tag, "-m", tag]); - created.push(tag); - } -} - -// what a failed bump left behind, and how to remove it -export function undoAdvice(created: string[], committed: boolean): string { - const tags = created.length > 0 ? `git tag -d ${created.join(" ")}` : ""; - if (committed) { - const prefix = tags.length > 0 ? `${tags} && ` : ""; - return `undo: ${prefix}git reset --hard HEAD~1`; + // findings come before the nothing-moves line: a release tag at HEAD + // is exactly what makes the plan look empty + for (const finding of problems.findings) { + console.log(`not ready to bump: ${finding}`); } - if (tags.length > 0) { - return `undo: ${tags}`; + if (plan.moves.length === 0) { + console.log("nothing to release: no workspace changed since its tag"); + return; } - return "undo: git checkout HEAD -- packages/*/package.json schemas/"; -} - -function report(plan: Move[]): void { - const width = Math.max(...plan.map((move) => move.name.length)); - for (const move of plan) { + console.log(`${options.keyword}: ${plan.moves.length} workspace(s) move`); + const width = Math.max(...plan.moves.map((move) => move.name.length)); + for (const move of plan.moves) { const arrow = move.firstRelease ? `first release -> ${move.to}` : `${move.from} -> ${move.to}`; console.log(` ${move.name.padEnd(width)} ${arrow} (${move.reason})`); } + const spec = specMove(plan); + if (spec !== undefined) { + const count = survey.schemas.flatMap((file) => file.sites).length; + console.log(` schemas: ${count} version literals -> ${spec.to}`); + } + if (problems.changelogs.length > 0) { + console.log("changelogs not cut for this release:"); + for (const problem of problems.changelogs) { + console.log(` ${problem}`); + } + } +} + +function reportApplied(plan: Plan, applied: Applied): void { + const { written, created } = applied; + const schemaCount = plan.schemas.length; + console.log(`tagged: ${created.join(", ")}`); + if (written.length > 0) { + const schemaNote = + schemaCount > 0 ? ` and ${schemaCount} schema file(s)` : ""; + console.log( + `committed Publish with ${written.length - schemaCount} ` + + `manifest(s)${schemaNote}`, + ); + console.log("next: git push --atomic origin main --follow-tags"); + return; + } + // publish.yml runs `on: push: branches: [main]`; with no commit the + // push moves no branch, so nothing triggers it + console.log( + "no commit was made: the push moves no branch and publish.yml " + + "will not trigger; push the tags (`git push origin --tags`) and " + + "then dispatch the workflow or publish locally, see RELEASING.md", + ); } export function main(argv: string[]): number { - const { keyword, all, dryRun } = parseArgs(argv); + const options = parseArgs(argv); const root = fileURLToPath(new URL("..", import.meta.url)); - const manifests = readManifests(root); - const guard = keywordProblems(keyword, all, manifests); + // the workspaces are cheap, and the keyword guard must stay cheap: + // `minor` without --all fails before any git or Lerna work + const workspaces = readWorkspaces(root); + const guard = keywordProblems(options.keyword, options.all, workspaces); if (guard.length > 0) { - for (const problem of guard) { - console.error(problem); - } + guard.forEach((problem) => console.error(problem)); return 1; } - const specTag = lastSpecTag(root); - const schemasChanged = schemasChangedSince(root, specTag); + const survey = surveyRelease(root, workspaces, options); console.log( - schemasChanged - ? `schemas/ changed since ${specTag ?? "the beginning"}` - : `schemas/ unchanged since ${specTag}`, + survey.schemasChanged + ? `schemas/ changed since ${survey.specTag ?? "the beginning"}` + : `schemas/ unchanged since ${survey.specTag}`, ); - const forced = forcedNames(manifests, keyword, schemasChanged); - const directlyChanged = directlyChangedNames(root, manifests); - const plan = planMoves({ - manifests, - listed: lernaChanged(root, forced), - directlyChanged, - released: releasedNames(root, manifests), - schemasChanged, - keyword, - all, - }); - const problems = planProblems(plan, manifests, keyword); - // the schemas ship inside @ethdebug/format, so their examples name - // the version it moves to; when it stays put they are left alone - const specMove = plan.find((move) => move.name === specPackage); - let schemas: { path: string; text: string }[] = []; - let siteCount = 0; - if (specMove !== undefined) { - const files = readSchemas(root); - siteCount = files.flatMap((file) => file.sites).length; - if (siteCount === 0) { - problems.push( - "schemas/: no example names the specification version; " + - "the release would rewrite nothing", - ); - } - problems.push( - ...files.flatMap((file) => checkVersions(file, specMove.from)), - ); - schemas = files - .map((file) => ({ - path: file.path, - text: setVersions(file.text, specMove.to), - before: file.text, - })) - .filter((file) => file.text !== file.before) - .map(({ path, text }) => ({ path, text })); - } - if (problems.length > 0) { - for (const problem of problems) { - console.error(problem); - } + const plan = decide(survey, options); + const problems = check(survey, plan, options); + if (problems.errors.length > 0) { + problems.errors.forEach((problem) => console.error(problem)); return 1; } - - // findings come before the nothing-moves exit: a release tag at HEAD - // is exactly what makes the plan look empty - const findings = preflight(root, plan, directlyChanged); - for (const finding of findings) { - console.log(`not ready to bump: ${finding}`); - } - if (plan.length === 0) { - console.log("nothing to release: no workspace changed since its tag"); + report(survey, plan, problems, options); + if (plan.moves.length === 0) { return 0; } - console.log(`${keyword}: ${plan.length} workspace(s) move`); - report(plan); - if (specMove !== undefined) { - const literals = `${siteCount} version literals`; - console.log(` schemas: ${literals} -> ${specMove.to}`); - } - - const changelogs = changelogProblems( - requiredChangelogs(plan, manifests, root).map(({ path, version }) => ({ - path, - version, - text: existsSync(join(root, path)) - ? readFileSync(join(root, path), "utf8") - : undefined, - })), - ); - if (changelogs.length > 0) { - console.log("changelogs not cut for this release:"); - for (const problem of changelogs) { - console.log(` ${problem}`); - } - } - if (dryRun) { + if (options.dryRun) { return 0; } - if (findings.length > 0 || changelogs.length > 0) { + if (problems.findings.length > 0 || problems.changelogs.length > 0) { console.error("fix the items above, then re-run"); return 1; } - - // HEAD right before the writes: the only reliable sign of whether - // this run committed, since HEAD already is a Publish commit after - // every release - const headBefore = git(root, ["rev-parse", "HEAD"]); - let written: string[] = []; - let schemaCount = 0; - const created: string[] = []; try { - written = writeManifests(root, manifests, plan); - for (const { path, text } of schemas) { - writeFileSync(join(root, path), text); - written.push(path); - } - schemaCount = schemas.length; - commitAndTag(root, written, plan, created); + reportApplied(plan, applyRelease(root, plan)); + return 0; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`bump failed: ${message}`); - const committed = git(root, ["rev-parse", "HEAD"]) !== headBefore; - console.error(undoAdvice(created, committed)); + if (!(error instanceof ApplyFailure)) { + throw error; + } + console.error(`bump failed: ${error.message}`); + console.error(undoAdvice(error.created, error.committed)); return 1; } - console.log(`tagged: ${created.join(", ")}`); - if (written.length > 0) { - const schemaNote = - schemaCount > 0 ? ` and ${schemaCount} schema file(s)` : ""; - console.log( - `committed Publish with ${written.length - schemaCount} ` + - `manifest(s)${schemaNote}`, - ); - console.log("next: git push --atomic origin main --follow-tags"); - return 0; - } - // publish.yml runs `on: push: branches: [main]`; with no commit the - // push moves no branch, so nothing triggers it - console.log( - "no commit was made: the push moves no branch and publish.yml " + - "will not trigger; push the tags (`git push origin --tags`) and " + - "then dispatch the workflow or publish locally, see RELEASING.md", - ); - return 0; } if (import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/package.json b/package.json index 2bda9ce6c..71ccc805d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "yarn lerna run build --ignore @ethdebug/format-web --ignore @ethdebug/bug-playground", "bundle": "tsx ./bin/bundle-schema.ts", "typecheck": "yarn lerna run typecheck --ignore @ethdebug/format-web --ignore @ethdebug/bug-playground", + "typecheck:bin": "tsc -p tsconfig.bin.json", "test": "vitest run", "test:coverage": "vitest run --coverage", "start": "./bin/start", diff --git a/tsconfig.bin.json b/tsconfig.bin.json new file mode 100644 index 000000000..c5372c35d --- /dev/null +++ b/tsconfig.bin.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "incremental": false + }, + "include": ["bin/**/*.ts"], + "exclude": ["bin/version-old.ts"] +}