// Workstream I drift gate. Fails a product repo whose forced copies of the // shared conventions have diverged from this dev-kit's canonical source. Each // gated file uses the loosest mechanism that still pins what is shared: // - byte-verbatim: CLAUDE.md fenced core, nix/ctl-shell.nix, biome.json, // tsconfig.base.json, the .gitignore core block, scripts/demo (only a // product with tests/demo.spec.ts is asked for it), publish-docs-site.yml // (only a repo with a docs site may carry it, and never a turnkey) // - masked per-repo line: checks.yml, bump.yml, publish-docs.yml and the // other dispatching workflows (`product:` key) // - retired: upgrade-latest.yml, sync-dev-kit.yml, sync-ctl-packages.yml, // which bump.yml replaced; carrying one is drift // - base + sanctioned extension block: dprint.json (repo-specific excludes) // - must import the shared module: flake.nix (otherwise the product's own) // - structural: root tsconfig.json (shape-dependent includes stay free), // deployment/build-image.sh (per-product wrapper around a verbatim // bootstrap), manifest.seed.json (per-product pins, SDK-schema shape) // // Files are copied verbatim (not @import-ed / not a dep) so a human reads them // on GitHub with no tooling — which is exactly where drift starts. The gate // makes "single-sourced in @norskvideo/ctl-dev-kit" true: a product runs it in // CI against the dev-kit version it has installed, so bumping the pinned // dev-kit forces a re-sync. Canonical bytes ship alongside this script // (conventions/* + build/*), resolved package-relative so they work both // workspace-symlinked and installed from the published tarball. import { execFileSync } from "node:child_process"; import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed"; import { checkTheme } from "../docs-theme/sync.ts"; import { ctlPinProblems } from "./ctl-pin-agreement.ts"; export interface DriftReport { ok: boolean; problems: string[]; } export const BEGIN = ""; export const END = ""; // The .gitignore shared block is delimited by these markers (see gitignore.core), // so the sync writer can locate-and-replace it deterministically. The gate itself // checks the whole marker-delimited block as a verbatim substring. export const GITIGNORE_BEGIN = "# BEGIN ctl-shared-gitignore v1"; export const GITIGNORE_END = "# END ctl-shared-gitignore v1"; const RESYNC = "Edit the dev-kit source and re-sync — never hand-edit the copy."; // First line that differs, so the failure names the drift instead of dumping the // whole block. Compared line-wise; the byte check is the substring test above. function firstDiffLine(actual: string, expected: string): string { const a = actual.split("\n"); const e = expected.split("\n"); for (let i = 0; i < Math.max(a.length, e.length); i++) { if (a[i] !== e[i]) { return `line ${i + 1}: expected ${JSON.stringify(e[i] ?? "")}, found ${JSON.stringify(a[i] ?? "")}`; } } return "content differs"; } // The one legitimately per-repo line in the shared workflows: the dashboard key // the CI result is dispatched under. Everything else is verbatim-shared. export const PRODUCT_LINE = /^(\s*product:\s*).*$/gm; export const PRODUCT_SENTINEL = "__PRODUCT__"; // build-image.yml carries a second per-repo line: the Docker Hub repo the built // image is published to (empty on a build-only product). Masked like product:, // but — unlike product: — an empty value is a valid filled state, so it has no // unfilled-sentinel check. export const PUBLISH_IMAGE_LINE = /^(\s*PUBLISH_IMAGE:\s*).*$/gm; // integration.yml (04c s9 #1) is universally present and was hand-owned in six // shapes, so on the day it joined the conventions set the gate could not demand // the canonical of every copy: sync-dev-kit then committed straight to main // when checks were green, and would have rewritten six tiers before the hooks their // suites need existed. A copy therefore declares itself converged by carrying // the canonical's own first line; one without it is hand-owned and ungated // until its repo converges deliberately. When every repo carries the marker, // this becomes unconditional like smoke.yml. export const INTEGRATION_MARKER = "# ctl-dev-kit convention: integration"; export function carriesIntegrationMarker(text: string): boolean { return text.split("\n")[0]?.trim() === INTEGRATION_MARKER; } // smoke.yml carries a second per-repo line: the matrix `runner:` label array // the journey runs on ('["x64"]' for the pool, '["ARM64","nvidia"]' for the GPU // box). Masked like product:; any filled value is valid, so no sentinel check. export const RUNNER_LINE = /^(\s*-?\s*runner:\s*).*$/gm; function workflowProblem( relPath: string, canonicalRef: string, actual: string, canonical: string, maskLines: RegExp[] = [PRODUCT_LINE], ): string | undefined { // Compare structure with every per-repo value masked so any real value passes, // then separately reject a copy that still carries the unfilled product: // sentinel. const mask = (s: string) => maskLines.reduce((acc, re) => acc.replace(re, `$1${PRODUCT_SENTINEL}`), s); if (mask(actual) !== mask(canonical)) { return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(mask(actual), mask(canonical))}). ${RESYNC}`; } if (new RegExp(`^\\s*product:\\s*${PRODUCT_SENTINEL}\\s*$`, "m").test(actual)) { return `${relPath} still has the ${PRODUCT_SENTINEL} placeholder — set \`product:\` to this repo's dashboard key.`; } return undefined; } // The dev-only ctl pin the nightly bumps in each repo's flake: the version // string and the four per-platform source hashes. It lives in the product's own // flake.nix; sync-drift carries it across when it migrates a legacy flake. export const CTL_VERSION_LINE = /ctlVersion = "[^"]*";/; export const CTL_HASH_LINE = /hash = "sha256-[^"]*";/g; // The shared dev-shell module and how a product's flake.nix must reach it. The // flake is otherwise the product's own, so a product can add what it alone // needs; building from the module is what keeps the shared pieces shared. export const CTL_SHELL_PATH = "nix/ctl-shell.nix"; export const CTL_SHELL_IMPORT = "import ./nix/ctl-shell.nix"; export function importsCtlShell(flake: string): boolean { return flake.includes(CTL_SHELL_IMPORT); } // dprint.json is canonical-plus-extension: the shared config is verbatim, but a // repo may exclude paths only it has (e.g. probe's generated components/lib/) // inside a sanctioned marker block at the head of `excludes`. Everything outside // the block is byte-compared; inside, only well-formed `"entry",` lines are // allowed so the block can't smuggle arbitrary config past the byte check. export const DPRINT_BEGIN = "// BEGIN repo-specific excludes"; export const DPRINT_END = "// END repo-specific excludes"; export function splitAtMarkers(s: string): { prefix: string; body: string[]; suffix: string } | undefined { const lines = s.split("\n"); const begin = lines.findIndex((l) => l.trim() === DPRINT_BEGIN); const end = lines.findIndex((l) => l.trim() === DPRINT_END); if (begin === -1 || end === -1 || end < begin) return undefined; return { prefix: lines.slice(0, begin + 1).join("\n"), body: lines.slice(begin + 1, end), suffix: lines.slice(end).join("\n"), }; } function dprintProblem(actual: string, canonical: string): string | undefined { const c = splitAtMarkers(canonical); if (!c) throw new Error("canonical dprint.base.jsonc is missing its own marker block"); const a = splitAtMarkers(actual); if (!a) { return `dprint.json is missing the '${DPRINT_BEGIN}' / '${DPRINT_END}' marker lines inside "excludes". Re-sync from @norskvideo/ctl-dev-kit conventions/dprint.base.jsonc; repo-specific excludes go between the markers.`; } const masked = (x: { prefix: string; suffix: string }) => `${x.prefix}\n${x.suffix}`; if (masked(a) !== masked(c)) { return `dprint.json has drifted from @norskvideo/ctl-dev-kit conventions/dprint.base.jsonc outside the repo-specific excludes block (${firstDiffLine(masked(a), masked(c))}). ${RESYNC}`; } const badLine = a.body.find((l) => !/^\s*"[^"]*",$/.test(l)); if (badLine !== undefined) { return `dprint.json repo-specific excludes block has a malformed line (${JSON.stringify(badLine.trim())}) — one double-quoted exclude entry per line, each ending with a comma.`; } return undefined; } // deployment/build-image.sh: the wrapper around the shared build is per-product // (tag, port, and studio's sanctioned bespoke build), but its dev-kit-locating // bootstrap is the one forced copy — it must find this package before it can // source anything from it, so it is kept identical everywhere and gated here. // The script must also actually source a build/ script from the located dev-kit. const SOURCES_DEV_KIT = /^source "\$\{dev_kit\}\/build\/[^"]+\.sh"$/m; function buildImageProblem(actual: string, canonicalBootstrap: string): string | undefined { const fnStart = canonicalBootstrap.indexOf("find_dev_kit() {"); if (fnStart === -1) throw new Error("canonical build-image.bootstrap.sh is missing find_dev_kit()"); const fn = canonicalBootstrap.slice(fnStart); if (!actual.includes(fn)) { return `deployment/build-image.sh does not contain the canonical find_dev_kit bootstrap from @norskvideo/ctl-dev-kit build/build-image.bootstrap.sh. ${RESYNC}`; } if (!SOURCES_DEV_KIT.test(actual)) { return `deployment/build-image.sh never sources a script from "\${dev_kit}/build/" — the build machinery is single-sourced in @norskvideo/ctl-dev-kit, not copied into the product.`; } return undefined; } // Root tsconfig.json is gated structurally, not byte-wise: its `include` list // legitimately differs by repo shape (probe adds scripts + a playwright config, // studio adds scripts/, the turnkeys are tests-only), so the gate pins what is // genuinely common — all compiler semantics live in the byte-gated // tsconfig.base.json, the root file only extends it and names the trees — and // leaves the include list free beyond the tests/**/* every repo carries. const ROOT_TSCONFIG_KEYS = new Set(["//", "extends", "include"]); function rootTsconfigProblem(actual: string): string | undefined { let parsed: unknown; try { parsed = JSON.parse(actual); } catch (e) { return `tsconfig.json at the repo root is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`; } const cfg = parsed as Record; const extraKeys = Object.keys(cfg).filter((k) => !ROOT_TSCONFIG_KEYS.has(k)); if (extraKeys.includes("compilerOptions")) { return `tsconfig.json must not carry compilerOptions — all compiler semantics live in the drift-gated tsconfig.base.json.`; } if (extraKeys.length > 0) { return `tsconfig.json carries unexpected top-level keys (${extraKeys.join(", ")}) — only "//", "extends" and "include" are sanctioned; semantics belong in tsconfig.base.json.`; } if (cfg.extends !== "./tsconfig.base.json") { return `tsconfig.json must have "extends": "./tsconfig.base.json" (found ${JSON.stringify(cfg.extends)}).`; } if (!Array.isArray(cfg.include) || !cfg.include.includes("tests/**/*")) { return `tsconfig.json "include" must contain "tests/**/*" (every product typechecks its tests tree); shape-specific extras are fine.`; } return undefined; } // .gitignore is base-block-plus-additions: the canonical block must appear // verbatim (an unrecognised entry inside it would be drift), while entries a // repo's shape genuinely needs (build outputs, codegen trees) live freely // outside it — extra ignore lines for paths another repo lacks are inert, so // they need no sanctioning mechanism beyond staying out of the block. function gitignoreProblem(actual: string, canonicalBlock: string): string | undefined { if (actual.includes(canonicalBlock)) return undefined; return `.gitignore does not contain the ctl-shared-gitignore block verbatim. Copy conventions/gitignore.core from @norskvideo/ctl-dev-kit; repo-specific entries go outside the block. ${RESYNC}`; } // manifest.seed.json is conformance-checked, not byte-compared: its pins are // per-product by design, but the SHAPE is the SDK's SeedSchema. This is the CI // gate ctl-sdk/manifest-seed's comment always assumed — version.ts consumes the // JSON as typed data precisely because every seed is validated here. function seedProblem(actual: string): string | undefined { let raw: unknown; try { raw = JSON.parse(actual); } catch (e) { return `manifest.seed.json is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`; } const parsed = parseManifestSeed(raw); if (parsed.status === "error") { return `manifest.seed.json does not conform to @norskvideo/ctl-sdk/manifest-seed's SeedSchema:\n${parsed.error}`; } return undefined; } function coreProblem(claude: string, canonicalCore: string): string | undefined { if (claude.includes(canonicalCore)) return undefined; const begin = claude.indexOf(BEGIN); const end = claude.indexOf(END); if (begin === -1 || end === -1) { return `CLAUDE.md is missing the '${BEGIN}' / '${END}' marker block. Copy conventions/CLAUDE.core.md from @norskvideo/ctl-dev-kit verbatim between the markers.`; } const region = claude.slice(begin, end + END.length); return `CLAUDE.md fenced core has drifted from @norskvideo/ctl-dev-kit conventions/CLAUDE.core.md (${firstDiffLine(region, canonicalCore.trimEnd())}). ${RESYNC}`; } export interface CanonicalBytes { core: string; /** build/flake.nix — the starter flake a Studio product begins from, and what * sync-drift migrates a legacy verbatim flake to. Not gated: the flake is the * product's own. */ flake: string; /** build/ctl-shell.nix — the shared dev-shell module, gated verbatim at * nix/ctl-shell.nix for every shape. */ ctlShell: string; /** conventions/bump.yml -- the one nightly bump (dev-kit, ctl-* and moq-*, * then the engine/Studio/norsk-ctl pins) and its one PR. Masked like * checks.yml; required, and sync-drift creates it where absent. */ bump: string; /** conventions/bot-prs.yml -- merges the bump's green PR and reports a red * one. Verbatim (it reads its dashboard key at run time), so sync-drift * creates it where absent. */ botPrs: string; publishDocs: string; /** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */ buildDocs: string; /** conventions/publish-docs-site.yml — the docs SITE's publish (website/ -> * Cloudflare Pages on every push to main). Verbatim; allowed only where * there is a site to publish and never in a turnkey. */ publishDocsSite: string; checks: string; biome: string; tsconfigBase: string; dprint: string; buildImageBootstrap: string; gitignoreCore: string; buildImage: string; smoke: string; /** conventions/integration.yml — the integration tier. Gated only for a copy * carrying INTEGRATION_MARKER on line 1; same two masked lines as smoke. */ integration: string; /** conventions/demo.sh — the scripts/demo shim a product with tests/demo.spec.ts carries. */ demoShim: string; /** conventions/server-install.sh and server-uninstall.sh — the server * deployment kit's deployment/install.sh and uninstall.sh. Gated only for a * copy that names its canonical source, so a product's own hand-written * install.sh at that path is left to it. */ serverInstall?: string; serverUninstall?: string; /** doc-guide/with-display.sh — the X-display wrapper BOTH docs workflows invoke * as `scripts/with-display.sh`. Gated because the workflow is: an ungated * hand copy is how build-docs.yml came to fail in both turnkeys, which never * made one. */ withDisplay: string; /** build/flake.sdk-app.nix — the starter flake for a `shape: "sdk-app"` * product (bun tooling + node runtime, no norsk-ctl daemon fetch). Optional so * a studio-only caller need not supply it; sync-drift needs it to migrate a * legacy sdk-app flake. */ flakeSdkApp?: string; /** conventions/checks.sdk-app.yml — the checks.yml variant for an sdk-app: the * Studio licence step removed (it reads shared/src/version.ts + * product-template.ts). Optional for the same reason as flakeSdkApp. */ checksSdkApp?: string; /** conventions/bump.sdk-app.yml — the bump.yml variant for an sdk-app: its * pins step has no Studio pair or manifest.seed.json, and bumps the norsk * family off the SDK nightly. Optional for the same reason as flakeSdkApp. */ bumpSdkApp?: string; } export const SERVER_KIT_COPIES = [ { rel: "deployment/install.sh", source: "server-install.sh", key: "serverInstall" }, { rel: "deployment/uninstall.sh", source: "server-uninstall.sh", key: "serverUninstall" }, ] as const; /** A copy names its source in its header; a product's own script does not. */ export const isServerKitCopy = (content: string, source: string): boolean => content.includes(`@norskvideo/ctl-dev-kit conventions/${source}`); export function serverKitProblems(repoRoot: string, canonical: CanonicalBytes): string[] { const problems: string[] = []; for (const { rel, source, key } of SERVER_KIT_COPIES) { const expected = canonical[key]; const path = join(repoRoot, rel); if (expected === undefined || !existsSync(path)) continue; const actual = readFileSync(path, "utf8"); if (!isServerKitCopy(actual, source) || actual === expected) continue; problems.push( `${rel} has drifted from @norskvideo/ctl-dev-kit conventions/${source} (${firstDiffLine(actual, expected)}). ${RESYNC}`, ); } return problems; } // 05-demo s6: a product that has a demo (tests/demo.spec.ts) exposes it the // shared way — scripts/demo, the verbatim executable shim that re-execs under // the nix dev shell and delegates to `bun run demo`, and the package.json // "demo" script that resolves to the harness's ctl-demo bin. A product without // a demo is asked for neither; a shim left behind is still a copy and stays // verbatim. const DEMO_SPEC = "tests/demo.spec.ts"; const DEMO_SHIM = "scripts/demo"; const DEMO_SCRIPT = "ctl-demo"; export function demoProblems(repoRoot: string, canonicalShim: string): string[] { const problems: string[] = []; const hasSpec = existsSync(join(repoRoot, DEMO_SPEC)); const shimPath = join(repoRoot, DEMO_SHIM); if (existsSync(shimPath) && statSync(shimPath).isDirectory()) { problems.push( `${DEMO_SHIM} is a directory; the convention puts the executable shim at that path — move the directory (scripts/demo-tools, say) and re-run sync-drift to write the shim.`, ); } else if (existsSync(shimPath)) { const actual = readFileSync(shimPath, "utf8"); if (actual !== canonicalShim) { problems.push( `${DEMO_SHIM} has drifted from @norskvideo/ctl-dev-kit conventions/demo.sh (${firstDiffLine(actual, canonicalShim)}). ${RESYNC}`, ); } if ((statSync(shimPath).mode & 0o111) === 0) { problems.push(`${DEMO_SHIM} is not executable (chmod +x) — it is the entry a developer runs.`); } } else if (hasSpec) { problems.push( `${DEMO_SHIM} not found but ${DEMO_SPEC} exists. A product with a demo carries the shim verbatim from @norskvideo/ctl-dev-kit conventions/demo.sh (sync-drift writes it). ${RESYNC}`, ); } if (hasSpec) { const pkgPath = join(repoRoot, "package.json"); let scripts: Record | undefined; try { scripts = existsSync(pkgPath) ? (JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record }).scripts : undefined; } catch (e) { problems.push(`package.json is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`); } if (scripts?.demo !== DEMO_SCRIPT) { problems.push( `package.json must carry "demo": "${DEMO_SCRIPT}" when ${DEMO_SPEC} exists — ${DEMO_SHIM} delegates to \`bun run demo\`.`, ); } } return problems; } // Product logs (norsk-mgr docs/internal/design/product-logs.md): every compose // service a product template declares must say how it logs, or the node's log // agent ships nothing (or garbage) for it. The schema's // productTemplateLogsProblems says what is wrong with one template; what this // gate pins is that the repo asks it. A repo that packs a product template // must have a test that calls it — the packer itself cannot refuse, since // products also pack at runtime and a missed entry must not become a 500. const PACKS_TEMPLATE = /\bpackProductTemplate\b/; const CHECKS_LOGS = /\bproductTemplateLogsProblems\b/; const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "lib", "coverage", ".wireit"]); function sourceFiles(dir: string, out: string[] = []): string[] { // `dir` is the repo root or an entry just stat'd as a directory: one the walk // cannot list is a real failure, never an empty directory for (const name of readdirSync(dir)) { if (SKIP_DIRS.has(name)) continue; const path = join(dir, name); let st: ReturnType; try { st = statSync(path); } catch (e) { // Only a symlink whose target is absent is skipped: a docs site's link to // a doc-guide capture not generated yet (a fresh checkout). Anything else // that stops the walk reading an entry is a real failure. if ((e as NodeJS.ErrnoException).code === "ENOENT" && lstatSync(path).isSymbolicLink()) continue; throw e; } if (st.isDirectory()) sourceFiles(path, out); else if (/\.(ts|tsx)$/.test(name)) out.push(path); } return out; } export function productLogsProblems(repoRoot: string): string[] { const files = sourceFiles(repoRoot); const read = (f: string) => readFileSync(f, "utf8"); if (!files.some((f) => PACKS_TEMPLATE.test(read(f)))) return []; const tested = files.some((f) => /\.test\.tsx?$/.test(f) && CHECKS_LOGS.test(read(f))); return tested ? [] : [ "this repo packs a product template but no test calls productTemplateLogsProblems " + "(@norskvideo/ctl-product-template-schema). Add one asserting it returns [] for every template " + "the product builds, and give the manifest a `logs:` entry per compose service " + "(norsk-mgr docs/internal/design/product-logs.md).", ]; } /** The Bun the dev-kit's build/ctl-shell.nix pins: the version its pin's URL * line names (`… .tar.gz"; # bun 1.4.2`). The shell runs it; the product's * image runs package.json's "packageManager", which must name the same. */ export function pinnedBunVersion(ctlShell: string): string | undefined { return /archive\/[0-9a-f]{40}\.tar\.gz"; # bun (\d+\.\d+\.\d+)/.exec(ctlShell)?.[1]; } /** package.json's "packageManager" against the shell's Bun. They drifted once * already: the fleet's shells and images ran 1.3.13 by agreement alone, and a * move to 1.4.2 (1.3.13's HTTP/2 client segfaulted) had to touch every repo. */ export function bunVersionProblems(repoRoot: string, ctlShellCanonical: string): string[] { const want = pinnedBunVersion(ctlShellCanonical); if (want === undefined) { return ["@norskvideo/ctl-dev-kit build/ctl-shell.nix names no Bun version on its pin (`# bun X.Y.Z`)."]; } const pkgPath = join(repoRoot, "package.json"); if (!existsSync(pkgPath)) return []; let have: unknown; try { have = (JSON.parse(readFileSync(pkgPath, "utf8")) as { packageManager?: unknown }).packageManager; } catch (e) { return [`package.json is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`]; } return have === `bun@${want}` ? [] : [ `package.json "packageManager" is ${JSON.stringify(have ?? null)}, not "bun@${want}": it is the Bun the ` + "product's image runs, and must be the one the shell runs (@norskvideo/ctl-dev-kit build/ctl-shell.nix). " + "Re-sync to set it.", ]; } // A file that must be a verbatim, byte-for-byte copy of its dev-kit canonical. function verbatimProblem( repoRoot: string, relPath: string, canonicalRef: string, canonical: string, ): string | undefined { const path = join(repoRoot, relPath); if (!existsSync(path)) { return `${relPath} not found at repo root (${path}). It is copied verbatim from @norskvideo/ctl-dev-kit ${canonicalRef}.`; } const actual = readFileSync(path, "utf8"); if (actual !== canonical) { return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(actual, canonical)}). ${RESYNC}`; } return undefined; } // A product's shape. `studio` (the default, and every product before this) is a // control-plane backend composing a Norsk Studio workflow launched by the // norsk-ctl daemon. `sdk-app` is a bespoke Norsk SDK application whose runtime is // its own daemon — no Studio workflow, no {media,studio} seed, no ctl-daemon // flake, no product-template licence handshake. The shape selects which canonical // a shape-variant file is gated against and which studio-only checks are skipped; // absent ⇒ `studio`, so existing repos are unaffected. export type ProductShape = "studio" | "sdk-app"; /** The docs site, when a repo has one: the directory holding `astro.config.mjs`. * One name across the fleet (norsk-ctl's and the Manager's both), so the gate * can find a site without being told. */ export const DOCS_SITE_DIR = "website"; /** `/website/astro.config.mjs` — the site's existence test. A docs site is * a NESTED standalone package a repo may or may not have, which is why the * theme is gated conditionally while every other convention file is demanded * outright. */ export function hasDocsSite(repoRoot: string): boolean { return existsSync(join(repoRoot, DOCS_SITE_DIR, "astro.config.mjs")); } /** The shared docs theme's copies inside a repo's docs site, as gate problems. * The theme has its own writer/checker pair (docs-theme/sync.ts) because it * predates this clause and a site in THIS monorepo runs it directly; routing it * through here as well is what makes `bun run check:drift` sufficient for a * product repo, which has no second suite to hang it off. */ export function docsThemeProblems(repoRoot: string, themeCanonical?: string): string[] { if (!hasDocsSite(repoRoot)) return []; const site = join(repoRoot, DOCS_SITE_DIR); const { problems } = themeCanonical === undefined ? checkTheme(site) : checkTheme(site, themeCanonical); // Paths are reported site-relative by the theme's own checker, so prefix them // — a product repo reads them from its root — and name the repo script that // fixes them, which the theme's message cannot know. return problems.map((p) => `${DOCS_SITE_DIR}/${p} (\`bun run sync:drift\` rewrites it.)`); } export interface CheckDriftOptions { /** The GitHub repo name (`norsk-ctl-product-playout`), which decides whether the * public docs variant may be carried. The CLI resolves it ({@link resolveRepoName}); * a fixture passes it. Unknown is allowed only while nothing depends on it. */ repoName?: string; /** The product shape ({@link resolveShape}); a fixture passes it. Absent ⇒ `studio`. */ shape?: ProductShape; /** The canonical docs-theme directory. Defaults to this package's own, which * is what a product repo wants; a fixture passes one. */ themeCanonical?: string; } /** A product's shape from `package.json` `ctlProduct.shape`; `studio` when the * field (or the file) is absent, so every existing repo keeps its behaviour. */ export function resolveShape(repoRoot: string): ProductShape { try { const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { ctlProduct?: { shape?: string }; }; return pkg.ctlProduct?.shape === "sdk-app" ? "sdk-app" : "studio"; } catch { return "studio"; } } /** `owner/name` from GITHUB_REPOSITORY (Actions), else the last path segment of * the origin remote. Undefined when neither is available. */ export function resolveRepoName(repoRoot: string, env: NodeJS.ProcessEnv = process.env): string | undefined { const fromEnv = env.GITHUB_REPOSITORY?.split("/").pop(); if (fromEnv) return fromEnv; try { const url = execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); const name = url .split(/[/:]/) .pop() ?.replace(/\.git$/, ""); return name || undefined; } catch { return undefined; } } const PUBLIC_DOCS_REPO = /^norsk-ctl-product-/; const TURNKEY_REPO = /^norsk-ctl-turnkey-/; /** The one nightly bump (ci-fragility proposal B2). */ export const BUMP_WORKFLOW = ".github/workflows/bump.yml"; /** The three bump bots bump.yml replaced, removed from a product by sync-drift. */ export const RETIRED_BUMP_WORKFLOWS = [ ".github/workflows/upgrade-latest.yml", ".github/workflows/sync-dev-kit.yml", ".github/workflows/sync-ctl-packages.yml", ] as const; /** The docs site's publish workflow, where a repo carries one. */ export const DOCS_SITE_WORKFLOW = ".github/workflows/publish-docs-site.yml"; /** ADR-0012: exposure is declared by which docs workflow a repo carries, so the * gate is what makes that declaration safe — exactly one variant; the public * one only where GitHub calls the repo norsk-ctl-product-*; a name it cannot * determine is a finding, never a silent pass. */ export function docsExposureProblems(hasPublish: boolean, hasBuild: boolean, repoName: string | undefined): string[] { const problems: string[] = []; if (hasPublish && hasBuild) { problems.push( "a repo carries exactly one docs workflow: .github/workflows/publish-docs.yml (public: the bundle is published for the norsk-ctl docs site) OR .github/workflows/build-docs.yml (embedded: the bundle goes into the image and nowhere else). Both are present; delete one.", ); } if (hasPublish && repoName === undefined) { problems.push( ".github/workflows/publish-docs.yml publishes this repo's docs publicly, and the gate cannot tell which repo this is (no GITHUB_REPOSITORY, no origin remote). Only a norsk-ctl-product-* repo may carry the public variant.", ); } else if (hasPublish && !PUBLIC_DOCS_REPO.test(repoName as string)) { problems.push( `.github/workflows/publish-docs.yml publishes this repo's docs publicly, but this repo is ${repoName}, not a norsk-ctl-product-* repo. A turnkey's documentation never leaves the box: carry build-docs.yml (the embedded variant) instead.`, ); } return problems; } /** Where a PUBLIC docs site may exist. The rule is negative on the repo name * where publish-docs.yml's is positive, because sites live outside * norsk-ctl-product-* (norsk-relay has one, and so do norsk-ctl and the * Manager); what is certain is that a turnkey has none -- its documentation * reaches one customer, inside the image they run (ADR-0012). And a site has * to exist to be published: a repo without website/astro.config.mjs carrying * the workflow would deploy nothing, or whatever a stale dist held. */ export function docsSiteExposureProblems( hasWorkflow: boolean, hasSite: boolean, repoName: string | undefined, ): string[] { if (!hasWorkflow) return []; const problems: string[] = []; if (!hasSite) { problems.push( `${DOCS_SITE_WORKFLOW} publishes a docs site, but this repo has none (no ${DOCS_SITE_DIR}/astro.config.mjs). Add the site (conventions/docs-site.md) or delete the workflow.`, ); } if (repoName === undefined) { problems.push( `${DOCS_SITE_WORKFLOW} publishes this repo's docs site publicly, and the gate cannot tell which repo this is (no GITHUB_REPOSITORY, no origin remote). A turnkey (norsk-ctl-turnkey-*) may not carry it.`, ); } else if (TURNKEY_REPO.test(repoName)) { problems.push( `${DOCS_SITE_WORKFLOW} publishes a public docs site, but this repo is ${repoName}, a turnkey. A turnkey has no public site: its documentation reaches one customer, inside the image they run (ADR-0012). Delete the workflow.`, ); } return problems; } export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: CheckDriftOptions = {}): DriftReport { const problems: string[] = []; const push = (problem: string | undefined) => { if (problem) problems.push(problem); }; const shape: ProductShape = opts.shape ?? "studio"; const claudePath = join(repoRoot, "CLAUDE.md"); if (!existsSync(claudePath)) { problems.push(`CLAUDE.md not found at repo root (${claudePath}). It must embed the ctl-shared-conventions core.`); } else { push(coreProblem(readFileSync(claudePath, "utf8"), canonical.core)); } push(verbatimProblem(repoRoot, CTL_SHELL_PATH, "build/ctl-shell.nix", canonical.ctlShell)); const flakePath = join(repoRoot, "flake.nix"); if (!existsSync(flakePath)) { problems.push( `flake.nix not found at repo root (${flakePath}). Start from @norskvideo/ctl-dev-kit ${shape === "sdk-app" ? "build/flake.sdk-app.nix" : "build/flake.nix"}.`, ); } else if (!importsCtlShell(readFileSync(flakePath, "utf8"))) { problems.push( `flake.nix does not build its shells from ${CTL_SHELL_PATH} (no \`${CTL_SHELL_IMPORT}\`). The shared shell pieces are single-sourced there; sync-drift migrates a legacy verbatim flake.`, ); } push(verbatimProblem(repoRoot, "biome.json", "conventions/biome.base.json", canonical.biome)); push(verbatimProblem(repoRoot, "tsconfig.base.json", "conventions/tsconfig.base.json", canonical.tsconfigBase)); const rootTsconfigPath = join(repoRoot, "tsconfig.json"); if (!existsSync(rootTsconfigPath)) { problems.push( `tsconfig.json not found at repo root (${rootTsconfigPath}). It must extend the drift-gated tsconfig.base.json and include tests/**/*.`, ); } else { push(rootTsconfigProblem(readFileSync(rootTsconfigPath, "utf8"))); } const dprintPath = join(repoRoot, "dprint.json"); if (!existsSync(dprintPath)) { problems.push( `dprint.json not found at repo root (${dprintPath}). Copy conventions/dprint.base.jsonc from @norskvideo/ctl-dev-kit (repo-specific excludes go inside its marker block).`, ); } else { push(dprintProblem(readFileSync(dprintPath, "utf8"), canonical.dprint)); } // manifest.seed.json pins a {media, studio} image pair — a Studio-product // concept. An sdk-app is its own runtime (no Studio image), so it carries no // seed and the check does not apply. if (shape === "studio") { const seedPath = join(repoRoot, "manifest.seed.json"); if (!existsSync(seedPath)) { problems.push( `manifest.seed.json not found at repo root (${seedPath}). Every product pins its {media, studio} pair there (RFC 0001 Workstream C).`, ); } else { push(seedProblem(readFileSync(seedPath, "utf8"))); } } const gitignorePath = join(repoRoot, ".gitignore"); if (!existsSync(gitignorePath)) { problems.push( `.gitignore not found at repo root (${gitignorePath}). It must contain the ctl-shared-gitignore block from @norskvideo/ctl-dev-kit conventions/gitignore.core.`, ); } else { push(gitignoreProblem(readFileSync(gitignorePath, "utf8"), canonical.gitignoreCore)); } // deployment/build-image.sh wraps the dev-kit bundle-image build (a // daemon-launched product container on port 4321). An sdk-app ships its own // container, not the bundle image, so the wrapper does not apply. if (shape === "studio") { const buildImagePath = join(repoRoot, "deployment", "build-image.sh"); if (!existsSync(buildImagePath)) { problems.push( `deployment/build-image.sh not found (${buildImagePath}). Every product ships the thin wrapper over the dev-kit build; see build/build-image.bootstrap.sh.`, ); } else { push(buildImageProblem(readFileSync(buildImagePath, "utf8"), canonical.buildImageBootstrap)); } } // checks.yml is REQUIRED — it is the CI entry point that runs this very gate // (plus the shared quality jobs), so a repo without it never re-converges. // Only the `product:` dispatch key is per-repo; it is masked like // bump.yml's. const checksPath = join(repoRoot, ".github", "workflows", "checks.yml"); const checksRel = ".github/workflows/checks.yml"; const checksCanon = shape === "sdk-app" ? canonical.checksSdkApp : canonical.checks; const checksRef = shape === "sdk-app" ? "conventions/checks.sdk-app.yml" : "conventions/checks.yml"; if (checksCanon === undefined) { throw new Error( `checkDrift: shape "${shape}" requires canonical.${shape === "sdk-app" ? "checksSdkApp" : "checks"}`, ); } else if (!existsSync(checksPath)) { problems.push( `${checksRel} not found (${checksPath}). Copy ${checksRef} from @norskvideo/ctl-dev-kit and set \`product:\` to this repo's dashboard key.`, ); } else { push(workflowProblem(checksRel, checksRef, readFileSync(checksPath, "utf8"), checksCanon)); } // build-image.yml is REQUIRED — every product builds its image in CI (a // standalone-build proof), and a deployed one publishes it to Docker Hub. Two // lines are per-repo: the notify job's `product:` dispatch key and the // PUBLISH_IMAGE env (the Docker Hub repo, or empty for a build-only product); // both are masked. // build-image.yml builds the bundle image in CI — studio-product-shaped, so // required only for the studio shape. (An sdk-app's own container build is not // the dev-kit bundle image; it is out of scope for the shared gate today.) if (shape === "studio") { const buildImageYmlPath = join(repoRoot, ".github", "workflows", "build-image.yml"); const buildImageYmlRel = ".github/workflows/build-image.yml"; if (!existsSync(buildImageYmlPath)) { problems.push( `${buildImageYmlRel} not found (${buildImageYmlPath}). Copy conventions/build-image.yml from @norskvideo/ctl-dev-kit, set \`product:\` to this repo's dashboard key, and set PUBLISH_IMAGE to its Docker Hub repo (empty for a build-only product).`, ); } else { push( workflowProblem( buildImageYmlRel, "conventions/build-image.yml", readFileSync(buildImageYmlPath, "utf8"), canonical.buildImage, [PRODUCT_LINE, PUBLISH_IMAGE_LINE], ), ); } } // bump.yml is REQUIRED: the one nightly bump (ci-fragility proposal B2), and // sync-drift creates it. Shape-selected like checks.yml: an sdk-app has no // Studio pair to bump, so it carries its own variant. Only its `product:` // dispatch keys are per-repo. const bumpPath = join(repoRoot, BUMP_WORKFLOW); const bumpCanon = shape === "sdk-app" ? canonical.bumpSdkApp : canonical.bump; const bumpRef = shape === "sdk-app" ? "conventions/bump.sdk-app.yml" : "conventions/bump.yml"; if (bumpCanon === undefined) { throw new Error(`checkDrift: shape "${shape}" requires canonical.bumpSdkApp`); } else if (!existsSync(bumpPath)) { problems.push( `${BUMP_WORKFLOW} not found (${bumpPath}). It is this repo's one nightly bump: \`bun run sync:drift\` creates it from @norskvideo/ctl-dev-kit ${bumpRef}, keeping this repo's dashboard key.`, ); } else { push(workflowProblem(BUMP_WORKFLOW, bumpRef, readFileSync(bumpPath, "utf8"), bumpCanon)); } // The three bots bump.yml replaced. One left beside it would run a second // bump that conflicts with the first and is tested alone -- what B2 ended. for (const rel of RETIRED_BUMP_WORKFLOWS) { if (existsSync(join(repoRoot, rel))) { problems.push( `${rel} is retired: ${BUMP_WORKFLOW} does its job now, in the one nightly bump. Delete it (\`bun run sync:drift\` removes it once ${BUMP_WORKFLOW} exists).`, ); } } // bot-prs.yml: optional until a repo's first sync creates it, verbatim once // present. const botPrsPath = join(repoRoot, ".github", "workflows", "bot-prs.yml"); if (existsSync(botPrsPath) && readFileSync(botPrsPath, "utf8") !== canonical.botPrs) { push(`.github/workflows/bot-prs.yml differs from conventions/bot-prs.yml. ${RESYNC}`); } // smoke.yml is OPTIONAL until every product carries a tests/smoke.spec.ts // (04b s7.2 sequences the required-file rule last). When present it is the // shared, single-sourced workflow; only its `product:` dispatch key and the // matrix `runner:` label array are per-repo. const smokePath = join(repoRoot, ".github", "workflows", "smoke.yml"); if (existsSync(smokePath)) { push( workflowProblem( ".github/workflows/smoke.yml", "conventions/smoke.yml", readFileSync(smokePath, "utf8"), canonical.smoke, [PRODUCT_LINE, RUNNER_LINE], ), ); } // integration.yml: gated once a copy carries the convention marker (see // INTEGRATION_MARKER for why not before); then it is smoke.yml's twin, with // the `product:` key and the matrix `runner:` array as the per-repo lines. const integrationPath = join(repoRoot, ".github", "workflows", "integration.yml"); if (existsSync(integrationPath)) { const actual = readFileSync(integrationPath, "utf8"); if (carriesIntegrationMarker(actual)) { push( workflowProblem( ".github/workflows/integration.yml", "conventions/integration.yml", actual, canonical.integration, [PRODUCT_LINE, RUNNER_LINE], ), ); } } // publish-docs.yml is OPTIONAL — a product carries it once it has an // illustrated manual to publish nightly. Everything per-repo about the manual // lives in scripts/doc-guide/regen-manual.sh (+ an optional prepull-extra.sh); // the one per-repo line is the `product:` key its `docs` result is dispatched // under, masked like checks.yml's. It was held verbatim when that dispatch was // added, so the only passing copy was one that sent the placeholder. const publishDocsPath = join(repoRoot, ".github", "workflows", "publish-docs.yml"); if (existsSync(publishDocsPath)) { push( workflowProblem( ".github/workflows/publish-docs.yml", "conventions/publish-docs.yml", readFileSync(publishDocsPath, "utf8"), canonical.publishDocs, ), ); } // build-docs.yml is the embedded twin of publish-docs.yml: optional, verbatim. const buildDocsPath = join(repoRoot, ".github", "workflows", "build-docs.yml"); if (existsSync(buildDocsPath)) { const actual = readFileSync(buildDocsPath, "utf8"); if (actual !== canonical.buildDocs) { push( `.github/workflows/build-docs.yml has drifted from @norskvideo/ctl-dev-kit conventions/build-docs.yml (${firstDiffLine(actual, canonical.buildDocs)}). ${RESYNC}`, ); } } for (const p of docsExposureProblems(existsSync(publishDocsPath), existsSync(buildDocsPath), opts.repoName)) push(p); // Both docs workflows run `bash scripts/with-display.sh scripts/doc-guide/...`. // The workflow is gated byte-for-byte and the script it invokes was not gated // at all, so a repo could satisfy the gate and still have no way to run the // step: build-docs.yml failed on its first scheduled run in BOTH turnkeys with // "bash: scripts/with-display.sh: No such file or directory", after the prose // bundle had already built. It stays a repo-local executable on purpose (the // package.json guide scripts invoke it by that path); it is the ABSENCE from // the gate that was the bug. const carriesDocs = existsSync(publishDocsPath) || existsSync(buildDocsPath); if (carriesDocs) { const which = existsSync(buildDocsPath) ? "build-docs.yml" : "publish-docs.yml"; const withDisplayPath = join(repoRoot, "scripts", "with-display.sh"); if (!existsSync(withDisplayPath)) { push( `.github/workflows/${which} runs \`bash scripts/with-display.sh\`, but this repo has no scripts/with-display.sh — the step cannot run. ${RESYNC}`, ); } else { const actual = readFileSync(withDisplayPath, "utf8"); if (actual !== canonical.withDisplay) { push( `scripts/with-display.sh has drifted from @norskvideo/ctl-dev-kit doc-guide/with-display.sh (${firstDiffLine(actual, canonical.withDisplay)}). ${RESYNC}`, ); } } } // The docs site's theme (docs-theme/): gated only when the repo HAS a site, // since that is a nested standalone package rather than a repo-root file. A // site in this monorepo also runs the theme gate from its own test suite; a // product repo has no such suite, so check:drift is where the copies get // checked, and one clause covers both. for (const p of docsThemeProblems(repoRoot, opts.themeCanonical)) push(p); // publish-docs-site.yml: the site's publish, verbatim (no per-repo line: the // project name is a repo variable, the credentials are secrets), and only // where docsSiteExposureProblems allows a public site at all. const siteWorkflowPath = join(repoRoot, DOCS_SITE_WORKFLOW); if (existsSync(siteWorkflowPath)) { const actual = readFileSync(siteWorkflowPath, "utf8"); if (actual !== canonical.publishDocsSite) { push( `${DOCS_SITE_WORKFLOW} has drifted from @norskvideo/ctl-dev-kit conventions/publish-docs-site.yml (${firstDiffLine(actual, canonical.publishDocsSite)}). ${RESYNC}`, ); } } for (const p of docsSiteExposureProblems(existsSync(siteWorkflowPath), hasDocsSite(repoRoot), opts.repoName)) push(p); for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p); for (const p of serverKitProblems(repoRoot, canonical)) push(p); for (const p of productLogsProblems(repoRoot)) push(p); for (const p of bunVersionProblems(repoRoot, canonical.ctlShell)) push(p); for (const p of ctlPinProblems(repoRoot)) push(p); return { ok: problems.length === 0, problems }; } if (import.meta.main) { const repoRoot = process.argv[2] ?? process.cwd(); const canonical: CanonicalBytes = { core: readFileSync(join(import.meta.dir, "CLAUDE.core.md"), "utf8"), flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"), ctlShell: readFileSync(join(import.meta.dir, "..", "build", "ctl-shell.nix"), "utf8"), bump: readFileSync(join(import.meta.dir, "bump.yml"), "utf8"), botPrs: readFileSync(join(import.meta.dir, "bot-prs.yml"), "utf8"), publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"), buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"), publishDocsSite: readFileSync(join(import.meta.dir, "publish-docs-site.yml"), "utf8"), withDisplay: readFileSync(join(import.meta.dir, "..", "doc-guide", "with-display.sh"), "utf8"), checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"), biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"), tsconfigBase: readFileSync(join(import.meta.dir, "tsconfig.base.json"), "utf8"), dprint: readFileSync(join(import.meta.dir, "dprint.base.jsonc"), "utf8"), buildImageBootstrap: readFileSync(join(import.meta.dir, "..", "build", "build-image.bootstrap.sh"), "utf8"), gitignoreCore: readFileSync(join(import.meta.dir, "gitignore.core"), "utf8"), buildImage: readFileSync(join(import.meta.dir, "build-image.yml"), "utf8"), smoke: readFileSync(join(import.meta.dir, "smoke.yml"), "utf8"), integration: readFileSync(join(import.meta.dir, "integration.yml"), "utf8"), demoShim: readFileSync(join(import.meta.dir, "demo.sh"), "utf8"), serverInstall: readFileSync(join(import.meta.dir, "server-install.sh"), "utf8"), serverUninstall: readFileSync(join(import.meta.dir, "server-uninstall.sh"), "utf8"), flakeSdkApp: readFileSync(join(import.meta.dir, "..", "build", "flake.sdk-app.nix"), "utf8"), checksSdkApp: readFileSync(join(import.meta.dir, "checks.sdk-app.yml"), "utf8"), bumpSdkApp: readFileSync(join(import.meta.dir, "bump.sdk-app.yml"), "utf8"), }; const report = checkDrift(repoRoot, canonical, { repoName: resolveRepoName(repoRoot), shape: resolveShape(repoRoot), }); if (report.ok) { console.log("drift-check: all shared-convention copies match @norskvideo/ctl-dev-kit."); process.exit(0); } console.error( `drift-check FAILED (${report.problems.length} problem(s)):\n${report.problems.map((p) => ` - ${p}`).join("\n\n")}`, ); process.exit(1); }