@norskvideo/ctl-dev-kit 0.2.63 → 0.2.65

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