@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
|
package/conventions/checks.yml
CHANGED
|
@@ -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
|
|
47
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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.
|
|
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
|
+
}
|