Skip to content

Commit ae0faa8

Browse files
authored
ci(workflows): enforce generated-artifact drift checks in CI, not just locally (#3254)
* ci(workflows): enforce the 3 generated-artifact drift checks that only ran locally selfhost:env-reference:check, command-reference:check, and docs:drift-check are part of the local `npm run test:ci` aggregate script, but none of the three were ever wired into .github/workflows/ci.yml's validate-code job -- only the local convenience script enforced them, so a merged PR that changed src/selfhost/ai.ts, src/github/commands.ts, or a GITTENSORY_REVIEW_*/gate-mode field could (and did, twice in one day) silently leave the corresponding generated reference/docs page stale with zero CI signal, discovered only when someone happened to run the full local script. Gated on the same `backend` path filter as the neighboring db-schema-drift and cf-typegen drift checks, since all three read src/**/scripts/** source. * ci(workflows): also gate the drift checks on UI-side changes The review on this PR correctly flagged that gating the three new drift checks on `backend` alone misses the case where the UI-side artifact they validate (the generated reference file, or a docs page) is edited directly without any backend source change -- selfhost-env- reference.ts, command-reference.ts, and the docs pages docs:drift-check cross-checks all live under apps/gittensory-ui/**. Add `|| needs.changes.outputs.ui == 'true'` to all three conditions so a UI-only edit that desyncs one of them from its source of truth still fails CI. * test(ci): cover the 3 new generated-artifact drift-check workflow steps Mirrors ci-cf-typegen-check.test.ts's own assertion shape for the same class of drift guard: asserts each check's npm script exists and is wired into test:ci, that ci.yml's validate-code job runs a step for it with the exact command, and that all three share the same backend-or-ui gating condition (the review on this PR flagged the missing test coverage for a workflow-only change; this closes that gap the way the repo's existing precedent for exactly this kind of change already does). * chore(selfhost): regenerate env-reference after rebase (again) Third time today this drifted from an unrelated src/selfhost/ai.ts change landing on main between rebases -- exactly the recurring gap this PR's new CI step closes going forward.
1 parent a0d3bd6 commit ae0faa8

3 files changed

Lines changed: 123 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,30 @@ jobs:
205205
- name: cf-typegen drift check
206206
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
207207
run: npm run cf-typegen:check
208+
# Same drift class as cf-typegen/schema-drift above, just a different generated artifact: this file's
209+
# firstReference line numbers point into src/selfhost/ai.ts, so any backend change that shifts those
210+
# lines (not just one that adds/removes an env var) can make it stale. Was previously enforced ONLY by
211+
# the local `npm run test:ci` aggregate script, never by this workflow -- went stale twice in one day
212+
# (2026-07-04) with zero CI signal until someone happened to run the full local script. Gated on `ui`
213+
# too, not just `backend`: the generated file itself lives under apps/gittensory-ui/**, so a PR that
214+
# hand-edits it (or a docs page docs:drift-check below cross-checks) without touching backend source
215+
# would otherwise never re-run this check.
216+
- name: Selfhost env-reference drift check
217+
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
218+
run: npm run selfhost:env-reference:check
219+
# Same generated-artifact-drift class, extracted from src/github/commands.ts's command catalogs (#3046).
220+
# Also local-only until now; same backend-or-ui gating rationale as the step above.
221+
- name: Command reference drift check
222+
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
223+
run: npm run command-reference:check
224+
# Cross-checks docs pages against source-of-truth enumerable surfaces (feature flags, commands, gate
225+
# modes) -- see the script's own header comment. Also local-only until now; gated on `ui` as well as
226+
# `backend` since the docs pages it validates are themselves UI-side content that can drift on their
227+
# own (a docs-only edit that renames/drops a documented flag/command/gate-mode) with no backend change
228+
# to trigger a re-check otherwise.
229+
- name: Docs drift check
230+
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
231+
run: npm run docs:drift-check
208232
- name: Validate observability configs
209233
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.observability == 'true' }}
210234
env:

apps/gittensory-ui/src/lib/selfhost-env-reference.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
1515
},
1616
{
1717
name: "AI_EMBED_API_KEY",
18-
firstReference: "src/server.ts:440",
18+
firstReference: "src/server.ts:441",
1919
},
2020
{
2121
name: "AI_EMBED_BASE_URL",
22-
firstReference: "src/server.ts:437",
22+
firstReference: "src/server.ts:438",
2323
},
2424
{
2525
name: "AI_EMBED_MODEL",
@@ -47,7 +47,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
4747
},
4848
{
4949
name: "BACKUP_ACKNOWLEDGED",
50-
firstReference: "src/server.ts:379",
50+
firstReference: "src/server.ts:380",
5151
},
5252
{
5353
name: "BROWSER_WS_ENDPOINT",
@@ -83,11 +83,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
8383
},
8484
{
8585
name: "CRON_INTERVAL_MS",
86-
firstReference: "src/server.ts:917",
86+
firstReference: "src/server.ts:919",
8787
},
8888
{
8989
name: "DATABASE_PATH",
90-
firstReference: "src/server.ts:249",
90+
firstReference: "src/server.ts:250",
9191
},
9292
{
9393
name: "DATABASE_URL",
@@ -127,7 +127,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
127127
},
128128
{
129129
name: "GITHUB_CACHE_TTL_SECONDS",
130-
firstReference: "src/server.ts:509",
130+
firstReference: "src/server.ts:510",
131131
},
132132
{
133133
name: "GITHUB_INSTALLATION_CONCURRENCY_DEFER_MS",
@@ -143,7 +143,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
143143
},
144144
{
145145
name: "GITTENSORY_REPO_CONFIG_DIR",
146-
firstReference: "src/server.ts:288",
146+
firstReference: "src/server.ts:289",
147147
},
148148
{
149149
name: "GITTENSORY_VERSION",
@@ -187,7 +187,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
187187
},
188188
{
189189
name: "MIGRATIONS_DIR",
190-
firstReference: "src/server.ts:392",
190+
firstReference: "src/server.ts:393",
191191
},
192192
{
193193
name: "OBSERVABILITY_SMOKE_POLL_MS",
@@ -247,7 +247,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
247247
},
248248
{
249249
name: "ORB_BROKER_URL",
250-
firstReference: "src/server.ts:966",
250+
firstReference: "src/server.ts:968",
251251
},
252252
{
253253
name: "ORB_COLLECTOR_TOKEN",
@@ -263,7 +263,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
263263
},
264264
{
265265
name: "ORB_RELAY_MODE",
266-
firstReference: "src/server.ts:968",
266+
firstReference: "src/server.ts:970",
267267
},
268268
{
269269
name: "OTEL_EXPORTER_OTLP_ENDPOINT",
@@ -299,11 +299,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
299299
},
300300
{
301301
name: "PGVECTOR_ENABLED",
302-
firstReference: "src/server.ts:229",
302+
firstReference: "src/server.ts:230",
303303
},
304304
{
305305
name: "PORT",
306-
firstReference: "src/server.ts:716",
306+
firstReference: "src/server.ts:718",
307307
},
308308
{
309309
name: "PUBLIC_API_ORIGIN",
@@ -319,7 +319,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
319319
},
320320
{
321321
name: "QDRANT_URL",
322-
firstReference: "src/server.ts:528",
322+
firstReference: "src/server.ts:529",
323323
},
324324
{
325325
name: "QUEUE_BACKGROUND_CONCURRENCY",
@@ -343,7 +343,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
343343
},
344344
{
345345
name: "REVIEW_AUDIT_DIR",
346-
firstReference: "src/server.ts:573",
346+
firstReference: "src/server.ts:574",
347347
},
348348
{
349349
name: "SELFHOST_BUNDLE_ALL",
@@ -379,7 +379,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
379379
},
380380
{
381381
name: "SETUP_OUTPUT_PATH",
382-
firstReference: "src/server.ts:833",
382+
firstReference: "src/server.ts:835",
383383
},
384384
{
385385
name: "SLACK_WEBHOOK_URL",
@@ -392,15 +392,15 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
392392
"| --- | --- |",
393393
"| `AI_COMBINE` | `src/selfhost/ai.ts:1028` |",
394394
"| `AI_DUAL_REVIEW` | `src/selfhost/ai.ts:1003` |",
395-
"| `AI_EMBED_API_KEY` | `src/server.ts:440` |",
396-
"| `AI_EMBED_BASE_URL` | `src/server.ts:437` |",
395+
"| `AI_EMBED_API_KEY` | `src/server.ts:441` |",
396+
"| `AI_EMBED_BASE_URL` | `src/server.ts:438` |",
397397
"| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:900` |",
398398
"| `AI_ON_MERGE` | `src/selfhost/ai.ts:1030` |",
399399
"| `AI_PROVIDER` | `src/selfhost/ai-config.ts:43` |",
400400
"| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:904` |",
401401
"| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:87` |",
402402
"| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts:903` |",
403-
"| `BACKUP_ACKNOWLEDGED` | `src/server.ts:379` |",
403+
"| `BACKUP_ACKNOWLEDGED` | `src/server.ts:380` |",
404404
"| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts:11` |",
405405
"| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts:138` |",
406406
"| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts:79` |",
@@ -409,8 +409,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
409409
"| `CODEX_AI_MODEL` | `src/selfhost/ai.ts:83` |",
410410
"| `CODEX_AI_TIMEOUT_MS` | `src/selfhost/ai.ts:142` |",
411411
"| `CODEX_HOME` | `src/selfhost/ai.ts:309` |",
412-
"| `CRON_INTERVAL_MS` | `src/server.ts:917` |",
413-
"| `DATABASE_PATH` | `src/server.ts:249` |",
412+
"| `CRON_INTERVAL_MS` | `src/server.ts:919` |",
413+
"| `DATABASE_PATH` | `src/server.ts:250` |",
414414
"| `DATABASE_URL` | `src/selfhost/preflight.ts:201` |",
415415
"| `DISCORD_REPO_WEBHOOKS` | `src/services/notify-discord.ts:41` |",
416416
"| `DISCORD_WEBHOOK_URL` | `src/services/notify-discord.ts:78` |",
@@ -420,11 +420,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
420420
"| `FOREGROUND_LIVENESS_MAX_RELEASE_PER_SWEEP` | `src/selfhost/foreground-liveness.ts:53` |",
421421
"| `GITHUB_APP_ID` | `src/selfhost/orb-collector.ts:59` |",
422422
"| `GITHUB_APP_PRIVATE_KEY` | `src/selfhost/orb-collector.ts:166` |",
423-
"| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:509` |",
423+
"| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts:510` |",
424424
"| `GITHUB_INSTALLATION_CONCURRENCY_DEFER_MS` | `src/selfhost/installation-concurrency-admission.ts:47` |",
425425
"| `GITHUB_INSTALLATION_CONCURRENCY_ENABLED` | `src/selfhost/installation-concurrency-admission.ts:34` |",
426426
"| `GITHUB_INSTALLATION_CONCURRENCY_LIMIT` | `src/selfhost/installation-concurrency-admission.ts:43` |",
427-
"| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:288` |",
427+
"| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts:289` |",
428428
"| `GITTENSORY_VERSION` | `src/selfhost/otel.ts:62` |",
429429
"| `HOME` | `src/selfhost/ai.ts:309` |",
430430
"| `MAINTENANCE_ADMISSION_DEFER_MS` | `src/selfhost/maintenance-admission.ts:171` |",
@@ -435,7 +435,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
435435
"| `MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS` | `src/selfhost/maintenance-admission.ts:155` |",
436436
"| `MAINTENANCE_ADMISSION_MAX_LIVE_PENDING` | `src/selfhost/maintenance-admission.ts:151` |",
437437
"| `MAINTENANCE_ADMISSION_MAX_PENDING` | `src/selfhost/maintenance-admission.ts:159` |",
438-
"| `MIGRATIONS_DIR` | `src/server.ts:392` |",
438+
"| `MIGRATIONS_DIR` | `src/server.ts:393` |",
439439
"| `OBSERVABILITY_SMOKE_POLL_MS` | `scripts/smoke-observability-traces.mjs:8` |",
440440
"| `OBSERVABILITY_SMOKE_TIMEOUT_MS` | `scripts/smoke-observability-traces.mjs:6` |",
441441
"| `OLLAMA_AI_API_KEY` | `src/selfhost/ai.ts:897` |",
@@ -450,11 +450,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
450450
"| `ORB_AIR_GAP` | `src/selfhost/orb-collector.ts:161` |",
451451
"| `ORB_ANONYMIZE` | `src/selfhost/orb-collector.ts:174` |",
452452
"| `ORB_APP_ID` | `src/selfhost/orb-collector.ts:59` |",
453-
"| `ORB_BROKER_URL` | `src/server.ts:966` |",
453+
"| `ORB_BROKER_URL` | `src/server.ts:968` |",
454454
"| `ORB_COLLECTOR_TOKEN` | `src/selfhost/orb-collector.ts:205` |",
455455
"| `ORB_COLLECTOR_URL` | `src/selfhost/orb-collector.ts:172` |",
456456
"| `ORB_ENROLLMENT_SECRET` | `src/selfhost/orb-collector.ts:165` |",
457-
"| `ORB_RELAY_MODE` | `src/server.ts:968` |",
457+
"| `ORB_RELAY_MODE` | `src/server.ts:970` |",
458458
"| `OTEL_EXPORTER_OTLP_ENDPOINT` | `src/selfhost/otel.ts:47` |",
459459
"| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `src/selfhost/otel.ts:45` |",
460460
"| `OTEL_SERVICE_ENVIRONMENT` | `src/selfhost/otel.ts:60` |",
@@ -463,18 +463,18 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
463463
"| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts:74` |",
464464
"| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts:76` |",
465465
"| `PGPOOL_MAX` | `src/selfhost/queue-common.ts:713` |",
466-
"| `PGVECTOR_ENABLED` | `src/server.ts:229` |",
467-
"| `PORT` | `src/server.ts:716` |",
466+
"| `PGVECTOR_ENABLED` | `src/server.ts:230` |",
467+
"| `PORT` | `src/server.ts:718` |",
468468
"| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts:192` |",
469469
"| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts:50` |",
470470
"| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts:71` |",
471-
"| `QDRANT_URL` | `src/server.ts:528` |",
471+
"| `QDRANT_URL` | `src/server.ts:529` |",
472472
"| `QUEUE_BACKGROUND_CONCURRENCY` | `src/selfhost/queue-common.ts:130` |",
473473
"| `QUEUE_CONCURRENCY` | `src/selfhost/pg-queue.ts:286` |",
474474
"| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts:721` |",
475475
"| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts:702` |",
476476
"| `REDIS_URL` | `src/selfhost/preflight.ts:144` |",
477-
"| `REVIEW_AUDIT_DIR` | `src/server.ts:573` |",
477+
"| `REVIEW_AUDIT_DIR` | `src/server.ts:574` |",
478478
"| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs:13` |",
479479
"| `SELFHOST_SERVICE` | `scripts/smoke-observability-traces.mjs:5` |",
480480
"| `SELFHOST_SETUP_TOKEN` | `src/selfhost/preflight.ts:186` |",
@@ -483,6 +483,6 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
483483
"| `SENTRY_RELEASE` | `src/selfhost/otel.ts:62` |",
484484
"| `SENTRY_SERVER_NAME` | `src/selfhost/sentry.ts:407` |",
485485
"| `SENTRY_TRACES_SAMPLE_RATE` | `src/selfhost/sentry.ts:195` |",
486-
"| `SETUP_OUTPUT_PATH` | `src/server.ts:833` |",
486+
"| `SETUP_OUTPUT_PATH` | `src/server.ts:835` |",
487487
"| `SLACK_WEBHOOK_URL` | `src/services/notify-discord.ts:173` |",
488488
].join("\n");
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { readFileSync } from "node:fs";
2+
import { parse } from "yaml";
3+
import { describe, expect, it } from "vitest";
4+
5+
function readYaml(path: string): Record<string, unknown> {
6+
return record(parse(readFileSync(path, "utf8")), path);
7+
}
8+
9+
function record(value: unknown, label: string): Record<string, unknown> {
10+
if (!value || typeof value !== "object" || Array.isArray(value)) {
11+
throw new Error(`${label} must be an object`);
12+
}
13+
return value as Record<string, unknown>;
14+
}
15+
16+
function recordArray(value: unknown, label: string): Array<Record<string, unknown>> {
17+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
18+
return value.map((entry, index) => record(entry, `${label}[${index}]`));
19+
}
20+
21+
// Generated-artifact drift guard: selfhost:env-reference:check, command-reference:check, and docs:drift-check
22+
// were each part of the local `npm run test:ci` aggregate script, but none were wired into
23+
// .github/workflows/ci.yml's validate-code job -- only local discipline enforced them, so a merged PR could (and
24+
// did, twice in one day) leave the corresponding generated reference file or docs page stale with zero CI
25+
// signal. Mirrors ci-cf-typegen-check.test.ts's own assertion shape for the same class of drift guard.
26+
describe("generated-artifact drift checks are wired into CI, not just local test:ci", () => {
27+
const checks: Array<{ script: string; command: string; stepName: string }> = [
28+
{ script: "selfhost:env-reference:check", command: "npm run selfhost:env-reference:check", stepName: "Selfhost env-reference drift check" },
29+
{ script: "command-reference:check", command: "npm run command-reference:check", stepName: "Command reference drift check" },
30+
{ script: "docs:drift-check", command: "npm run docs:drift-check", stepName: "Docs drift check" },
31+
];
32+
33+
it.each(checks)("package.json defines $script and wires it into test:ci", ({ script, command }) => {
34+
const pkg = record(JSON.parse(readFileSync("package.json", "utf8")), "package.json");
35+
const scripts = record(pkg.scripts, "package.json.scripts");
36+
37+
expect(scripts[script]).toBeDefined();
38+
expect(String(scripts["test:ci"])).toContain(command);
39+
});
40+
41+
it.each(checks)("ci.yml's validate-code job runs a step for $script, gated on backend OR ui changes", ({ command, stepName }) => {
42+
const workflow = readYaml(".github/workflows/ci.yml");
43+
const validateCode = record(record(workflow.jobs, "workflow.jobs")["validate-code"], "workflow.jobs.validate-code");
44+
const steps = recordArray(validateCode.steps, "jobs.validate-code.steps");
45+
46+
const step = steps.find((entry) => entry.name === stepName);
47+
expect(step).toBeDefined();
48+
expect(String(step!.run)).toBe(command);
49+
// Gated on backend OR ui (not backend alone): the generated artifact each check validates lives under
50+
// apps/gittensory-ui/**, so a UI-only edit that hand-desyncs it from its source of truth must still
51+
// re-trigger the check, not just a backend source change (#gittensory-pr-3254-review).
52+
const condition = String(step!.if);
53+
expect(condition).toContain("needs.changes.outputs.backend == 'true'");
54+
expect(condition).toContain("needs.changes.outputs.ui == 'true'");
55+
});
56+
57+
it("all three drift-check steps share the exact same gating condition", () => {
58+
const workflow = readYaml(".github/workflows/ci.yml");
59+
const validateCode = record(record(workflow.jobs, "workflow.jobs")["validate-code"], "workflow.jobs.validate-code");
60+
const steps = recordArray(validateCode.steps, "jobs.validate-code.steps");
61+
62+
const conditions = checks.map(({ stepName }) => {
63+
const step = steps.find((entry) => entry.name === stepName);
64+
expect(step).toBeDefined();
65+
return String(step!.if);
66+
});
67+
expect(new Set(conditions).size).toBe(1);
68+
});
69+
});

0 commit comments

Comments
 (0)