308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* version-consistency-smoke — Part 122 cp20.
|
|
*
|
|
* Asserts every place Morphit's version string lives agrees with
|
|
* the root package.json `version` field. Three categories of
|
|
* touchpoint:
|
|
*
|
|
* A. The workspace package.json `version` fields, discovered
|
|
* DYNAMICALLY by reading the root package.json's `workspaces`
|
|
* array. Adding/removing a workspace automatically expands
|
|
* or contracts the smoke's coverage — no need to update this
|
|
* file when the workspace list changes (DD-cp20-14, Part 122
|
|
* cp20 deep-deep).
|
|
*
|
|
* B. Two runtime version constants shipped to operators and
|
|
* external monitors via /v1/health:
|
|
* - apps/relay/src/api/health.ts: const VERSION = '…'
|
|
* - apps/indexer/src/api/health.ts: const INDEXER_VERSION = '…'
|
|
*
|
|
* C. Two doc example responses that operators/integrators read:
|
|
* - docs/API.md (one fenced ```json block with
|
|
* "version": "…")
|
|
* - apps/indexer/README.md (same)
|
|
*
|
|
* Why this exists. Pre-cp20 the runtime constants reported
|
|
* `0.3.0-phase3a` and `0.1.0-phase3b` while the root package.json
|
|
* said `0.0.0-phase3b` and the docs said `0.1.0-phase3b` — four
|
|
* different version strings, none of them the release tag. At
|
|
* v1.0.0-beta.1 launch a user hitting morphit.io/v1/health would
|
|
* have seen a phase-name that contradicted the release notes.
|
|
*
|
|
* The gate. On any version bump, the human edits ONE source of
|
|
* truth (root package.json) and then propagates to the other 13
|
|
* sites BEFORE this smoke turns green. Mismatch surfaces as a
|
|
* loud CI failure with a remediation hint per touchpoint.
|
|
*
|
|
* Output contract: emits `✓ all N version-consistency scenarios
|
|
* pass` on the last line; scenario N is the number of distinct
|
|
* touchpoints verified. Mismatch exits non-zero.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const REPO = join(import.meta.dirname, '..', '..', '..');
|
|
|
|
interface Touchpoint {
|
|
readonly label: string;
|
|
readonly path: string;
|
|
readonly extract: (text: string) => string | null;
|
|
/** What to tell the developer when this touchpoint is wrong. */
|
|
readonly remediation: string;
|
|
}
|
|
|
|
/** Reads root package.json `version` — the SOURCE OF TRUTH. */
|
|
function rootVersion(): string {
|
|
const raw = readFileSync(join(REPO, 'package.json'), 'utf8');
|
|
const pkg = JSON.parse(raw) as { version?: unknown };
|
|
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
|
|
throw new Error(
|
|
'root package.json `version` is missing or non-string — ' +
|
|
'this is the source of truth, fix it first.'
|
|
);
|
|
}
|
|
return pkg.version;
|
|
}
|
|
|
|
/** Extractor for a package.json file. */
|
|
function packageJsonExtractor(text: string): string | null {
|
|
try {
|
|
const pkg = JSON.parse(text) as { version?: unknown };
|
|
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Extractor for `const NAME = '...';` patterns in TS source.
|
|
*
|
|
* Returns the FIRST match. We anchor on the const-name to avoid
|
|
* picking up an unrelated literal somewhere else in the file. */
|
|
function tsConstExtractor(constName: string) {
|
|
const re = new RegExp(
|
|
`const\\s+${constName}\\s*(?::\\s*\\w+\\s*)?=\\s*['"]([^'"]+)['"]`
|
|
);
|
|
return (text: string): string | null => {
|
|
const m = text.match(re);
|
|
return m && m[1] ? m[1] : null;
|
|
};
|
|
}
|
|
|
|
/** Extractor for `"version": "…"` inside a fenced ```json block.
|
|
*
|
|
* We only want example-response versions, not arbitrary JSON
|
|
* fragments elsewhere in the doc. The regex requires the line
|
|
* to be inside a fenced block AND look like a real version
|
|
* string (digits + dots, optional pre-release suffix). We match
|
|
* the FIRST such line — by convention the API.md and README
|
|
* example responses are at the top of the health-endpoint
|
|
* section, so this is stable. */
|
|
function docExampleExtractor(text: string): string | null {
|
|
// Match "version": "<vstring>" anywhere; the smoke doc is small
|
|
// enough that "first occurrence" is well-defined.
|
|
const m = text.match(/"version"\s*:\s*"([0-9][0-9A-Za-z.+\-]*)"/);
|
|
return m && m[1] ? m[1] : null;
|
|
}
|
|
|
|
const TOUCHPOINTS_STATIC: readonly Touchpoint[] = [
|
|
// Category A — workspace package.json files are NOT listed here;
|
|
// they're discovered dynamically below from root package.json's
|
|
// `workspaces` array. This way the smoke stays correct when a
|
|
// workspace is added or removed without anyone remembering to
|
|
// update this file (DD-cp20-14, Part 122 cp20 deep-deep).
|
|
//
|
|
// The static list below covers the non-workspace touchpoints
|
|
// only.
|
|
|
|
// Category B — runtime constants shipped to /v1/health.
|
|
{
|
|
label: "apps/relay/src/api/health.ts (`const VERSION`)",
|
|
path: 'apps/relay/src/api/health.ts',
|
|
extract: tsConstExtractor('VERSION'),
|
|
remediation:
|
|
'update `const VERSION = ...` in apps/relay/src/api/health.ts'
|
|
},
|
|
{
|
|
label: "apps/indexer/src/api/health.ts (`const INDEXER_VERSION`)",
|
|
path: 'apps/indexer/src/api/health.ts',
|
|
extract: tsConstExtractor('INDEXER_VERSION'),
|
|
remediation:
|
|
'update `const INDEXER_VERSION = ...` in apps/indexer/src/api/health.ts'
|
|
},
|
|
{
|
|
// The MCP server advertises its version in the SDK server-info
|
|
// handshake (buildServer). Hoisted to `const MCP_VERSION` so this
|
|
// smoke can gate it like the other runtime constants — it used to
|
|
// be an inline literal that the smoke did not cover (cp308 deep-deep
|
|
// drift finding).
|
|
label: "apps/mcp-server/src/main.ts (`const MCP_VERSION`)",
|
|
path: 'apps/mcp-server/src/main.ts',
|
|
extract: tsConstExtractor('MCP_VERSION'),
|
|
remediation: 'update `const MCP_VERSION = ...` in apps/mcp-server/src/main.ts'
|
|
},
|
|
|
|
// Category C — doc example responses.
|
|
{
|
|
label: 'docs/API.md (health example response)',
|
|
path: 'docs/API.md',
|
|
extract: docExampleExtractor,
|
|
remediation:
|
|
'update the `"version"` line in the /v1/health example response in docs/API.md'
|
|
},
|
|
{
|
|
label: 'apps/indexer/README.md (health example response)',
|
|
path: 'apps/indexer/README.md',
|
|
extract: docExampleExtractor,
|
|
remediation:
|
|
'update the `"version"` line in the /v1/health example response in apps/indexer/README.md'
|
|
}
|
|
] as const;
|
|
|
|
/** Build the full touchpoint list dynamically.
|
|
*
|
|
* Reads the root package.json's `workspaces` array, treats every
|
|
* entry as a workspace directory (must have package.json), and
|
|
* emits one Touchpoint per (root + workspace) plus the static
|
|
* Category B + Category C touchpoints above.
|
|
*
|
|
* Why dynamic: hardcoding the list of workspace package.json
|
|
* files in this smoke means adding a new workspace silently
|
|
* leaves its version unchecked. Reading the canonical workspaces
|
|
* array closes that drift mode — the smoke automatically expands
|
|
* to cover whatever the root package.json declares.
|
|
*
|
|
* Limitation: only handles exact path entries in `workspaces`,
|
|
* not globs like `apps/*`. If a future maintainer adds glob
|
|
* workspace entries, this function should be extended with
|
|
* fs.globSync (Node 22+). Today's root package.json has only
|
|
* exact paths, so this is sufficient. */
|
|
function buildTouchpoints(): readonly Touchpoint[] {
|
|
const rootRaw = readFileSync(join(REPO, 'package.json'), 'utf8');
|
|
const rootPkg = JSON.parse(rootRaw) as { workspaces?: unknown };
|
|
if (!Array.isArray(rootPkg.workspaces)) {
|
|
throw new Error(
|
|
'root package.json `workspaces` is missing or non-array — ' +
|
|
'cannot enumerate workspace package.json files'
|
|
);
|
|
}
|
|
const wsEntries: string[] = [];
|
|
for (const w of rootPkg.workspaces) {
|
|
if (typeof w !== 'string') {
|
|
throw new Error(
|
|
'root package.json `workspaces` entry not a string: ' +
|
|
JSON.stringify(w)
|
|
);
|
|
}
|
|
if (w.includes('*') || w.includes('?')) {
|
|
throw new Error(
|
|
"root package.json `workspaces` entry '" +
|
|
w +
|
|
"' contains a glob; " +
|
|
'this smoke only supports exact paths today — extend ' +
|
|
'buildTouchpoints() with fs.globSync (Node 22+) if glob ' +
|
|
'support is needed.'
|
|
);
|
|
}
|
|
wsEntries.push(w);
|
|
}
|
|
|
|
const dynamic: Touchpoint[] = [
|
|
{
|
|
label: 'root package.json',
|
|
path: 'package.json',
|
|
extract: packageJsonExtractor,
|
|
remediation: 'edit `version` in the root package.json'
|
|
}
|
|
];
|
|
for (const ws of wsEntries) {
|
|
dynamic.push({
|
|
label: `${ws}/package.json`,
|
|
path: `${ws}/package.json`,
|
|
extract: packageJsonExtractor,
|
|
remediation: `edit \`version\` in ${ws}/package.json`
|
|
});
|
|
}
|
|
|
|
return [...dynamic, ...TOUCHPOINTS_STATIC];
|
|
}
|
|
|
|
function main(): void {
|
|
const expected = rootVersion();
|
|
const TOUCHPOINTS = buildTouchpoints();
|
|
const mismatches: Array<{
|
|
label: string;
|
|
got: string | null;
|
|
remediation: string;
|
|
}> = [];
|
|
|
|
for (const tp of TOUCHPOINTS) {
|
|
let text: string;
|
|
try {
|
|
text = readFileSync(join(REPO, tp.path), 'utf8');
|
|
} catch (e) {
|
|
mismatches.push({
|
|
label: tp.label,
|
|
got: null,
|
|
remediation: `file missing: ${tp.path} — ${tp.remediation}`
|
|
});
|
|
continue;
|
|
}
|
|
const got = tp.extract(text);
|
|
if (got !== expected) {
|
|
mismatches.push({
|
|
label: tp.label,
|
|
got,
|
|
remediation: tp.remediation
|
|
});
|
|
}
|
|
}
|
|
|
|
// cp188 — release-notes file MUST exist for the current version.
|
|
// Ken's standing rule: every release ships notes that go online
|
|
// with it. The release CI uploads the tarball but does not author
|
|
// the release body, so nothing otherwise forces a notes file into
|
|
// existence on a version bump. Tie it to the version here: bumping
|
|
// package.json to v1.0.0-beta.2 without creating
|
|
// RELEASE-NOTES-v1.0.0-beta.2.md now fails this gate.
|
|
const notesFile = `RELEASE-NOTES-v${expected}.md`;
|
|
try {
|
|
const notes = readFileSync(join(REPO, notesFile), 'utf8');
|
|
if (notes.trim().length === 0) {
|
|
mismatches.push({
|
|
label: notesFile,
|
|
got: '<empty>',
|
|
remediation: `${notesFile} exists but is empty — write the release notes that will be published online with this version`
|
|
});
|
|
}
|
|
} catch {
|
|
mismatches.push({
|
|
label: notesFile,
|
|
got: null,
|
|
remediation: `create ${notesFile} at the repo root — every release must ship notes for publishing online (copy the structure of the prior RELEASE-NOTES-v*.md)`
|
|
});
|
|
}
|
|
|
|
if (mismatches.length > 0) {
|
|
console.error(
|
|
`✗ version-consistency-smoke FAILED — expected '${expected}' (from root package.json), found:`
|
|
);
|
|
for (const m of mismatches) {
|
|
console.error(
|
|
` - ${m.label}: ${m.got === null ? '<not found>' : `'${m.got}'`}`
|
|
);
|
|
console.error(` fix: ${m.remediation}`);
|
|
}
|
|
console.error(
|
|
`\nWhen bumping for a release, change all ${TOUCHPOINTS.length} touchpoints in the same commit.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
`✓ all ${TOUCHPOINTS.length} version-consistency scenarios pass (every touchpoint reports '${expected}'), and ${notesFile} exists`
|
|
);
|
|
}
|
|
|
|
main();
|