@norskvideo/ctl-dev-kit 0.2.63 → 0.2.65

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.
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // Known-flaky tests, kept out of the gate but not out of the run (ci-fragility
4
+ // proposal E). A test that fails one run in three on a loaded runner turns the
5
+ // whole checks job red; a person re-runs it, and a real red hides among the
6
+ // flaky ones. Quarantine takes it out of the gating run, runs it on its own
7
+ // straight after, and reports the result without failing the job.
8
+ //
9
+ // The list is the repo's `.github/quarantine.json`, product-owned (not a
10
+ // drift-checked copy), so a reader on GitHub, the dashboard and the job all see
11
+ // the same thing. Every entry carries an owner, the date it went in and the
12
+ // condition for taking it out, and an entry older than STALE_DAYS is flagged on
13
+ // every run: quarantine is a holding pen, not a way to skip a test for ever.
14
+ //
15
+ // {"tests": [{"file": "tests/unit/x.test.ts", "name": "<test name>",
16
+ // "owner": "<who>", "since": "YYYY-MM-DD",
17
+ // "reason": "<what fails>", "exit": "<when it comes out>"}]}
18
+ //
19
+ // Run by its node_modules path from the checks quality job (conventions/
20
+ // checks.yml), like zod-singleton.ts:
21
+ // quarantine.ts check validate the list; exit 1 if it is malformed
22
+ // quarantine.ts bun-options the BUN_OPTIONS that leave the listed tests out
23
+ // quarantine.ts run run each listed test alone and report; exit 0
24
+
25
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
26
+
27
+ export const QUARANTINE_FILE = ".github/quarantine.json";
28
+ export const STALE_DAYS = 30;
29
+
30
+ export interface QuarantineEntry {
31
+ file: string;
32
+ name: string;
33
+ owner: string;
34
+ since: string;
35
+ reason: string;
36
+ exit: string;
37
+ issue?: string;
38
+ }
39
+
40
+ const REQUIRED = ["file", "name", "owner", "since", "reason", "exit"] as const;
41
+
42
+ export interface ParsedQuarantine {
43
+ entries: QuarantineEntry[];
44
+ errors: string[];
45
+ warnings: string[];
46
+ }
47
+
48
+ const DAY_MS = 86_400_000;
49
+ const isDate = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`));
50
+
51
+ /** `text` is the file's contents, undefined when the repo has none. */
52
+ export function parseQuarantine(
53
+ text: string | undefined,
54
+ opts: { today: string; exists: (file: string) => boolean },
55
+ ): ParsedQuarantine {
56
+ const out: ParsedQuarantine = { entries: [], errors: [], warnings: [] };
57
+ if (text === undefined) return out;
58
+ let doc: unknown;
59
+ try {
60
+ doc = JSON.parse(text);
61
+ } catch (e) {
62
+ out.errors.push(`${QUARANTINE_FILE} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
63
+ return out;
64
+ }
65
+ const tests = (doc as { tests?: unknown } | null)?.tests;
66
+ if (!Array.isArray(tests)) {
67
+ out.errors.push(`${QUARANTINE_FILE} has no "tests" array`);
68
+ return out;
69
+ }
70
+ const seen = new Set<string>();
71
+ tests.forEach((raw, i) => {
72
+ const e = (raw ?? {}) as Record<string, unknown>;
73
+ const at = `${QUARANTINE_FILE} tests[${i}]`;
74
+ const missing = REQUIRED.filter((f) => typeof e[f] !== "string" || (e[f] as string).trim() === "");
75
+ if (missing.length > 0) {
76
+ out.errors.push(
77
+ `${at}: missing ${missing.join(", ")} (every entry needs an owner, a date and an exit condition)`,
78
+ );
79
+ return;
80
+ }
81
+ const entry = e as unknown as QuarantineEntry;
82
+ if (!isDate(entry.since)) {
83
+ out.errors.push(`${at}: since "${entry.since}" is not a YYYY-MM-DD date`);
84
+ return;
85
+ }
86
+ const age = Math.round((Date.parse(`${opts.today}T00:00:00Z`) - Date.parse(`${entry.since}T00:00:00Z`)) / DAY_MS);
87
+ if (age < 0) {
88
+ out.errors.push(`${at}: since ${entry.since} is in the future`);
89
+ return;
90
+ }
91
+ if (!opts.exists(entry.file)) {
92
+ out.errors.push(`${at}: ${entry.file} is not in the repo; drop the entry or fix the path`);
93
+ return;
94
+ }
95
+ const key = `${entry.file}\0${entry.name}`;
96
+ if (seen.has(key)) {
97
+ out.errors.push(`${at}: "${entry.name}" is listed twice`);
98
+ return;
99
+ }
100
+ seen.add(key);
101
+ if (age > STALE_DAYS) {
102
+ out.warnings.push(
103
+ `"${entry.name}" has been quarantined since ${entry.since} (${age} days, owner ${entry.owner}): fix it and take it out, or delete it`,
104
+ );
105
+ }
106
+ out.entries.push(entry);
107
+ });
108
+ return out;
109
+ }
110
+
111
+ const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
112
+
113
+ /** A regex matching the one test, wherever its describe blocks put it. */
114
+ export function namePattern(name: string): string {
115
+ return `${escapeRegex(name)}$`;
116
+ }
117
+
118
+ /** BUN_OPTIONS for the gating run: every test except the listed ones. Bun
119
+ * splits BUN_OPTIONS on whitespace and has no exclude-by-name flag, so it is
120
+ * one negative lookahead with each space written as \s. */
121
+ export function bunOptions(entries: readonly QuarantineEntry[]): string {
122
+ if (entries.length === 0) return "";
123
+ const names = entries.map((e) => namePattern(e.name).replace(/\s/g, "\\s")).join("|");
124
+ return `--test-name-pattern=^(?!.*(?:${names})).*`;
125
+ }
126
+
127
+ export type RunResult = "passed" | "failed" | "missing";
128
+
129
+ /** A run that matched nothing means the test was renamed or deleted, and the
130
+ * entry is stale; that is not a pass, whatever the exit code. */
131
+ export function classifyRun(exitCode: number, output: string): RunResult {
132
+ if (/matched 0 tests/.test(output) || /Ran 0 tests/.test(output)) return "missing";
133
+ return exitCode === 0 ? "passed" : "failed";
134
+ }
135
+
136
+ export function summary(results: readonly { entry: QuarantineEntry; result: RunResult }[]): string {
137
+ const lines = [
138
+ `### Quarantined tests (run and reported, not gating)`,
139
+ "",
140
+ `From \`${QUARANTINE_FILE}\`. A failure here does not fail the job; it is the flake the entry is waiting on.`,
141
+ "",
142
+ "| Result | Test | File | Owner | Since | Exit condition |",
143
+ "|---|---|---|---|---|---|",
144
+ ];
145
+ for (const { entry, result } of results) {
146
+ const cell = (s: string) => s.replace(/\|/g, "\\|");
147
+ lines.push(
148
+ `| ${result} | ${cell(entry.name)} | \`${entry.file}\` | ${cell(entry.owner)} | ${entry.since} | ${cell(entry.exit)} |`,
149
+ );
150
+ }
151
+ return `${lines.join("\n")}\n`;
152
+ }
153
+
154
+ function load(): ParsedQuarantine {
155
+ const text = existsSync(QUARANTINE_FILE) ? readFileSync(QUARANTINE_FILE, "utf8") : undefined;
156
+ return parseQuarantine(text, { today: new Date().toISOString().slice(0, 10), exists: existsSync });
157
+ }
158
+
159
+ function report(parsed: ParsedQuarantine): void {
160
+ for (const w of parsed.warnings) console.log(`::warning title=quarantine::${w}`);
161
+ for (const e of parsed.errors) console.log(`::error title=quarantine::${e}`);
162
+ }
163
+
164
+ async function main(cmd: string | undefined): Promise<number> {
165
+ const parsed = load();
166
+ if (cmd === "check") {
167
+ report(parsed);
168
+ console.log(`${parsed.entries.length} quarantined test(s) in ${QUARANTINE_FILE}`);
169
+ return parsed.errors.length > 0 ? 1 : 0;
170
+ }
171
+ if (cmd === "bun-options") {
172
+ if (parsed.errors.length > 0) return 1;
173
+ process.stdout.write(bunOptions(parsed.entries));
174
+ return 0;
175
+ }
176
+ if (cmd === "run") {
177
+ report(parsed);
178
+ const results: { entry: QuarantineEntry; result: RunResult }[] = [];
179
+ for (const entry of parsed.entries) {
180
+ console.log(`== quarantined: ${entry.name} (${entry.file})`);
181
+ const proc = Bun.spawn(["bun", "test", entry.file, `--test-name-pattern=${namePattern(entry.name)}`], {
182
+ stdout: "pipe",
183
+ stderr: "pipe",
184
+ });
185
+ const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
186
+ const code = await proc.exited;
187
+ process.stdout.write(out);
188
+ process.stdout.write(err);
189
+ const result = classifyRun(code, `${out}${err}`);
190
+ results.push({ entry, result });
191
+ if (result !== "passed") {
192
+ console.log(`::warning title=quarantined test ${result} (not gating)::${entry.name} (${entry.file})`);
193
+ }
194
+ }
195
+ const failed = results.filter((r) => r.result !== "passed").length;
196
+ if (process.env.GITHUB_STEP_SUMMARY && results.length > 0) {
197
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary(results));
198
+ }
199
+ if (process.env.GITHUB_OUTPUT) {
200
+ appendFileSync(process.env.GITHUB_OUTPUT, `quarantined=${results.length}\nquarantine_failed=${failed}\n`);
201
+ }
202
+ console.log(`${results.length} quarantined test(s) run, ${failed} not passing (not gating)`);
203
+ return 0;
204
+ }
205
+ console.error("usage: quarantine.ts check | bun-options | run");
206
+ return 2;
207
+ }
208
+
209
+ if (import.meta.main) process.exit(await main(process.argv[2]));
@@ -193,12 +193,37 @@ jobs:
193
193
  NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
194
194
  run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
195
195
 
196
+ # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
197
+ # told not to, and nothing removes it: it is a node singleton, and the reap
198
+ # step only takes containers that mount this job's test-temp. So the first
199
+ # test daemon on a shared runner left one, and every later one adopted it
200
+ # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
201
+ # to it). This job's daemons run without the node relay and discovery, so
202
+ # they neither start nor adopt one, and whatever is already on the host is
203
+ # named here. It reports and never fails: the leftover may be someone's.
204
+ - name: Isolate this job from node singletons other jobs left on this host
205
+ run: |
206
+ set -uo pipefail
207
+ echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
208
+ echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
209
+ left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
210
+ if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
211
+ echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
212
+ echo "$left"
213
+ while IFS= read -r line; do
214
+ role="$(awk '{print $2}' <<<"$line")"
215
+ case "$role" in
216
+ moq-relay|discovery)
217
+ echo "::warning title=node singleton left on this host::$line -- left by an earlier job (a daemon's auto-start, or a run killed before its reap). This job's daemons run without it and will not adopt it; remove it with docker rm -f if nothing on this host needs it." ;;
218
+ esac
219
+ done <<<"$left"
220
+
196
221
  # Pick the binary for THIS runner's architecture: the GPU box is aarch64,
197
222
  # the x64 pool is x86_64, and the wrong one dies with "Exec format error"
198
223
  # deep in the suite. No `|| true` on --version: a bad binary must fail here.
199
224
  #
200
225
  # WHICH binary: the version this commit pins, flake.nix ctlVersion -- the
201
- # daemon the dev shell launches, moved by upgrade-latest's bump PR. It used
226
+ # daemon the dev shell launches, moved by bump.yml's nightly PR. It used
202
227
  # to be S3's moving `latest`, read at run time, so a result could change
203
228
  # with no product commit and a re-run was never a real repeat (funke went
204
229
  # red and green again on 2026-10-09 without a commit either time;
@@ -221,7 +246,7 @@ jobs:
221
246
  echo "norsk-ctl $CTL_CHANNEL channel -> $ver ($arch), in place of the repo pin"
222
247
  else
223
248
  ver="$( { sed -nE 's/^[[:space:]]*ctlVersion = "([^"]+)";.*/\1/p' flake.nix 2>/dev/null || true; } | head -1)"
224
- [ -n "$ver" ] || { echo "::error::flake.nix pins no ctlVersion. CI runs the norsk-ctl this commit pins (upgrade-latest moves it); add ctlVersion = \"<version>\"; to flake.nix."; exit 1; }
249
+ [ -n "$ver" ] || { echo "::error::flake.nix pins no ctlVersion. CI runs the norsk-ctl this commit pins (the nightly bump.yml moves it); add ctlVersion = \"<version>\"; to flake.nix."; exit 1; }
225
250
  echo "norsk-ctl pinned by flake.nix ctlVersion -> $ver ($arch)"
226
251
  fi
227
252
  curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-$arch" -o "$RUNNER_TEMP/norsk-ctl"
@@ -7,8 +7,12 @@
7
7
  // It only touches copies whose convergent form is mechanical — there is no
8
8
  // judgement anywhere:
9
9
  // - verbatim (biome.json, tsconfig.base.json) -> overwrite with canonical
10
- // - masked workflows (checks / upgrade / sync / -> canonical, repo's product: key kept
10
+ // - masked workflows (checks / bump / -> canonical, repo's product: key kept
11
11
  // publish-docs)
12
+ // - bump.yml -> CREATED where absent, under the key the
13
+ // retired bump bots carried; then those
14
+ // three (upgrade-latest / sync-dev-kit /
15
+ // sync-ctl-packages) are DELETED
12
16
  // - nix/ctl-shell.nix -> overwrite with canonical (created if absent)
13
17
  // - flake.nix (legacy verbatim copy only) -> the starter, repo's floating ctl pin kept
14
18
  // - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest
@@ -22,11 +26,12 @@
22
26
  // cannot mechanically converge (e.g. a workflow with no product key to preserve)
23
27
  // is left for check-drift to flag, and a convention that genuinely breaks the
24
28
  // product surfaces as a red lint/typecheck/test — not as a silent bad copy.
25
- import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
29
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
26
30
  import { dirname, join } from "node:path";
27
31
  import { checkTheme, syncTheme } from "../docs-theme/sync.ts";
28
32
  import {
29
33
  BEGIN,
34
+ BUMP_WORKFLOW,
30
35
  type CanonicalBytes,
31
36
  CTL_HASH_LINE,
32
37
  CTL_SHELL_PATH,
@@ -43,6 +48,7 @@ import {
43
48
  PRODUCT_SENTINEL,
44
49
  type ProductShape,
45
50
  PUBLISH_IMAGE_LINE,
51
+ RETIRED_BUMP_WORKFLOWS,
46
52
  RUNNER_LINE,
47
53
  resolveRepoName,
48
54
  resolveShape,
@@ -69,7 +75,7 @@ function writeIfChanged(path: string, content: string, rel: string, written: str
69
75
  }
70
76
  }
71
77
 
72
- // checks.yml / upgrade-latest.yml / sync-dev-kit.yml: canonical byte-for-byte
78
+ // checks.yml / publish-docs.yml: canonical byte-for-byte
73
79
  // except the one `product:` dispatch key, which the gate masks. Re-emit the
74
80
  // canonical with the repo's own key filled back in. A workflow with no key to
75
81
  // preserve (absent, or still the sentinel) is left for the gate to flag.
@@ -87,6 +93,48 @@ function syncWorkflow(repoRoot: string, rel: string, canonical: string, r: SyncR
87
93
  writeIfChanged(path, canonical.replace(PRODUCT_LINE, `$1${key}`), rel, r.written);
88
94
  }
89
95
 
96
+ const productKey = (path: string): string | undefined => {
97
+ if (!existsSync(path)) return undefined;
98
+ const key = readFileSync(path, "utf8").match(/^\s*product:\s*(\S+)/m)?.[1];
99
+ return key && key !== PRODUCT_SENTINEL ? key : undefined;
100
+ };
101
+
102
+ // bump.yml, the one nightly bump (ci-fragility proposal B2), and the end of
103
+ // the three bots it replaced. This is how it reaches a product: the old
104
+ // sync-dev-kit.yml installs a dev-kit that has it and runs this writer, which
105
+ // creates bump.yml under the dashboard key the old bots reported under and
106
+ // deletes them -- sync-dev-kit.yml included, in the very PR it is opening. The
107
+ // old bot-prs on main still triages that PR (its branch is sync-dev-kit/*),
108
+ // and the new integration and build-image it brings still run on that branch,
109
+ // so the transition lands like any other bump. Once landed, bump.yml owns the
110
+ // night and the old three are gone.
111
+ //
112
+ // The key comes from bump.yml itself once it has one; before that from
113
+ // upgrade-latest.yml (the nightly column's reporter), sync-dev-kit.yml, and
114
+ // last checks.yml (required, always filled). With no key at all nothing is
115
+ // created and nothing removed: deleting the old bots before their replacement
116
+ // exists would leave the product with no nightly bump, silently. The gate then
117
+ // names the missing bump.yml.
118
+ function syncBump(repoRoot: string, canonical: string, r: SyncReport): void {
119
+ const wf = (rel: string) => join(repoRoot, rel);
120
+ const key =
121
+ productKey(wf(BUMP_WORKFLOW)) ??
122
+ productKey(wf(".github/workflows/upgrade-latest.yml")) ??
123
+ productKey(wf(".github/workflows/sync-dev-kit.yml")) ??
124
+ productKey(wf(".github/workflows/checks.yml"));
125
+ if (key === undefined) {
126
+ r.skipped.push(`${BUMP_WORKFLOW} (no product: key to give it; the retired bump workflows are left in place)`);
127
+ return;
128
+ }
129
+ mkdirSync(dirname(wf(BUMP_WORKFLOW)), { recursive: true });
130
+ writeIfChanged(wf(BUMP_WORKFLOW), canonical.replace(PRODUCT_LINE, `$1${key}`), BUMP_WORKFLOW, r.written);
131
+ for (const rel of RETIRED_BUMP_WORKFLOWS) {
132
+ if (!existsSync(wf(rel))) continue;
133
+ rmSync(wf(rel));
134
+ r.written.push(rel);
135
+ }
136
+ }
137
+
90
138
  // publish-docs.yml: optional, and masked like checks.yml (only re-synced where
91
139
  // the repo already carries it). Every copy predating the mask was written
92
140
  // verbatim with the sentinel, so a copy with no key of its own takes the one
@@ -307,7 +355,7 @@ function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): vo
307
355
  // carry one. It is CREATED only where the gate would accept it: a repo with a
308
356
  // site (there is something to publish), named, and not a turnkey (a turnkey has
309
357
  // no public site, ADR-0012). That is how a product that grows a site acquires
310
- // its publish: the next sync-dev-kit run installs it, green until the Pages
358
+ // its publish: the next nightly bump installs it, green until the Pages
311
359
  // project exists. A name this writer cannot determine creates nothing, since
312
360
  // the gate could only refuse the result.
313
361
  const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
@@ -338,7 +386,7 @@ function syncPublishDocsSite(repoRoot: string, canonical: string, repoName: stri
338
386
  // Only for a repo that HAS a site: sync never creates one (a site is a nested
339
387
  // standalone package with its own install, not a file this writer can conjure),
340
388
  // it only keeps an existing one's copies in step. That is what makes a theme
341
- // change roll out through sync-dev-kit.yml like any other convention.
389
+ // change roll out through bump.yml like any other convention.
342
390
  function syncDocsTheme(repoRoot: string, r: SyncReport): void {
343
391
  if (!hasDocsSite(repoRoot)) {
344
392
  r.skipped.push(`${DOCS_SITE_DIR}/ docs theme (no docs site)`);
@@ -372,21 +420,12 @@ export function syncDrift(
372
420
  writeIfChanged(join(repoRoot, "tsconfig.base.json"), canonical.tsconfigBase, "tsconfig.base.json", r.written);
373
421
 
374
422
  syncWorkflow(repoRoot, ".github/workflows/checks.yml", checksCanon, r);
375
- // upgrade-latest too: an sdk-app has no Studio pair, so it has its own variant
376
- const upgradeCanon = shape === "sdk-app" ? canonical.upgradeLatestSdkApp : canonical.upgradeLatest;
377
- if (upgradeCanon === undefined) throw new Error(`syncDrift: shape "${shape}" requires canonical.upgradeLatestSdkApp`);
378
- syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", upgradeCanon, r);
379
- syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
380
- // No per-repo line to preserve, so it is written whole -- and CREATED where
381
- // absent, unlike syncWorkflow's copies. That is how a repo acquires the
382
- // workflow at all: its next sync run installs it.
383
- writeIfChanged(
384
- join(repoRoot, ".github/workflows/sync-ctl-packages.yml"),
385
- canonical.syncCtlPackages,
386
- ".github/workflows/sync-ctl-packages.yml",
387
- r.written,
388
- );
389
- // bot-prs.yml likewise: verbatim, and created where absent.
423
+ // bump.yml: an sdk-app has no Studio pair, so it has its own variant.
424
+ const bumpCanon = shape === "sdk-app" ? canonical.bumpSdkApp : canonical.bump;
425
+ if (bumpCanon === undefined) throw new Error(`syncDrift: shape "${shape}" requires canonical.bumpSdkApp`);
426
+ syncBump(repoRoot, bumpCanon, r);
427
+ // bot-prs.yml: no per-repo line to preserve, so it is written whole -- and
428
+ // CREATED where absent. That is how a repo acquires it: its next sync installs it.
390
429
  writeIfChanged(
391
430
  join(repoRoot, ".github/workflows/bot-prs.yml"),
392
431
  canonical.botPrs,
@@ -428,9 +467,7 @@ if (import.meta.main) {
428
467
  core: readFileSync(join(dir, "CLAUDE.core.md"), "utf8"),
429
468
  flake: readFileSync(join(dir, "..", "build", "flake.nix"), "utf8"),
430
469
  ctlShell: readFileSync(join(dir, "..", "build", "ctl-shell.nix"), "utf8"),
431
- upgradeLatest: readFileSync(join(dir, "upgrade-latest.yml"), "utf8"),
432
- syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
433
- syncCtlPackages: readFileSync(join(dir, "sync-ctl-packages.yml"), "utf8"),
470
+ bump: readFileSync(join(dir, "bump.yml"), "utf8"),
434
471
  botPrs: readFileSync(join(dir, "bot-prs.yml"), "utf8"),
435
472
  publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
436
473
  buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
@@ -448,7 +485,7 @@ if (import.meta.main) {
448
485
  demoShim: readFileSync(join(dir, "demo.sh"), "utf8"),
449
486
  flakeSdkApp: readFileSync(join(dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
450
487
  checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"),
451
- upgradeLatestSdkApp: readFileSync(join(dir, "upgrade-latest.sdk-app.yml"), "utf8"),
488
+ bumpSdkApp: readFileSync(join(dir, "bump.sdk-app.yml"), "utf8"),
452
489
  };
453
490
  const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot), { repoName: resolveRepoName(repoRoot) });
454
491
  if (report.written.length === 0) {
@@ -18,9 +18,7 @@ export interface Canon {
18
18
  /** build/ctl-shell.nix, emitted as nix/ctl-shell.nix — the flake imports it. */
19
19
  ctlShell: string;
20
20
  flakeLock: string;
21
- upgradeLatest: string;
22
- syncDevKit: string;
23
- syncCtlPackages: string;
21
+ bump: string;
24
22
  botPrs: string;
25
23
  checks: string;
26
24
  biome: string;
@@ -45,9 +43,7 @@ export function loadCanon(): Canon {
45
43
  flake: readFileSync(join(buildDir, "flake.nix"), "utf8"),
46
44
  ctlShell: readFileSync(join(buildDir, "ctl-shell.nix"), "utf8"),
47
45
  flakeLock: readFileSync(join(buildDir, "flake.lock"), "utf8"),
48
- upgradeLatest: readFileSync(join(conventionsDir, "upgrade-latest.yml"), "utf8"),
49
- syncDevKit: readFileSync(join(conventionsDir, "sync-dev-kit.yml"), "utf8"),
50
- syncCtlPackages: readFileSync(join(conventionsDir, "sync-ctl-packages.yml"), "utf8"),
46
+ bump: readFileSync(join(conventionsDir, "bump.yml"), "utf8"),
51
47
  botPrs: readFileSync(join(conventionsDir, "bot-prs.yml"), "utf8"),
52
48
  checks: readFileSync(join(conventionsDir, "checks.yml"), "utf8"),
53
49
  buildDocs: readFileSync(join(conventionsDir, "build-docs.yml"), "utf8"),
@@ -220,9 +220,7 @@ function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[]
220
220
  },
221
221
  { path: ".gitignore", content: `${canon.gitignoreCore}\n${shape.gitignoreTail(ctx)}` },
222
222
  { path: ".github/workflows/checks.yml", content: fillProduct(canon.checks) },
223
- { path: ".github/workflows/upgrade-latest.yml", content: fillProduct(canon.upgradeLatest) },
224
- { path: ".github/workflows/sync-dev-kit.yml", content: fillProduct(canon.syncDevKit) },
225
- { path: ".github/workflows/sync-ctl-packages.yml", content: canon.syncCtlPackages },
223
+ { path: ".github/workflows/bump.yml", content: fillProduct(canon.bump) },
226
224
  { path: ".github/workflows/bot-prs.yml", content: canon.botPrs },
227
225
  { path: ".github/workflows/build-image.yml", content: fillProduct(canon.buildImage) },
228
226
  { path: ".github/actions/ci-status-dispatch/action.yml", content: loadAsset("ci-status-dispatch.yml") },
@@ -228,7 +228,7 @@ machine-writable source of truth — RFC 0001 Workstream C) via
228
228
  \`shared/src/version.ts\`, which validates the seed through the SDK's
229
229
  \`parseManifestSeed\` at module load. \`version.test.ts\` fails if the seed and
230
230
  the \`norsk-sdk\`/\`norsk-studio*\` npm pins in \`shared/package.json\` drift
231
- apart — the shared \`upgrade-latest.yml\` bumps them all in lockstep nightly.
231
+ apart — the shared \`bump.yml\` bumps them all in lockstep nightly.
232
232
 
233
233
  ## The stored-template trap
234
234
 
@@ -537,7 +537,7 @@ describe("npm pins stay coupled to the seed", () => {
537
537
  // the studio npm packages and the studio Docker image from different commits,
538
538
  // so the image rides the newest published Docker Hub tag on the same
539
539
  // major.minor.patch line as the npm nightly (exactly how the shared
540
- // upgrade-latest.yml advances the pair).
540
+ // bump.yml advances the pair).
541
541
  it("studio image stays on the npm pin's major.minor.patch line", () => {
542
542
  expect(STUDIO_IMAGE_TAG.split("-")[0]).toBe(studioNpmPin.split("-")[0]);
543
543
  });
@@ -5,9 +5,9 @@
5
5
  * product's scripts/doc-guide/regen-manual.sh asks this, from the tier's own
6
6
  * output, instead of "are the capture files on disk": that rule passed a
7
7
  * composition guide that hung after two captures (2026-10-03) and passed any
8
- * guide whose assertions failed after its captures were written. The engine
9
- * tier is also half of the publish gate (dev-kit conventions/build-image.yml),
10
- * so what it forgives, the gate forgives.
8
+ * guide whose assertions failed after its captures were written. The
9
+ * conventions' engine-tier step (build-image.yml guides-engine, publish-docs.yml)
10
+ * asks it the same way; that tier reports but does not gate (norsk-ctl task 57).
11
11
  *
12
12
  * Forgiven: every result line a test passed, apart from unnamed hook failures
13
13
  * in guides that also passed a test. Anything else -- a named test failing, a
@@ -85,7 +85,7 @@ a class attribute on every page of the site.
85
85
  site: `hasDocsSite()` tests for `website/astro.config.mjs`, and
86
86
  `docsThemeProblems()` routes this directory's `checkTheme` into the same report as
87
87
  `biome.json` and the shared workflows. `conventions/sync-drift.ts` is the writer
88
- half, so `sync-dev-kit.yml` rolls a theme change out to every product like any
88
+ half, so `bump.yml` rolls a theme change out to every product like any
89
89
  other convention, through a bot PR that `bot-prs.yml` merges once its tiers pass. The site directory is `website/` across the fleet
90
90
  (`DOCS_SITE_DIR`).
91
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",