@norskvideo/ctl-dev-kit 0.2.37 → 0.2.39

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.
@@ -37,6 +37,14 @@
37
37
  mounts don't forward host->guest inotify events, so file watchers never fire —
38
38
  a failure that looks exactly like a code bug. Unit tests that don't bind-mount
39
39
  may keep using `os.tmpdir()`.
40
+ - **Address another container by its unique `<id>-<service>-1`, never a bare
41
+ service name, unless the caller is on the instance's own network only.** A
42
+ bare name (`studio`, `media`) resolves on every network the caller is on, and
43
+ on `norsk-net` it matches every instance's container: a call silently lands on
44
+ another instance. `media` sits on `<id>_default` only (Docker Desktop UDP
45
+ replies break on a dual-homed container), so never add `norsk-net` to it or to
46
+ a sidecar. Full rules: norsk-ctl `docs/product-template-format.md`,
47
+ "Networking: how services address each other".
40
48
  - **Don't pipe test runs to `tail`** — you lose the failure context. Write output
41
49
  to a temp file, then tail _that_ file for the results.
42
50
  - **Evolve the ctl<->product contract additively.** An older ctl must launch a
@@ -269,11 +269,205 @@ jobs:
269
269
  docker rm -f $s
270
270
  fi
271
271
 
272
+ # THE PUBLISH GATE. Nothing is published -- no dated tag, no channel tag, no
273
+ # channels row -- until both halves pass: the repo's own checks, and its guides
274
+ # (the end-to-end journey the nightly manual is generated from). Until
275
+ # 2026-10-06 publish needed only [label, build], so a bot push that passed unit
276
+ # tests shipped a probe whose preview was broken for four days, while the one
277
+ # test that knew sat red in a nightly nobody saw (task 57).
278
+ gate-checks:
279
+ uses: ./.github/workflows/checks.yml
280
+ with:
281
+ report: false
282
+ secrets: inherit
283
+
284
+ # The guides run from the SAME commit as the image, exactly as the nightly
285
+ # publish-docs.yml runs them -- these steps are its steps (a convention test
286
+ # holds them identical). Skipped, with the reason carried to the published row
287
+ # and the board, for a build-only repo, a product with no guides, or one that
288
+ # opted out in .github/publish-gate.json.
289
+ gate-guides:
290
+ needs: label
291
+ runs-on: x64
292
+ outputs:
293
+ guides: ${{ steps.plan.outputs.guides }}
294
+ reason: ${{ steps.plan.outputs.reason }}
295
+ steps:
296
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
297
+ # out of the workspace for every later step in this job, and clears whatever
298
+ # the old repo-local default left behind on this runner. The body carries the
299
+ # why. It fails the job only when legacy litter survives -- which would take
300
+ # the checkout down a step later anyway, but report nothing useful.
301
+ - name: Route test-temp out of the workspace (pre-checkout)
302
+ run: |
303
+ set -uo pipefail
304
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
305
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
306
+ # host -- even when the launch passes --container-user (reproduced
307
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
308
+ # inotify constraint that binds macOS dev and nothing here, so every run
309
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
310
+ # cannot remove. That took commentary's checks AND integration red on
311
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
312
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
313
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
314
+ mkdir -p "$base"
315
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
316
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
317
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
318
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
319
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
320
+ done
321
+ # LEGACY: a runner that ran the old default still carries a workspace
322
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
323
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
324
+ # RESTARTING leaked container re-creates the path as root seconds after
325
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
326
+ # by construction, because a killed run labels nothing.
327
+ tt="$GITHUB_WORKSPACE/test-temp"
328
+ [ -d "$tt" ] || exit 0
329
+ stuck=""
330
+ for c in $(docker ps -aq 2>/dev/null || true); do
331
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
332
+ stuck="$stuck $c"
333
+ fi
334
+ done
335
+ if [ -n "$stuck" ]; then
336
+ echo "reaping containers holding mounts under $tt:$stuck"
337
+ docker rm -f $stuck || true
338
+ fi
339
+ # The DIRECTORY, not just its contents: an empty but root-owned base
340
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
341
+ # container unlink the leaf.
342
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
343
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
344
+ # Fail HERE if it survived. The checkout fails on it either way, but
345
+ # reports only an EACCES rmdir with no clue what was holding the path.
346
+ if [ -e "$tt" ]; then
347
+ echo "::error::$tt survived the pre-checkout clean"
348
+ ls -lan "$tt" || true
349
+ exit 1
350
+ fi
351
+
352
+ - uses: actions/checkout@v5
353
+ with:
354
+ clean: true
355
+
356
+ - name: Decide whether the guides gate this publish
357
+ id: plan
358
+ run: |
359
+ set -euo pipefail
360
+ skip() { echo "guides=skip" >> "$GITHUB_OUTPUT"; echo "reason=$1" >> "$GITHUB_OUTPUT"; echo "guides skipped: $1"; }
361
+ if [ "${{ needs.label.outputs.publish }}" != "true" ]; then
362
+ skip "build-only: nothing is published"
363
+ elif [ ! -f scripts/doc-guide/regen-manual.sh ]; then
364
+ skip "this product has no guides (scripts/doc-guide/regen-manual.sh)"
365
+ else
366
+ decision="$(nix develop .#build --command bash -c '
367
+ set -euo pipefail
368
+ bun install --frozen-lockfile >/dev/null
369
+ cli="$(bun -e "console.log(Bun.resolveSync(\"@norskvideo/ctl-dev-kit/publish/product-channel\", process.cwd()))")"
370
+ bun "$cli" guide-gate
371
+ ')"
372
+ if [ "$decision" = "run" ]; then
373
+ echo "guides=run" >> "$GITHUB_OUTPUT"
374
+ else
375
+ skip "${decision#skip$'\t'}"
376
+ fi
377
+ fi
378
+
379
+ - name: Write the Norsk license (from the org secret)
380
+ if: ${{ steps.plan.outputs.guides == 'run' }}
381
+ env:
382
+ NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
383
+ run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
384
+
385
+ - name: Download the released norsk-ctl binary (latest channel)
386
+ if: ${{ steps.plan.outputs.guides == 'run' }}
387
+ run: |
388
+ set -euo pipefail
389
+ S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
390
+ ver="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/latest")"
391
+ echo "norsk-ctl latest channel -> $ver"
392
+ curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-linux-x64" -o "$RUNNER_TEMP/norsk-ctl"
393
+ chmod +x "$RUNNER_TEMP/norsk-ctl"
394
+ "$RUNNER_TEMP/norsk-ctl" --version || true
395
+
396
+ # Cold-runner first pulls overrun the harness's per-test launch timeouts;
397
+ # pre-pull the universal media + studio images (every product pins them in
398
+ # manifest.seed.json) so compose/run hit local images. A product needing
399
+ # more (e.g. a WHIP driver) pulls them in scripts/doc-guide/prepull-extra.sh.
400
+ - name: Pre-pull the media + studio images
401
+ if: ${{ steps.plan.outputs.guides == 'run' }}
402
+ run: |
403
+ set -euo pipefail
404
+ for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
405
+ echo "pre-pulling $img"
406
+ docker pull "$img"
407
+ done
408
+ if [ -x scripts/doc-guide/prepull-extra.sh ]; then
409
+ echo "running scripts/doc-guide/prepull-extra.sh"
410
+ ./scripts/doc-guide/prepull-extra.sh
411
+ fi
412
+
413
+ # Rebuild every workspace the manual depends on (a guide's engine tier packs
414
+ # the freshly-built dashboard + frontend dist into the launched template),
415
+ # then run the product's doc-guide tiers and assemble the manual.
416
+ # regen-manual.sh owns the slug list and which tiers run; with-display.sh
417
+ # wraps the whole run in an X display for headless chromium (the engine tier
418
+ # drives a raw chromium that needs one).
419
+ - name: Regenerate the illustrated manual (all tiers)
420
+ if: ${{ steps.plan.outputs.guides == 'run' }}
421
+ env:
422
+ NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
423
+ NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
424
+ # This runner launches Studio as a HOST sibling (DooD), so the harness
425
+ # reaches host-published ports via the host-gateway alias, not
426
+ # localhost — same as the integration suite.
427
+ NORSK_TEST_HOST: host.docker.internal
428
+ # Reach launched instances over norsk-net by service DNS (as the
429
+ # integration tier already does) AND, on a ctl that supports it, launch
430
+ # them binding no host ports — so concurrent doc-guide instances can't
431
+ # collide on the product's deterministic host-port bands.
432
+ NORSK_TEST_NET: direct
433
+ run: |
434
+ nix develop .#build --command bash -c '
435
+ set -euo pipefail
436
+ # Belt-and-braces now that the checkout cleans: a persisted node_modules
437
+ # workspace dep whose version moved leaves stale copies that
438
+ # --frozen-lockfile does not reliably relink, so a new export is "not
439
+ # found". Nuke EVERY node_modules — including nested per-workspace ones
440
+ # — for a deterministic install. A nightly can afford it.
441
+ find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
442
+ bun install --frozen-lockfile
443
+ bun run build:no-lint
444
+ bash scripts/with-display.sh scripts/doc-guide/regen-manual.sh
445
+ '
446
+
447
+ - name: Reap the containers this job launched
448
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
449
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
450
+ # conventions/workflow-shell.ts); everything this job launched mounts its
451
+ # own test-temp, so that is what goes.
452
+ if: always()
453
+ run: |
454
+ base="${NORSK_CTL_TEST_TMP:-}"
455
+ tt="$GITHUB_WORKSPACE/test-temp"
456
+ s=""
457
+ for c in $(docker ps -aq); do
458
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
459
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
460
+ done
461
+ if [ -n "$s" ]; then
462
+ echo "::warning::removing containers this job left running:$s"
463
+ docker rm -f $s
464
+ fi
465
+
272
466
  # Combine the per-arch images into a multi-arch manifest and write the two
273
467
  # published tags. Skipped for a build-only product (empty PUBLISH_IMAGE) and on
274
468
  # a dry run.
275
469
  publish:
276
- needs: [label, build]
470
+ needs: [label, build, gate-checks, gate-guides]
277
471
  if: ${{ needs.label.outputs.publish == 'true' && !inputs.dry_run }}
278
472
  runs-on: x64
279
473
  steps:
@@ -375,12 +569,148 @@ jobs:
375
569
  docker rm -f $s
376
570
  fi
377
571
 
572
+ # Record the published image in this product's channels object
573
+ # (s3://norsk.video/products/<product>/channels.json), which ctl reads to
574
+ # register the product. Only reached after the gate passed, so a row is a
575
+ # statement that the image passed it. The role is per product and can write
576
+ # only its own prefix (norsk-ctl docs/ci/aws-oidc/apply-products.sh); until it
577
+ # exists this warns and records nothing, rather than failing a publish.
578
+ record:
579
+ needs: [label, gate-guides, publish]
580
+ if: ${{ needs.publish.result == 'success' }}
581
+ runs-on: x64
582
+ permissions:
583
+ contents: read
584
+ id-token: write
585
+ steps:
586
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
587
+ # out of the workspace for every later step in this job, and clears whatever
588
+ # the old repo-local default left behind on this runner. The body carries the
589
+ # why. It fails the job only when legacy litter survives -- which would take
590
+ # the checkout down a step later anyway, but report nothing useful.
591
+ - name: Route test-temp out of the workspace (pre-checkout)
592
+ run: |
593
+ set -uo pipefail
594
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
595
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
596
+ # host -- even when the launch passes --container-user (reproduced
597
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
598
+ # inotify constraint that binds macOS dev and nothing here, so every run
599
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
600
+ # cannot remove. That took commentary's checks AND integration red on
601
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
602
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
603
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
604
+ mkdir -p "$base"
605
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
606
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
607
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
608
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
609
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
610
+ done
611
+ # LEGACY: a runner that ran the old default still carries a workspace
612
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
613
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
614
+ # RESTARTING leaked container re-creates the path as root seconds after
615
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
616
+ # by construction, because a killed run labels nothing.
617
+ tt="$GITHUB_WORKSPACE/test-temp"
618
+ [ -d "$tt" ] || exit 0
619
+ stuck=""
620
+ for c in $(docker ps -aq 2>/dev/null || true); do
621
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
622
+ stuck="$stuck $c"
623
+ fi
624
+ done
625
+ if [ -n "$stuck" ]; then
626
+ echo "reaping containers holding mounts under $tt:$stuck"
627
+ docker rm -f $stuck || true
628
+ fi
629
+ # The DIRECTORY, not just its contents: an empty but root-owned base
630
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
631
+ # container unlink the leaf.
632
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
633
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
634
+ # Fail HERE if it survived. The checkout fails on it either way, but
635
+ # reports only an EACCES rmdir with no clue what was holding the path.
636
+ if [ -e "$tt" ]; then
637
+ echo "::error::$tt survived the pre-checkout clean"
638
+ ls -lan "$tt" || true
639
+ exit 1
640
+ fi
641
+
642
+ - uses: actions/checkout@v5
643
+ with:
644
+ clean: true
645
+
646
+ - name: Warn that no channels role is configured
647
+ if: ${{ vars.AWS_PRODUCT_ROLE_ARN == '' }}
648
+ run: echo "::warning::AWS_PRODUCT_ROLE_ARN is not set, so this publish was not recorded for ctl (norsk-ctl docs/ci/aws-oidc/apply-products.sh)"
649
+
650
+ - name: Configure AWS credentials (this product's channels role)
651
+ if: ${{ vars.AWS_PRODUCT_ROLE_ARN != '' }}
652
+ uses: aws-actions/configure-aws-credentials@v6
653
+ with:
654
+ role-to-assume: ${{ vars.AWS_PRODUCT_ROLE_ARN }}
655
+ role-session-name: product-channels-${{ github.run_id }}
656
+ aws-region: eu-west-1
657
+
658
+ - name: Record the image in the product's channels object
659
+ if: ${{ vars.AWS_PRODUCT_ROLE_ARN != '' }}
660
+ env:
661
+ LABEL: ${{ needs.label.outputs.label }}
662
+ CHANNEL: ${{ needs.label.outputs.channel }}
663
+ GUIDES: ${{ needs.gate-guides.outputs.guides }}
664
+ SKIP_REASON: ${{ needs.gate-guides.outputs.reason }}
665
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
666
+ run: |
667
+ set -euo pipefail
668
+ image="${PUBLISH_IMAGE}:${LABEL}"
669
+ runtime="$(docker buildx imagetools inspect "$image" --format '{{ json (index .Image "linux/amd64") }}' \
670
+ | jq -r '.config.Labels["norsk-ctl.runtime-images"] // ""')"
671
+ aws() { nix shell --inputs-from . nixpkgs#awscli2 --command aws "$@"; }
672
+ nix develop .#build --command bash -c 'bun install --frozen-lockfile >/dev/null'
673
+ cli="$(nix develop .#build --command bun -e 'console.log(Bun.resolveSync("@norskvideo/ctl-dev-kit/publish/product-channel", process.cwd()))')"
674
+ product="$(nix develop .#build --command bun "$cli" product --template licenseTemplate.json --image "$PUBLISH_IMAGE")"
675
+ uri="s3://norsk.video/products/${product}/channels.json"
676
+ current="$RUNNER_TEMP/channels.current.json"
677
+ : > "$current"
678
+ if aws s3api head-object --bucket norsk.video --key "products/${product}/channels.json" >/dev/null 2>&1; then
679
+ aws s3 cp "$uri" "$current"
680
+ fi
681
+ skipped=()
682
+ if [ "$GUIDES" = "skip" ]; then skipped=(--skipped "$SKIP_REASON"); fi
683
+ nix develop .#build --command bun "$cli" row --current "$current" \
684
+ --product "$product" --image-ref "$PUBLISH_IMAGE" --channel "$CHANNEL" \
685
+ --image "$image" --sha "$GITHUB_SHA" --published-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
686
+ --runtime-images "$runtime" --run "$RUN_URL" "${skipped[@]}" > "$RUNNER_TEMP/channels.json"
687
+ aws s3 cp "$RUNNER_TEMP/channels.json" "$uri" --content-type application/json --cache-control "max-age=60"
688
+ echo "recorded $image as ${product}'s ${CHANNEL} -> $uri"
689
+ - name: Reap the containers this job launched
690
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
691
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
692
+ # conventions/workflow-shell.ts); everything this job launched mounts its
693
+ # own test-temp, so that is what goes.
694
+ if: always()
695
+ run: |
696
+ base="${NORSK_CTL_TEST_TMP:-}"
697
+ tt="$GITHUB_WORKSPACE/test-temp"
698
+ s=""
699
+ for c in $(docker ps -aq); do
700
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
701
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
702
+ done
703
+ if [ -n "$s" ]; then
704
+ echo "::warning::removing containers this job left running:$s"
705
+ docker rm -f $s
706
+ fi
707
+
378
708
  # Report this pipeline's result to the aggregated product CI dashboard
379
709
  # (id3as/ci-workflows) instead of posting its own pony -- the dashboard renders
380
710
  # the pony/emoji from the dispatched result. always() so a red or manual run
381
711
  # still reports.
382
712
  notify:
383
- needs: [build, publish]
713
+ needs: [build, gate-checks, gate-guides, publish, record]
384
714
  if: ${{ !cancelled() }}
385
715
  runs-on: x64
386
716
  steps:
@@ -456,6 +786,7 @@ jobs:
456
786
  product: __PRODUCT__
457
787
  pipeline: build-image
458
788
  status: ${{ steps.meta.outputs.state }}
789
+ detail: "${{ needs.gate-guides.outputs.guides == 'skip' && format('guides not gating: {0}', needs.gate-guides.outputs.reason) || '' }}"
459
790
  - name: Reap the containers this job launched
460
791
  # Whatever the tests did. A teardown cut short by a test timeout leaves
461
792
  # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
@@ -22,6 +22,13 @@ on:
22
22
  push:
23
23
  branches: [main]
24
24
  pull_request:
25
+ # build-image.yml calls this as half of the publish gate, with report off so
26
+ # the board gets one `checks` cell per push, not two.
27
+ workflow_call:
28
+ inputs:
29
+ report:
30
+ type: boolean
31
+ default: true
25
32
 
26
33
  permissions:
27
34
  contents: read
@@ -274,7 +281,7 @@ jobs:
274
281
  # superseded run.
275
282
  notify:
276
283
  needs: [drift, quality]
277
- if: ${{ !cancelled() && github.event_name == 'push' }}
284
+ if: ${{ !cancelled() && github.event_name == 'push' && inputs.report != false }}
278
285
  runs-on: x64
279
286
  steps:
280
287
  # Runs BEFORE checkout, and does two things: points the harness's temp base
@@ -19,11 +19,11 @@ socket), with their ports **published on the host**.
19
19
 
20
20
  Consequence — two address classes the harness must not confuse:
21
21
 
22
- | Reaching… | Address |
23
- | ------------------------------------ | ----------------------------------------- |
24
- | the daemon, the product backend | `localhost:<port>` (in-process) |
25
- | studio / media host-published ports | `${NORSK_TEST_HOST}:<port>` |
26
- | a launched container by compose name | `<instance>-<service>-1` **on norsk-net** |
22
+ | Reaching… | Address |
23
+ | ------------------------------------ | ----------------------------------------------------------------------------------------------------- |
24
+ | the daemon, the product backend | `localhost:<port>` (in-process) |
25
+ | studio / media host-published ports | `${NORSK_TEST_HOST}:<port>` |
26
+ | a launched container by compose name | `<instance>-<service>-1` on its **instance network** (`<instance>_default`); studio also on norsk-net |
27
27
 
28
28
  `NORSK_TEST_HOST` is `host.docker.internal` in CI and unset (→ `localhost`)
29
29
  locally. Set it in the workflow env. The shared `studio-state` fetchers
@@ -43,8 +43,13 @@ runner, one of these must hold:
43
43
 
44
44
  - `NORSK_TEST_NET=direct` — the preferred answer, and what the canonical
45
45
  `integration.yml` / `smoke.yml` / `build-docs.yml` topology step already sets.
46
- Instances are reached by compose name on `norsk-net` at the **container** port,
47
- so no host publish is involved at all.
46
+ Instances are reached by compose name at the **container** port, so no host
47
+ publish is involved at all. Studio is on `norsk-net`; **media is on its
48
+ instance network `<instance>_default` only** (Docker Desktop delivers published
49
+ UDP to either IP of a dual-homed container and breaks SRT replies, see
50
+ norsk-ctl `docs/product-template-format.md`, Networking). The harness's media helpers
51
+ (`mediaHttpBase`, `srtEgressUrl`, `mediaDirectUrl`) join the runner to that
52
+ network for you, and `cleanupDaemon` leaves it before deleting the instance.
48
53
  - the launch passes `--publish-debug-ports`, restoring the old binding. The
49
54
  shared `runProductSmoke` and demo drivers do this for you when the host they
50
55
  fetch from is not loopback, and only after probing that the resolved ctl
@@ -147,24 +152,39 @@ reach. Two cases:
147
152
  for `NORSK_TEST_HOST`). `assertMultivariantHasRenditions` does this by default.
148
153
  - **Proxy-less harness** — the advertised `/instance/<id>/media/...` route is
149
154
  served **only by the daemon's nginx proxy**; with no proxy, nothing answers on
150
- `:443`. Reach the media container **directly on norsk-net** instead:
151
- `mediaDirectUrl(url)` rewrites it to `http://<id>-media-1:8080/<native-path>`
152
- (the same bypass `whip-driver` uses; the media route is unauthenticated). Pass
153
- it via `assertMultivariantHasRenditions`'s `resolveFetchUrl` hook, and **join
154
- the runner to norsk-net** in setup so the compose service name resolves:
155
-
156
- ```ts
157
- spawnSync("docker", ["network", "create", "norsk-net"]); // idempotent
158
- spawnSync("docker", ["network", "connect", "norsk-net", hostname()]);
159
- ```
155
+ `:443`. Reach the media container **directly** instead: `mediaDirectUrl(url)`
156
+ rewrites it to `http://<id>-media-1:8080/<native-path>` (the same bypass
157
+ `whip-driver` uses; the media route is unauthenticated) and joins the runner to
158
+ the instance network `<id>_default`, where that name resolves. Pass it via
159
+ `assertMultivariantHasRenditions`'s `resolveFetchUrl` hook. Anything else that
160
+ addresses `<id>-media-1` by hand (a sibling container, a browser in the runner)
161
+ must be on `<id>_default` too: `currentInstanceNetworks().join(id)` for the
162
+ runner, `docker network connect <id>_default <container>` for a sibling, and
163
+ leave or remove it before the instance is deleted, or `compose down` cannot
164
+ remove the network.
160
165
 
161
166
  ## Capture servers (studio pushes → the runner)
162
167
 
163
- When a test stands up an HLS/SCTE-35 capture server that **studio pushes to**,
164
- studio (a host sibling on norsk-net) can't reach the runner via the host LAN IP.
165
- Join norsk-net and hand studio the runner's **norsk-net IP** (`docker inspect`
166
- self), falling back to the host LAN IP only when `NORSK_TEST_HOST` is unset. See
167
- `manifest-capture.ts`'s `captureHost()`.
168
+ When a test stands up an HLS/SCTE-35 capture server (or an SRT sink) that
169
+ **media pushes to**, media sits on its instance network only and can't reach a
170
+ containerised runner via the host LAN IP. Hand it the runner's address **on the
171
+ instance network**: `currentInstanceNetworks().addressOn(instanceId)` joins that
172
+ network and returns the runner's IP on it, or `null` when the runner is not in a
173
+ container (or the join failed). The harness ships the primitive, not the policy:
174
+ each product picks its own fallback for the `null` case, usually the address
175
+ media already reaches the host by.
176
+
177
+ ```ts
178
+ import { currentInstanceNetworks } from "@norskvideo/ctl-test-harness/container-net";
179
+
180
+ function captureHost(instanceId: string): string {
181
+ // hostLanIp is the product's own off-container fallback.
182
+ return currentInstanceNetworks().addressOn(instanceId) ?? hostLanIp();
183
+ }
184
+ ```
185
+
186
+ The runner leaves every network it joined during `cleanupDaemon`, before the
187
+ instance is deleted.
168
188
 
169
189
  ## Build the product image in the integration job
170
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",
@@ -18,6 +18,7 @@
18
18
  "./licence/check-entitlement": "./licence/check-entitlement.ts",
19
19
  "./licence/check-product-name": "./licence/check-product-name.ts",
20
20
  "./licence/check-template": "./licence/check-template.ts",
21
+ "./publish/product-channel": "./publish/product-channel.ts",
21
22
  "./package.json": "./package.json",
22
23
  "./testing/byte-snapshot": "./testing/byte-snapshot.ts",
23
24
  "./testing/invariants": "./testing/invariants.ts",
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * A product's half of the channels object ctl reads to register it
4
+ * (`@norskvideo/ctl-product-template-schema/product-channels`). Used by
5
+ * conventions/build-image.yml, which every product repo copies verbatim:
6
+ *
7
+ * guide-gate [<opt-out file>] -> `run`, or `skip<TAB><reason>`
8
+ * product --template <file> --image <ref>
9
+ * -> the licence product the image is
10
+ * row --current <file> --product <p> --image-ref <repo> --channel <ch>
11
+ * --image <ref> --sha <sha> --published-at <iso> [--runtime-images <a,b>]
12
+ * [--skipped <reason>] [--run <url>]
13
+ * -> the merged channels object
14
+ *
15
+ * The opt-out (`.github/publish-gate.json`) exists for a product whose guides
16
+ * are not reliable yet: it skips only the guide half of the gate, must give a
17
+ * reason, and the reason travels into the published row, so nobody reads
18
+ * "passed" off an image whose guides never ran.
19
+ */
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import {
22
+ type ProductChannelRow,
23
+ ProductChannelSchema,
24
+ parseProductChannels,
25
+ withChannelRow,
26
+ } from "@norskvideo/ctl-product-template-schema/product-channels";
27
+ import { builtImageName } from "../licence/check-template.ts";
28
+
29
+ export type GuideGate = { run: true } | { run: false; reason: string };
30
+
31
+ /** Whether the guides gate this product's publish, from its opt-out file. */
32
+ export function guideGate(optOutJson: string | undefined): GuideGate {
33
+ if (optOutJson === undefined) return { run: true };
34
+ const parsed = JSON.parse(optOutJson) as { guides?: unknown };
35
+ const guides = parsed.guides;
36
+ if (typeof guides !== "object" || guides === null) {
37
+ throw new Error('publish-gate.json: "guides" must be an object, e.g. {"skip": true, "reason": "..."}');
38
+ }
39
+ const { skip, reason } = guides as { skip?: unknown; reason?: unknown };
40
+ if (skip !== true) return { run: true };
41
+ if (typeof reason !== "string" || reason.trim() === "") {
42
+ throw new Error("publish-gate.json: skipping the guides needs a reason");
43
+ }
44
+ return { run: false, reason: reason.trim() };
45
+ }
46
+
47
+ /** The licence product whose imageRef is the published image's repo. */
48
+ export function productForImage(templateJson: string, image: string): string {
49
+ const repo = builtImageName({ publishImage: image, buildDefault: "" });
50
+ const entries = (JSON.parse(templateJson) as { products?: { product?: string; imageRef?: string }[] }).products ?? [];
51
+ const match = entries.find((e) => e.imageRef === repo && typeof e.product === "string");
52
+ if (!match?.product) throw new Error(`no licence product in licenseTemplate.json names ${repo}`);
53
+ return match.product;
54
+ }
55
+
56
+ export function gateRow(opts: {
57
+ image: string;
58
+ sha: string;
59
+ publishedAt: string;
60
+ runtimeImages: string;
61
+ guides: GuideGate;
62
+ run?: string;
63
+ }): ProductChannelRow {
64
+ return {
65
+ image: opts.image,
66
+ sha: opts.sha,
67
+ publishedAt: opts.publishedAt,
68
+ runtimeImages: opts.runtimeImages
69
+ .split(",")
70
+ .map((s) => s.trim())
71
+ .filter(Boolean),
72
+ gate: {
73
+ checks: "passed",
74
+ guides: opts.guides.run ? "passed" : { skipped: opts.guides.reason },
75
+ ...(opts.run ? { run: opts.run } : {}),
76
+ },
77
+ };
78
+ }
79
+
80
+ function readIfPresent(path: string | undefined): string | undefined {
81
+ if (!path || !existsSync(path)) return undefined;
82
+ const text = readFileSync(path, "utf8");
83
+ return text.trim() === "" ? undefined : text;
84
+ }
85
+
86
+ if (import.meta.main) {
87
+ const argv = process.argv.slice(2);
88
+ const [cmd, positional] = argv;
89
+ const flag = (name: string): string | undefined => {
90
+ const i = argv.indexOf(`--${name}`);
91
+ return i === -1 ? undefined : argv[i + 1];
92
+ };
93
+ const need = (name: string): string => {
94
+ const v = flag(name);
95
+ if (!v) {
96
+ console.error(`product-channel ${cmd}: --${name} is required`);
97
+ process.exit(2);
98
+ }
99
+ return v;
100
+ };
101
+
102
+ if (cmd === "guide-gate") {
103
+ const gate = guideGate(readIfPresent(positional ?? ".github/publish-gate.json"));
104
+ console.log(gate.run ? "run" : `skip\t${gate.reason}`);
105
+ } else if (cmd === "product") {
106
+ console.log(productForImage(readFileSync(need("template"), "utf8"), need("image")));
107
+ } else if (cmd === "row") {
108
+ const currentText = readIfPresent(flag("current"));
109
+ let current = null;
110
+ if (currentText !== undefined) {
111
+ const parsed = parseProductChannels(JSON.parse(currentText));
112
+ if (!parsed.ok) {
113
+ console.error(`product-channel row: the current object does not parse:\n${parsed.error}`);
114
+ process.exit(1);
115
+ }
116
+ current = parsed.value;
117
+ }
118
+ const skipped = flag("skipped");
119
+ const merged = withChannelRow(current, {
120
+ product: need("product"),
121
+ imageRef: need("image-ref"),
122
+ channel: ProductChannelSchema.parse(need("channel")),
123
+ updated: need("published-at"),
124
+ row: gateRow({
125
+ image: need("image"),
126
+ sha: need("sha"),
127
+ publishedAt: need("published-at"),
128
+ runtimeImages: flag("runtime-images") ?? "",
129
+ guides: skipped ? { run: false, reason: skipped } : { run: true },
130
+ run: flag("run"),
131
+ }),
132
+ });
133
+ process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`);
134
+ } else {
135
+ console.error("usage: product-channel.ts guide-gate [file] | product --template <f> --image <ref> | row ...");
136
+ process.exit(2);
137
+ }
138
+ }