From e3dad2bd2b8678fed7f3392b485dbe5d7d0da85d Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 25 Sep 2026 20:44:08 +0000 Subject: [PATCH] Let the release commit pass its own gates: README status version and the release-prep check The release pipeline runs pnpm test on Changesets' version commit before it publishes. That commit moves package.json to 0.1.0 and consumes every pending changeset, and two gates asserted the pre-bump state only, so the 0.1.0 publish would have stopped in the Verify step: - test/readme-standard.test.ts requires README `## Status` to name the version package.json declares, and at 0.1.0 and later to state the API is "settled and safe to depend on". Changesets rewrites only package.json, so the README still said 0.0.18. - test/release/release-prep.test.ts asserted a 0.0.x manifest and a pending set resolving to 0.1.0, both false on the version commit. Fix: - scripts/sync-readme-version.ts rewrites the one `**Version X.Y.Z**` marker in `## Status` to package.json's version, and refuses when the section or a single marker is missing. The `version` script runs it after sync-version.mjs, so the bump and the README line land in the same commit, as the VERSION constant already does. Tested on README text built in the test, plus a check that this README has exactly one marker. - README `## Status` now states what 0.1.0 promises ("settled and safe to depend on", stable exports and codes, breaking changes bump the minor below 1.0), which is true after the bump and says "from 0.1.0" before it. - The release-prep check recognizes the release commit: the manifest at the prepared version AND nothing pending. Only there are the three prepare-state checks skipped. A premature bump and a set removed without the bump both still fail, pinned by synthetic cases; the section and inventory-enumeration checks run in every state. Claude-Session: https://claude.ai/code/session_01U24bnHKNxQWMxmfN7ny7w9 --- README.md | 7 +- package.json | 2 +- scripts/sync-readme-version.ts | 83 ++++++++++++++++++++++++ test/release/release-prep.test.ts | 50 +++++++++++++- test/scripts/sync-readme-version.test.ts | 82 +++++++++++++++++++++++ 5 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 scripts/sync-readme-version.ts create mode 100644 test/scripts/sync-readme-version.test.ts diff --git a/README.md b/README.md index 4691e00..9ccb40c 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,11 @@ a stable code instead of failing or guessing. **Version 0.0.18**, published on npm from a public repository. -On the `0.0.x` ladder the public API is not settled and may still move before `0.1.0`, so pin an -exact version rather than a range. Typed read and typed emit both ship for every transaction set +From `0.1.0` the public API is settled and safe to depend on: the exported readers, builders, +types and warning codes are the surface we keep stable, and the warning registry only ever gains +codes. Below 1.0 a breaking change bumps the minor version and is called out in the changelog with +its migration. On a `0.0.x` version, pin an exact version rather than a range. Typed read and typed +emit both ship for every transaction set this package covers: 270, 271, 276, 277 and 277CA, 278 request and response, 820, 834, 835, 837P, 837I and 837D, 999 and TA1, each with a reader and a matching domain builder, and the two claims attachments guides 45 CFR 162.2002 adopts for the period on and after 26 May 2028: the 277 request diff --git a/package.json b/package.json index 4565b1a..0049929 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "verify:exports": "node scripts/verify-exports.mjs && node scripts/verify-exports.cjs", "pack:docs": "bash scripts/build-docs-artifacts.sh", "attw": "node scripts/attw.mjs", - "version": "changeset version && node scripts/sync-version.mjs && prettier --write package.json src/index.ts", + "version": "changeset version && node scripts/sync-version.mjs && tsx scripts/sync-readme-version.ts && prettier --write package.json src/index.ts README.md", "release": "changeset publish", "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm attw", "prepare": "command -v simple-git-hooks >/dev/null 2>&1 && simple-git-hooks || true" diff --git a/scripts/sync-readme-version.ts b/scripts/sync-readme-version.ts new file mode 100644 index 0000000..e14062f --- /dev/null +++ b/scripts/sync-readme-version.ts @@ -0,0 +1,83 @@ +/** + * Sync the version README.md's `## Status` section names with `package.json`'s `version`. + * + * Why this exists: `test/readme-standard.test.ts` requires `## Status` to name exactly the version + * `package.json` declares, and the release pipeline runs `pnpm test` on the "Version Packages" + * commit before it publishes. Changesets rewrites `package.json` only, so without this step that + * commit fails its own test and the publish stops. The `version` script runs this after + * `changeset version` and `sync-version.mjs`, so the bump and the README line land in the same + * commit, the same way the `VERSION` constant does. + * + * It rewrites exactly one thing: the `**Version X.Y.Z**` marker in the `## Status` section. It is + * idempotent, and it refuses (exit 1) when the section or the marker is missing or appears more + * than once, so a reworded status cannot silently stop being synced. + * + * tsx scripts/sync-readme-version.ts + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** The status marker: a bold `Version` followed by a semantic version. */ +const MARKER = /\*\*Version (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\*\*/g; + +/** + * The README with the `## Status` marker naming `version`, or an error message. + * + * @param readme - The whole README.md text. + * @param version - `package.json` `version`. + * @returns `{ text }` with the synced README, or `{ error }` naming why it could not be synced. + */ +export function syncReadmeVersion( + readme: string, + version: string, +): { readonly text: string } | { readonly error: string } { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + return { error: `package.json version ${JSON.stringify(version)} is not a semantic version` }; + } + const start = readme.indexOf("\n## Status\n"); + if (start === -1) return { error: "README.md has no `## Status` section" }; + const next = readme.indexOf("\n## ", start + 1); + const end = next === -1 ? readme.length : next; + const section = readme.slice(start, end); + const markers = [...section.matchAll(MARKER)]; + if (markers.length !== 1) { + return { + error: + `README.md \`## Status\` carries ${String(markers.length)} \`**Version X.Y.Z**\` ` + + "markers; expected exactly one, so there is no single line to sync", + }; + } + // A replacer function, so a version string is inserted literally and never read as a pattern. + const synced = section.replace(MARKER, () => `**Version ${version}**`); + return { text: readme.slice(0, start) + synced + readme.slice(end) }; +} + +function main(): void { + const readmePath = join(REPO_ROOT, "README.md"); + const manifest: unknown = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")); + const version = + typeof manifest === "object" && manifest !== null && "version" in manifest + ? manifest.version + : undefined; + const readme = readFileSync(readmePath, "utf8"); + const result = syncReadmeVersion(readme, typeof version === "string" ? version : ""); + if ("error" in result) { + console.error(`sync-readme-version: ${result.error}`); + process.exit(1); + } + if (result.text === readme) { + console.log(`sync-readme-version: README.md already names ${String(version)}`); + return; + } + writeFileSync(readmePath, result.text); + console.log(`sync-readme-version: README.md -> ${String(version)}`); +} + +// Run only as a CLI, never on import (the test imports the function above). +if (import.meta.url === `file://${process.argv[1] ?? ""}`) { + main(); +} diff --git a/test/release/release-prep.test.ts b/test/release/release-prep.test.ts index 7dd736c..9cc6ea3 100644 --- a/test/release/release-prep.test.ts +++ b/test/release/release-prep.test.ts @@ -22,6 +22,17 @@ * like a segment, so the AC8 control proves the detector rather than * assuming it, is assembled at runtime by {@link seg} - the same shape * `test/scripts/phi-scan.test.ts` uses, and for the same reason. + * + * THE RELEASE COMMIT ITSELF PASSES, AND THAT IS A STATE, NOT AN EXEMPTION. The + * release pipeline runs `pnpm test` on Changesets' version commit before it + * publishes, and that commit moves the manifest to `0.1.0` and consumes every + * pending changeset at once. There the prepare-state checks that read the + * pending set and the manifest band are skipped, and only there: + * {@link isReleaseCommit} holds when the manifest reads the prepared version + * AND nothing is pending. A premature bump (the prepared version with the set + * still pending) and a set removed without the bump both still fail, each + * pinned by a case below, and the section and inventory-enumeration checks run + * in every state. */ import { readFileSync, readdirSync } from "node:fs"; @@ -901,20 +912,53 @@ function failsWith(state: RepoState, pattern: RegExp): void { expect(failures.some((failure) => pattern.test(failure))).toBe(true); } +/** + * Whether the tree is Changesets' version commit for the prepared release: the + * manifest already reads the prepared version and the whole set is consumed. + * Both halves are required, so neither a hand bump with the set still pending + * nor a set deleted without the bump reads as a release. + */ +function isReleaseCommit( + manifestVersion: string, + changesets: readonly PendingChangeset[], +): boolean { + return manifestVersion === PREPARED_VERSION && changesets.length === 0; +} + +const RELEASE_COMMIT = isReleaseCommit(readManifestVersion(ROOT), readPendingChangesets(ROOT)); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- +describe("release prep 0.1.0: recognizing the release commit", () => { + it("is the prepared version with nothing left pending", () => { + expect(isReleaseCommit(PREPARED_VERSION, [])).toBe(true); + }); + + it("is not a bump made while the set is still pending", () => { + expect(isReleaseCommit(PREPARED_VERSION, FIXTURE_CHANGESETS)).toBe(false); + }); + + it("is not a set removed without the bump", () => { + expect(isReleaseCommit("0.0.18", [])).toBe(false); + }); + + it("is not the 0.0.x manifest with the set pending, which is the prepared state", () => { + expect(isReleaseCommit("0.0.18", FIXTURE_CHANGESETS)).toBe(false); + }); +}); + describe("release prep 0.1.0: the repository", () => { - it("passes the release-prep check", { timeout: 60_000 }, () => { + it.skipIf(RELEASE_COMMIT)("passes the release-prep check", { timeout: 60_000 }, () => { expect(checkReleasePrep(readRepoState())).toEqual([]); }); - it("AC7: the package manifest is still in the 0.0.x band", () => { + it.skipIf(RELEASE_COMMIT)("AC7: the package manifest is still in the 0.0.x band", () => { expect(readManifestVersion(ROOT)).toMatch(MANIFEST_BAND); }); - it("AC3: the pending changesets resolve to exactly 0.1.0", () => { + it.skipIf(RELEASE_COMMIT)("AC3: the pending changesets resolve to exactly 0.1.0", () => { const parsed = readPendingChangesets(ROOT).map(parseChangeset); expect(parsed.filter((entry) => typeof entry === "string")).toEqual([]); const bumps = parsed.filter((entry): entry is ParsedChangeset => typeof entry !== "string"); diff --git a/test/scripts/sync-readme-version.test.ts b/test/scripts/sync-readme-version.test.ts new file mode 100644 index 0000000..c7765f5 --- /dev/null +++ b/test/scripts/sync-readme-version.test.ts @@ -0,0 +1,82 @@ +/** + * `scripts/sync-readme-version.ts` keeps the version README.md's `## Status` names equal to + * `package.json` across a Changesets version commit, which is what lets that commit pass + * `test/readme-standard.test.ts` before the release publishes it. Every case is driven on README + * text built here, never on the repository's own README. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { syncReadmeVersion } from "../../scripts/sync-readme-version.js"; + +const README = [ + "# @cosyte/x12", + "", + "## Status", + "", + "**Version 0.0.18**, published on npm.", + "", + "From `0.1.0` the public API is settled and safe to depend on.", + "", + "## Install", + "", + "**Version 9.9.9** outside the section is never touched.", + "", +].join("\n"); + +describe("syncing the README status version", () => { + it("rewrites the one marker in ## Status to the manifest version", () => { + const result = syncReadmeVersion(README, "0.1.0"); + expect("text" in result && result.text).toBe( + README.replace("**Version 0.0.18**", "**Version 0.1.0**"), + ); + }); + + it("leaves everything outside the marker byte for byte", () => { + const result = syncReadmeVersion(README, "0.1.0"); + const text = "text" in result ? result.text : ""; + expect(text).toContain("From `0.1.0` the public API is settled and safe to depend on."); + expect(text).toContain("**Version 9.9.9** outside the section is never touched."); + }); + + it("is idempotent", () => { + expect(syncReadmeVersion(README, "0.0.18")).toEqual({ text: README }); + }); + + it("refuses a README with no ## Status section", () => { + expect(syncReadmeVersion("# x\n\n## Install\n", "0.1.0")).toHaveProperty("error"); + }); + + it("refuses a status with no marker, so a reworded line cannot stop being synced in silence", () => { + const reworded = README.replace("**Version 0.0.18**", "Version 0.0.18"); + expect(syncReadmeVersion(reworded, "0.1.0")).toHaveProperty("error"); + }); + + it("refuses a status with two markers", () => { + const doubled = README.replace( + "**Version 0.0.18**, published on npm.", + "**Version 0.0.18**, then **Version 0.0.17**.", + ); + expect(syncReadmeVersion(doubled, "0.1.0")).toHaveProperty("error"); + }); + + it("refuses a manifest version that is not a semantic version", () => { + expect(syncReadmeVersion(README, "")).toHaveProperty("error"); + expect(syncReadmeVersion(README, "$&")).toHaveProperty("error"); + }); + + it("finds exactly one marker in this repository's README", () => { + const readme = readFileSync(join(import.meta.dirname, "..", "..", "README.md"), "utf8"); + const pkg: unknown = JSON.parse( + readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8"), + ); + const version = + typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" + ? pkg.version + : ""; + expect(syncReadmeVersion(readme, version)).toEqual({ text: readme }); + }); +});