@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.
@@ -5,8 +5,10 @@
5
5
  // tsconfig.base.json, the .gitignore core block, scripts/demo (only a
6
6
  // product with tests/demo.spec.ts is asked for it), publish-docs-site.yml
7
7
  // (only a repo with a docs site may carry it, and never a turnkey)
8
- // - masked per-repo line: checks.yml, upgrade-latest.yml, publish-docs.yml
9
- // and the other dispatching workflows (`product:` key)
8
+ // - masked per-repo line: checks.yml, bump.yml, publish-docs.yml and the
9
+ // other dispatching workflows (`product:` key)
10
+ // - retired: upgrade-latest.yml, sync-dev-kit.yml, sync-ctl-packages.yml,
11
+ // which bump.yml replaced; carrying one is drift
10
12
  // - base + sanctioned extension block: dprint.json (repo-specific excludes)
11
13
  // - must import the shared module: flake.nix (otherwise the product's own)
12
14
  // - structural: root tsconfig.json (shape-dependent includes stay free),
@@ -262,14 +264,13 @@ export interface CanonicalBytes {
262
264
  /** build/ctl-shell.nix — the shared dev-shell module, gated verbatim at
263
265
  * nix/ctl-shell.nix for every shape. */
264
266
  ctlShell: string;
265
- upgradeLatest: string;
266
- syncDevKit: string;
267
- /** conventions/sync-ctl-packages.yml — the ctl-* pin freshness PR. No per-repo
268
- * line at all, so it is verbatim and sync-drift creates it where absent. */
269
- syncCtlPackages: string;
270
- /** conventions/bot-prs.yml -- merges the pin bots' green PRs and reports red
271
- * ones. Verbatim (it reads its dashboard key at run time), so sync-drift
272
- * creates it where absent, like sync-ctl-packages. */
267
+ /** conventions/bump.yml -- the one nightly bump (dev-kit, ctl-* and moq-*,
268
+ * then the engine/Studio/norsk-ctl pins) and its one PR. Masked like
269
+ * checks.yml; required, and sync-drift creates it where absent. */
270
+ bump: string;
271
+ /** conventions/bot-prs.yml -- merges the bump's green PR and reports a red
272
+ * one. Verbatim (it reads its dashboard key at run time), so sync-drift
273
+ * creates it where absent. */
273
274
  botPrs: string;
274
275
  publishDocs: string;
275
276
  /** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */
@@ -305,10 +306,10 @@ export interface CanonicalBytes {
305
306
  * Studio licence step removed (it reads shared/src/version.ts +
306
307
  * product-template.ts). Optional for the same reason as flakeSdkApp. */
307
308
  checksSdkApp?: string;
308
- /** conventions/upgrade-latest.sdk-app.yml — the upgrade-latest.yml variant for
309
- * an sdk-app: no Studio pair or manifest.seed.json, the norsk family bumped off
310
- * the SDK nightly. Optional for the same reason as flakeSdkApp. */
311
- upgradeLatestSdkApp?: string;
309
+ /** conventions/bump.sdk-app.yml — the bump.yml variant for an sdk-app: its
310
+ * pins step has no Studio pair or manifest.seed.json, and bumps the norsk
311
+ * family off the SDK nightly. Optional for the same reason as flakeSdkApp. */
312
+ bumpSdkApp?: string;
312
313
  }
313
314
 
314
315
  // 05-demo s6: a product that has a demo (tests/demo.spec.ts) exposes it the
@@ -514,6 +515,16 @@ export function resolveRepoName(repoRoot: string, env: NodeJS.ProcessEnv = proce
514
515
  const PUBLIC_DOCS_REPO = /^norsk-ctl-product-/;
515
516
  const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
516
517
 
518
+ /** The one nightly bump (ci-fragility proposal B2). */
519
+ export const BUMP_WORKFLOW = ".github/workflows/bump.yml";
520
+
521
+ /** The three bump bots bump.yml replaced, removed from a product by sync-drift. */
522
+ export const RETIRED_BUMP_WORKFLOWS = [
523
+ ".github/workflows/upgrade-latest.yml",
524
+ ".github/workflows/sync-dev-kit.yml",
525
+ ".github/workflows/sync-ctl-packages.yml",
526
+ ] as const;
527
+
517
528
  /** The docs site's publish workflow, where a repo carries one. */
518
529
  export const DOCS_SITE_WORKFLOW = ".github/workflows/publish-docs-site.yml";
519
530
 
@@ -657,7 +668,7 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
657
668
  // checks.yml is REQUIRED — it is the CI entry point that runs this very gate
658
669
  // (plus the shared quality jobs), so a repo without it never re-converges.
659
670
  // Only the `product:` dispatch key is per-repo; it is masked like
660
- // upgrade-latest.yml's.
671
+ // bump.yml's.
661
672
  const checksPath = join(repoRoot, ".github", "workflows", "checks.yml");
662
673
  const checksRel = ".github/workflows/checks.yml";
663
674
  const checksCanon = shape === "sdk-app" ? canonical.checksSdkApp : canonical.checks;
@@ -702,52 +713,35 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
702
713
  }
703
714
  }
704
715
 
705
- // upgrade-latest.yml is OPTIONAL — a product may ship without a nightly bump
706
- // (e.g. until it has an integration tier). But when present it is the shared,
707
- // single-sourced workflow and must not diverge. Shape-selected like checks.yml:
708
- // an sdk-app has no Studio pair to bump, so it carries its own variant.
709
- const workflowPath = join(repoRoot, ".github", "workflows", "upgrade-latest.yml");
710
- if (existsSync(workflowPath)) {
711
- const upgradeCanon = shape === "sdk-app" ? canonical.upgradeLatestSdkApp : canonical.upgradeLatest;
712
- if (upgradeCanon === undefined) {
713
- throw new Error(`checkDrift: shape "${shape}" requires canonical.upgradeLatestSdkApp`);
714
- }
715
- push(
716
- workflowProblem(
717
- ".github/workflows/upgrade-latest.yml",
718
- shape === "sdk-app" ? "conventions/upgrade-latest.sdk-app.yml" : "conventions/upgrade-latest.yml",
719
- readFileSync(workflowPath, "utf8"),
720
- upgradeCanon,
721
- ),
722
- );
723
- }
724
-
725
- // sync-dev-kit.yml is OPTIONAL for the same reason as upgrade-latest.yml — a
726
- // product may not carry it yet — but when present it is the shared,
727
- // single-sourced workflow that keeps this repo's dev-kit pin fresh, and must
728
- // not diverge. Only its `product:` dispatch key is per-repo.
729
- const syncPath = join(repoRoot, ".github", "workflows", "sync-dev-kit.yml");
730
- if (existsSync(syncPath)) {
731
- push(
732
- workflowProblem(
733
- ".github/workflows/sync-dev-kit.yml",
734
- "conventions/sync-dev-kit.yml",
735
- readFileSync(syncPath, "utf8"),
736
- canonical.syncDevKit,
737
- ),
716
+ // bump.yml is REQUIRED: the one nightly bump (ci-fragility proposal B2), and
717
+ // sync-drift creates it. Shape-selected like checks.yml: an sdk-app has no
718
+ // Studio pair to bump, so it carries its own variant. Only its `product:`
719
+ // dispatch keys are per-repo.
720
+ const bumpPath = join(repoRoot, BUMP_WORKFLOW);
721
+ const bumpCanon = shape === "sdk-app" ? canonical.bumpSdkApp : canonical.bump;
722
+ const bumpRef = shape === "sdk-app" ? "conventions/bump.sdk-app.yml" : "conventions/bump.yml";
723
+ if (bumpCanon === undefined) {
724
+ throw new Error(`checkDrift: shape "${shape}" requires canonical.bumpSdkApp`);
725
+ } else if (!existsSync(bumpPath)) {
726
+ problems.push(
727
+ `${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.`,
738
728
  );
729
+ } else {
730
+ push(workflowProblem(BUMP_WORKFLOW, bumpRef, readFileSync(bumpPath, "utf8"), bumpCanon));
739
731
  }
740
732
 
741
- // sync-ctl-packages.yml is OPTIONAL until a repo has been synced once — it is
742
- // NEW, and sync-drift creates it. Unlike its neighbours it carries no per-repo
743
- // line (it dispatches to no dashboard), so the whole file is canonical.
744
- const syncCtlPath = join(repoRoot, ".github", "workflows", "sync-ctl-packages.yml");
745
- if (existsSync(syncCtlPath) && readFileSync(syncCtlPath, "utf8") !== canonical.syncCtlPackages) {
746
- push(`.github/workflows/sync-ctl-packages.yml differs from conventions/sync-ctl-packages.yml. ${RESYNC}`);
733
+ // The three bots bump.yml replaced. One left beside it would run a second
734
+ // bump that conflicts with the first and is tested alone -- what B2 ended.
735
+ for (const rel of RETIRED_BUMP_WORKFLOWS) {
736
+ if (existsSync(join(repoRoot, rel))) {
737
+ problems.push(
738
+ `${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).`,
739
+ );
740
+ }
747
741
  }
748
742
 
749
- // bot-prs.yml: the same shape -- optional until a repo's first sync creates
750
- // it, verbatim once present.
743
+ // bot-prs.yml: optional until a repo's first sync creates it, verbatim once
744
+ // present.
751
745
  const botPrsPath = join(repoRoot, ".github", "workflows", "bot-prs.yml");
752
746
  if (existsSync(botPrsPath) && readFileSync(botPrsPath, "utf8") !== canonical.botPrs) {
753
747
  push(`.github/workflows/bot-prs.yml differs from conventions/bot-prs.yml. ${RESYNC}`);
@@ -879,9 +873,7 @@ if (import.meta.main) {
879
873
  core: readFileSync(join(import.meta.dir, "CLAUDE.core.md"), "utf8"),
880
874
  flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"),
881
875
  ctlShell: readFileSync(join(import.meta.dir, "..", "build", "ctl-shell.nix"), "utf8"),
882
- upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
883
- syncDevKit: readFileSync(join(import.meta.dir, "sync-dev-kit.yml"), "utf8"),
884
- syncCtlPackages: readFileSync(join(import.meta.dir, "sync-ctl-packages.yml"), "utf8"),
876
+ bump: readFileSync(join(import.meta.dir, "bump.yml"), "utf8"),
885
877
  botPrs: readFileSync(join(import.meta.dir, "bot-prs.yml"), "utf8"),
886
878
  publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
887
879
  buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
@@ -899,7 +891,7 @@ if (import.meta.main) {
899
891
  demoShim: readFileSync(join(import.meta.dir, "demo.sh"), "utf8"),
900
892
  flakeSdkApp: readFileSync(join(import.meta.dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
901
893
  checksSdkApp: readFileSync(join(import.meta.dir, "checks.sdk-app.yml"), "utf8"),
902
- upgradeLatestSdkApp: readFileSync(join(import.meta.dir, "upgrade-latest.sdk-app.yml"), "utf8"),
894
+ bumpSdkApp: readFileSync(join(import.meta.dir, "bump.sdk-app.yml"), "utf8"),
903
895
  };
904
896
  const report = checkDrift(repoRoot, canonical, {
905
897
  repoName: resolveRepoName(repoRoot),
@@ -127,6 +127,9 @@ jobs:
127
127
 
128
128
  quality:
129
129
  runs-on: x64
130
+ outputs:
131
+ quarantined: ${{ steps.quarantine.outputs.quarantined }}
132
+ quarantine_failed: ${{ steps.quarantine.outputs.quarantine_failed }}
130
133
  steps:
131
134
  # Runs BEFORE checkout, and does two things: points the harness's temp base
132
135
  # out of the workspace for every later step in this job, and clears whatever
@@ -200,7 +203,11 @@ jobs:
200
203
  bun install --frozen-lockfile
201
204
  bun run lint
202
205
  bun run typecheck
203
- bun run test:unit
206
+ # Known-flaky tests (.github/quarantine.json) leave the gating run
207
+ # here and run on their own in the next step (conventions/quarantine.ts).
208
+ q=node_modules/@norskvideo/ctl-dev-kit/conventions/quarantine.ts
209
+ bun "$q" check
210
+ BUN_OPTIONS="$(bun "$q" bun-options)" bun run test:unit
204
211
  bun run docs:check
205
212
  # The tree must resolve ONE zod 4 (conventions/zod-singleton.ts).
206
213
  # studio guards this in-repo but never shipped the guard; products
@@ -208,6 +215,16 @@ jobs:
208
215
  # in here and breaks a schema handed across the instance boundary.
209
216
  bun node_modules/@norskvideo/ctl-dev-kit/conventions/zod-singleton.ts
210
217
  '
218
+ # The quarantined tests, each run on its own and reported: a summary
219
+ # table, a warning per failure, and counts for the checks cell's detail.
220
+ # It never fails the job -- that is what quarantine means -- and it runs
221
+ # even when the gate is red, so the flake's record has no holes. Nothing
222
+ # listed, nothing run.
223
+ - name: Quarantined tests (run and reported, not gating)
224
+ id: quarantine
225
+ if: ${{ !cancelled() }}
226
+ continue-on-error: true
227
+ run: nix develop .#build --command bun node_modules/@norskvideo/ctl-dev-kit/conventions/quarantine.ts run
211
228
  # licenseTemplate.json records the entry a licence is minted from: this
212
229
  # product at the image CI builds. One naming another image mints a licence
213
230
  # that entitles nothing, first seen as a refused launch at the customer.
@@ -335,6 +352,8 @@ jobs:
335
352
  product: __PRODUCT__
336
353
  pipeline: checks
337
354
  status: ${{ steps.meta.outputs.state }}
355
+ # Quarantined tests leave the gate, never the board: say how many.
356
+ detail: "${{ needs.quality.outputs.quarantined > 0 && format('quarantined: {0} test(s), {1} not passing this run (not gating; .github/quarantine.json)', needs.quality.outputs.quarantined, needs.quality.outputs.quarantine_failed) || '' }}"
338
357
  - name: Reap the containers this job launched
339
358
  # Whatever the tests did. A teardown cut short by a test timeout leaves
340
359
  # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
@@ -130,6 +130,9 @@ jobs:
130
130
 
131
131
  quality:
132
132
  runs-on: x64
133
+ outputs:
134
+ quarantined: ${{ steps.quarantine.outputs.quarantined }}
135
+ quarantine_failed: ${{ steps.quarantine.outputs.quarantine_failed }}
133
136
  steps:
134
137
  # Runs BEFORE checkout, and does two things: points the harness's temp base
135
138
  # out of the workspace for every later step in this job, and clears whatever
@@ -203,7 +206,11 @@ jobs:
203
206
  bun install --frozen-lockfile
204
207
  bun run lint
205
208
  bun run typecheck
206
- bun run test:unit
209
+ # Known-flaky tests (.github/quarantine.json) leave the gating run
210
+ # here and run on their own in the next step (conventions/quarantine.ts).
211
+ q=node_modules/@norskvideo/ctl-dev-kit/conventions/quarantine.ts
212
+ bun "$q" check
213
+ BUN_OPTIONS="$(bun "$q" bun-options)" bun run test:unit
207
214
  bun run docs:check
208
215
  # The tree must resolve ONE zod 4 (conventions/zod-singleton.ts).
209
216
  # studio guards this in-repo but never shipped the guard; products
@@ -211,6 +218,16 @@ jobs:
211
218
  # in here and breaks a schema handed across the instance boundary.
212
219
  bun node_modules/@norskvideo/ctl-dev-kit/conventions/zod-singleton.ts
213
220
  '
221
+ # The quarantined tests, each run on its own and reported: a summary
222
+ # table, a warning per failure, and counts for the checks cell's detail.
223
+ # It never fails the job -- that is what quarantine means -- and it runs
224
+ # even when the gate is red, so the flake's record has no holes. Nothing
225
+ # listed, nothing run.
226
+ - name: Quarantined tests (run and reported, not gating)
227
+ id: quarantine
228
+ if: ${{ !cancelled() }}
229
+ continue-on-error: true
230
+ run: nix develop .#build --command bun node_modules/@norskvideo/ctl-dev-kit/conventions/quarantine.ts run
214
231
 
215
232
  # The two halves of one licence guarantee, run together because they only
216
233
  # work as a pair: check-template asserts this repo RECORDS the entitlement
@@ -356,6 +373,8 @@ jobs:
356
373
  product: __PRODUCT__
357
374
  pipeline: checks
358
375
  status: ${{ steps.meta.outputs.state }}
376
+ # Quarantined tests leave the gate, never the board: say how many.
377
+ detail: "${{ needs.quality.outputs.quarantined > 0 && format('quarantined: {0} test(s), {1} not passing this run (not gating; .github/quarantine.json)', needs.quality.outputs.quarantined, needs.quality.outputs.quarantine_failed) || '' }}"
359
378
  - name: Reap the containers this job launched
360
379
  # Whatever the tests did. A teardown cut short by a test timeout leaves
361
380
  # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
@@ -6,9 +6,8 @@
6
6
  // a different copy from the one the root was moved to, and nothing says so:
7
7
  // playout's tests/ pinned the dev-kit ^0.1.57 while the root moved to 0.2, its
8
8
  // guides ran a guide proxy that built the wrong Studio prefix, and first-light
9
- // failed every night from 2026-09-07. sync-dev-kit.yml and
10
- // sync-ctl-packages.yml now move every pin; this keeps a hand edit (or a new
11
- // nested package) from splitting them again.
9
+ // failed every night from 2026-09-07. bump.yml now moves every pin; this keeps
10
+ // a hand edit (or a new nested package) from splitting them again.
12
11
  import { lstatSync, readdirSync, readFileSync } from "node:fs";
13
12
  import { join, relative } from "node:path";
14
13
 
@@ -61,7 +60,7 @@ export function ctlPinProblems(repoRoot: string): string[] {
61
60
  if (byRange.size < 2) continue;
62
61
  const where = [...byRange].flatMap(([range, files]) => files.map((f) => `${f} ${range}`)).join(", ");
63
62
  problems.push(
64
- `${name} is pinned at ${byRange.size} different ranges (${where}). A nested package then runs a different copy from the root's; pin it at one range everywhere (sync-dev-kit / sync-ctl-packages move every pin).`,
63
+ `${name} is pinned at ${byRange.size} different ranges (${where}). A nested package then runs a different copy from the root's; pin it at one range everywhere (the nightly bump.yml moves every pin).`,
65
64
  );
66
65
  }
67
66
  return problems;
@@ -132,7 +132,7 @@ reaches every product's manual at once.
132
132
  shared workflows — the clause is conditional on `website/astro.config.mjs`
133
133
  existing, so a repo without a site is not asked for one.
134
134
  - `bun run sync:drift` rewrites the copies from the installed dev-kit. The
135
- nightly `sync-dev-kit.yml` runs it after bumping the pin, so a theme change
135
+ nightly `bump.yml` runs it after bumping the pin, so a theme change
136
136
  rolls out hands-free and only a change that genuinely breaks the site goes red.
137
137
 
138
138
  **Keeping the copies at exactly those paths is load-bearing**, not tidiness:
@@ -246,11 +246,11 @@ that branch (`--branch` is the ref that ran), never over production.
246
246
  Two steps, once a dev-kit carrying the workflow is published:
247
247
 
248
248
  ```
249
- bun update @norskvideo/ctl-dev-kit # bump the pin (or let sync-dev-kit.yml do it)
249
+ bun update @norskvideo/ctl-dev-kit # bump the pin (or let bump.yml do it)
250
250
  bun run sync:drift # writes .github/workflows/publish-docs-site.yml
251
251
  ```
252
252
 
253
- Commit the result; `bun run check:drift` passes. The nightly `sync-dev-kit.yml`
253
+ Commit the result; `bun run check:drift` passes. The nightly `bump.yml`
254
254
  does both hands-free, so a product with a site acquires the workflow on its
255
255
  next sync without anyone touching the repo. Then create the Pages project, set
256
256
  the two secrets and the variable, and the next push to `main` publishes.
@@ -265,7 +265,7 @@ converge: move the repo's bespoke steps into the hooks above, replace the
265
265
  workflow with the canonical (product and runner filled in), and prove it with
266
266
  `gh workflow run integration.yml --ref <branch>` before merging — the tier
267
267
  only fires on `main` and on dispatch, so a PR's checks never run it. Once the
268
- marker is in, `sync-dev-kit` keeps the copy current and `check:drift` names any
268
+ marker is in, the nightly `bump.yml` keeps the copy current and `check:drift` names any
269
269
  hand edit.
270
270
 
271
271
  **Why the pipe is shaped the way it is.** The suite runs inside a shell
@@ -39,8 +39,9 @@
39
39
  # topology step below says so in NORSK_TEST_HOST/NORSK_TEST_NET); the GPU box
40
40
  # is '["ARM64","nvidia"]'.
41
41
  #
42
- # On a pull request the suite runs for a BOT branch only (upgrade-latest/*,
43
- # sync-dev-kit/*, chore/sync-ctl-packages): a bump reaches main only once this
42
+ # On a pull request the suite runs for a BOT branch only (bump/nightly, and the
43
+ # retired bots' upgrade-latest/*, sync-dev-kit/* and chore/sync-ctl-packages,
44
+ # which the PR that brings bump.yml rides on): a bump reaches main only once this
44
45
  # tier has passed on it, and bot-prs merges on that (ci-workflows
45
46
  # docs/dashboard/ci-fragility.md, proposal B). A human PR does not pay for the
46
47
  # slow tier; it runs on main after the merge, as before. A PR's result is the
@@ -91,7 +92,7 @@ concurrency:
91
92
 
92
93
  jobs:
93
94
  integration:
94
- if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && (startsWith(github.head_ref, 'upgrade-latest/') || startsWith(github.head_ref, 'sync-dev-kit/') || github.head_ref == 'chore/sync-ctl-packages')) }}
95
+ if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && (github.head_ref == 'bump/nightly' || startsWith(github.head_ref, 'upgrade-latest/') || startsWith(github.head_ref, 'sync-dev-kit/') || github.head_ref == 'chore/sync-ctl-packages')) }}
95
96
  strategy:
96
97
  matrix:
97
98
  include:
@@ -283,12 +284,37 @@ jobs:
283
284
  NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
284
285
  run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
285
286
 
287
+ # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
288
+ # told not to, and nothing removes it: it is a node singleton, and the reap
289
+ # step only takes containers that mount this job's test-temp. So the first
290
+ # test daemon on a shared runner left one, and every later one adopted it
291
+ # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
292
+ # to it). This job's daemons run without the node relay and discovery, so
293
+ # they neither start nor adopt one, and whatever is already on the host is
294
+ # named here. It reports and never fails: the leftover may be someone's.
295
+ - name: Isolate this job from node singletons other jobs left on this host
296
+ run: |
297
+ set -uo pipefail
298
+ echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
299
+ echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
300
+ left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
301
+ if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
302
+ echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
303
+ echo "$left"
304
+ while IFS= read -r line; do
305
+ role="$(awk '{print $2}' <<<"$line")"
306
+ case "$role" in
307
+ moq-relay|discovery)
308
+ 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." ;;
309
+ esac
310
+ done <<<"$left"
311
+
286
312
  # Pick the binary for THIS runner's architecture: the GPU box is aarch64,
287
313
  # the x64 pool is x86_64, and the wrong one dies with "Exec format error"
288
314
  # deep in the suite. No `|| true` on --version: a bad binary must fail here.
289
315
  #
290
316
  # WHICH binary: the version this commit pins, flake.nix ctlVersion -- the
291
- # daemon the dev shell launches, moved by upgrade-latest's bump PR. It used
317
+ # daemon the dev shell launches, moved by bump.yml's nightly PR. It used
292
318
  # to be S3's moving `latest`, read at run time, so a result could change
293
319
  # with no product commit and a re-run was never a real repeat (funke went
294
320
  # red and green again on 2026-10-09 without a commit either time;
@@ -311,7 +337,7 @@ jobs:
311
337
  echo "norsk-ctl $CTL_CHANNEL channel -> $ver ($arch), in place of the repo pin"
312
338
  else
313
339
  ver="$( { sed -nE 's/^[[:space:]]*ctlVersion = "([^"]+)";.*/\1/p' flake.nix 2>/dev/null || true; } | head -1)"
314
- [ -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; }
340
+ [ -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; }
315
341
  echo "norsk-ctl pinned by flake.nix ctlVersion -> $ver ($arch)"
316
342
  fi
317
343
  curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-$arch" -o "$RUNNER_TEMP/norsk-ctl"
@@ -22,6 +22,9 @@
22
22
  #
23
23
  # Nightly-only by design: a guide's engine tier stands up real instances (slow,
24
24
  # and subject to teardown flakes), so it does not ride every push. The
25
+ # deterministic tiers decide this workflow's result; the engine tier reports
26
+ # without failing it (norsk-ctl task 57), and a failed engine tier keeps
27
+ # yesterday's manual rather than publishing one with gaps. The
25
28
  # licensed-runner setup mirrors integration.yml — ctl comes from the released
26
29
  # binary via NORSK_CTL_BINARY, Docker is used directly on the x64 pool.
27
30
  name: publish-docs
@@ -46,6 +49,8 @@ concurrency:
46
49
  jobs:
47
50
  publish:
48
51
  runs-on: x64
52
+ outputs:
53
+ engine: ${{ steps.engine.outcome }}
49
54
  # One guide run per repo at a time, nightly and publish gate alike: both
50
55
  # launch the same instance ids on the x64 pool's shared docker daemon, and
51
56
  # ctl names each compose project after its instance id. Queue, never cancel.
@@ -174,15 +179,40 @@ jobs:
174
179
  NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
175
180
  run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
176
181
 
182
+ # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
183
+ # told not to, and nothing removes it: it is a node singleton, and the reap
184
+ # step only takes containers that mount this job's test-temp. So the first
185
+ # test daemon on a shared runner left one, and every later one adopted it
186
+ # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
187
+ # to it). This job's daemons run without the node relay and discovery, so
188
+ # they neither start nor adopt one, and whatever is already on the host is
189
+ # named here. It reports and never fails: the leftover may be someone's.
190
+ - name: Isolate this job from node singletons other jobs left on this host
191
+ run: |
192
+ set -uo pipefail
193
+ echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
194
+ echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
195
+ left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
196
+ if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
197
+ echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
198
+ echo "$left"
199
+ while IFS= read -r line; do
200
+ role="$(awk '{print $2}' <<<"$line")"
201
+ case "$role" in
202
+ moq-relay|discovery)
203
+ 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." ;;
204
+ esac
205
+ done <<<"$left"
206
+
177
207
  # The norsk-ctl this commit pins (flake.nix ctlVersion, moved by
178
- # upgrade-latest's bump PR), never S3's moving `latest`: a guide run is then
208
+ # bump.yml's nightly PR), never S3's moving `latest`: a guide run is then
179
209
  # a real repeat, and a red belongs to a commit (ci-fragility proposal A).
180
210
  - name: Download the pinned norsk-ctl binary (flake.nix ctlVersion)
181
211
  run: |
182
212
  set -euo pipefail
183
213
  S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
184
214
  ver="$( { sed -nE 's/^[[:space:]]*ctlVersion = "([^"]+)";.*/\1/p' flake.nix 2>/dev/null || true; } | head -1)"
185
- [ -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; }
215
+ [ -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; }
186
216
  echo "norsk-ctl pinned by flake.nix ctlVersion -> $ver"
187
217
  curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-linux-x64" -o "$RUNNER_TEMP/norsk-ctl"
188
218
  chmod +x "$RUNNER_TEMP/norsk-ctl"
@@ -204,13 +234,15 @@ jobs:
204
234
  ./scripts/doc-guide/prepull-extra.sh
205
235
  fi
206
236
 
207
- # Rebuild every workspace the manual depends on (a guide's engine tier packs
208
- # the freshly-built dashboard + frontend dist into the launched template),
209
- # then run the product's doc-guide tiers and assemble the manual.
210
- # regen-manual.sh owns the slug list and which tiers run; with-display.sh
211
- # wraps the whole run in an X display for headless chromium (the engine tier
212
- # drives a raw chromium that needs one).
213
- - name: Regenerate the illustrated manual (all tiers)
237
+ # The deterministic tiers (fixture and frontend: no daemon, no instances),
238
+ # then the manual assembled from what they captured. These gate. The engine
239
+ # tier runs on its own below: it stands up real instances, is known-flaky
240
+ # on container DNS (norsk-ctl task 57), and reports without gating until
241
+ # that is fixed (ci-workflows docs/dashboard/ci-fragility.md, proposal E).
242
+ # regen-manual.sh owns the slug list and honours SKIP_ENGINE; it rebuilds
243
+ # every workspace the manual depends on first, and with-display.sh gives
244
+ # headless chromium an X display.
245
+ - name: Regenerate the illustrated manual (deterministic tiers)
214
246
  env:
215
247
  NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
216
248
  NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
@@ -223,6 +255,7 @@ jobs:
223
255
  # them binding no host ports — so concurrent doc-guide instances can't
224
256
  # collide on the product's deterministic host-port bands.
225
257
  NORSK_TEST_NET: direct
258
+ SKIP_ENGINE: "1"
226
259
  run: |
227
260
  nix develop .#build --command bash -c '
228
261
  set -euo pipefail
@@ -237,11 +270,71 @@ jobs:
237
270
  bash scripts/with-display.sh scripts/doc-guide/regen-manual.sh
238
271
  '
239
272
 
273
+ # The engine tier: real instances, driven in a browser. Reported, not
274
+ # gating (see the deterministic step). A non-zero exit is forgiven only
275
+ # when the tier's own results say a teardown hook timed out after every
276
+ # guide's test passed (doc-guide/engine-verdict.ts), as regen-manual.sh
277
+ # forgives it. A product with no docs:guides:engine script has no engine
278
+ # tier. Runs on the workspaces the step before it installed and built.
279
+ - name: Run the engine-tier guides (reported, not gating)
280
+ id: engine
281
+ continue-on-error: true
282
+ env:
283
+ NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
284
+ NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
285
+ # This runner launches Studio as a HOST sibling (DooD), so the harness
286
+ # reaches host-published ports via the host-gateway alias, not
287
+ # localhost — same as the integration suite.
288
+ NORSK_TEST_HOST: host.docker.internal
289
+ # Reach launched instances over norsk-net by service DNS (as the
290
+ # integration tier already does) AND, on a ctl that supports it, launch
291
+ # them binding no host ports — so concurrent doc-guide instances can't
292
+ # collide on the product's deterministic host-port bands.
293
+ NORSK_TEST_NET: direct
294
+ run: |
295
+ nix develop .#build --command bash -c '
296
+ set -euo pipefail
297
+ if ! jq -e ".scripts[\"docs:guides:engine\"]" package.json >/dev/null; then
298
+ echo "no docs:guides:engine script: this product has no engine tier"
299
+ exit 0
300
+ fi
301
+ export DOCS_GENERATE=1
302
+ log="$(mktemp)"
303
+ if bash scripts/with-display.sh bun run docs:guides:engine >"$log" 2>&1; then
304
+ cat "$log"
305
+ else
306
+ cat "$log"
307
+ verdict="$(bun -e "console.log(Bun.resolveSync(\"@norskvideo/ctl-dev-kit/doc-guide/engine-verdict\", process.cwd()))")"
308
+ bun "$verdict" "$log"
309
+ fi
310
+ '
311
+
312
+ # Only after a passing engine tier: the deterministic step assembled a
313
+ # manual with the engine pages as gaps.
314
+ - name: Assemble the manual with the engine captures
315
+ if: ${{ steps.engine.outcome == 'success' }}
316
+ run: nix develop .#build --command bun run docs:manual
317
+
318
+ # Said three ways, so a failure is never silent: a warning on the run, a
319
+ # line in its summary, and the cell's detail (the notify job).
320
+ - name: Report the engine tier (not gating)
321
+ if: ${{ !cancelled() }}
322
+ env:
323
+ OUTCOME: ${{ steps.engine.outcome }}
324
+ run: |
325
+ case "$OUTCOME" in
326
+ success) echo "engine-tier guides passed (or this product has none)" ;;
327
+ failure)
328
+ echo "::warning title=engine-tier guides failed (not gating)::the engine tier does not gate until norsk-ctl task 57 is fixed. The manual was not republished: yesterday's stays. See the engine step's log."
329
+ printf '### Engine-tier guides: failed (not gating)\n\nThe deterministic guide tiers gate; the engine tier only reports until its container networking is fixed (norsk-ctl task 57). The manual was not republished: yesterday's stays. See the step log.\n' >> "$GITHUB_STEP_SUMMARY" ;;
330
+ *) echo "engine tier: ${OUTCOME:-not run}" ;;
331
+ esac
332
+
240
333
  # Publish the assembled manual as the moving docs-latest release asset.
241
334
  # gh is on PATH inside the nix shell; `upload --clobber` replaces the
242
335
  # same-named asset in place, so the release stays latest-only.
243
336
  - name: Publish manual.html to the moving docs-latest release
244
- if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }}
337
+ if: ${{ steps.engine.outcome == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish) }}
245
338
  env:
246
339
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
247
340
  run: |
@@ -290,7 +383,7 @@ jobs:
290
383
  # exports map, so this works whether ctl-dev-kit is the published package or
291
384
  # a workspace link (scripts/set-ctl-source.ts local mode).
292
385
  - name: Publish the versioned docs bundle and move the pointer
293
- if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }}
386
+ if: ${{ steps.engine.outcome == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish) }}
294
387
  env:
295
388
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
296
389
  # How many bundles to keep on the release, the current one included.
@@ -325,7 +418,7 @@ jobs:
325
418
  # key through ci-workflows' registry, so a new product needs no new mapping
326
419
  # here. Not wrapped in nix -- plain curl, and the quoting stays readable.
327
420
  - name: Tell norsk-ctl a new bundle is published
328
- if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }}
421
+ if: ${{ steps.engine.outcome == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish) }}
329
422
  env:
330
423
  DISPATCH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
331
424
  run: |
@@ -437,6 +530,8 @@ jobs:
437
530
  product: __PRODUCT__
438
531
  pipeline: docs
439
532
  status: ${{ steps.meta.outputs.state }}
533
+ # The engine tier reports, never gates; a failure keeps yesterday's manual.
534
+ detail: "${{ needs.publish.outputs.engine == 'failure' && 'engine-tier guides failed (not gating; norsk-ctl task 57): the manual was not republished' || '' }}"
440
535
  - name: Reap the containers this job launched
441
536
  # Whatever the tests did. A teardown cut short by a test timeout leaves
442
537
  # its instance running on the shared Docker daemon (see JOB_REAP_STEP in