@norskvideo/ctl-dev-kit 0.2.62 → 0.2.64

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.
@@ -90,6 +90,12 @@
90
90
  opens a PR, and `bot-prs` merges it once checks, smoke, integration and the
91
91
  guides gate (build-image) are green on it. A red bot PR is the alarm for a bad
92
92
  upstream change; main stays green.
93
+ - **Flaky checks stay out of gates, never out of sight.** The guides gate runs
94
+ the deterministic tiers; the engine tier runs beside it and reports (summary,
95
+ warning, cell detail) without gating until norsk-ctl task 57 is fixed. A
96
+ known-flaky test goes in `.github/quarantine.json` with an owner, a date and
97
+ an exit condition: `checks` leaves it out of the gating run, then runs it on
98
+ its own and reports it. Fix it and take it out; don't let an entry age.
93
99
  - **A config-driven workflow needs a parity-checked invariant contract.** If this
94
100
  product emits its Studio graph dynamically from config, its load-bearing rules
95
101
  are documented as stable-ID statements (`INV-<AREA>-<NNN>`) in an
@@ -164,6 +164,31 @@ jobs:
164
164
  NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
165
165
  run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
166
166
 
167
+ # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
168
+ # told not to, and nothing removes it: it is a node singleton, and the reap
169
+ # step only takes containers that mount this job's test-temp. So the first
170
+ # test daemon on a shared runner left one, and every later one adopted it
171
+ # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
172
+ # to it). This job's daemons run without the node relay and discovery, so
173
+ # they neither start nor adopt one, and whatever is already on the host is
174
+ # named here. It reports and never fails: the leftover may be someone's.
175
+ - name: Isolate this job from node singletons other jobs left on this host
176
+ run: |
177
+ set -uo pipefail
178
+ echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
179
+ echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
180
+ left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
181
+ if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
182
+ echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
183
+ echo "$left"
184
+ while IFS= read -r line; do
185
+ role="$(awk '{print $2}' <<<"$line")"
186
+ case "$role" in
187
+ moq-relay|discovery)
188
+ 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." ;;
189
+ esac
190
+ done <<<"$left"
191
+
167
192
  # The norsk-ctl this commit pins (flake.nix ctlVersion, moved by
168
193
  # upgrade-latest's bump PR), never S3's moving `latest`: a guide run is then
169
194
  # a real repeat, and a red belongs to a commit (ci-fragility proposal A).
@@ -23,7 +23,8 @@
23
23
  #
24
24
  # BOT PRs: on a pull request from a bot branch (upgrade-latest/*,
25
25
  # sync-dev-kit/*, chore/sync-ctl-packages) the workflow builds both arches and
26
- # runs the guides gate exactly as a push would, and publishes, records and
26
+ # runs the guides gate exactly as a push would (the deterministic tiers gate;
27
+ # the engine tier reports beside it), and publishes, records and
27
28
  # reports nothing. A bump reaches main only once that has passed on it, and
28
29
  # bot-prs merges on it (ci-workflows docs/dashboard/ci-fragility.md, proposal
29
30
  # B): a dev-kit sync that brought a newer copy of THIS file broke
@@ -302,15 +303,13 @@ jobs:
302
303
  gate-guides:
303
304
  needs: label
304
305
  runs-on: x64
305
- # One guide run per repo at a time, nightly and publish gate alike: both
306
- # launch the same instance ids on the x64 pool's shared docker daemon, and
307
- # ctl names each compose project after its instance id. Queue, never cancel.
308
- concurrency:
309
- group: doc-guides-${{ github.repository }}
310
- cancel-in-progress: false
306
+ # No doc-guides concurrency group: the deterministic tiers launch no
307
+ # instances, so there is nothing to serialise, and a pending job in a group
308
+ # is cancelled by the next one queued. guides-engine takes the group.
311
309
  outputs:
312
310
  guides: ${{ steps.plan.outputs.guides }}
313
311
  reason: ${{ steps.plan.outputs.reason }}
312
+ engine: ${{ steps.plan.outputs.engine }}
314
313
  steps:
315
314
  # Runs BEFORE checkout, and does two things: points the harness's temp base
316
315
  # out of the workspace for every later step in this job, and clears whatever
@@ -390,6 +389,10 @@ jobs:
390
389
  ')"
391
390
  if [ "$decision" = "run" ]; then
392
391
  echo "guides=run" >> "$GITHUB_OUTPUT"
392
+ # The engine tier runs in guides-engine, beside this gate.
393
+ if jq -e '.scripts["docs:guides:engine"]' package.json >/dev/null; then
394
+ echo "engine=run" >> "$GITHUB_OUTPUT"
395
+ fi
393
396
  else
394
397
  skip "${decision#skip$'\t'}"
395
398
  fi
@@ -401,6 +404,31 @@ jobs:
401
404
  NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
402
405
  run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
403
406
 
407
+ # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
408
+ # told not to, and nothing removes it: it is a node singleton, and the reap
409
+ # step only takes containers that mount this job's test-temp. So the first
410
+ # test daemon on a shared runner left one, and every later one adopted it
411
+ # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
412
+ # to it). This job's daemons run without the node relay and discovery, so
413
+ # they neither start nor adopt one, and whatever is already on the host is
414
+ # named here. It reports and never fails: the leftover may be someone's.
415
+ - name: Isolate this job from node singletons other jobs left on this host
416
+ run: |
417
+ set -uo pipefail
418
+ echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
419
+ echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
420
+ left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
421
+ if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
422
+ echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
423
+ echo "$left"
424
+ while IFS= read -r line; do
425
+ role="$(awk '{print $2}' <<<"$line")"
426
+ case "$role" in
427
+ moq-relay|discovery)
428
+ 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." ;;
429
+ esac
430
+ done <<<"$left"
431
+
404
432
  # The norsk-ctl this commit pins (flake.nix ctlVersion, moved by
405
433
  # upgrade-latest's bump PR), never S3's moving `latest`: a guide run is then
406
434
  # a real repeat, and a red belongs to a commit (ci-fragility proposal A).
@@ -433,13 +461,15 @@ jobs:
433
461
  ./scripts/doc-guide/prepull-extra.sh
434
462
  fi
435
463
 
436
- # Rebuild every workspace the manual depends on (a guide's engine tier packs
437
- # the freshly-built dashboard + frontend dist into the launched template),
438
- # then run the product's doc-guide tiers and assemble the manual.
439
- # regen-manual.sh owns the slug list and which tiers run; with-display.sh
440
- # wraps the whole run in an X display for headless chromium (the engine tier
441
- # drives a raw chromium that needs one).
442
- - name: Regenerate the illustrated manual (all tiers)
464
+ # The deterministic tiers (fixture and frontend: no daemon, no instances),
465
+ # then the manual assembled from what they captured. These gate. The engine
466
+ # tier runs on its own below: it stands up real instances, is known-flaky
467
+ # on container DNS (norsk-ctl task 57), and reports without gating until
468
+ # that is fixed (ci-workflows docs/dashboard/ci-fragility.md, proposal E).
469
+ # regen-manual.sh owns the slug list and honours SKIP_ENGINE; it rebuilds
470
+ # every workspace the manual depends on first, and with-display.sh gives
471
+ # headless chromium an X display.
472
+ - name: Regenerate the illustrated manual (deterministic tiers)
443
473
  if: ${{ steps.plan.outputs.guides == 'run' }}
444
474
  env:
445
475
  NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
@@ -453,6 +483,7 @@ jobs:
453
483
  # them binding no host ports — so concurrent doc-guide instances can't
454
484
  # collide on the product's deterministic host-port bands.
455
485
  NORSK_TEST_NET: direct
486
+ SKIP_ENGINE: "1"
456
487
  run: |
457
488
  nix develop .#build --command bash -c '
458
489
  set -euo pipefail
@@ -486,6 +517,228 @@ jobs:
486
517
  docker rm -f $s
487
518
  fi
488
519
 
520
+ # The engine tier of the guides, beside the gate rather than in it (see the
521
+ # deterministic step above): it runs whenever the gate runs the guides and the
522
+ # product has an engine tier, and reports -- a warning, the run summary and
523
+ # the build-image cell's detail -- but publish does not wait on it, and a
524
+ # failure leaves the job green so bot-prs can still merge. To make it gate
525
+ # again once norsk-ctl task 57 is fixed, add it to publish's needs and drop
526
+ # continue-on-error from the engine step.
527
+ guides-engine:
528
+ needs: [label, gate-guides]
529
+ # !cancelled(): a red deterministic gate must not hide the engine tier's
530
+ # result. The plan's output is set before any guide runs.
531
+ if: ${{ !cancelled() && needs.gate-guides.outputs.engine == 'run' }}
532
+ runs-on: x64
533
+ # One engine-tier run per repo at a time, nightly (publish-docs.yml) and
534
+ # this alike: both launch the same instance ids on the x64 pool's shared
535
+ # docker daemon, and ctl names each compose project after its instance id.
536
+ # Queue, never cancel.
537
+ concurrency:
538
+ group: doc-guides-${{ github.repository }}
539
+ cancel-in-progress: false
540
+ outputs:
541
+ result: ${{ steps.engine.outcome }}
542
+ steps:
543
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
544
+ # out of the workspace for every later step in this job, and clears whatever
545
+ # the old repo-local default left behind on this runner. The body carries the
546
+ # why. It fails the job only when legacy litter survives -- which would take
547
+ # the checkout down a step later anyway, but report nothing useful.
548
+ - name: Route test-temp out of the workspace (pre-checkout)
549
+ run: |
550
+ set -uo pipefail
551
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
552
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
553
+ # host -- even when the launch passes --container-user (reproduced
554
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
555
+ # inotify constraint that binds macOS dev and nothing here, so every run
556
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
557
+ # cannot remove. That took commentary's checks AND integration red on
558
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
559
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
560
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
561
+ mkdir -p "$base"
562
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
563
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
564
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
565
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
566
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
567
+ done
568
+ # LEGACY: a runner that ran the old default still carries a workspace
569
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
570
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
571
+ # RESTARTING leaked container re-creates the path as root seconds after
572
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
573
+ # by construction, because a killed run labels nothing.
574
+ tt="$GITHUB_WORKSPACE/test-temp"
575
+ [ -d "$tt" ] || exit 0
576
+ stuck=""
577
+ for c in $(docker ps -aq 2>/dev/null || true); do
578
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
579
+ stuck="$stuck $c"
580
+ fi
581
+ done
582
+ if [ -n "$stuck" ]; then
583
+ echo "reaping containers holding mounts under $tt:$stuck"
584
+ docker rm -f $stuck || true
585
+ fi
586
+ # The DIRECTORY, not just its contents: an empty but root-owned base
587
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
588
+ # container unlink the leaf.
589
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
590
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
591
+ # Fail HERE if it survived. The checkout fails on it either way, but
592
+ # reports only an EACCES rmdir with no clue what was holding the path.
593
+ if [ -e "$tt" ]; then
594
+ echo "::error::$tt survived the pre-checkout clean"
595
+ ls -lan "$tt" || true
596
+ exit 1
597
+ fi
598
+
599
+ - uses: actions/checkout@v5
600
+ with:
601
+ clean: true
602
+
603
+ - name: Write the Norsk license (from the org secret)
604
+ env:
605
+ NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
606
+ run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
607
+ # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
608
+ # told not to, and nothing removes it: it is a node singleton, and the reap
609
+ # step only takes containers that mount this job's test-temp. So the first
610
+ # test daemon on a shared runner left one, and every later one adopted it
611
+ # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
612
+ # to it). This job's daemons run without the node relay and discovery, so
613
+ # they neither start nor adopt one, and whatever is already on the host is
614
+ # named here. It reports and never fails: the leftover may be someone's.
615
+ - name: Isolate this job from node singletons other jobs left on this host
616
+ run: |
617
+ set -uo pipefail
618
+ echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
619
+ echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
620
+ left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
621
+ if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
622
+ echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
623
+ echo "$left"
624
+ while IFS= read -r line; do
625
+ role="$(awk '{print $2}' <<<"$line")"
626
+ case "$role" in
627
+ moq-relay|discovery)
628
+ 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." ;;
629
+ esac
630
+ done <<<"$left"
631
+
632
+ # The norsk-ctl this commit pins (flake.nix ctlVersion, moved by
633
+ # upgrade-latest's bump PR), never S3's moving `latest`: a guide run is then
634
+ # a real repeat, and a red belongs to a commit (ci-fragility proposal A).
635
+ - name: Download the pinned norsk-ctl binary (flake.nix ctlVersion)
636
+ run: |
637
+ set -euo pipefail
638
+ S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
639
+ ver="$( { sed -nE 's/^[[:space:]]*ctlVersion = "([^"]+)";.*/\1/p' flake.nix 2>/dev/null || true; } | head -1)"
640
+ [ -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; }
641
+ echo "norsk-ctl pinned by flake.nix ctlVersion -> $ver"
642
+ curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-linux-x64" -o "$RUNNER_TEMP/norsk-ctl"
643
+ chmod +x "$RUNNER_TEMP/norsk-ctl"
644
+ "$RUNNER_TEMP/norsk-ctl" --version || true
645
+ # Cold-runner first pulls overrun the harness's per-test launch timeouts;
646
+ # pre-pull the universal media + studio images (every product pins them in
647
+ # manifest.seed.json) so compose/run hit local images. A product needing
648
+ # more (e.g. a WHIP driver) pulls them in scripts/doc-guide/prepull-extra.sh.
649
+ - name: Pre-pull the media + studio images
650
+ run: |
651
+ set -euo pipefail
652
+ for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
653
+ echo "pre-pulling $img"
654
+ docker pull "$img"
655
+ done
656
+ if [ -x scripts/doc-guide/prepull-extra.sh ]; then
657
+ echo "running scripts/doc-guide/prepull-extra.sh"
658
+ ./scripts/doc-guide/prepull-extra.sh
659
+ fi
660
+ - name: Install and build the workspaces the guides drive
661
+ run: |
662
+ nix develop .#build --command bash -c '
663
+ set -euo pipefail
664
+ find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
665
+ bun install --frozen-lockfile
666
+ bun run build:no-lint
667
+ '
668
+
669
+ # The engine tier: real instances, driven in a browser. Reported, not
670
+ # gating (see the deterministic step). A non-zero exit is forgiven only
671
+ # when the tier's own results say a teardown hook timed out after every
672
+ # guide's test passed (doc-guide/engine-verdict.ts), as regen-manual.sh
673
+ # forgives it. A product with no docs:guides:engine script has no engine
674
+ # tier. Runs on the workspaces the step before it installed and built.
675
+ - name: Run the engine-tier guides (reported, not gating)
676
+ id: engine
677
+ continue-on-error: true
678
+ env:
679
+ NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
680
+ NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
681
+ # This runner launches Studio as a HOST sibling (DooD), so the harness
682
+ # reaches host-published ports via the host-gateway alias, not
683
+ # localhost — same as the integration suite.
684
+ NORSK_TEST_HOST: host.docker.internal
685
+ # Reach launched instances over norsk-net by service DNS (as the
686
+ # integration tier already does) AND, on a ctl that supports it, launch
687
+ # them binding no host ports — so concurrent doc-guide instances can't
688
+ # collide on the product's deterministic host-port bands.
689
+ NORSK_TEST_NET: direct
690
+ run: |
691
+ nix develop .#build --command bash -c '
692
+ set -euo pipefail
693
+ if ! jq -e ".scripts[\"docs:guides:engine\"]" package.json >/dev/null; then
694
+ echo "no docs:guides:engine script: this product has no engine tier"
695
+ exit 0
696
+ fi
697
+ export DOCS_GENERATE=1
698
+ log="$(mktemp)"
699
+ if bash scripts/with-display.sh bun run docs:guides:engine >"$log" 2>&1; then
700
+ cat "$log"
701
+ else
702
+ cat "$log"
703
+ verdict="$(bun -e "console.log(Bun.resolveSync(\"@norskvideo/ctl-dev-kit/doc-guide/engine-verdict\", process.cwd()))")"
704
+ bun "$verdict" "$log"
705
+ fi
706
+ '
707
+
708
+ # Said three ways, so a failure is never silent: a warning on the run, a
709
+ # line in its summary, and the cell's detail (the notify job).
710
+ - name: Report the engine tier (not gating)
711
+ if: ${{ !cancelled() }}
712
+ env:
713
+ OUTCOME: ${{ steps.engine.outcome }}
714
+ run: |
715
+ case "$OUTCOME" in
716
+ success) echo "engine-tier guides passed (or this product has none)" ;;
717
+ failure)
718
+ echo "::warning title=engine-tier guides failed (not gating)::the engine tier does not gate until norsk-ctl task 57 is fixed. See the engine step's log."
719
+ 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). See the step log.\n' >> "$GITHUB_STEP_SUMMARY" ;;
720
+ *) echo "engine tier: ${OUTCOME:-not run}" ;;
721
+ esac
722
+
723
+ - name: Reap the containers this job launched
724
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
725
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
726
+ # conventions/workflow-shell.ts); everything this job launched mounts its
727
+ # own test-temp, so that is what goes.
728
+ if: always()
729
+ run: |
730
+ base="${NORSK_CTL_TEST_TMP:-}"
731
+ tt="$GITHUB_WORKSPACE/test-temp"
732
+ s=""
733
+ for c in $(docker ps -aq); do
734
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
735
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
736
+ done
737
+ if [ -n "$s" ]; then
738
+ echo "::warning::removing containers this job left running:$s"
739
+ docker rm -f $s
740
+ fi
741
+
489
742
  # Combine the per-arch images into a multi-arch manifest and write the two
490
743
  # published tags. Skipped for a build-only product (empty PUBLISH_IMAGE) and on
491
744
  # a dry run.
@@ -733,7 +986,15 @@ jobs:
733
986
  # the pony/emoji from the dispatched result. always() so a red or manual run
734
987
  # still reports.
735
988
  notify:
736
- needs: [label, build, gate-checks, gate-guides, publish, record]
989
+ needs: [
990
+ label,
991
+ build,
992
+ gate-checks,
993
+ gate-guides,
994
+ guides-engine,
995
+ publish,
996
+ record
997
+ ]
737
998
  if: ${{ !cancelled() && github.event_name != 'pull_request' }}
738
999
  runs-on: x64
739
1000
  steps:
@@ -796,9 +1057,14 @@ jobs:
796
1057
  - uses: actions/checkout@v5
797
1058
  with:
798
1059
  clean: true
1060
+ # guides-engine reports in the detail below and never sets the state: a
1061
+ # failed engine tier is not a failed build, nor is one cancelled in its
1062
+ # concurrency queue.
799
1063
  - id: meta
1064
+ env:
1065
+ NEEDS: ${{ toJSON(needs) }}
800
1066
  run: |
801
- if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
1067
+ if jq -e 'del(.["guides-engine"]) | [.[].result] | any(. == "failure" or . == "cancelled")' <<<"$NEEDS" >/dev/null; then
802
1068
  echo "state=failure" >> "$GITHUB_OUTPUT"
803
1069
  else
804
1070
  echo "state=success" >> "$GITHUB_OUTPUT"
@@ -811,7 +1077,8 @@ jobs:
811
1077
  status: ${{ steps.meta.outputs.state }}
812
1078
  # Only where something is published: on a build-only repo the gate has
813
1079
  # nothing to gate, and saying so on every build is noise.
814
- detail: "${{ needs.label.outputs.publish == 'true' && needs.gate-guides.outputs.guides == 'skip' && format('guides not gating: {0}', needs.gate-guides.outputs.reason) || '' }}"
1080
+ # And when the engine-tier guides failed: they report, never gate.
1081
+ detail: "${{ needs.label.outputs.publish == 'true' && needs.gate-guides.outputs.guides == 'skip' && format('guides not gating: {0}', needs.gate-guides.outputs.reason) || needs.guides-engine.outputs.result == 'failure' && 'engine-tier guides failed (not gating; norsk-ctl task 57)' || '' }}"
815
1082
  - name: Reap the containers this job launched
816
1083
  # Whatever the tests did. A teardown cut short by a test timeout leaves
817
1084
  # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
@@ -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
@@ -283,6 +283,31 @@ jobs:
283
283
  NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
284
284
  run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
285
285
 
286
+ # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
287
+ # told not to, and nothing removes it: it is a node singleton, and the reap
288
+ # step only takes containers that mount this job's test-temp. So the first
289
+ # test daemon on a shared runner left one, and every later one adopted it
290
+ # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
291
+ # to it). This job's daemons run without the node relay and discovery, so
292
+ # they neither start nor adopt one, and whatever is already on the host is
293
+ # named here. It reports and never fails: the leftover may be someone's.
294
+ - name: Isolate this job from node singletons other jobs left on this host
295
+ run: |
296
+ set -uo pipefail
297
+ echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
298
+ echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
299
+ left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
300
+ if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
301
+ echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
302
+ echo "$left"
303
+ while IFS= read -r line; do
304
+ role="$(awk '{print $2}' <<<"$line")"
305
+ case "$role" in
306
+ moq-relay|discovery)
307
+ 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." ;;
308
+ esac
309
+ done <<<"$left"
310
+
286
311
  # Pick the binary for THIS runner's architecture: the GPU box is aarch64,
287
312
  # the x64 pool is x86_64, and the wrong one dies with "Exec format error"
288
313
  # deep in the suite. No `|| true` on --version: a bad binary must fail here.
@@ -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,6 +179,31 @@ 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
208
  # upgrade-latest's bump 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).
@@ -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
@@ -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,6 +193,31 @@ 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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.62",
3
+ "version": "0.2.64",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",
@@ -39,9 +39,9 @@
39
39
  "devDependencies": {
40
40
  "@norskvideo/ctl-oas-to-ts": "^0.1.0",
41
41
  "@norskvideo/norsk-sdk": "1.3.1-2026-10-08-846f25e3",
42
- "@norskvideo/norsk-studio": "1.30.0-2026-10-08-8f4b6c11",
43
- "@norskvideo/norsk-studio-builder": "1.30.0-2026-10-08-8f4b6c11",
44
- "@norskvideo/norsk-studio-built-ins": "1.30.0-2026-10-08-8f4b6c11",
42
+ "@norskvideo/norsk-studio": "1.30.0-2026-10-09-0cd6f070",
43
+ "@norskvideo/norsk-studio-builder": "1.30.0-2026-10-09-0cd6f070",
44
+ "@norskvideo/norsk-studio-built-ins": "1.30.0-2026-10-09-0cd6f070",
45
45
  "@playwright/test": "^1.59.0",
46
46
  "@types/bun": "latest",
47
47
  "@types/cors": "^2.8.17",