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 }); + }); +});