From eb9693fe2b6b1cc2732bd14b4e4602761ac09006 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 17 Sep 2026 20:31:09 -0400 Subject: [PATCH 1/4] build: add semver for the release tooling --- package.json | 2 ++ yarn.lock | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/package.json b/package.json index ca4eb2efe..78237272c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", + "@types/semver": "^7.7.0", "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^3.2.4", @@ -34,6 +35,7 @@ "lerna": "^8.2.4", "lint-staged": "^15.4.1", "prettier": "^3.4.2", + "semver": "^7.7.3", "tsx": "^4.21.0", "typescript-eslint": "^8.53.0", "vitest": "^3.2.4" diff --git a/yarn.lock b/yarn.lock index fff686d1c..aab449a34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5858,6 +5858,11 @@ resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-3.0.1.tgz#1254750a4fec4aff2ebec088ccd0bb02e91fedb4" integrity sha512-giB9gzDeiCeloIXDgzFBCgjj1k4WxcDrZtGl6h1IqmUPlxF+Nx8Ve+96QCyDZ/HseB/uvDsKbpib9hU5cU53pw== +"@types/semver@^7.7.0": + version "7.8.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.8.0.tgz#0bfe3ec51f5e9615bc317174cd5b88ea08b7fc2f" + integrity sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ== + "@types/send@*": version "0.17.4" resolved "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz" From 9e31379a81f269c20fba13b265b3c6a9e0e56d6f Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 17 Sep 2026 22:47:33 -0400 Subject: [PATCH 2/4] bin: bump versions with a script that owns the plan, commit and tags Lerna 8 cannot produce the release scheme this repository wants. A keyword bump on a numeric prerelease yields "alpha", because Lerna resolves the identifier as `--preid || existing || "alpha"`. An explicit version moves every workspace whose prerelease number is truthy, which is lockstep for -1 and above and changed-only for -0. One run takes one --preid, so two identifiers cannot start a series together. bin/version.ts therefore does the bump itself. Prereleases carry a named identifier: X.Y.Z-draft.N for @ethdebug/format, whose version is the specification version, and X.Y.Z-preview.N for every other workspace. The script asks `lerna changed` which workspaces changed since their tags, ignoring changelogs and test files, and forces @ethdebug/format when schemas/ changed, because the schemas live outside the package directory but ship inside it. Every dependent of a moving workspace moves too. Each moving workspace gets its own next version from semver: `prerelease` counts up, `patch` graduates every prerelease workspace, and the series-start keywords `preminor`, `premajor`, `minor` and `major` require --all and refuse to run while a prerelease exists, except that `preminor` and `premajor` may abandon a whole-repository draft series for the next one. Before it writes anything, the script reports each move with its reason, checks that the branch is main, that the tree is clean, that the nearest annotated tag is a release tag, that no tag for a new version exists, that no release tag already points at HEAD while a workspace changed, and that every changelog has a section for its workspace's new version and nothing left under Unreleased. A dry run prints the same report and exits 0. A real run then rewrites the versions and the internal dependency ranges in the package.json files, commits them as "Publish" with hooks disabled, and creates one annotated tag per moving workspace. It never pushes. If it fails after it started writing, it prints the undo commands for the stage it reached. --- bin/version.test.ts | 649 +++++++++++++++++++++++++++++++++++++ bin/version.ts | 766 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1415 insertions(+) create mode 100644 bin/version.test.ts create mode 100644 bin/version.ts diff --git a/bin/version.test.ts b/bin/version.test.ts new file mode 100644 index 000000000..e915f1d38 --- /dev/null +++ b/bin/version.test.ts @@ -0,0 +1,649 @@ +import { describe, expect, it } from "vitest"; +import { + changelogProblems, + forcedNames, + hasReleaseSection, + hasUnreleasedEntries, + identifierFor, + keywordProblems, + type Manifest, + type Move, + nextVersion, + parseArgs, + parseChanged, + planMoves, + planProblems, + requiredChangelogs, + rewriteManifest, + undoAdvice, +} 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({ + keyword: "prerelease", + all: false, + dryRun: false, + }); + }); + + it("reads a keyword and the flags in any order", () => { + expect(parseArgs(["--all", "preminor", "--dry-run"])).toEqual({ + keyword: "preminor", + all: true, + dryRun: true, + }); + }); + + it("rejects an explicit version, prepatch, and unknown options", () => { + expect(() => parseArgs(["0.1.0-3"])).toThrow(/usage/); + expect(() => parseArgs(["prepatch"])).toThrow(/usage/); + expect(() => parseArgs(["--force"])).toThrow(/unknown option/); + expect(() => parseArgs(["patch", "minor"])).toThrow(/usage/); + }); + + it("rejects an empty positional", () => { + expect(() => parseArgs([""])).toThrow(/usage/); + expect(() => parseArgs(["", "--dry-run"])).toThrow(/usage/); + }); +}); + +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/); + }); +}); + +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("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/); + }); +}); + +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"); + }, + ); +}); + +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", + ); + }); +}); + +// 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/version.ts b/bin/version.ts new file mode 100644 index 000000000..3a3e0607b --- /dev/null +++ b/bin/version.ts @@ -0,0 +1,766 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +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 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 interface Options { + keyword: Keyword; + all: boolean; + dryRun: boolean; +} + +function isKeyword(value: string): value is Keyword { + return (keywords as readonly string[]).includes(value); +} + +export function parseArgs(argv: string[]): Options { + const options = argv.filter((arg) => arg.startsWith("--")); + const positional = argv.filter((arg) => !arg.startsWith("--")); + const unknown = options.filter( + (arg) => arg !== "--all" && arg !== "--dry-run", + ); + if (unknown.length > 0) { + throw new Error(`unknown option: ${unknown.join(", ")}`); + } + // an empty positional is a usage error too, not a missing keyword + if (positional.length > 1 || positional.some((arg) => !isKeyword(arg))) { + 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", + 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[]; + // 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: 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; +} + +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; +} + +function schemasChangedSince(root: string, tag: string | undefined): boolean { + if (tag === undefined) { + return true; + } + const status = gitStatus(root, [ + "diff", + "--quiet", + tag, + "HEAD", + "--", + "schemas/", + ]); + if (status !== 0 && status !== 1) { + throw new Error(`git diff against ${tag} failed`); + } + 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); + 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); +} + +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); +} + +// 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; +} + +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 releaseTagsAtHead(root: string): string[] { + return git(root, ["tag", "--points-at", "HEAD", "--list", "@ethdebug/*@*"]) + .split("\n") + .filter((tag) => tag.length > 0); +} + +function preflight( + root: string, + plan: Move[], + directlyChanged: string[], +): string[] { + const findings: string[] = []; + const branch = git(root, ["rev-parse", "--abbrev-ref", "HEAD"]); + if (branch !== "main") { + findings.push(`on branch ${branch}, not main`); + } + if (git(root, ["status", "--porcelain", "--untracked-files=no"]).length > 0) { + findings.push("the working tree has uncommitted changes"); + } + const tags = tagCheck(root); + if (tags !== undefined) { + findings.push(tags); + } + // 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 + // first-release-only plan, which tags HEAD without committing: there + // 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", + ); + } + } + const taken = existingTags(root, plan); + 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; +} + +// 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(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`; + } + if (tags.length > 0) { + return `undo: ${tags}`; + } + return "undo: git checkout HEAD -- packages/*/package.json"; +} + +function report(plan: Move[]): void { + const width = Math.max(...plan.map((move) => move.name.length)); + for (const move of plan) { + const arrow = move.firstRelease + ? `first release -> ${move.to}` + : `${move.from} -> ${move.to}`; + console.log(` ${move.name.padEnd(width)} ${arrow} (${move.reason})`); + } +} + +export function main(argv: string[]): number { + const { keyword, all, dryRun } = parseArgs(argv); + const root = fileURLToPath(new URL("..", import.meta.url)); + const manifests = readManifests(root); + + const guard = keywordProblems(keyword, all, manifests); + if (guard.length > 0) { + for (const problem of guard) { + console.error(problem); + } + return 1; + } + + const specTag = lastSpecTag(root); + const schemasChanged = schemasChangedSince(root, specTag); + console.log( + schemasChanged + ? `schemas/ changed since ${specTag ?? "the beginning"}` + : `schemas/ unchanged since ${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); + if (problems.length > 0) { + for (const problem of problems) { + 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"); + return 0; + } + console.log(`${keyword}: ${plan.length} workspace(s) move`); + report(plan); + + 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) { + return 0; + } + if (findings.length > 0 || 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[] = []; + const created: string[] = []; + try { + written = writeManifests(root, manifests, plan); + commitAndTag(root, written, plan, created); + } 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)); + return 1; + } + console.log(`tagged: ${created.join(", ")}`); + if (written.length > 0) { + console.log(`committed Publish with ${written.length} manifest(s)`); + 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) { + try { + process.exit(main(process.argv.slice(2))); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} From 4c0bf3448adbb0917fd0b67c2e508730ce4f3127 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 17 Sep 2026 22:47:33 -0400 Subject: [PATCH 3/4] bin: choose the npm dist-tag from the version and the known versions CI can set exactly one dist-tag per publish, because trusted publishing covers `npm publish` and not `npm dist-tag`. A stable version publishes under "latest", unless a higher stable version is known, then under "release-.". A prerelease publishes under "latest" while the package has no stable version, and under its identifier ("draft" or "preview") after that. The known versions are the registry's list merged with the versions from the local tags of the package. The registry document was seen to lag minutes behind a publish; the tags do not lag, and they make a re-run after a partial failure give the same answer. --- bin/publish-tagged.test.ts | 78 +++++++++++++++++++++++++++++----- bin/publish-tagged.ts | 87 +++++++++++++++++++++++++++++++++----- 2 files changed, 143 insertions(+), 22 deletions(-) diff --git a/bin/publish-tagged.test.ts b/bin/publish-tagged.test.ts index 31745f3a4..e9d75f4d4 100644 --- a/bin/publish-tagged.test.ts +++ b/bin/publish-tagged.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { classifyView, + distTag, npmEnv, parseTags, publishArgs, @@ -102,16 +103,24 @@ describe("topoSort", () => { describe("classifyView", () => { it("treats E404 as unpublished", () => { expect( - classifyView(1, '{"error":{"code":"E404","summary":"x"}}', "0.1.0-1"), + classifyView(1, '{"error":{"code":"E404","summary":"x"}}', "0.1.0-1") + .result, ).toBe("unpublished"); }); it("treats a version present in the array as published", () => { - expect(classifyView(0, '["0.1.0-0","0.1.0-1"]', "0.1.0-1")).toBe( + expect(classifyView(0, '["0.1.0-0","0.1.0-1"]', "0.1.0-1").result).toBe( "published", ); }); it("treats a version absent from the array as unpublished", () => { - expect(classifyView(0, '["0.1.0-0"]', "0.1.0-1")).toBe("unpublished"); + expect(classifyView(0, '["0.1.0-0"]', "0.1.0-1").result).toBe( + "unpublished", + ); + }); + it("returns the known versions from the registry", () => { + expect( + classifyView(0, '["0.1.0-1","0.1.0-2"]', "0.1.0-2").versions, + ).toEqual(["0.1.0-1", "0.1.0-2"]); }); it("aborts on any other error", () => { expect(() => @@ -247,7 +256,7 @@ describe("readWorkspaces", () => { describe("publishArgs", () => { it("tags a publish as latest, on registry.npmjs.org", () => { - expect(publishArgs(false, {})).toEqual([ + expect(publishArgs(false, {}, "latest")).toEqual([ "publish", "--access", "public", @@ -258,8 +267,21 @@ describe("publishArgs", () => { ]); }); + it("tags a publish with the given dist-tag", () => { + expect(publishArgs(false, {}, "draft")).toContain("draft"); + expect(publishArgs(false, {}, "draft")).toEqual([ + "publish", + "--access", + "public", + "--tag", + "draft", + "--registry", + registry, + ]); + }); + it("appends --dry-run when requested", () => { - expect(publishArgs(true, {})).toEqual([ + expect(publishArgs(true, {}, "latest")).toEqual([ "publish", "--access", "public", @@ -272,7 +294,7 @@ describe("publishArgs", () => { }); it("appends --provenance under GitHub Actions", () => { - expect(publishArgs(false, { GITHUB_ACTIONS: "true" })).toEqual([ + expect(publishArgs(false, { GITHUB_ACTIONS: "true" }, "latest")).toEqual([ "publish", "--access", "public", @@ -285,13 +307,47 @@ describe("publishArgs", () => { }); it("omits --provenance outside GitHub Actions", () => { - expect(publishArgs(false, {})).not.toContain("--provenance"); + expect(publishArgs(false, {}, "latest")).not.toContain("--provenance"); }); it("always includes --registry pointing at registry.npmjs.org", () => { - expect(publishArgs(false, {})).toContain(registry); - expect(publishArgs(true, {})).toContain(registry); - expect(publishArgs(false, { GITHUB_ACTIONS: "true" })).toContain(registry); - expect(publishArgs(true, { GITHUB_ACTIONS: "true" })).toContain(registry); + expect(publishArgs(false, {}, "latest")).toContain(registry); + expect(publishArgs(true, {}, "latest")).toContain(registry); + expect(publishArgs(false, { GITHUB_ACTIONS: "true" }, "latest")).toContain( + registry, + ); + expect(publishArgs(true, { GITHUB_ACTIONS: "true" }, "latest")).toContain( + registry, + ); + }); +}); + +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 342351b79..63a57c268 100644 --- a/bin/publish-tagged.ts +++ b/bin/publish-tagged.ts @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import semver from "semver"; import { checkPackList, packList } from "./packlist.js"; export const registry = "https://registry.npmjs.org"; @@ -122,11 +123,16 @@ export function topoSort(workspaces: Workspace[]): Workspace[] { export type ViewResult = "published" | "unpublished"; +export interface View { + result: ViewResult; + versions: string[]; +} + export function classifyView( status: number, stdout: string, version: string, -): ViewResult { +): View { let parsed: unknown; try { parsed = JSON.parse(stdout); @@ -136,17 +142,21 @@ export function classifyView( if (status !== 0) { const code = (parsed as { error?: { code?: string } }).error?.code; if (code === "E404") { - return "unpublished"; + return { result: "unpublished", versions: [] }; } throw new Error(`npm view failed: ${code ?? stdout}`); } if (!Array.isArray(parsed)) { throw new Error(`npm view: unexpected non-array result: ${stdout}`); } - return parsed.includes(version) ? "published" : "unpublished"; + const versions = parsed as string[]; + return { + result: versions.includes(version) ? "published" : "unpublished", + versions, + }; } -export function viewVersions(name: string, version: string): ViewResult { +export function viewVersions(name: string, version: string): View { const result = spawnSync( "npm", ["view", name, "versions", "--json", "--registry", registry], @@ -163,13 +173,61 @@ export function viewVersions(name: string, version: string): ViewResult { } } -export function publishArgs(dryRun: boolean, env: NodeJS.ProcessEnv): string[] { +// 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, + tag: string, +): string[] { const args = [ "publish", "--access", "public", "--tag", - "latest", + tag, "--registry", registry, ]; @@ -182,8 +240,8 @@ export function publishArgs(dryRun: boolean, env: NodeJS.ProcessEnv): string[] { return args; } -function publish(workspace: Workspace, dryRun: boolean): void { - const args = publishArgs(dryRun, process.env); +function publish(workspace: Workspace, dryRun: boolean, tag: string): void { + const args = publishArgs(dryRun, process.env, tag); const result = spawnSync("npm", args, { cwd: workspace.dir, stdio: "inherit", @@ -214,7 +272,8 @@ export function main(argv: string[]): number { for (const workspace of selected) { const label = `${workspace.name}@${workspace.version}`; failed = label; - if (viewVersions(workspace.name, workspace.version) === "published") { + const view = viewVersions(workspace.name, workspace.version); + if (view.result === "published") { console.log(`${label}: already published, skipping`); skipped.push(label); failed = undefined; @@ -226,8 +285,14 @@ export function main(argv: string[]): number { `${label}: disallowed files in tarball:\n ${bad.join("\n ")}`, ); } - console.log(`${label}: publishing${dryRun ? " (dry run)" : ""}`); - publish(workspace, dryRun); + const tag = distTag(workspace.version, [ + ...view.versions, + ...localTagVersions(root, workspace.name), + ]); + console.log( + `${label}: publishing under ${tag}${dryRun ? " (dry run)" : ""}`, + ); + publish(workspace, dryRun, tag); published.push(label); failed = undefined; } From e72dc584397f53f055c4469ee53f39dbe58e4888 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 17 Sep 2026 22:47:33 -0400 Subject: [PATCH 4/4] docs: describe the draft/preview scheme and the version script The versioning model names the identifiers, what moves in a release, the series convention and the point at which to revisit it, and the dist-tag rule. Steps 3 and 4 of the runbook use bin/version.ts for the preview and for the bump, with a table of the keyword per phase and the filler wording for a changelog section with no changes. The tag-move recipe creates annotated tags, because `lerna changed` ignores lightweight ones. A short section records why Lerna does not bump versions here. --- .github/workflows/publish.yml | 4 +- RELEASING.md | 199 +++++++++++++++++++--------------- 2 files changed, 113 insertions(+), 90 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 60a555de7..23637d29c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,8 +9,8 @@ on: type: boolean default: true -# lerna.json sets no commit message; adding "[skip ci]" there would -# silently stop this workflow from publishing. +# bin/version.ts commits the bump as "Publish" with no "[skip ci]"; +# adding one would silently stop this workflow from publishing. jobs: check: diff --git a/RELEASING.md b/RELEASING.md index c5003f527..5c72d5f44 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,24 +8,45 @@ guards that run in CI live in `bin/check-tarballs.ts` and ## Versioning model -- Lerna runs in independent mode (`lerna.json`), so every workspace - carries its own version and its own git tag of the form - `@ethdebug/@`. +- Every workspace carries its own version and its own annotated git + tag of the form `@ethdebug/@`. Lerna runs in + independent mode (`lerna.json`) so that `lerna changed` reports + each workspace on its own, but Lerna does not bump versions: + `bin/version.ts` does (see "Why not `lerna version`" below). - The version of `@ethdebug/format` is the version of the specification itself. -- Prereleases use a plain numeric suffix: `0.1.0-0`, `0.1.0-1`, and - so on. Write them as `0.1.0-`. +- Prereleases carry a named identifier: `X.Y.Z-draft.` for + `@ethdebug/format`, because a prerelease of the spec is a draft, + and `X.Y.Z-preview.` for every other workspace, because those + packages work but implement a draft. Releases before `0.1.0-draft.0` + used a plain number (`0.1.0-0` to `0.1.0-2`); they sort before the + named ones. - Ten workspaces can take part in a release: the seven public packages (`format`, `pointers`, `evm`, `bugc`, `bugc-react`, `pointers-react`, `programs-react`) and the three private ones - (`format-web`, `bug-playground`, `conformance`). Private packages - get versions and tags like the others, but the publish script - skips them. -- Lerna bumps only the packages that changed since their last tag, - plus the packages that depend on them, and it tags only those. The - first release (`0.1.0-1`) moved all ten because no package had a - tag yet. To move all ten in lockstep on a later release, add - `--force-publish` to the `lerna version` command below. + (`format-web`, `bug-playground`, `conformance`). Private workspaces + get versions and tags like the others, but the publish script skips + them. +- What moves in a release: every workspace whose directory changed + since its own tag (changes to `CHANGELOG.md` and to test files do + not count), `@ethdebug/format` when `schemas/` changed (the schemas + live outside the package directory but ship inside it), and every + workspace that depends on a moving one, directly or transitively. + Every workspace depends on `@ethdebug/format`, so a specification + change moves all ten. +- Series convention, for now: all workspaces start a `major.minor` + series together and graduate together with the spec; between those + events each workspace moves only when it or a dependency changed, + with its own counter. Revisit this when one package needs a long + preview, or its own keyword, while the others release stable. Until + then, while any workspace is in a preview, a stable release of + another workspace graduates that preview too. +- npm dist-tags: a stable version publishes under `latest` (unless a + higher stable version exists, then `release-.`); a + prerelease publishes under `latest` while the package has no stable + version on the registry, and under its identifier (`draft`, + `preview`) after that. After a graduation the `draft`/`preview` + tag stays on the last prerelease until the next series starts. ## Cutting a release @@ -43,78 +64,76 @@ guards that run in CI live in `bin/check-tarballs.ts` and yarn lerna list --all --json ``` -3. Update the changelogs before bumping. The root `CHANGELOG.md` - tracks the spec version; each public package under +3. Preview the release and cut the changelogs. The root + `CHANGELOG.md` tracks the spec version; each public package under `packages/*/CHANGELOG.md` tracks that package's own version. - See which packages the next step will move: - ```console - yarn lerna changed + yarn tsx bin/version.ts [keyword] [--all] --dry-run ``` - The bump in step 4 moves every package that command lists, plus - every package that depends on one of them. - - For the root file, and for each package about to be bumped, - rename its `## Unreleased` heading to - `## `: that package's own new version, - then today's date. Leave a fresh, empty `## Unreleased` heading - above the section you renamed. A package that is not being bumped - needs no change. - - When you rename `## Unreleased` in the root file, reconcile its - entries against the previous published version. Each `Producers:` - and `Consumers:` line states the net effect for a party that - moves from that version to the new one. If one Unreleased entry - reverses an obligation of another Unreleased entry, neither - impact line keeps that obligation; the summaries may still tell - the history. - - A package that is bumped only because a dependency of it changed - has nothing under `## Unreleased`. Give it a `### Changed` entry - reading "Updated `@ethdebug/` to ``.", so that every - published version has a section of its own. - - Commit the renamed files on their own, right before the version - bump in the next step: + The dry run changes nothing. It lists every workspace that will + move, with its old and new version and the reason (`changed`, + `schemas`, `dependent`, `graduates`, or `all`), and it lists each + changelog that is not cut yet, with the exact `## ` + heading it expects. The keyword is one of: + + | keyword | use | + | ---------------- | --------------------------------------------- | + | `prerelease` | an ordinary release (the default) | + | `patch` | a stable fix; also graduates every prerelease | + | `preminor --all` | start the next `0.(Y+1).0` series with drafts | + | `premajor --all` | start the next major series with drafts | + | `minor --all` | release the next minor series stable at once | + | `major --all` | release the next major series stable at once | + + `--all` moves every workspace. The series-start keywords require + it and refuse to run while any workspace is a prerelease. Only + `preminor` and `premajor` know an exception: they run when every + workspace is a prerelease of the same `major.minor.patch` (the + draft and preview identifiers differ by design), which starts the + next draft series without a stable release in between. `minor` and + `major` have no exception, because on a prerelease they graduate in + place; run `patch` first while any prerelease exists. + + For each listed file, rename its `## Unreleased` heading to the + heading the dry run printed (`## `, that + file's own version, then today's date) and leave a fresh, empty + `## Unreleased` heading above it. A file whose section would be + empty gets one sentence: `No changes to the specification.` in the + root file, ``Updated `@ethdebug/` to ``.`` or + `No changes.` in a package file, so that every published version + has a section of its own. The root file is needed only when + `@ethdebug/format` moves. + + Reconcile the root file's Unreleased entries against the previous + published version: each `Producers:` and `Consumers:` line states + the net effect for a party that moves from that version to the new + one, so an obligation that a later entry in the same section + reverses appears in neither impact line. + + Commit the cut on its own: ```console git add CHANGELOG.md packages/*/CHANGELOG.md - git commit -m "docs: cut changelog entries for " + git commit -m "docs: cut changelog entries for release" ``` - Pre-flight check: in each file you touched, the only remaining - `## Unreleased` section is the empty one at the top. Never publish - with entries still sitting under `## Unreleased` in a changelog - for a package (or the spec) being released. - -4. Bump to an explicit version. Lerna commits the result as - `Publish` and creates one tag per bumped workspace on that - commit: +4. Bump. The script checks that you are on `main` with a clean tree, + that the nearest annotated tag is a release tag, that no tag for a + new version exists, and that every changelog is cut; then it + writes the versions and the internal dependency ranges into the + `package.json` files, commits them as `Publish` with hooks + disabled, and creates one annotated tag per moving workspace: ```console - yarn lerna version 0.1.0- --no-push --no-commit-hooks --yes + yarn tsx bin/version.ts [keyword] [--all] ``` - Each flag matters: - - The version MUST be explicit. `yarn lerna version prerelease` - does not produce `0.1.0-1` from `0.1.0-0`: Lerna resolves the - prerelease identifier as `--preid || existing preid || "alpha"`, - and a numeric prerelease has no identifier, so the result is - `0.1.0-alpha.0`. - - `--no-commit-hooks`: the repository's pre-commit hook runs - lint-staged, which would rewrite files in the `Publish` commit. - - `--no-push`: Lerna would otherwise run - `git push --follow-tags --no-verify --atomic ` - and, when the error text mentions "atomic", silently retry - WITHOUT `--atomic`. The push happens by hand in step 6 instead: - no non-atomic fallback, no `--no-verify` skipping the pre-push - hooks, and step 5's inspection happens before anything reaches - the remote. - - `--yes`: skips the confirmation prompt. Preview with the command - in step 2 first; do not use `--no-git-tag-version` as a - preview, because it still rewrites every `package.json`. + The script never pushes. If it fails after it started writing, it + prints the undo commands for the stage it reached. The dry run of + step 3 reports the same guards and findings as this run, but it + always exits 0, so read its output rather than its exit status. 5. Inspect the result before pushing: @@ -143,17 +162,7 @@ guards that run in CI live in `bin/check-tarballs.ts` and again, confirm that no tag for the version exists on the remote: ```console - git ls-remote --tags origin | grep '0.1.0-' # must be empty - ``` - - If Lerna's own push did run (`--no-push` was forgotten), its - non-atomic retry may have left the remote with tags but no commit, - or the reverse. See what landed with `git ls-remote --tags origin` - and `git ls-remote origin main`, then either finish the push with - the command above or delete each stray remote tag: - - ```console - git push origin :refs/tags/ + git ls-remote --tags origin | grep '@$' # must be empty ``` 7. Watch the workflow and confirm the result on the registry: @@ -197,7 +206,8 @@ manual dispatch. It has two jobs. re-run safe. Any registry error other than "package not found" aborts the run. 4. Checks the tarball contents (see "Guards" below), then runs - `npm publish` with `--access public`, `--tag latest` and + `npm publish` with `--access public`, the dist-tag chosen by the + rule in "Versioning model", and `--registry https://registry.npmjs.org`, plus `--provenance` when running under GitHub Actions. The first failed publish stops the run; the summary at the end lists the published, skipped and @@ -248,11 +258,14 @@ run. ```console for tag in $(git tag --points-at ); do - git tag -f "$tag" + git tag -f -a "$tag" -m "$tag" done git push --force origin $(git tag --points-at ) ``` + The tags must stay annotated: `lerna changed` ignores lightweight + tags. + The manifests already carry the version, so the tag-to-manifest check still passes. @@ -281,6 +294,17 @@ Requirements: Add `--dry-run` to see what would happen without publishing. Outside GitHub Actions the script does not pass `--provenance`. +## Why not `lerna version` + +Lerna 8 cannot produce this scheme. A keyword bump on a numeric +prerelease yields `alpha` (`--preid || existing || "alpha"`); an +explicit version moves every workspace whose prerelease number is +truthy, which is lockstep for `-1` and above and changed-only for +`-0`; and one run takes one `--preid`, so `draft` and `preview` cannot +start a series together. `bin/version.ts` therefore computes each +workspace's next version with `semver` and makes the commit and the +tags itself. Lerna still runs scripts and detects changes. + ## Guards - `bin/check-tarballs.ts` (CI, `run-tests` job) lists the files that @@ -309,10 +333,9 @@ GitHub Actions the script does not pass `--provenance`. the group when a newer one queues, so a `Publish` commit whose run shows "cancelled" must be re-run from the Actions UI or dispatched by hand. -- Prereleases are published under the `latest` dist-tag, so a plain - `npm install @ethdebug/format` installs a prerelease. At the first - stable release, either publish prereleases under `next` or move - `latest` with `npm dist-tag` afterwards. +- CI sets exactly one dist-tag per publish (trusted publishing covers + `npm publish` only, not `npm dist-tag`). After a graduation the + `draft` and `preview` tags keep pointing at the last prerelease. - npm's January 2027 change removes direct publishing with granular access tokens that bypass 2FA. It does not affect OIDC trusted publishing, and this repository stores no token, so nothing has to