@norskvideo/ctl-dev-kit 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/build-image.bootstrap.sh +18 -0
- package/conventions/CLAUDE.core.md +14 -10
- package/conventions/biome.base.json +44 -0
- package/conventions/check-drift.ts +270 -31
- package/conventions/checks.yml +77 -0
- package/conventions/dprint.base.jsonc +37 -0
- package/conventions/gitignore.core +18 -0
- package/conventions/tsconfig.base.json +14 -0
- package/conventions/upgrade-latest.yml +26 -13
- package/create-product/assets/ci-status-dispatch.yml +70 -0
- package/create-product/backend-turnkey.ts +1389 -0
- package/create-product/canon.ts +52 -0
- package/create-product/cli.ts +84 -0
- package/create-product/create-product.ts +178 -0
- package/package.json +11 -2
- package/testing/INVARIANTS.template.md +49 -0
- package/testing/byte-snapshot.ts +43 -0
- package/testing/invariants.ts +117 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# The one forced copy inside every product's deployment/build-image.sh: the
|
|
2
|
+
# bootstrap that locates this package before it can source anything from it.
|
|
3
|
+
# check-drift.ts extracts the find_dev_kit function below and requires it
|
|
4
|
+
# verbatim in each product's script (which must also `source` a script from
|
|
5
|
+
# this package's build/). The surrounding wrapper is per-product: tag, port,
|
|
6
|
+
# and — for studio's sanctioned bespoke build — which build/ script it sources.
|
|
7
|
+
find_dev_kit() {
|
|
8
|
+
local dir="$1"
|
|
9
|
+
while [ "${dir}" != "/" ]; do
|
|
10
|
+
if [ -d "${dir}/node_modules/@norskvideo/ctl-dev-kit" ]; then
|
|
11
|
+
printf '%s' "${dir}/node_modules/@norskvideo/ctl-dev-kit"
|
|
12
|
+
return 0
|
|
13
|
+
fi
|
|
14
|
+
dir="$(dirname "${dir}")"
|
|
15
|
+
done
|
|
16
|
+
echo "could not find @norskvideo/ctl-dev-kit — run bun install" >&2
|
|
17
|
+
return 1
|
|
18
|
+
}
|
|
@@ -51,15 +51,19 @@
|
|
|
51
51
|
diverges. Only the `product:` key is per-repo. Never hand-edit the copy — edit
|
|
52
52
|
the dev-kit source and re-sync.
|
|
53
53
|
- **A config-driven workflow needs a parity-checked invariant contract.** If this
|
|
54
|
-
product emits its Studio graph dynamically from config,
|
|
55
|
-
|
|
56
|
-
`
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
(
|
|
63
|
-
|
|
54
|
+
product emits its Studio graph dynamically from config, its load-bearing rules
|
|
55
|
+
are documented as stable-ID statements (`INV-<AREA>-<NNN>`) in an
|
|
56
|
+
`INVARIANTS.md`, and every ID is cited verbatim by an executable rule that
|
|
57
|
+
guards it. The parity guard — `invariantParityFromDisk` from
|
|
58
|
+
`@norskvideo/ctl-dev-kit/testing/invariants` (template + copy-paste test in
|
|
59
|
+
`testing/INVARIANTS.template.md`) — fails CI on any drift: a documented ID
|
|
60
|
+
with no citing rule, or a rule citing an ID the doc doesn't define. A config
|
|
61
|
+
change is never done until the invariant is in BOTH the doc and its rule
|
|
62
|
+
(same ID, added together); a runtime-only invariant the emitted graph can't
|
|
63
|
+
show is declared in the guard's `untestableIds`, still parity-checked. Where
|
|
64
|
+
the doc lives and whether rules sit in one sweep suite
|
|
65
|
+
(`norsk-ctl-product-playout`, `-commentary`) or beside the code they guard
|
|
66
|
+
(`-probe`, `-turnkey-funke-pegasus`) is the repo's choice — the contract is
|
|
67
|
+
the IDs and the guard, not the layout.
|
|
64
68
|
|
|
65
69
|
<!-- END ctl-shared-conventions v1 -->
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": true
|
|
7
|
+
},
|
|
8
|
+
"formatter": {
|
|
9
|
+
"enabled": true,
|
|
10
|
+
"indentStyle": "space",
|
|
11
|
+
"indentWidth": 2,
|
|
12
|
+
"lineWidth": 120
|
|
13
|
+
},
|
|
14
|
+
"linter": {
|
|
15
|
+
"enabled": true,
|
|
16
|
+
"rules": {
|
|
17
|
+
"preset": "recommended"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"assist": {
|
|
21
|
+
"actions": {
|
|
22
|
+
"source": {
|
|
23
|
+
"organizeImports": "on"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": {
|
|
28
|
+
"includes": [
|
|
29
|
+
"**/*.ts",
|
|
30
|
+
"**/*.tsx",
|
|
31
|
+
"**/*.js",
|
|
32
|
+
"**/*.json",
|
|
33
|
+
"!**/node_modules",
|
|
34
|
+
"!**/dist",
|
|
35
|
+
"!**/generated",
|
|
36
|
+
"!**/_gen",
|
|
37
|
+
"!**/worker-mode/proto",
|
|
38
|
+
"!**/dashboards/*/assets",
|
|
39
|
+
"!**/website",
|
|
40
|
+
"!.longshot",
|
|
41
|
+
"!**/mcp/resources/plugin-template"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
// Workstream I drift gate. Fails a product repo whose forced copies of the
|
|
2
|
-
// shared conventions have diverged from this dev-kit's canonical source
|
|
3
|
-
//
|
|
4
|
-
// -
|
|
2
|
+
// shared conventions have diverged from this dev-kit's canonical source. Each
|
|
3
|
+
// gated file uses the loosest mechanism that still pins what is shared:
|
|
4
|
+
// - byte-verbatim: CLAUDE.md fenced core, flake.nix, biome.json,
|
|
5
|
+
// tsconfig.base.json, the .gitignore core block
|
|
6
|
+
// - masked per-repo line: checks.yml + upgrade-latest.yml (`product:` key)
|
|
7
|
+
// - base + sanctioned extension block: dprint.json (repo-specific excludes)
|
|
8
|
+
// - structural: root tsconfig.json (shape-dependent includes stay free),
|
|
9
|
+
// deployment/build-image.sh (per-product wrapper around a verbatim
|
|
10
|
+
// bootstrap), manifest.seed.json (per-product pins, SDK-schema shape)
|
|
5
11
|
//
|
|
6
|
-
//
|
|
7
|
-
// GitHub with no tooling — which is exactly where drift starts. The gate
|
|
8
|
-
// "single-sourced in @norskvideo/ctl-dev-kit" true: a product runs it in
|
|
9
|
-
// against the dev-kit version it has installed, so bumping the pinned
|
|
10
|
-
// forces a re-sync. Canonical bytes
|
|
11
|
-
// (conventions
|
|
12
|
-
//
|
|
12
|
+
// Files are copied verbatim (not @import-ed / not a dep) so a human reads them
|
|
13
|
+
// on GitHub with no tooling — which is exactly where drift starts. The gate
|
|
14
|
+
// makes "single-sourced in @norskvideo/ctl-dev-kit" true: a product runs it in
|
|
15
|
+
// CI against the dev-kit version it has installed, so bumping the pinned
|
|
16
|
+
// dev-kit forces a re-sync. Canonical bytes ship alongside this script
|
|
17
|
+
// (conventions/* + build/*), resolved package-relative so they work both
|
|
18
|
+
// workspace-symlinked and installed from the published tarball.
|
|
13
19
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
20
|
import { join } from "node:path";
|
|
21
|
+
import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
|
|
15
22
|
|
|
16
23
|
export interface DriftReport {
|
|
17
24
|
ok: boolean;
|
|
@@ -36,20 +43,154 @@ function firstDiffLine(actual: string, expected: string): string {
|
|
|
36
43
|
return "content differs";
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
// The one legitimately per-repo line in
|
|
40
|
-
// the
|
|
41
|
-
const PRODUCT_LINE = /^(\s*product:\s*).*$/
|
|
46
|
+
// The one legitimately per-repo line in the shared workflows: the dashboard key
|
|
47
|
+
// the CI result is dispatched under. Everything else is verbatim-shared.
|
|
48
|
+
const PRODUCT_LINE = /^(\s*product:\s*).*$/gm;
|
|
42
49
|
const PRODUCT_SENTINEL = "__PRODUCT__";
|
|
43
50
|
|
|
44
|
-
function workflowProblem(actual: string, canonical: string): string | undefined {
|
|
51
|
+
function workflowProblem(relPath: string, canonicalRef: string, actual: string, canonical: string): string | undefined {
|
|
45
52
|
// Compare structure with the product value masked so any real key passes, then
|
|
46
53
|
// separately reject a copy that still carries the unfilled sentinel.
|
|
47
54
|
const mask = (s: string) => s.replace(PRODUCT_LINE, `$1${PRODUCT_SENTINEL}`);
|
|
48
55
|
if (mask(actual) !== mask(canonical)) {
|
|
49
|
-
return
|
|
56
|
+
return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(mask(actual), mask(canonical))}). ${RESYNC}`;
|
|
50
57
|
}
|
|
51
58
|
if (actual.match(/^\s*product:\s*(\S+)/m)?.[1] === PRODUCT_SENTINEL) {
|
|
52
|
-
return
|
|
59
|
+
return `${relPath} still has the ${PRODUCT_SENTINEL} placeholder — set \`product:\` to this repo's dashboard key.`;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The dev-only ctl pin the nightly bumps in each repo's flake: the version
|
|
65
|
+
// string and the four per-platform source hashes. CI downloads ctl directly, so
|
|
66
|
+
// this pin only feeds the dev flake; it floats per repo and over time and is NOT
|
|
67
|
+
// a shared convention. Mask it so the gate compares flake STRUCTURE, not the pin
|
|
68
|
+
// — same idea as masking upgrade-latest's product: line.
|
|
69
|
+
const CTL_VERSION_LINE = /ctlVersion = "[^"]*";/;
|
|
70
|
+
const CTL_HASH_LINE = /hash = "sha256-[^"]*";/g;
|
|
71
|
+
|
|
72
|
+
function flakeProblem(actual: string, canonical: string): string | undefined {
|
|
73
|
+
const mask = (s: string) =>
|
|
74
|
+
s.replace(CTL_VERSION_LINE, 'ctlVersion = "__CTL__";').replace(CTL_HASH_LINE, 'hash = "sha256-__HASH__";');
|
|
75
|
+
const [ma, mc] = [mask(actual), mask(canonical)];
|
|
76
|
+
if (ma === mc) return undefined;
|
|
77
|
+
return `flake.nix has drifted from @norskvideo/ctl-dev-kit build/flake.nix (${firstDiffLine(ma, mc)}). ${RESYNC}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// dprint.json is canonical-plus-extension: the shared config is verbatim, but a
|
|
81
|
+
// repo may exclude paths only it has (e.g. probe's generated components/lib/)
|
|
82
|
+
// inside a sanctioned marker block at the head of `excludes`. Everything outside
|
|
83
|
+
// the block is byte-compared; inside, only well-formed `"entry",` lines are
|
|
84
|
+
// allowed so the block can't smuggle arbitrary config past the byte check.
|
|
85
|
+
const DPRINT_BEGIN = "// BEGIN repo-specific excludes";
|
|
86
|
+
const DPRINT_END = "// END repo-specific excludes";
|
|
87
|
+
|
|
88
|
+
function splitAtMarkers(s: string): { prefix: string; body: string[]; suffix: string } | undefined {
|
|
89
|
+
const lines = s.split("\n");
|
|
90
|
+
const begin = lines.findIndex((l) => l.trim() === DPRINT_BEGIN);
|
|
91
|
+
const end = lines.findIndex((l) => l.trim() === DPRINT_END);
|
|
92
|
+
if (begin === -1 || end === -1 || end < begin) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
prefix: lines.slice(0, begin + 1).join("\n"),
|
|
95
|
+
body: lines.slice(begin + 1, end),
|
|
96
|
+
suffix: lines.slice(end).join("\n"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function dprintProblem(actual: string, canonical: string): string | undefined {
|
|
101
|
+
const c = splitAtMarkers(canonical);
|
|
102
|
+
if (!c) throw new Error("canonical dprint.base.jsonc is missing its own marker block");
|
|
103
|
+
const a = splitAtMarkers(actual);
|
|
104
|
+
if (!a) {
|
|
105
|
+
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.`;
|
|
106
|
+
}
|
|
107
|
+
const masked = (x: { prefix: string; suffix: string }) => `${x.prefix}\n${x.suffix}`;
|
|
108
|
+
if (masked(a) !== masked(c)) {
|
|
109
|
+
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}`;
|
|
110
|
+
}
|
|
111
|
+
const badLine = a.body.find((l) => !/^\s*"[^"]*",$/.test(l));
|
|
112
|
+
if (badLine !== undefined) {
|
|
113
|
+
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.`;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// deployment/build-image.sh: the wrapper around the shared build is per-product
|
|
119
|
+
// (tag, port, and studio's sanctioned bespoke build), but its dev-kit-locating
|
|
120
|
+
// bootstrap is the one forced copy — it must find this package before it can
|
|
121
|
+
// source anything from it, so it is kept identical everywhere and gated here.
|
|
122
|
+
// The script must also actually source a build/ script from the located dev-kit.
|
|
123
|
+
const SOURCES_DEV_KIT = /^source "\$\{dev_kit\}\/build\/[^"]+\.sh"$/m;
|
|
124
|
+
|
|
125
|
+
function buildImageProblem(actual: string, canonicalBootstrap: string): string | undefined {
|
|
126
|
+
const fnStart = canonicalBootstrap.indexOf("find_dev_kit() {");
|
|
127
|
+
if (fnStart === -1) throw new Error("canonical build-image.bootstrap.sh is missing find_dev_kit()");
|
|
128
|
+
const fn = canonicalBootstrap.slice(fnStart);
|
|
129
|
+
if (!actual.includes(fn)) {
|
|
130
|
+
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}`;
|
|
131
|
+
}
|
|
132
|
+
if (!SOURCES_DEV_KIT.test(actual)) {
|
|
133
|
+
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.`;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Root tsconfig.json is gated structurally, not byte-wise: its `include` list
|
|
139
|
+
// legitimately differs by repo shape (probe adds scripts + a playwright config,
|
|
140
|
+
// studio adds scripts/, the turnkeys are tests-only), so the gate pins what is
|
|
141
|
+
// genuinely common — all compiler semantics live in the byte-gated
|
|
142
|
+
// tsconfig.base.json, the root file only extends it and names the trees — and
|
|
143
|
+
// leaves the include list free beyond the tests/**/* every repo carries.
|
|
144
|
+
const ROOT_TSCONFIG_KEYS = new Set(["//", "extends", "include"]);
|
|
145
|
+
|
|
146
|
+
function rootTsconfigProblem(actual: string): string | undefined {
|
|
147
|
+
let parsed: unknown;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(actual);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return `tsconfig.json at the repo root is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`;
|
|
152
|
+
}
|
|
153
|
+
const cfg = parsed as Record<string, unknown>;
|
|
154
|
+
const extraKeys = Object.keys(cfg).filter((k) => !ROOT_TSCONFIG_KEYS.has(k));
|
|
155
|
+
if (extraKeys.includes("compilerOptions")) {
|
|
156
|
+
return `tsconfig.json must not carry compilerOptions — all compiler semantics live in the drift-gated tsconfig.base.json.`;
|
|
157
|
+
}
|
|
158
|
+
if (extraKeys.length > 0) {
|
|
159
|
+
return `tsconfig.json carries unexpected top-level keys (${extraKeys.join(", ")}) — only "//", "extends" and "include" are sanctioned; semantics belong in tsconfig.base.json.`;
|
|
160
|
+
}
|
|
161
|
+
if (cfg.extends !== "./tsconfig.base.json") {
|
|
162
|
+
return `tsconfig.json must have "extends": "./tsconfig.base.json" (found ${JSON.stringify(cfg.extends)}).`;
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(cfg.include) || !cfg.include.includes("tests/**/*")) {
|
|
165
|
+
return `tsconfig.json "include" must contain "tests/**/*" (every product typechecks its tests tree); shape-specific extras are fine.`;
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// .gitignore is base-block-plus-additions: the canonical block must appear
|
|
171
|
+
// verbatim (an unrecognised entry inside it would be drift), while entries a
|
|
172
|
+
// repo's shape genuinely needs (build outputs, codegen trees) live freely
|
|
173
|
+
// outside it — extra ignore lines for paths another repo lacks are inert, so
|
|
174
|
+
// they need no sanctioning mechanism beyond staying out of the block.
|
|
175
|
+
function gitignoreProblem(actual: string, canonicalBlock: string): string | undefined {
|
|
176
|
+
if (actual.includes(canonicalBlock)) return undefined;
|
|
177
|
+
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}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// manifest.seed.json is conformance-checked, not byte-compared: its pins are
|
|
181
|
+
// per-product by design, but the SHAPE is the SDK's SeedSchema. This is the CI
|
|
182
|
+
// gate ctl-sdk/manifest-seed's comment always assumed — version.ts consumes the
|
|
183
|
+
// JSON as typed data precisely because every seed is validated here.
|
|
184
|
+
function seedProblem(actual: string): string | undefined {
|
|
185
|
+
let raw: unknown;
|
|
186
|
+
try {
|
|
187
|
+
raw = JSON.parse(actual);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return `manifest.seed.json is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`;
|
|
190
|
+
}
|
|
191
|
+
const parsed = parseManifestSeed(raw);
|
|
192
|
+
if (parsed.status === "error") {
|
|
193
|
+
return `manifest.seed.json does not conform to @norskvideo/ctl-sdk/manifest-seed's SeedSchema:\n${parsed.error}`;
|
|
53
194
|
}
|
|
54
195
|
return undefined;
|
|
55
196
|
}
|
|
@@ -66,18 +207,47 @@ function coreProblem(claude: string, canonicalCore: string): string | undefined
|
|
|
66
207
|
return `CLAUDE.md fenced core has drifted from @norskvideo/ctl-dev-kit conventions/CLAUDE.core.md (${firstDiffLine(region, canonicalCore.trimEnd())}). ${RESYNC}`;
|
|
67
208
|
}
|
|
68
209
|
|
|
69
|
-
export
|
|
210
|
+
export interface CanonicalBytes {
|
|
211
|
+
core: string;
|
|
212
|
+
flake: string;
|
|
213
|
+
upgradeLatest: string;
|
|
214
|
+
checks: string;
|
|
215
|
+
biome: string;
|
|
216
|
+
tsconfigBase: string;
|
|
217
|
+
dprint: string;
|
|
218
|
+
buildImageBootstrap: string;
|
|
219
|
+
gitignoreCore: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A file that must be a verbatim, byte-for-byte copy of its dev-kit canonical.
|
|
223
|
+
function verbatimProblem(
|
|
70
224
|
repoRoot: string,
|
|
71
|
-
|
|
72
|
-
|
|
225
|
+
relPath: string,
|
|
226
|
+
canonicalRef: string,
|
|
227
|
+
canonical: string,
|
|
228
|
+
): string | undefined {
|
|
229
|
+
const path = join(repoRoot, relPath);
|
|
230
|
+
if (!existsSync(path)) {
|
|
231
|
+
return `${relPath} not found at repo root (${path}). It is copied verbatim from @norskvideo/ctl-dev-kit ${canonicalRef}.`;
|
|
232
|
+
}
|
|
233
|
+
const actual = readFileSync(path, "utf8");
|
|
234
|
+
if (actual !== canonical) {
|
|
235
|
+
return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(actual, canonical)}). ${RESYNC}`;
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftReport {
|
|
73
241
|
const problems: string[] = [];
|
|
242
|
+
const push = (problem: string | undefined) => {
|
|
243
|
+
if (problem) problems.push(problem);
|
|
244
|
+
};
|
|
74
245
|
|
|
75
246
|
const claudePath = join(repoRoot, "CLAUDE.md");
|
|
76
247
|
if (!existsSync(claudePath)) {
|
|
77
248
|
problems.push(`CLAUDE.md not found at repo root (${claudePath}). It must embed the ctl-shared-conventions core.`);
|
|
78
249
|
} else {
|
|
79
|
-
|
|
80
|
-
if (problem) problems.push(problem);
|
|
250
|
+
push(coreProblem(readFileSync(claudePath, "utf8"), canonical.core));
|
|
81
251
|
}
|
|
82
252
|
|
|
83
253
|
const flakePath = join(repoRoot, "flake.nix");
|
|
@@ -86,12 +256,69 @@ export function checkDrift(
|
|
|
86
256
|
`flake.nix not found at repo root (${flakePath}). It is copied verbatim from @norskvideo/ctl-dev-kit build/flake.nix.`,
|
|
87
257
|
);
|
|
88
258
|
} else {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
259
|
+
const problem = flakeProblem(readFileSync(flakePath, "utf8"), canonical.flake);
|
|
260
|
+
if (problem) problems.push(problem);
|
|
261
|
+
}
|
|
262
|
+
push(verbatimProblem(repoRoot, "biome.json", "conventions/biome.base.json", canonical.biome));
|
|
263
|
+
push(verbatimProblem(repoRoot, "tsconfig.base.json", "conventions/tsconfig.base.json", canonical.tsconfigBase));
|
|
264
|
+
|
|
265
|
+
const rootTsconfigPath = join(repoRoot, "tsconfig.json");
|
|
266
|
+
if (!existsSync(rootTsconfigPath)) {
|
|
267
|
+
problems.push(
|
|
268
|
+
`tsconfig.json not found at repo root (${rootTsconfigPath}). It must extend the drift-gated tsconfig.base.json and include tests/**/*.`,
|
|
269
|
+
);
|
|
270
|
+
} else {
|
|
271
|
+
push(rootTsconfigProblem(readFileSync(rootTsconfigPath, "utf8")));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const dprintPath = join(repoRoot, "dprint.json");
|
|
275
|
+
if (!existsSync(dprintPath)) {
|
|
276
|
+
problems.push(
|
|
277
|
+
`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).`,
|
|
278
|
+
);
|
|
279
|
+
} else {
|
|
280
|
+
push(dprintProblem(readFileSync(dprintPath, "utf8"), canonical.dprint));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const seedPath = join(repoRoot, "manifest.seed.json");
|
|
284
|
+
if (!existsSync(seedPath)) {
|
|
285
|
+
problems.push(
|
|
286
|
+
`manifest.seed.json not found at repo root (${seedPath}). Every product pins its {media, studio} pair there (RFC 0001 Workstream C).`,
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
push(seedProblem(readFileSync(seedPath, "utf8")));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const gitignorePath = join(repoRoot, ".gitignore");
|
|
293
|
+
if (!existsSync(gitignorePath)) {
|
|
294
|
+
problems.push(
|
|
295
|
+
`.gitignore not found at repo root (${gitignorePath}). It must contain the ctl-shared-gitignore block from @norskvideo/ctl-dev-kit conventions/gitignore.core.`,
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
push(gitignoreProblem(readFileSync(gitignorePath, "utf8"), canonical.gitignoreCore));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const buildImagePath = join(repoRoot, "deployment", "build-image.sh");
|
|
302
|
+
if (!existsSync(buildImagePath)) {
|
|
303
|
+
problems.push(
|
|
304
|
+
`deployment/build-image.sh not found (${buildImagePath}). Every product ships the thin wrapper over the dev-kit build; see build/build-image.bootstrap.sh.`,
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
push(buildImageProblem(readFileSync(buildImagePath, "utf8"), canonical.buildImageBootstrap));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// checks.yml is REQUIRED — it is the CI entry point that runs this very gate
|
|
311
|
+
// (plus the shared quality jobs), so a repo without it never re-converges.
|
|
312
|
+
// Only the `product:` dispatch key is per-repo; it is masked like
|
|
313
|
+
// upgrade-latest.yml's.
|
|
314
|
+
const checksPath = join(repoRoot, ".github", "workflows", "checks.yml");
|
|
315
|
+
const checksRel = ".github/workflows/checks.yml";
|
|
316
|
+
if (!existsSync(checksPath)) {
|
|
317
|
+
problems.push(
|
|
318
|
+
`${checksRel} not found (${checksPath}). Copy conventions/checks.yml from @norskvideo/ctl-dev-kit and set \`product:\` to this repo's dashboard key.`,
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
push(workflowProblem(checksRel, "conventions/checks.yml", readFileSync(checksPath, "utf8"), canonical.checks));
|
|
95
322
|
}
|
|
96
323
|
|
|
97
324
|
// upgrade-latest.yml is OPTIONAL — a product may ship without a nightly bump
|
|
@@ -99,8 +326,14 @@ export function checkDrift(
|
|
|
99
326
|
// single-sourced workflow and must not diverge.
|
|
100
327
|
const workflowPath = join(repoRoot, ".github", "workflows", "upgrade-latest.yml");
|
|
101
328
|
if (existsSync(workflowPath)) {
|
|
102
|
-
|
|
103
|
-
|
|
329
|
+
push(
|
|
330
|
+
workflowProblem(
|
|
331
|
+
".github/workflows/upgrade-latest.yml",
|
|
332
|
+
"conventions/upgrade-latest.yml",
|
|
333
|
+
readFileSync(workflowPath, "utf8"),
|
|
334
|
+
canonical.upgradeLatest,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
104
337
|
}
|
|
105
338
|
|
|
106
339
|
return { ok: problems.length === 0, problems };
|
|
@@ -108,14 +341,20 @@ export function checkDrift(
|
|
|
108
341
|
|
|
109
342
|
if (import.meta.main) {
|
|
110
343
|
const repoRoot = process.argv[2] ?? process.cwd();
|
|
111
|
-
const canonical = {
|
|
344
|
+
const canonical: CanonicalBytes = {
|
|
112
345
|
core: readFileSync(join(import.meta.dir, "CLAUDE.core.md"), "utf8"),
|
|
113
346
|
flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"),
|
|
114
347
|
upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
|
|
348
|
+
checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
|
|
349
|
+
biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
|
|
350
|
+
tsconfigBase: readFileSync(join(import.meta.dir, "tsconfig.base.json"), "utf8"),
|
|
351
|
+
dprint: readFileSync(join(import.meta.dir, "dprint.base.jsonc"), "utf8"),
|
|
352
|
+
buildImageBootstrap: readFileSync(join(import.meta.dir, "..", "build", "build-image.bootstrap.sh"), "utf8"),
|
|
353
|
+
gitignoreCore: readFileSync(join(import.meta.dir, "gitignore.core"), "utf8"),
|
|
115
354
|
};
|
|
116
355
|
const report = checkDrift(repoRoot, canonical);
|
|
117
356
|
if (report.ok) {
|
|
118
|
-
console.log("drift-check:
|
|
357
|
+
console.log("drift-check: all shared-convention copies match @norskvideo/ctl-dev-kit.");
|
|
119
358
|
process.exit(0);
|
|
120
359
|
}
|
|
121
360
|
console.error(
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Pre-merge gates, single-sourced in @norskvideo/ctl-dev-kit (conventions/checks.yml)
|
|
2
|
+
# and drift-checked — only the ci-status-dispatch `product:` key is per-repo.
|
|
3
|
+
#
|
|
4
|
+
# Two jobs:
|
|
5
|
+
# - drift: the RFC 0001 Workstream I shared-conventions drift-check. Fails if
|
|
6
|
+
# this repo's forced copies have diverged from the pinned dev-kit — the
|
|
7
|
+
# fenced CLAUDE.md core, flake.nix, the shared config files, the manifest
|
|
8
|
+
# seed. Copies exist so a human reads them on GitHub with no tooling; the
|
|
9
|
+
# gate is the only thing keeping them true.
|
|
10
|
+
# - quality: the repo's own lint + typecheck + unit tests, via the shared
|
|
11
|
+
# package.json script interface every product carries. The "whole project
|
|
12
|
+
# clean" convention in the CLAUDE core has no teeth without this.
|
|
13
|
+
#
|
|
14
|
+
# Both run the same proven way build-image does — inside `nix develop .#build`
|
|
15
|
+
# (which provides bun) on the x64 self-hosted pool — but skip the Docker image
|
|
16
|
+
# build, so they also gate pull requests. To re-sync after a legitimate dev-kit
|
|
17
|
+
# change: bump the @norskvideo/ctl-dev-kit pin, then copy the new files in.
|
|
18
|
+
name: checks
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
push:
|
|
22
|
+
branches: [main]
|
|
23
|
+
pull_request:
|
|
24
|
+
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
drift:
|
|
30
|
+
runs-on: x64
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v5
|
|
33
|
+
- name: Shared-conventions drift-check (Workstream I)
|
|
34
|
+
run: |
|
|
35
|
+
nix develop .#build --command bash -c '
|
|
36
|
+
set -euo pipefail
|
|
37
|
+
bun install --frozen-lockfile
|
|
38
|
+
bun run check:drift
|
|
39
|
+
'
|
|
40
|
+
|
|
41
|
+
quality:
|
|
42
|
+
runs-on: x64
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v5
|
|
45
|
+
- name: Lint + typecheck + unit tests
|
|
46
|
+
run: |
|
|
47
|
+
nix develop .#build --command bash -c '
|
|
48
|
+
set -euo pipefail
|
|
49
|
+
bun install --frozen-lockfile
|
|
50
|
+
bun run lint
|
|
51
|
+
bun run typecheck
|
|
52
|
+
bun run test:unit
|
|
53
|
+
'
|
|
54
|
+
|
|
55
|
+
# Report this pipeline's result to the aggregated product CI dashboard
|
|
56
|
+
# (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
|
|
57
|
+
# the pony/emoji from the dispatched result. always() so a red or manual run
|
|
58
|
+
# still reports.
|
|
59
|
+
notify:
|
|
60
|
+
needs: [drift, quality]
|
|
61
|
+
if: always() && github.event_name == 'push'
|
|
62
|
+
runs-on: x64
|
|
63
|
+
steps:
|
|
64
|
+
- uses: actions/checkout@v5
|
|
65
|
+
- id: meta
|
|
66
|
+
run: |
|
|
67
|
+
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
|
|
68
|
+
echo "state=failure" >> "$GITHUB_OUTPUT"
|
|
69
|
+
else
|
|
70
|
+
echo "state=success" >> "$GITHUB_OUTPUT"
|
|
71
|
+
fi
|
|
72
|
+
- uses: ./.github/actions/ci-status-dispatch
|
|
73
|
+
with:
|
|
74
|
+
token: ${{ secrets.CI_DISPATCH_TOKEN }}
|
|
75
|
+
product: __PRODUCT__
|
|
76
|
+
pipeline: checks
|
|
77
|
+
status: ${{ steps.meta.outputs.state }}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Dprint owns Markdown + YAML only. Biome stays the formatter/linter for
|
|
3
|
+
// everything it supports (JS/TS/JSON/CSS) — see biome.json. Migrate YAML to
|
|
4
|
+
// Biome once it ships a YAML formatter (https://biomejs.dev/blog/roadmap-2026/).
|
|
5
|
+
"markdown": {
|
|
6
|
+
"textWrap": "maintain"
|
|
7
|
+
},
|
|
8
|
+
"yaml": {
|
|
9
|
+
// No trailing comma in expanded flow sequences — kubb's OpenAPI YAML parser
|
|
10
|
+
// rejects `[a, b,]` (Unexpected token), which broke `bun run gen`.
|
|
11
|
+
"trailingComma": false,
|
|
12
|
+
"flowSequence.preferSingleLine": true
|
|
13
|
+
},
|
|
14
|
+
"includes": ["**/*.{md,yaml,yml}"],
|
|
15
|
+
"excludes": [
|
|
16
|
+
// BEGIN repo-specific excludes
|
|
17
|
+
// END repo-specific excludes
|
|
18
|
+
"node_modules/",
|
|
19
|
+
"**/dist/",
|
|
20
|
+
"**/dist-embedded/",
|
|
21
|
+
"**/build/",
|
|
22
|
+
"**/src/generated/",
|
|
23
|
+
"**/test-temp/",
|
|
24
|
+
".longshot/",
|
|
25
|
+
".ai/",
|
|
26
|
+
".claude/",
|
|
27
|
+
"**/docs/generated/",
|
|
28
|
+
"**/docs/committed/",
|
|
29
|
+
"**/studio-save-files/",
|
|
30
|
+
"**/starter-pack/",
|
|
31
|
+
"**/terraform/templates/"
|
|
32
|
+
],
|
|
33
|
+
"plugins": [
|
|
34
|
+
"https://plugins.dprint.dev/markdown-0.22.1.wasm",
|
|
35
|
+
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# BEGIN ctl-shared-gitignore v1 (do not edit — drift-gated; repo-specific entries go outside this block)
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
generated
|
|
5
|
+
logs/
|
|
6
|
+
test-temp/
|
|
7
|
+
test-results/
|
|
8
|
+
.DS_Store
|
|
9
|
+
.bun_audit
|
|
10
|
+
.envrc
|
|
11
|
+
.vscode
|
|
12
|
+
.claude/
|
|
13
|
+
*.pem
|
|
14
|
+
*.log
|
|
15
|
+
*.tgz
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
# END ctl-shared-gitignore v1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Shared TS config for the server/library packages in the norsk-ctl compile graph (its sub-projects + the @product/* libs). Packages extend this and set only their own lib/jsx/outDir/include/exclude. `types` is pinned explicitly on purpose: auto-inclusion of @types walks node_modules/@types, which is unreliable under bun's isolated linker + scoped --filter installs, so ambient node/bun globals must be named here to be deterministic. Browser/frontend and product packages keep their own configs.",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["node", "bun"]
|
|
13
|
+
}
|
|
14
|
+
}
|