@norskvideo/ctl-dev-kit 0.2.99 → 0.2.101

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.
@@ -35,7 +35,15 @@
35
35
  # actually read;
36
36
  # - checks or a tier still running -> leave it for the next trigger; a tier
37
37
  # that has not started an hour after checks finished never will (its
38
- # workflow is broken, or does not run on PRs), so that is red too.
38
+ # workflow is broken, or does not run on PRs), so that is red too;
39
+ # - a tier integration.yml cancelled because the host was too contended to
40
+ # judge it (its job carries a `ctl-contention-uncertain` annotation) -> not
41
+ # yet judged, so neither red nor merged: re-run once a backoff has passed
42
+ # (vars.BOT_PRS_CONTENDED_BACKOFF_MINUTES, default 30, doubling per
43
+ # attempt), up to vars.BOT_PRS_CONTENDED_ATTEMPTS attempts (default 4);
44
+ # after that it is red as "still contended after N attempts: needs a human
45
+ # or a quieter runner", never as a failure. Any other cancelled check is
46
+ # red, and a cancelled tier is re-run up to its third attempt, as before.
39
47
  # The cell goes green again once nothing is red.
40
48
  #
41
49
  # Why the conflict rebuild: when the three bots fired within minutes of each
@@ -111,6 +119,14 @@ jobs:
111
119
  WRITE_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
112
120
  REPO: ${{ github.repository }}
113
121
  BASE: ${{ inputs.base || 'main' }}
122
+ # A run integration.yml cancelled because the host was too contended
123
+ # to judge it carries this annotation title on its job. Such a run is
124
+ # not yet judged: re-run with backoff, up to the attempt budget.
125
+ # Repo variables, so the verbatim copy needs no edit; unset or not a
126
+ # number takes the default (4 attempts, 30 minutes doubling).
127
+ CONTENDED_MARK: ctl-contention-uncertain
128
+ CONTENDED_ATTEMPTS: ${{ vars.BOT_PRS_CONTENDED_ATTEMPTS }}
129
+ CONTENDED_BACKOFF_MINUTES: ${{ vars.BOT_PRS_CONTENDED_BACKOFF_MINUTES }}
114
130
  run: |
115
131
  set -euo pipefail
116
132
  api="https://api.github.com/repos/$REPO"
@@ -181,9 +197,17 @@ jobs:
181
197
  # guides queue on one concurrency group per repo and GitHub keeps one
182
198
  # pending run per group, cancelling the older, so three bot PRs opening
183
199
  # within the hour can cancel a guides run with nothing wrong in it.
200
+ # CONTENDED (optional): the run ids contended_runs found. A cancelled
201
+ # tier among them was never judged: it is re-run once the backoff
202
+ # since its cancel has passed (CONTENDED_BACKOFF_MINUTES, doubling per
203
+ # attempt), up to CONTENDED_ATTEMPTS attempts, then reported as still
204
+ # contended. It never reads ok.
184
205
  tier_verdict() {
185
- local required="$1" runs="$2" now="$3" f run status conclusion checks_age
206
+ local required="$1" runs="$2" now="$3" contended="${4:-}" f run id status conclusion checks_age
207
+ local attempt max="${CONTENDED_ATTEMPTS:-}" base="${CONTENDED_BACKOFF_MINUTES:-}" backoff age
186
208
  local -a waiting=() failed=() rerun=()
209
+ [[ "$max" =~ ^[1-9][0-9]*$ ]] || max=4
210
+ [[ "$base" =~ ^[0-9]+$ ]] || base=30
187
211
  checks_age="$(jq -r --argjson now "$now" '
188
212
  [.workflow_runs[] | select((.path | split("@")[0]) == ".github/workflows/checks.yml"
189
213
  and .event == "pull_request" and .status == "completed")] | max_by(.id)
@@ -202,8 +226,22 @@ jobs:
202
226
  fi
203
227
  status="$(jq -r .status <<<"$run")"
204
228
  conclusion="$(jq -r '.conclusion // ""' <<<"$run")"
229
+ id="$(jq -r .id <<<"$run")"
230
+ attempt="$(jq -r '.run_attempt // 1' <<<"$run")"
205
231
  if [ "$status" != "completed" ]; then
206
232
  waiting+=("${f%.yml} $status")
233
+ elif [ "$conclusion" = "cancelled" ] && grep -qxF "$id" <<<"$contended"; then
234
+ if [ "$attempt" -ge "$max" ]; then
235
+ failed+=("${f%.yml} still contended after $attempt attempts: needs a human or a quieter runner")
236
+ continue
237
+ fi
238
+ backoff=$((base * 60 * (1 << (attempt - 1))))
239
+ age=$((now - $(jq -r '.updated_at | fromdateiso8601' <<<"$run")))
240
+ if [ "$age" -ge "$backoff" ]; then
241
+ rerun+=("$id")
242
+ else
243
+ waiting+=("${f%.yml} not yet judged, the host was contended (attempt $attempt of $max); re-running in $(((backoff - age + 59) / 60))m")
244
+ fi
207
245
  elif [ "$conclusion" = "cancelled" ] && [ "$(jq -r '.run_attempt // 1' <<<"$run")" -lt 3 ]; then
208
246
  rerun+=("$(jq -r .id <<<"$run")")
209
247
  elif [ "$conclusion" != "success" ]; then
@@ -221,6 +259,41 @@ jobs:
221
259
  fi
222
260
  }
223
261
 
262
+ # contended_runs CHECK_RUNS -> the workflow run ids (one per line) that
263
+ # integration.yml cancelled because the host was too contended to
264
+ # judge them: a CANCELLED job of the run carries the CONTENDED_MARK
265
+ # annotation. The marker on a job that failed (its cancel never
266
+ # landed) is not one: that run is red. Annotations it cannot read
267
+ # leave the run red, as every cancel was before.
268
+ contended_runs() {
269
+ local checks="$1" id notes
270
+ while IFS= read -r id; do
271
+ [ -n "$id" ] || continue
272
+ notes="$(gh_api "$api/check-runs/$id/annotations?per_page=100")"
273
+ if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"$notes"; then
274
+ echo "::warning::could not read the annotations of check run $id; a cancel there counts as red" >&2
275
+ continue
276
+ fi
277
+ jq -e --arg m "$CONTENDED_MARK" 'any(.[]; .title == $m)' >/dev/null <<<"$notes" || continue
278
+ jq -r --argjson id "$id" '.check_runs[] | select(.id == $id)
279
+ | (.details_url // "") | (capture("/actions/runs/(?<r>[0-9]+)").r // empty)' <<<"$checks"
280
+ done < <(jq -r '.check_runs[] | select(.conclusion == "cancelled" and (.output.annotations_count // 0) > 0) | .id' <<<"$checks") | sort -u
281
+ }
282
+
283
+ # failed_checks CHECK_RUNS CONTENDED -> the names of the checks that
284
+ # failed, comma separated. A cancelled job of a contended run (every
285
+ # one of them: GitHub cancels the run's other jobs with it) is left to
286
+ # tier_verdict, which re-runs it; any other cancel is red, as before.
287
+ failed_checks() {
288
+ jq -r --arg c "$2" '($c | split("\n") | map(select(. != ""))) as $contended
289
+ | [.check_runs[]
290
+ | select(.conclusion | IN("failure","cancelled","timed_out","action_required","startup_failure"))
291
+ | select(.conclusion != "cancelled"
292
+ or (((.details_url // "") | (capture("/actions/runs/(?<r>[0-9]+)").r // "")) as $r
293
+ | any($contended[]; . == $r) | not))
294
+ | .name] | unique | join(", ")' <<<"$1"
295
+ }
296
+
224
297
  # The workflow that rebuilds each bot branch: bump.yml, for the retired
225
298
  # bots' branches too (their workflows went when bump.yml arrived, and
226
299
  # bump.yml supersedes their PRs for every component it moves).
@@ -276,7 +349,8 @@ jobs:
276
349
  fi
277
350
  runs="$(gh_api "$api/commits/$sha/check-runs?per_page=100")"
278
351
  expect "reading checks for #$n" '.check_runs | type == "array"' "$runs"
279
- failed="$(jq -r '[.check_runs[] | select(.conclusion | IN("failure","cancelled","timed_out","action_required","startup_failure")) | .name] | unique | join(", ")' <<<"$runs")"
352
+ contended="$(contended_runs "$runs")"
353
+ failed="$(failed_checks "$runs" "$contended")"
280
354
  pending="$(jq -r '[.check_runs[] | select(.status != "completed")] | length' <<<"$runs")"
281
355
  passed="$(jq -r '[.check_runs[] | select(.conclusion == "success")] | length' <<<"$runs")"
282
356
  if [ -n "$failed" ]; then red+=("#$n $ref: failing $failed"); continue; fi
@@ -286,14 +360,18 @@ jobs:
286
360
  required="$(tiers_for "$sha")"
287
361
  wf_runs="$(gh_api "$api/actions/runs?head_sha=$sha&per_page=100")"
288
362
  expect "reading workflow runs for #$n" '.workflow_runs | type == "array"' "$wf_runs"
289
- verdict="$(tier_verdict "$required" "$wf_runs" "$(date +%s)")"
363
+ verdict="$(tier_verdict "$required" "$wf_runs" "$(date +%s)" "$contended")"
290
364
  case "$verdict" in
291
365
  ok) ;;
292
366
  red\ *) red+=("#$n $ref: ${verdict#red }"); continue ;;
293
367
  rerun\ *)
294
368
  for id in ${verdict#rerun }; do
295
369
  code="$(gh_actions -o /dev/null -w '%{http_code}' -X POST "$api/actions/runs/$id/rerun")"
296
- echo "#$n $ref: a gating tier (run $id) was cancelled; re-running it (HTTP $code)"
370
+ if grep -qxF "$id" <<<"$contended"; then
371
+ echo "#$n $ref: a gating tier (run $id) was cancelled as uncertain, the host was contended; re-running it (HTTP $code)"
372
+ else
373
+ echo "#$n $ref: a gating tier (run $id) was cancelled; re-running it (HTTP $code)"
374
+ fi
297
375
  done
298
376
  continue ;;
299
377
  *) echo "#$n $ref: ${verdict#wait }; next time"; continue ;;
@@ -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,89 @@ 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), plus a
144
+ `::notice title=ctl-contention-uncertain::` marker, then cancels its own run.
145
+ A cancelled run is not reported to the CI board, and bot-prs merges only a
146
+ success. One failure on a quiet stretch keeps the run red, as before.
147
+ - On a bot PR, bot-prs reads that marker off the cancelled job's annotations
148
+ and treats the run as **not yet judged**: it re-runs it once a backoff has
149
+ passed since the cancel (30 minutes, doubling per attempt), up to 4 attempts,
150
+ then reports it as "still contended after N attempts: needs a human or a
151
+ quieter runner", distinct from a failure. Repo variables
152
+ `BOT_PRS_CONTENDED_ATTEMPTS` and `BOT_PRS_CONTENDED_BACKOFF_MINUTES` override
153
+ the budget. A run cancelled for any other reason (a person, a concurrency
154
+ group) is treated exactly as before.
155
+ - If the host is starved early in the suite, or for a long stretch anywhere, the
156
+ suite is stopped there rather than run for 40 minutes to an uncertain end.
157
+ - An uncertain attempt is rerun in the same job (`rerun.max`, default 1), after
158
+ waiting up to `rerun.maxWaitSec` for the host to go quiet.
159
+
160
+ **When nothing changes.** No `contention.json`: the suite runs exactly as it did.
161
+ No readable PSI (an old kernel, `psi=0`, a runner that cannot read it): the
162
+ summary says "pressure unavailable" and every verdict is the suite's own. A
163
+ harness without `ctl-contention`, or a config it cannot read: a warning, then the
164
+ suite runs as before. Every job prints a "Probe whether this runner can read
165
+ host pressure (PSI)" step whether or not the repo has opted in, so a runner's
166
+ first log answers whether the signal exists there.
167
+
168
+ **Defaults** (`classify`, judged on the failure's window widened by `marginSec`):
169
+
170
+ | Key | Default | Why |
171
+ | -------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- |
172
+ | `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 |
173
+ | `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 |
174
+ | `classify.memSomeMean` | 0.10 | reclaim as a constant cost of running |
175
+ | `classify.memFullPeak` | 0.05 | half a second in any 10 in which nothing ran at all |
176
+ | `classify.minCoverage` | 0.5 | a window the sampler barely saw is not judged (and so stays red) |
177
+ | `classify.killWindowSec` | 120 | a suite killed from outside is judged on its last two minutes |
178
+ | `abort.cpuSome` | 0.70 | mean over the rule's window; with `memSome` 0.30 and `memFull` 0.10 |
179
+ | `abort.earlyWindowSec` | 60 | starved for a minute within the first `earlyWithinSec` (300): stop |
180
+ | `abort.sustainedWindowSec` | 300 | starved for five minutes anywhere: stop |
181
+ | `rerun.max` | 1 | reruns after an uncertain attempt; 0 turns them off |
182
+
183
+ Calling a real failure uncertain costs a rerun; calling a starved one red costs a
184
+ human an investigation of nothing. The bars are set where the first is unlikely,
185
+ and they are config so test2's recorded timelines can move them.
186
+
187
+ **From a test.** `hostContendedDuring({ fromMs, toMs })` from
188
+ `@norskvideo/ctl-test-harness/contention` answers for any window of the running
189
+ suite (it reads the timeline the wrapper names in `NORSK_CONTENTION_TIMELINE`),
190
+ with the numbers. Outside the wrapper it answers "unavailable", never
191
+ "contended".
192
+
193
+ **Exit statuses** of `ctl-contention run`: 0 pass; the suite's own status on a
194
+ real failure; 75 (EX_TEMPFAIL) when uncertain, which the workflow believes only
195
+ alongside a `verdict.json` that says so.
196
+
197
+ **Caveats.** A `ctl-candidate` dispatch that is cancelled is still scored red by
198
+ release-collect (the gate is advisory). bot-prs re-runs an uncertain cancel
199
+ only while the annotation is readable with the job token (`checks: read`); if
200
+ it cannot read it, the cancel counts as red, as every cancel did before.
201
+
119
202
  ## Reaching an image product's control plane
120
203
 
121
204
  `product add --image` loopback-publishes the control plane on a
@@ -255,7 +338,9 @@ NON-PRODUCTION` warning is expected in CI, not an error. The media engine's
255
338
  (`conventions/integration.yml`) and drift-gated like `smoke.yml`, with the same
256
339
  two per-repo lines: the `product:` dashboard key and the matrix `runner:` label
257
340
  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:
341
+ three optional hooks the workflow calls if they are executable (and one opt-in
342
+ file, `scripts/integration/contention.json`, described under "Contention
343
+ verdicts" below):
259
344
 
260
345
  | Hook | When | What goes there |
261
346
  | -------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
@@ -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,30 @@ 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 re-runs it later
522
+ # rather than merging it or reporting it red.
523
+ # Every other status, a real failure on a quiet box included, exits
524
+ # exactly as it did. The %2C is a comma: workflow-command properties
525
+ # are comma separated, so a bare one cut the title short.
526
+ verdict="$NORSK_CONTENTION_DIR/verdict.json"
527
+ if [ "$rc" -eq 75 ] && [ "$(jq -r .status "$verdict" 2>/dev/null)" = uncertain ]; then
528
+ echo "::warning title=integration uncertain%2C 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."
529
+ # The marker bot-prs reads off this job's annotations to tell this
530
+ # cancel from any other (bot-prs-contended.test.ts).
531
+ echo "::notice title=ctl-contention-uncertain::cancelled as uncertain, not failed: bot-prs re-runs it once the host may be quieter"
532
+ echo "NORSK_CONTENTION_UNCERTAIN=1" >> "$GITHUB_ENV"
533
+ exit 0
534
+ fi
535
+ exit "$rc"
464
536
 
465
537
  # The log AND the harness evidence. Playwright and the harness write
466
538
  # screenshots, traces and captured container logs under test-results/;
@@ -475,6 +547,7 @@ jobs:
475
547
  ${{ runner.temp }}/integration.log
476
548
  test-results/
477
549
  ${{ runner.temp }}/norsk-logs-${{ github.run_id }}-${{ github.run_attempt }}
550
+ ${{ runner.temp }}/contention-${{ github.run_id }}-${{ github.run_attempt }}
478
551
  retention-days: 7
479
552
  if-no-files-found: warn
480
553
 
@@ -514,6 +587,31 @@ jobs:
514
587
  docker rm -f $s
515
588
  fi
516
589
 
590
+ # Last, so the evidence is uploaded and the host reaped first. The suite
591
+ # step flagged this run uncertain: its failures were the starved host's,
592
+ # not the product's, so it is cancelled rather than failed. A cancelled
593
+ # run is not reported to the CI board (notify is !cancelled()), and
594
+ # bot-prs merges only a success, so a bump cannot land on it (it re-runs
595
+ # a run whose job carries the suite step's ctl-contention-uncertain notice).
596
+ # Cancellation is asynchronous: the step holds the job until it lands,
597
+ # and if it is refused or never lands the job FAILS, because an uncertain
598
+ # run must never finish reading green. contention-wired.test.ts.
599
+ - name: Cancel this run, the host was too contended to judge it
600
+ if: ${{ always() && env.NORSK_CONTENTION_UNCERTAIN == '1' }}
601
+ env:
602
+ GH_TOKEN: ${{ github.token }}
603
+ run: |
604
+ set -uo pipefail
605
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
606
+ -H "Authorization: Bearer $GH_TOKEN" \
607
+ -H "Accept: application/vnd.github+json" \
608
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/cancel")"
609
+ [ "$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; }
610
+ echo "cancel accepted; waiting for it to land"
611
+ sleep 300
612
+ 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"
613
+ exit 1
614
+
517
615
  # Report this pipeline's result to the aggregated product CI dashboard
518
616
  # (id3as/ci-workflows). !cancelled() so a real pass/fail reports, but a
519
617
  # 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.101",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",