@norskvideo/ctl-dev-kit 0.1.67 → 0.1.69

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.
@@ -254,6 +254,11 @@ export interface CanonicalBytes {
254
254
  smoke: string;
255
255
  /** conventions/demo.sh — the scripts/demo shim a product with tests/demo.spec.ts carries. */
256
256
  demoShim: string;
257
+ /** doc-guide/with-display.sh — the X-display wrapper BOTH docs workflows invoke
258
+ * as `scripts/with-display.sh`. Gated because the workflow is: an ungated
259
+ * hand copy is how build-docs.yml came to fail in both turnkeys, which never
260
+ * made one. */
261
+ withDisplay: string;
257
262
  }
258
263
 
259
264
  // 05-demo s6: a product that has a demo (tests/demo.spec.ts) exposes it the
@@ -568,6 +573,32 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
568
573
  }
569
574
  for (const p of docsExposureProblems(existsSync(publishDocsPath), existsSync(buildDocsPath), opts.repoName)) push(p);
570
575
 
576
+ // Both docs workflows run `bash scripts/with-display.sh scripts/doc-guide/...`.
577
+ // The workflow is gated byte-for-byte and the script it invokes was not gated
578
+ // at all, so a repo could satisfy the gate and still have no way to run the
579
+ // step: build-docs.yml failed on its first scheduled run in BOTH turnkeys with
580
+ // "bash: scripts/with-display.sh: No such file or directory", after the prose
581
+ // bundle had already built. It stays a repo-local executable on purpose (the
582
+ // package.json guide scripts invoke it by that path); it is the ABSENCE from
583
+ // the gate that was the bug.
584
+ const carriesDocs = existsSync(publishDocsPath) || existsSync(buildDocsPath);
585
+ if (carriesDocs) {
586
+ const which = existsSync(buildDocsPath) ? "build-docs.yml" : "publish-docs.yml";
587
+ const withDisplayPath = join(repoRoot, "scripts", "with-display.sh");
588
+ if (!existsSync(withDisplayPath)) {
589
+ push(
590
+ `.github/workflows/${which} runs \`bash scripts/with-display.sh\`, but this repo has no scripts/with-display.sh — the step cannot run. ${RESYNC}`,
591
+ );
592
+ } else {
593
+ const actual = readFileSync(withDisplayPath, "utf8");
594
+ if (actual !== canonical.withDisplay) {
595
+ push(
596
+ `scripts/with-display.sh has drifted from @norskvideo/ctl-dev-kit doc-guide/with-display.sh (${firstDiffLine(actual, canonical.withDisplay)}). ${RESYNC}`,
597
+ );
598
+ }
599
+ }
600
+ }
601
+
571
602
  for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p);
572
603
 
573
604
  return { ok: problems.length === 0, problems };
@@ -583,6 +614,7 @@ if (import.meta.main) {
583
614
  syncCtlPackages: readFileSync(join(import.meta.dir, "sync-ctl-packages.yml"), "utf8"),
584
615
  publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
585
616
  buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
617
+ withDisplay: readFileSync(join(import.meta.dir, "..", "doc-guide", "with-display.sh"), "utf8"),
586
618
  checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
587
619
  biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
588
620
  tsconfigBase: readFileSync(join(import.meta.dir, "tsconfig.base.json"), "utf8"),
@@ -214,6 +214,22 @@ function syncDemoShim(repoRoot: string, canonical: string, r: SyncReport): void
214
214
  chmodSync(path, 0o755);
215
215
  }
216
216
 
217
+ // Both docs workflows run `bash scripts/with-display.sh ...`. It stays a
218
+ // repo-local executable (the package.json guide scripts invoke it by that path),
219
+ // so a repo that carries a docs workflow gets the canonical copy written here
220
+ // rather than hand-copied — which is what neither turnkey ever did.
221
+ function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): void {
222
+ const carriesDocs =
223
+ existsSync(join(repoRoot, ".github/workflows/publish-docs.yml")) ||
224
+ existsSync(join(repoRoot, ".github/workflows/build-docs.yml"));
225
+ if (!carriesDocs) return;
226
+ const rel = "scripts/with-display.sh";
227
+ const path = join(repoRoot, rel);
228
+ mkdirSync(dirname(path), { recursive: true });
229
+ writeIfChanged(path, canonical, rel, r.written);
230
+ chmodSync(path, 0o755);
231
+ }
232
+
217
233
  export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncReport {
218
234
  const r: SyncReport = { written: [], skipped: [] };
219
235
 
@@ -253,6 +269,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
253
269
  syncGitignore(repoRoot, canonical.gitignoreCore, r);
254
270
  syncDprint(repoRoot, canonical.dprint, r);
255
271
  syncDemoShim(repoRoot, canonical.demoShim, r);
272
+ syncWithDisplay(repoRoot, canonical.withDisplay, r);
256
273
 
257
274
  return r;
258
275
  }
@@ -268,6 +285,7 @@ if (import.meta.main) {
268
285
  syncCtlPackages: readFileSync(join(dir, "sync-ctl-packages.yml"), "utf8"),
269
286
  publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
270
287
  buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
288
+ withDisplay: readFileSync(join(dir, "..", "doc-guide", "with-display.sh"), "utf8"),
271
289
  checks: readFileSync(join(dir, "checks.yml"), "utf8"),
272
290
  biome: readFileSync(join(dir, "biome.base.json"), "utf8"),
273
291
  tsconfigBase: readFileSync(join(dir, "tsconfig.base.json"), "utf8"),
@@ -1616,6 +1616,7 @@ export const backendTurnkey: ShapeModule = {
1616
1616
  { path: "docs/known-limitations.md", content: KNOWN_LIMITATIONS_MD },
1617
1617
  { path: ".github/workflows/build-docs.yml", content: ctx.canon.buildDocs },
1618
1618
  { path: "scripts/doc-guide/regen-manual.sh", content: REGEN_MANUAL_SH, executable: true },
1619
+ { path: "scripts/with-display.sh", content: ctx.canon.withDisplay, executable: true },
1619
1620
  { path: "scripts/docs-handover.sh", content: DOCS_HANDOVER_SH, executable: true },
1620
1621
  { path: "tests/demo.spec.ts", content: demoSpecTs(ctx) },
1621
1622
  { path: "tests/unit/demo-spec.test.ts", content: DEMO_SPEC_TEST_TS },
@@ -31,6 +31,9 @@ export interface Canon {
31
31
  invariantsTemplate: string;
32
32
  /** conventions/demo.sh, emitted as scripts/demo. */
33
33
  demoShim: string;
34
+ /** doc-guide/with-display.sh, emitted as scripts/with-display.sh — the docs
35
+ * workflow invokes it by that repo-local path. */
36
+ withDisplay: string;
34
37
  }
35
38
 
36
39
  export function loadCanon(): Canon {
@@ -43,6 +46,7 @@ export function loadCanon(): Canon {
43
46
  syncCtlPackages: readFileSync(join(conventionsDir, "sync-ctl-packages.yml"), "utf8"),
44
47
  checks: readFileSync(join(conventionsDir, "checks.yml"), "utf8"),
45
48
  buildDocs: readFileSync(join(conventionsDir, "build-docs.yml"), "utf8"),
49
+ withDisplay: readFileSync(join(conventionsDir, "..", "doc-guide", "with-display.sh"), "utf8"),
46
50
  biome: readFileSync(join(conventionsDir, "biome.base.json"), "utf8"),
47
51
  tsconfigBase: readFileSync(join(conventionsDir, "tsconfig.base.json"), "utf8"),
48
52
  dprint: readFileSync(join(conventionsDir, "dprint.base.jsonc"), "utf8"),
@@ -0,0 +1,117 @@
1
+ // Lint a repo's licenceTemplate.json against what the repo actually is.
2
+ //
3
+ // The template is this repo's half of a licence: the `products[]` entries a
4
+ // grant needs in order to entitle it. Minting composes {a grant's header} +
5
+ // {the union of one or more repos' entries}, so a template that names the wrong
6
+ // product — or an imageRef the build never produces — mints a licence that
7
+ // entitles nothing, and the first sign is a 60-second readiness timeout.
8
+ //
9
+ // That is not hypothetical. licensing/partner-all-products-template.json is
10
+ // hand-maintained, names four products, and has silently omitted both turnkeys
11
+ // for as long as they have existed. This lint is what makes the derived
12
+ // version impossible to get wrong the same way.
13
+ //
14
+ // It asserts recorded INTENT matches reality, not security: a user can retag
15
+ // any image, so imageRef buys no defence. What it buys is a statement of what
16
+ // was meant, which is only worth having if something keeps it true.
17
+ import { readFileSync } from "node:fs";
18
+
19
+ export interface RepoFacts {
20
+ /** The repo's own PRODUCT_NAME. */
21
+ product: string;
22
+ /** The image name CI builds under — see builtImageName. */
23
+ image: string;
24
+ }
25
+
26
+ /** The image name CI builds under, by CI's own rule: PUBLISH_IMAGE when set,
27
+ * else the build script's IMAGE_TAG default, tag stripped. The licence matches
28
+ * on the repo part alone (`imageRefMatches`: `parsed.repo !== entry.imageRef`),
29
+ * so the tag never belongs in a template. */
30
+ export function builtImageName(opts: { publishImage: string; buildDefault: string }): string | null {
31
+ const ref = opts.publishImage.trim() || opts.buildDefault.trim();
32
+ if (!ref) return null;
33
+ // Strip a tag, but not a registry port: a colon is a tag only after the last slash.
34
+ const lastSlash = ref.lastIndexOf("/");
35
+ const colon = ref.indexOf(":", lastSlash + 1);
36
+ return colon === -1 ? ref : ref.slice(0, colon);
37
+ }
38
+
39
+ const REQUIRED_ENTRY_FIELDS = ["product", "imageRef", "versionConstraint", "mode"] as const;
40
+
41
+ /** Fields that describe WHO a licence was cut for. A repo does not know them,
42
+ * and a merged staff licence that inherited one repo's expiry would be a
43
+ * quiet, dated surprise. */
44
+ const GRANT_ONLY_FIELDS = ["issuedTo", "email", "expiry", "timeout", "nonProduction"] as const;
45
+
46
+ export function templateProblems(templateJson: string, repo: RepoFacts): string[] {
47
+ let parsed: { products?: unknown } & Record<string, unknown>;
48
+ try {
49
+ parsed = JSON.parse(templateJson);
50
+ } catch (e) {
51
+ return [`licenseTemplate.json is not valid JSON: ${String(e)}`];
52
+ }
53
+ if (typeof parsed !== "object" || parsed === null) return ["licenseTemplate.json is not an object"];
54
+
55
+ const problems: string[] = [];
56
+ for (const f of GRANT_ONLY_FIELDS) {
57
+ if (f in parsed) {
58
+ problems.push(`licenseTemplate.json carries "${f}", which belongs to the grant, not the repo — remove it`);
59
+ }
60
+ }
61
+ if (!Array.isArray(parsed.products)) {
62
+ problems.push('licenseTemplate.json has no "products" array');
63
+ return problems;
64
+ }
65
+
66
+ const entries = parsed.products as Record<string, unknown>[];
67
+ for (const [i, entry] of entries.entries()) {
68
+ for (const f of REQUIRED_ENTRY_FIELDS) {
69
+ if (typeof entry?.[f] !== "string") problems.push(`products[${i}] is missing "${f}"`);
70
+ }
71
+ const ref = entry?.imageRef;
72
+ if (typeof ref === "string") {
73
+ const lastSlash = ref.lastIndexOf("/");
74
+ if (ref.indexOf(":", lastSlash + 1) !== -1) {
75
+ problems.push(`products[${i}].imageRef "${ref}" carries a tag; a licence matches the repo part only`);
76
+ }
77
+ }
78
+ }
79
+
80
+ const named = entries.map((e) => e?.product).filter((p): p is string => typeof p === "string");
81
+ if (!named.includes(repo.product)) {
82
+ problems.push(
83
+ `licenseTemplate.json does not name this repo's product ${repo.product} (names: ${named.join(", ") || "nothing"})`,
84
+ );
85
+ return problems;
86
+ }
87
+
88
+ const mine = entries.find((e) => e?.product === repo.product);
89
+ if (typeof mine?.imageRef === "string" && mine.imageRef !== repo.image) {
90
+ problems.push(`products[].imageRef for ${repo.product} is "${mine.imageRef}" but this repo builds "${repo.image}"`);
91
+ }
92
+ return problems;
93
+ }
94
+
95
+ if (import.meta.main) {
96
+ const [file, product, image] = process.argv.slice(2);
97
+ if (!file || !product || !image) {
98
+ console.error("usage: check-template.ts <licenseTemplate.json> <product-name> <built-image-name>");
99
+ process.exit(2);
100
+ }
101
+ let contents: string;
102
+ try {
103
+ contents = readFileSync(file, "utf8");
104
+ } catch {
105
+ // Absence is not yet an error: the turnkeys' templates wait on their
106
+ // customer-facing image names being settled. Flipping this to a failure is
107
+ // a one-line change once every repo carries one.
108
+ console.log(`licence-template: no ${file} — not yet required in this repo`);
109
+ process.exit(0);
110
+ }
111
+ const problems = templateProblems(contents, { product, image });
112
+ if (problems.length) {
113
+ for (const p of problems) console.error(`licence-template: ${p}`);
114
+ process.exit(1);
115
+ }
116
+ console.log(`licence-template: ${file} names ${product} at ${image}`);
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",