Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
83 changes: 83 additions & 0 deletions scripts/sync-readme-version.ts
Original file line number Diff line number Diff line change
@@ -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();
}
50 changes: 47 additions & 3 deletions test/release/release-prep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
82 changes: 82 additions & 0 deletions test/scripts/sync-readme-version.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading