@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.
- package/conventions/CLAUDE.core.md +6 -0
- package/conventions/build-docs.yml +25 -0
- package/conventions/build-image.yml +284 -17
- package/conventions/checks.sdk-app.yml +20 -1
- package/conventions/checks.yml +20 -1
- package/conventions/integration.yml +25 -0
- package/conventions/publish-docs.yml +105 -10
- package/conventions/quarantine.ts +209 -0
- package/conventions/smoke.yml +25 -0
- package/doc-guide/engine-verdict.js +3 -3
- package/package.json +4 -4
|
@@ -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
|
|
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
|
-
#
|
|
306
|
-
#
|
|
307
|
-
#
|
|
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
|
-
#
|
|
437
|
-
# the
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/conventions/checks.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
208
|
-
# the
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
|
|
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]));
|
package/conventions/smoke.yml
CHANGED
|
@@ -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
|
|
9
|
-
* tier
|
|
10
|
-
*
|
|
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.
|
|
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-
|
|
43
|
-
"@norskvideo/norsk-studio-builder": "1.30.0-2026-10-
|
|
44
|
-
"@norskvideo/norsk-studio-built-ins": "1.30.0-2026-10-
|
|
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",
|