@norskvideo/ctl-dev-kit 0.2.99 → 0.2.100

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.
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # ctl-dev-kit convention: the integration suite under contention verdicts.
3
+ #
4
+ # integration.yml runs this, inside the dev shell and from the repo root, in
5
+ # place of `bun run test:integration` when the repo has opted in with
6
+ # scripts/integration/contention.json (thresholds; `{}` takes every default).
7
+ # It runs the suite under @norskvideo/ctl-test-harness's ctl-contention, which
8
+ # samples host pressure (Linux PSI) alongside it and classifies the result:
9
+ #
10
+ # exit 0 the suite passed
11
+ # exit N a real failure: the suite's own status, as before
12
+ # exit 75 uncertain: every failure overlapped host contention, or the
13
+ # suite was stopped early because the host was starved
14
+ #
15
+ # An uncertain attempt is rerun up to rerun.max times (default 1), once the
16
+ # host has gone quiet or rerun.maxWaitSec has passed. Each attempt's timeline,
17
+ # verdict and summary land in $NORSK_CONTENTION_DIR/attempt-<n>/, the last
18
+ # verdict at $NORSK_CONTENTION_DIR/verdict.json for the workflow to read.
19
+ #
20
+ # Anything missing (a harness without ctl-contention, a config it cannot
21
+ # read) runs the suite exactly as before, with a warning: opting in must
22
+ # never be what turns a tier red.
23
+ set -uo pipefail
24
+
25
+ config=scripts/integration/contention.json
26
+ bin=node_modules/.bin/ctl-contention
27
+ dir="${NORSK_CONTENTION_DIR:-test-results/contention}"
28
+
29
+ as_before() {
30
+ echo "::warning title=contention verdicts off::$1; the suite runs without them, exactly as before."
31
+ exec bun run test:integration
32
+ }
33
+
34
+ [ -x "$bin" ] || as_before "$config is present but this repo's @norskvideo/ctl-test-harness has no ctl-contention (bump it)"
35
+ if ! max="$("$bin" config rerun.max --config "$config")"; then
36
+ as_before "ctl-contention could not read $config"
37
+ fi
38
+
39
+ # Between attempts: the containers the last attempt launched. Its teardown may
40
+ # never have run (an abort kills the suite), and a held port or a leftover
41
+ # engine would starve or break the rerun. The same two rules the job-end reap
42
+ # steps use: instances the harness recorded, and anything mounting this job's
43
+ # test-temp.
44
+ reap_leftovers() {
45
+ command -v docker >/dev/null 2>&1 || return 0
46
+ local m="${NORSK_LAUNCHED_MANIFEST:-}" base="${NORSK_CTL_TEST_TMP:-}" id ids c src
47
+ if [ -n "$m" ] && [ -f "$m" ]; then
48
+ while IFS= read -r id; do
49
+ [ -n "$id" ] || continue
50
+ ids="$(docker ps -aq --filter "label=norsk-ctl.instance=$id" 2>/dev/null || true)"
51
+ [ -z "$ids" ] || docker rm -f $ids >/dev/null 2>&1 || true
52
+ done < <(sort -u "$m")
53
+ fi
54
+ [ -n "$base" ] || return 0
55
+ for c in $(docker ps -aq 2>/dev/null || true); do
56
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
57
+ if grep -q "^$base/" <<<"$src"; then docker rm -f "$c" >/dev/null 2>&1 || true; fi
58
+ done
59
+ }
60
+
61
+ attempt=1
62
+ while :; do
63
+ out="$dir/attempt-$attempt"
64
+ "$bin" run --config "$config" --out "$out" -- bun run test:integration
65
+ rc=$?
66
+ [ -f "$out/verdict.json" ] && cp "$out/verdict.json" "$dir/verdict.json"
67
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$out/summary.md" ]; then
68
+ { echo "#### Attempt $attempt"; echo; cat "$out/summary.md"; echo; } >>"$GITHUB_STEP_SUMMARY"
69
+ fi
70
+ if [ "$rc" -ne 75 ] || [ "$attempt" -gt "$max" ]; then break; fi
71
+ echo "::warning title=integration attempt $attempt uncertain::the host was contended; waiting for it to go quiet, then rerunning (attempt $((attempt + 1)) of $((max + 1)))"
72
+ reap_leftovers
73
+ "$bin" wait-quiet --config "$config" || true
74
+ attempt=$((attempt + 1))
75
+ done
76
+ exit "$rc"
@@ -116,6 +116,81 @@ runner without the mount runs unserialised and says so in the log; a suite that
116
116
  has waited an hour runs anyway with a warning. A hand-owned `integration.yml`
117
117
  does not take the lock until it converges onto the canonical.
118
118
 
119
+ ### Contention verdicts: a starved host is not a red
120
+
121
+ The lock stops two suites starving each other, but not everything else on the
122
+ host. On test2, commentary has failed with browser main threads starved for
123
+ 6-80% of a test window, the engine 50 s behind realtime, a 0.55 s switch taking
124
+ 5-20 s, and jobs killed mid-run by SIGTERM. Those failures are the host's, not
125
+ the product's. A product can opt in to having the tier say so.
126
+
127
+ **Opting in.** Add `scripts/integration/contention.json` (and a
128
+ `@norskvideo/ctl-test-harness` new enough to ship `ctl-contention`). `{}` takes
129
+ every default; any key below overrides one, and a misspelt key is an error. With
130
+ the file present, the canonical workflow runs `bun run test:integration` through
131
+ `conventions/contention-suite.sh`, which runs it under the harness's
132
+ `ctl-contention run`:
133
+
134
+ - A sampler reads host pressure once a second for the whole suite: Linux PSI
135
+ (`/proc/pressure/{cpu,memory,io}`, host-wide, falling back to the cgroup v2
136
+ root's `*.pressure`), the load average and `MemAvailable`. The timeline is
137
+ `pressure.jsonl` in the job's `integration-attempt-N` artifact.
138
+ - Each failing test's window is read off bun's own `(fail) ... [duration]` line
139
+ as it streams past, so the product's `test:integration` script needs no
140
+ reporter flag.
141
+ - When the suite fails and **every** failure overlapped contention, the run is
142
+ **uncertain**: the job writes a `::warning::` and a job summary (the verdict,
143
+ each failure with its numbers, pressure per minute), then cancels its own run.
144
+ A cancelled run is not reported to the CI board, and bot-prs merges only a
145
+ success. One failure on a quiet stretch keeps the run red, as before.
146
+ - If the host is starved early in the suite, or for a long stretch anywhere, the
147
+ suite is stopped there rather than run for 40 minutes to an uncertain end.
148
+ - An uncertain attempt is rerun in the same job (`rerun.max`, default 1), after
149
+ waiting up to `rerun.maxWaitSec` for the host to go quiet.
150
+
151
+ **When nothing changes.** No `contention.json`: the suite runs exactly as it did.
152
+ No readable PSI (an old kernel, `psi=0`, a runner that cannot read it): the
153
+ summary says "pressure unavailable" and every verdict is the suite's own. A
154
+ harness without `ctl-contention`, or a config it cannot read: a warning, then the
155
+ suite runs as before. Every job prints a "Probe whether this runner can read
156
+ host pressure (PSI)" step whether or not the repo has opted in, so a runner's
157
+ first log answers whether the signal exists there.
158
+
159
+ **Defaults** (`classify`, judged on the failure's window widened by `marginSec`):
160
+
161
+ | Key | Default | Why |
162
+ | -------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- |
163
+ | `classify.cpuSomeMean` | 0.25 | cpu `some` bounds any one thread's wait from above; passing runs saw at most ~5% browser starvation, so 25% is 5x that |
164
+ | `classify.cpuSomePeak` | 0.60 | over any `peakWindowSec` (10 s): a stall long enough to break a seconds-scale assertion that a long test's mean hides |
165
+ | `classify.memSomeMean` | 0.10 | reclaim as a constant cost of running |
166
+ | `classify.memFullPeak` | 0.05 | half a second in any 10 in which nothing ran at all |
167
+ | `classify.minCoverage` | 0.5 | a window the sampler barely saw is not judged (and so stays red) |
168
+ | `classify.killWindowSec` | 120 | a suite killed from outside is judged on its last two minutes |
169
+ | `abort.cpuSome` | 0.70 | mean over the rule's window; with `memSome` 0.30 and `memFull` 0.10 |
170
+ | `abort.earlyWindowSec` | 60 | starved for a minute within the first `earlyWithinSec` (300): stop |
171
+ | `abort.sustainedWindowSec` | 300 | starved for five minutes anywhere: stop |
172
+ | `rerun.max` | 1 | reruns after an uncertain attempt; 0 turns them off |
173
+
174
+ Calling a real failure uncertain costs a rerun; calling a starved one red costs a
175
+ human an investigation of nothing. The bars are set where the first is unlikely,
176
+ and they are config so test2's recorded timelines can move them.
177
+
178
+ **From a test.** `hostContendedDuring({ fromMs, toMs })` from
179
+ `@norskvideo/ctl-test-harness/contention` answers for any window of the running
180
+ suite (it reads the timeline the wrapper names in `NORSK_CONTENTION_TIMELINE`),
181
+ with the numbers. Outside the wrapper it answers "unavailable", never
182
+ "contended".
183
+
184
+ **Exit statuses** of `ctl-contention run`: 0 pass; the suite's own status on a
185
+ real failure; 75 (EX_TEMPFAIL) when uncertain, which the workflow believes only
186
+ alongside a `verdict.json` that says so.
187
+
188
+ **Caveats.** A `ctl-candidate` dispatch that is cancelled is still scored red by
189
+ release-collect (the gate is advisory). bot-prs reports a PR whose integration
190
+ check was cancelled as needing a human rather than re-running it, because its
191
+ check-run filter counts `cancelled` before the tier rerun logic sees it; the
192
+ in-job rerun is what retries.
193
+
119
194
  ## Reaching an image product's control plane
120
195
 
121
196
  `product add --image` loopback-publishes the control plane on a
@@ -255,7 +330,9 @@ NON-PRODUCTION` warning is expected in CI, not an error. The media engine's
255
330
  (`conventions/integration.yml`) and drift-gated like `smoke.yml`, with the same
256
331
  two per-repo lines: the `product:` dashboard key and the matrix `runner:` label
257
332
  array. Everything else a repo used to hand-edit into the workflow now lives in
258
- three optional hooks the workflow calls if they are executable:
333
+ three optional hooks the workflow calls if they are executable (and one opt-in
334
+ file, `scripts/integration/contention.json`, described under "Contention
335
+ verdicts" below):
259
336
 
260
337
  | Hook | When | What goes there |
261
338
  | -------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
@@ -17,7 +17,7 @@
17
17
  # green, and ctl's (advisory) release gate, whose ctl-candidate dispatch runs it
18
18
  # and shows the result on the CI board.
19
19
  #
20
- # Per-repo variation lives in three optional hooks, not in this file:
20
+ # Per-repo variation lives in four optional hooks, not in this file:
21
21
  # scripts/integration/prepull-extra.sh extra images the suite launches (a
22
22
  # driver, a sink, a sidecar)
23
23
  # scripts/integration/prepare.sh built before the suite runs (a
@@ -26,6 +26,11 @@
26
26
  # scripts/integration/verify.sh after the suite; absent, a product
27
27
  # with tests/demo.spec.ts gets
28
28
  # `bun run demo -- check`
29
+ # scripts/integration/contention.json opts the suite into contention verdicts
30
+ # (thresholds; {} takes the defaults): a
31
+ # failure the starved HOST caused cancels
32
+ # the run instead of failing it. See
33
+ # contention-suite.sh and the steps below.
29
34
  #
30
35
  # The suite is piped into tee so its log survives as an artifact, and the step
31
36
  # sets pipefail FIRST: the default `bash -e {0}` has none, so without it the
@@ -102,6 +107,11 @@ jobs:
102
107
  include:
103
108
  - runner: '["x64"]'
104
109
  runs-on: ${{ fromJSON(matrix.runner) }}
110
+ # actions: write is for one call only: the last step cancelling this run
111
+ # when the host was too contended to judge it.
112
+ permissions:
113
+ contents: read
114
+ actions: write
105
115
  steps:
106
116
  # Runs BEFORE checkout, and does two things: points the harness's temp base
107
117
  # out of the workspace for every later step in this job, and clears whatever
@@ -192,6 +202,38 @@ jobs:
192
202
  echo "NORSK_RUNNER_OWNS_DOCKER=true" >> "$GITHUB_ENV"
193
203
  fi
194
204
 
205
+ # Contention verdicts (scripts/integration/contention.json) read host
206
+ # pressure from Linux PSI, and whether a runner can is a property of its
207
+ # kernel (CONFIG_PSI, the psi= boot flag) and of its container (cgroup v2,
208
+ # what is masked). This says which, on every run and whether or not the
209
+ # repo has opted in, so the log answers "does the signal exist on this
210
+ # host?" before anything depends on it. It only reports.
211
+ - name: Probe whether this runner can read host pressure (PSI)
212
+ run: |
213
+ set -uo pipefail
214
+ echo "host=$(hostname) kernel=$(uname -r) cpus=$(nproc) of $(grep -c ^processor /proc/cpuinfo 2>/dev/null || echo '?')"
215
+ echo "loadavg: $(cat /proc/loadavg 2>/dev/null || echo unreadable)"
216
+ grep -E '^(MemTotal|MemAvailable):' /proc/meminfo 2>/dev/null || true
217
+ readable=""
218
+ for f in /proc/pressure/cpu /proc/pressure/memory /proc/pressure/io /sys/fs/cgroup/cpu.pressure /sys/fs/cgroup/memory.pressure; do
219
+ if out="$(cat "$f" 2>&1)" && grep -q '^some avg10=' <<<"$out"; then
220
+ echo "$f: readable"
221
+ sed 's/^/ /' <<<"$out"
222
+ readable="$readable $f"
223
+ else
224
+ echo "$f: NOT readable (${out:-empty})"
225
+ fi
226
+ done
227
+ if [ -f /sys/fs/cgroup/cgroup.controllers ]; then echo "cgroup v2 at /sys/fs/cgroup; this process: $(cat /proc/self/cgroup 2>/dev/null)"; else echo "no cgroup v2 at /sys/fs/cgroup"; fi
228
+ case "$readable" in
229
+ *"/proc/pressure/cpu"*"/proc/pressure/memory"*) verdict="host-wide PSI readable (/proc/pressure)" ;;
230
+ *"/sys/fs/cgroup/cpu.pressure"*"/sys/fs/cgroup/memory.pressure"*) verdict="only this cgroup's PSI readable: blind to the engine containers, which are host siblings" ;;
231
+ *) verdict="PSI unavailable: contention verdicts would change nothing here" ;;
232
+ esac
233
+ if [ -f scripts/integration/contention.json ]; then optin="on (scripts/integration/contention.json)"; else optin="off (no scripts/integration/contention.json)"; fi
234
+ echo "PSI on $(hostname): $verdict; contention verdicts $optin"
235
+ echo "PSI on \`$(hostname)\`: $verdict; contention verdicts $optin" >> "$GITHUB_STEP_SUMMARY"
236
+
195
237
  # Integration needs this host's docker to be able to create a network: the
196
238
  # harness makes norsk-net and a network per instance. On 2026-10-02
197
239
  # ashton-nuc's filter table had lost DOCKER-ISOLATION-STAGE-2, so every
@@ -409,6 +451,9 @@ jobs:
409
451
  # and the first artifact built that way carried ten of another product's
410
452
  # instances alongside its own three. Same shape the test-temp base uses.
411
453
  NORSK_CI_LOG_DIR: ${{ runner.temp }}/norsk-logs-${{ github.run_id }}-${{ github.run_attempt }}
454
+ # Where contention-suite.sh puts each attempt's pressure timeline,
455
+ # verdict and summary, keyed like the log dir for the same reason.
456
+ NORSK_CONTENTION_DIR: ${{ runner.temp }}/contention-${{ github.run_id }}-${{ github.run_attempt }}
412
457
  run: |
413
458
  set -euo pipefail
414
459
  suite() {
@@ -424,7 +469,11 @@ jobs:
424
469
  echo "running scripts/integration/prepare.sh"
425
470
  ./scripts/integration/prepare.sh
426
471
  fi
427
- bun run test:integration
472
+ if [ -f scripts/integration/contention.json ]; then
473
+ bash node_modules/@norskvideo/ctl-dev-kit/conventions/contention-suite.sh
474
+ else
475
+ bun run test:integration
476
+ fi
428
477
  if [ -x scripts/integration/verify.sh ]; then
429
478
  echo "running scripts/integration/verify.sh"
430
479
  ./scripts/integration/verify.sh
@@ -460,7 +509,25 @@ jobs:
460
509
  else
461
510
  echo "no writable /srv/ci-scratch on this runner: the suite runs without the host lock"
462
511
  fi
512
+ set +e
463
513
  suite 9>&- 2>&1 | tee "$RUNNER_TEMP/integration.log"
514
+ rc=$?
515
+ set -e
516
+ # 75 is contention-suite.sh saying "uncertain": every failure
517
+ # overlapped host contention, or the suite was stopped because the
518
+ # host was starved. Believed only with the verdict file that says so,
519
+ # since a suite could exit 75 for its own reasons. Not a failure:
520
+ # the last step of this job cancels the run instead, so it reads
521
+ # cancelled (not red) on the board and bot-prs does not merge it.
522
+ # Every other status, a real failure on a quiet box included, exits
523
+ # exactly as it did.
524
+ verdict="$NORSK_CONTENTION_DIR/verdict.json"
525
+ if [ "$rc" -eq 75 ] && [ "$(jq -r .status "$verdict" 2>/dev/null)" = uncertain ]; then
526
+ echo "::warning title=integration uncertain, the host was contended::$(jq -r .reason "$verdict"). This run will be cancelled rather than failed; the pressure timeline and per-test classification are in the job summary and the integration artifact."
527
+ echo "NORSK_CONTENTION_UNCERTAIN=1" >> "$GITHUB_ENV"
528
+ exit 0
529
+ fi
530
+ exit "$rc"
464
531
 
465
532
  # The log AND the harness evidence. Playwright and the harness write
466
533
  # screenshots, traces and captured container logs under test-results/;
@@ -475,6 +542,7 @@ jobs:
475
542
  ${{ runner.temp }}/integration.log
476
543
  test-results/
477
544
  ${{ runner.temp }}/norsk-logs-${{ github.run_id }}-${{ github.run_attempt }}
545
+ ${{ runner.temp }}/contention-${{ github.run_id }}-${{ github.run_attempt }}
478
546
  retention-days: 7
479
547
  if-no-files-found: warn
480
548
 
@@ -514,6 +582,30 @@ jobs:
514
582
  docker rm -f $s
515
583
  fi
516
584
 
585
+ # Last, so the evidence is uploaded and the host reaped first. The suite
586
+ # step flagged this run uncertain: its failures were the starved host's,
587
+ # not the product's, so it is cancelled rather than failed. A cancelled
588
+ # run is not reported to the CI board (notify is !cancelled()), and
589
+ # bot-prs merges only a success, so a bump cannot land on it.
590
+ # Cancellation is asynchronous: the step holds the job until it lands,
591
+ # and if it is refused or never lands the job FAILS, because an uncertain
592
+ # run must never finish reading green. contention-wired.test.ts.
593
+ - name: Cancel this run, the host was too contended to judge it
594
+ if: ${{ always() && env.NORSK_CONTENTION_UNCERTAIN == '1' }}
595
+ env:
596
+ GH_TOKEN: ${{ github.token }}
597
+ run: |
598
+ set -uo pipefail
599
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
600
+ -H "Authorization: Bearer $GH_TOKEN" \
601
+ -H "Accept: application/vnd.github+json" \
602
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/cancel")"
603
+ [ "$code" = "202" ] || { echo "::error::could not cancel this uncertain run (HTTP $code), so it fails instead: read the contention summary before treating it as a product failure"; exit 1; }
604
+ echo "cancel accepted; waiting for it to land"
605
+ sleep 300
606
+ echo "::error::still running five minutes after the cancel was accepted, so it fails instead: read the contention summary before treating it as a product failure"
607
+ exit 1
608
+
517
609
  # Report this pipeline's result to the aggregated product CI dashboard
518
610
  # (id3as/ci-workflows). !cancelled() so a real pass/fail reports, but a
519
611
  # cancelled/superseded run does NOT — a cancellation dispatched as failure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.99",
3
+ "version": "0.2.100",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",