Skip to content

Commit 5a46672

Browse files
os-zhuangclaude
andcommitted
ci(spec): gate generated reference docs, and un-dormant the sibling generated gates
content/docs/references/** is generated from packages/spec and committed, but no CI job ever regenerated and diffed it — `grep -rlE "gen:docs|gen:schema" .github/workflows/` returned nothing. So the public reference docs drifted silently while main stayed green: #3076 added RowCrudActionOverride to the spec and the docs never learned the type existed. The parent commit regenerates; this one stops it recurring. build-docs.ts --check Follows the sibling convention (check:skill-docs / check:api-surface / …). Every write now goes through emit() and every wiped folder through manageDir(), so both modes run identical generation logic and differ only in the final disposition — write, or compare. That shared path is what makes the gate trustworthy: it cannot pass on output a real run would not produce. Verified output-identical to the previous generator (byte-for-byte across all 258 files). Catches all three drift classes, each proven to fail before being trusted: stale content (the #3076 case), a missing page, and a page left behind by a type removed from spec. The last two are why this is not `git diff --exit-code`: that misses untracked files. json-schema/ is gitignored, so `check:docs` runs gen:schema first and a run that finds no schemas errors rather than passing vacuously. Placement In lint.yml's "TypeScript Type Check", not ci.yml's "Build Docs". Build Docs is gated on a `docs` paths-filter that excludes packages/spec/**, so it skips exactly the spec-only PRs that cause this drift — #3076 was one. The typecheck job has no paths filter and is a required status check, so the gate cannot go dormant. It reads src/ + json-schema/ via tsx and needs no build, so it runs before the workspace build and fails in ~2s. Same hole found in the sibling job (fixed here, both currently in sync but unguarded): - check:spec-changes / check:upgrade-guide read the ADR-0087 registries and the protocol version, but ran under a filter listing only skills/** — a protocol bump or new migration could not trigger the job that verifies them. - the filter watched content/docs/guides/skills.mdx, a path #2584 moved; hand-edits to the real generated block in content/docs/ai/skills-reference.mdx went unchecked. Job renamed check-skill-docs → check-generated: it has guarded the protocol artifacts for a while, and a name that hid them is part of why their paths went missing from the filter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2b5ced2 commit 5a46672

4 files changed

Lines changed: 174 additions & 31 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
outputs:
2525
docs: ${{ steps.changes.outputs.docs }}
2626
core: ${{ steps.changes.outputs.core }}
27-
skilldocs: ${{ steps.changes.outputs.skilldocs }}
27+
generated: ${{ steps.changes.outputs.generated }}
2828
steps:
2929
- name: Checkout repository
3030
uses: actions/checkout@v7
@@ -38,10 +38,30 @@ jobs:
3838
- 'content/**'
3939
- 'pnpm-lock.yaml'
4040
- '.github/workflows/ci.yml'
41-
skilldocs:
41+
# Inputs AND outputs of every generated artifact checked by the
42+
# `check-generated` job. A path missing here makes that gate dormant on
43+
# exactly the PRs that can break it, so keep the two in lockstep.
44+
generated:
45+
# check:skill-docs — catalog source is skills/*/SKILL.md; the derived
46+
# listings are skills/README.md + the reference page below. (That page
47+
# was content/docs/guides/skills.mdx until #2584 moved it; this filter
48+
# kept watching the old path, so hand-edits to the generated block went
49+
# unchecked from then until now.)
4250
- 'skills/**'
43-
- 'content/docs/guides/skills.mdx'
51+
- 'content/docs/ai/skills-reference.mdx'
4452
- 'packages/spec/scripts/build-skill-docs.ts'
53+
# check:spec-changes / check:upgrade-guide — derived from the ADR-0087
54+
# registries and the protocol version, none of which the skills paths
55+
# above cover. Both gates were dormant on spec-only PRs: a protocol bump
56+
# or a new migration could not trigger the job that verifies them.
57+
- 'packages/spec/scripts/build-spec-changes.ts'
58+
- 'packages/spec/scripts/build-upgrade-guide.ts'
59+
- 'packages/spec/src/migrations/**'
60+
- 'packages/spec/src/conversions/**'
61+
- 'packages/spec/src/kernel/protocol-version.ts'
62+
- 'packages/spec/api-surface.json'
63+
- 'packages/spec/spec-changes.json'
64+
- 'docs/protocol-upgrade-guide.md'
4565
- '.github/workflows/ci.yml'
4666
core:
4767
- 'packages/**'
@@ -354,10 +374,18 @@ jobs:
354374
- name: Build Docs
355375
run: pnpm --filter @objectstack/docs build
356376

357-
check-skill-docs:
358-
name: Check Skill Docs
377+
# Generated artifacts that are committed to the tree: regenerate, fail on drift.
378+
# Named for the class, not for skill docs alone — it has checked the ADR-0087
379+
# protocol artifacts for a while, and a name that hid them is part of why their
380+
# paths went missing from the filter above.
381+
#
382+
# The generated reference docs (content/docs/references/**) are deliberately NOT
383+
# here: they need to gate spec-only PRs, and this whole job is filter-gated, so
384+
# they live in lint.yml's required, unfiltered "TypeScript Type Check" job.
385+
check-generated:
386+
name: Check Generated Artifacts
359387
needs: filter
360-
if: needs.filter.outputs.skilldocs == 'true'
388+
if: needs.filter.outputs.generated == 'true'
361389
runs-on: ubuntu-latest
362390
permissions:
363391
contents: read

.github/workflows/lint.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,21 @@ jobs:
119119
- name: Type check (@objectstack/spec)
120120
run: pnpm --filter @objectstack/spec exec tsc --noEmit
121121

122+
# Generated-docs gate: content/docs/references/** is generated from the spec
123+
# by `gen:schema && gen:docs` and committed. Nothing regenerated it in CI, so
124+
# it drifted silently — #3076 added RowCrudActionOverride to the spec and the
125+
# public reference docs never learned the type existed. Regenerates and fails
126+
# on any difference.
127+
#
128+
# Deliberately lives in this job, not in ci.yml's "Build Docs": that job is
129+
# gated on a `docs` paths-filter that does not include packages/spec/**, so it
130+
# skips exactly the spec-only PRs that cause this drift (#3076 was one). This
131+
# job has no paths filter and is a required status check, so the gate cannot go
132+
# dormant. It reads src/ + json-schema/ via tsx and needs no build, so it runs
133+
# before the workspace build and fails in ~2s.
134+
- name: Check generated reference docs are in sync with the spec
135+
run: pnpm --filter @objectstack/spec check:docs
136+
122137
# Example apps are AI-authoring reference templates; a red typecheck is a
123138
# bad signal to copy from. tsup transpiles them without a full typecheck,
124139
# so build alone will not catch type drift — typecheck them explicitly.

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
"gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts",
189189
"gen:openapi": "tsx scripts/build-openapi.ts",
190190
"gen:docs": "tsx scripts/build-docs.ts",
191+
"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check",
191192
"gen:skill-refs": "tsx scripts/build-skill-references.ts",
192193
"gen:skill-docs": "tsx scripts/build-skill-docs.ts",
193194
"check:skill-docs": "tsx scripts/build-skill-docs.ts --check",

packages/spec/scripts/build-docs.ts

Lines changed: 124 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
// Hand-written documentation lives in the module folders under content/docs/
1212
// (data-modeling/, automation/, permissions/, ui/, api/, ai/, plugins/, kernel/, ...).
1313
// See DX_ROADMAP.md and .cursorrules for details.
14+
//
15+
// Usage:
16+
// tsx scripts/build-docs.ts # write
17+
// tsx scripts/build-docs.ts --check # verify in sync (CI); exit 1 on drift
1418

1519
import fs from 'fs';
1620
import path from 'path';
@@ -20,6 +24,107 @@ const SRC_DIR = path.resolve(__dirname, '../src');
2024
// Output directly to references folder (flattened)
2125
// ⚠️ Everything inside category sub-folders is auto-generated and disposable.
2226
const DOCS_ROOT = path.resolve(__dirname, '../../../content/docs/references');
27+
const REPO_ROOT = path.resolve(__dirname, '../../..');
28+
29+
const CHECK = process.argv.includes('--check');
30+
31+
// ── Output sink ──────────────────────────────────────────────────────────────
32+
// Every generated file goes through emit(), every wholesale-regenerated folder
33+
// through manageDir(). Nothing touches the output tree until flush(), so the
34+
// two modes run byte-for-byte identical generation logic and differ only in the
35+
// final disposition — write to disk, or compare against it. That shared path is
36+
// what makes --check trustworthy: it cannot pass on output a real run wouldn't
37+
// produce, because it *is* the real run minus the writes.
38+
39+
/** Absolute path → intended content. */
40+
const emitted = new Map<string, string>();
41+
/** Absolute dirs regenerated wholesale — anything on disk here that we didn't
42+
* emit is stale, and a real run would delete it. */
43+
const managedDirs = new Set<string>();
44+
45+
function emit(filePath: string, content: string): void {
46+
emitted.set(path.resolve(filePath), content);
47+
}
48+
49+
function manageDir(dir: string): void {
50+
managedDirs.add(path.resolve(dir));
51+
}
52+
53+
/** Files this run generated, for the read-after-write lookups below. */
54+
function wasEmitted(filePath: string): boolean {
55+
return emitted.has(path.resolve(filePath));
56+
}
57+
58+
function walk(dir: string): string[] {
59+
if (!fs.existsSync(dir)) return [];
60+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
61+
const full = path.join(dir, e.name);
62+
return e.isDirectory() ? walk(full) : [full];
63+
});
64+
}
65+
66+
const rel = (p: string) => path.relative(REPO_ROOT, p);
67+
68+
/** Write the emitted tree, or (in --check) report how it differs from disk. */
69+
function flush(): void {
70+
if (!CHECK) {
71+
for (const dir of managedDirs) {
72+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
73+
}
74+
for (const [file, content] of emitted) {
75+
fs.mkdirSync(path.dirname(file), { recursive: true });
76+
fs.writeFileSync(file, content);
77+
console.log(`✓ Generated ${rel(file)}`);
78+
}
79+
console.log(`\n✅ Generated ${emitted.size} files`);
80+
return;
81+
}
82+
83+
// A run with no schemas to read emits almost nothing, and "nothing differs"
84+
// would read as success — the gate would pass while checking no pages at all.
85+
// json-schema/ is gitignored, so this is one forgotten `gen:schema` away on a
86+
// fresh checkout. Fail loudly instead of greenly.
87+
if (managedDirs.size === 0) {
88+
console.error(
89+
`\n✗ No JSON schemas found under ${rel(SCHEMA_DIR)} — nothing to check against.\n` +
90+
` Run \`pnpm --filter @objectstack/spec gen:schema\` first (\`check:docs\` does this for you).\n`,
91+
);
92+
process.exit(1);
93+
}
94+
95+
const changed: string[] = [];
96+
const added: string[] = [];
97+
for (const [file, content] of emitted) {
98+
if (!fs.existsSync(file)) added.push(rel(file));
99+
else if (fs.readFileSync(file, 'utf-8') !== content) changed.push(rel(file));
100+
}
101+
// A managed folder is regenerated in full, so an on-disk file we didn't emit
102+
// is one a real run would delete — e.g. the page of a type removed from spec.
103+
const stale = [...managedDirs]
104+
.flatMap(walk)
105+
.filter(f => !wasEmitted(f))
106+
.map(rel);
107+
108+
const drift = [
109+
...added.map(f => ` + ${f} (missing — spec adds it)`),
110+
...changed.map(f => ` ~ ${f} (out of date)`),
111+
...stale.map(f => ` - ${f} (stale — spec no longer defines it)`),
112+
];
113+
114+
if (drift.length === 0) {
115+
console.log(`✅ ${emitted.size} reference files in sync with packages/spec`);
116+
return;
117+
}
118+
119+
console.error(
120+
`\n✗ content/docs/references/ is out of date with packages/spec:\n\n` +
121+
drift.join('\n') +
122+
`\n\nThese files are GENERATED — do not hand-edit them. Regenerate and commit:\n\n` +
123+
` pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs\n` +
124+
` git add content/docs/references\n`,
125+
);
126+
process.exit(1);
127+
}
23128

24129
// Dynamically discover categories from src directory
25130
const getCategoryTitle = (dir: string) => {
@@ -329,9 +434,7 @@ Object.keys(CATEGORIES).forEach(category => {
329434
}
330435
return;
331436
}
332-
if (fs.existsSync(dir)) {
333-
fs.rmSync(dir, { recursive: true, force: true });
334-
}
437+
manageDir(dir);
335438
});
336439

337440
const generatedFiles: string[] = [];
@@ -368,24 +471,21 @@ Object.keys(CATEGORIES).forEach(category => {
368471
zodFileSchemas.get(zodFile)!.push({ name: schemaName, content });
369472
});
370473

371-
// Create Category Directory
372474
const categoryDir = path.join(DOCS_ROOT, category);
373-
if (!fs.existsSync(categoryDir)) fs.mkdirSync(categoryDir, { recursive: true });
374475

375476
// Generate file
376477
zodFileSchemas.forEach((schemas, zodFile) => {
377478
const fileName = `${zodFile}.mdx`;
378479
const mdx = generateZodFileMarkdown(zodFile, schemas, category);
379-
fs.writeFileSync(path.join(categoryDir, fileName), mdx);
380-
console.log(`✓ Generated ${category}/${fileName}`);
480+
emit(path.join(categoryDir, fileName), mdx);
381481
});
382-
482+
383483
// Generate Category Meta
384484
const meta = {
385485
title: CATEGORIES[category],
386486
pages: Array.from(zodFileSchemas.keys()).sort()
387487
};
388-
fs.writeFileSync(path.join(categoryDir, 'meta.json'), JSON.stringify(meta, null, 2));
488+
emit(path.join(categoryDir, 'meta.json'), JSON.stringify(meta, null, 2));
389489
});
390490

391491
// 2.5 Generate Category Overviews (index.mdx in each folder)
@@ -408,24 +508,21 @@ Object.entries(CATEGORIES).forEach(([category, title]) => {
408508
// Flow edge schema carry CEL-expression transforms) — generates no page,
409509
// so carding it would be a dangling 404 link. This aligns the index with
410510
// `meta.json`, which already lists only generated pages.
411-
if (!fs.existsSync(path.join(DOCS_ROOT, category, `${zodFile}.mdx`))) return;
511+
//
512+
// Asks the sink, not the disk: this run's own output is the authority on
513+
// what pages exist. (Equivalent on disk, since the folder was just wiped
514+
// and rewritten — but it stays correct under --check, where nothing is
515+
// written and the stale files are still lying around.)
516+
if (!wasEmitted(path.join(DOCS_ROOT, category, `${zodFile}.mdx`))) return;
412517
const fileTitle = zodFile.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
413518
// Link relative to the category folder (where index.mdx lives)
414519
mdx += ` <Card href="/docs/references/${category}/${zodFile}" title="${fileTitle}" description="Source: packages/spec/src/${category}/${zodFile}.zod.ts" />\n`;
415520
});
416521
mdx += `</Cards>\n`;
417522

418-
// Write as index.mdx inside the category folder?
419-
// If we do that, accessing /docs/references/ai works.
420-
// BUT 'index' must be in 'meta.json' pages? No, index is implicit usually.
421-
422-
// However, Fumadocs often treats folder/index.mdx as the page for the folder.
423-
// Ensure directory exists before writing
424-
const categoryDir = path.join(DOCS_ROOT, category);
425-
if (!fs.existsSync(categoryDir)) fs.mkdirSync(categoryDir, { recursive: true });
426-
427-
fs.writeFileSync(path.join(categoryDir, 'index.mdx'), mdx);
428-
console.log(`✓ Generated ${category}/index.mdx`);
523+
// Fumadocs treats folder/index.mdx as the page for the folder, so this is what
524+
// makes /docs/references/<category> resolve.
525+
emit(path.join(DOCS_ROOT, category, 'index.mdx'), mdx);
429526
});
430527

431528
// 3. Update root meta.json
@@ -437,7 +534,9 @@ const categoryDirs = Object.keys(CATEGORIES)
437534
})
438535
.sort();
439536

440-
// Collect other root files (if any exist, like implementation-status.mdx)
537+
// Collect other root files (if any exist, like implementation-status.mdx).
538+
// Root-level .mdx is hand-written and never generated, so this reads the disk in
539+
// both modes — it is an input to the sidebar, not part of the emitted tree.
441540
const rootFiles = fs.readdirSync(DOCS_ROOT)
442541
.filter(f => f.endsWith('.mdx') && !f.startsWith('index')) // Exclude index.mdx if it exists?
443542
.map(f => f.replace('.mdx', ''))
@@ -455,7 +554,7 @@ const meta = {
455554
// NOT be a root tab — the whole docs tree renders as one sidebar.
456555
pages: pages
457556
};
458-
fs.writeFileSync(path.join(DOCS_ROOT, 'meta.json'), JSON.stringify(meta, null, 2));
459-
console.log(`✓ Updated root meta.json`);
557+
emit(path.join(DOCS_ROOT, 'meta.json'), JSON.stringify(meta, null, 2));
460558

461-
console.log('Done!');
559+
// 4. Disposition: write the tree, or report drift against it.
560+
flush();

0 commit comments

Comments
 (0)