@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 },
|
package/create-product/canon.ts
CHANGED
|
@@ -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
|
+
}
|