@norskvideo/ctl-dev-kit 0.2.21 → 0.2.23

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.
@@ -244,3 +244,21 @@ jobs:
244
244
  "
245
245
  docker image rm -f "$IMAGE_TAG" >/dev/null
246
246
  '
247
+ - name: Reap the containers this job launched
248
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
249
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
250
+ # conventions/workflow-shell.ts); everything this job launched mounts its
251
+ # own test-temp, so that is what goes.
252
+ if: always()
253
+ run: |
254
+ base="${NORSK_CTL_TEST_TMP:-}"
255
+ tt="$GITHUB_WORKSPACE/test-temp"
256
+ s=""
257
+ for c in $(docker ps -aq); do
258
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
259
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
260
+ done
261
+ if [ -n "$s" ]; then
262
+ echo "::warning::removing containers this job left running:$s"
263
+ docker rm -f $s
264
+ fi
@@ -134,6 +134,24 @@ jobs:
134
134
  echo "channel=${channel}" >> "$GITHUB_OUTPUT"
135
135
  echo "publish=${publish}" >> "$GITHUB_OUTPUT"
136
136
  echo "label=${label} channel=${channel} publish=${publish}"
137
+ - name: Reap the containers this job launched
138
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
139
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
140
+ # conventions/workflow-shell.ts); everything this job launched mounts its
141
+ # own test-temp, so that is what goes.
142
+ if: always()
143
+ run: |
144
+ base="${NORSK_CTL_TEST_TMP:-}"
145
+ tt="$GITHUB_WORKSPACE/test-temp"
146
+ s=""
147
+ for c in $(docker ps -aq); do
148
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
149
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
150
+ done
151
+ if [ -n "$s" ]; then
152
+ echo "::warning::removing containers this job left running:$s"
153
+ docker rm -f $s
154
+ fi
137
155
 
138
156
  build:
139
157
  needs: label
@@ -232,6 +250,24 @@ jobs:
232
250
  - name: Push the per-arch image to Docker Hub
233
251
  if: ${{ env.PUBLISH_IMAGE != '' }}
234
252
  run: docker push "$IMAGE_TAG"
253
+ - name: Reap the containers this job launched
254
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
255
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
256
+ # conventions/workflow-shell.ts); everything this job launched mounts its
257
+ # own test-temp, so that is what goes.
258
+ if: always()
259
+ run: |
260
+ base="${NORSK_CTL_TEST_TMP:-}"
261
+ tt="$GITHUB_WORKSPACE/test-temp"
262
+ s=""
263
+ for c in $(docker ps -aq); do
264
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
265
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
266
+ done
267
+ if [ -n "$s" ]; then
268
+ echo "::warning::removing containers this job left running:$s"
269
+ docker rm -f $s
270
+ fi
235
271
 
236
272
  # Combine the per-arch images into a multi-arch manifest and write the two
237
273
  # published tags. Skipped for a build-only product (empty PUBLISH_IMAGE) and on
@@ -320,6 +356,24 @@ jobs:
320
356
  -t "${PUBLISH_IMAGE}:${channel}" \
321
357
  "${PUBLISH_IMAGE}:${label}-amd64" \
322
358
  "${PUBLISH_IMAGE}:${label}-arm64"
359
+ - name: Reap the containers this job launched
360
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
361
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
362
+ # conventions/workflow-shell.ts); everything this job launched mounts its
363
+ # own test-temp, so that is what goes.
364
+ if: always()
365
+ run: |
366
+ base="${NORSK_CTL_TEST_TMP:-}"
367
+ tt="$GITHUB_WORKSPACE/test-temp"
368
+ s=""
369
+ for c in $(docker ps -aq); do
370
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
371
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
372
+ done
373
+ if [ -n "$s" ]; then
374
+ echo "::warning::removing containers this job left running:$s"
375
+ docker rm -f $s
376
+ fi
323
377
 
324
378
  # Report this pipeline's result to the aggregated product CI dashboard
325
379
  # (id3as/ci-workflows) instead of posting its own pony -- the dashboard renders
@@ -402,3 +456,21 @@ jobs:
402
456
  product: __PRODUCT__
403
457
  pipeline: build-image
404
458
  status: ${{ steps.meta.outputs.state }}
459
+ - name: Reap the containers this job launched
460
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
461
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
462
+ # conventions/workflow-shell.ts); everything this job launched mounts its
463
+ # own test-temp, so that is what goes.
464
+ if: always()
465
+ run: |
466
+ base="${NORSK_CTL_TEST_TMP:-}"
467
+ tt="$GITHUB_WORKSPACE/test-temp"
468
+ s=""
469
+ for c in $(docker ps -aq); do
470
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
471
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
472
+ done
473
+ if [ -n "$s" ]; then
474
+ echo "::warning::removing containers this job left running:$s"
475
+ docker rm -f $s
476
+ fi
@@ -106,6 +106,24 @@ jobs:
106
106
  bun install --frozen-lockfile
107
107
  bun run check:drift
108
108
  '
109
+ - name: Reap the containers this job launched
110
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
111
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
112
+ # conventions/workflow-shell.ts); everything this job launched mounts its
113
+ # own test-temp, so that is what goes.
114
+ if: always()
115
+ run: |
116
+ base="${NORSK_CTL_TEST_TMP:-}"
117
+ tt="$GITHUB_WORKSPACE/test-temp"
118
+ s=""
119
+ for c in $(docker ps -aq); do
120
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
121
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
122
+ done
123
+ if [ -n "$s" ]; then
124
+ echo "::warning::removing containers this job left running:$s"
125
+ docker rm -f $s
126
+ fi
109
127
 
110
128
  quality:
111
129
  runs-on: x64
@@ -190,6 +208,24 @@ jobs:
190
208
  # in here and breaks a schema handed across the instance boundary.
191
209
  bun node_modules/@norskvideo/ctl-dev-kit/conventions/zod-singleton.ts
192
210
  '
211
+ - name: Reap the containers this job launched
212
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
213
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
214
+ # conventions/workflow-shell.ts); everything this job launched mounts its
215
+ # own test-temp, so that is what goes.
216
+ if: always()
217
+ run: |
218
+ base="${NORSK_CTL_TEST_TMP:-}"
219
+ tt="$GITHUB_WORKSPACE/test-temp"
220
+ s=""
221
+ for c in $(docker ps -aq); do
222
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
223
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
224
+ done
225
+ if [ -n "$s" ]; then
226
+ echo "::warning::removing containers this job left running:$s"
227
+ docker rm -f $s
228
+ fi
193
229
 
194
230
  # Report this pipeline's result to the aggregated product CI dashboard
195
231
  # (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
@@ -274,3 +310,21 @@ jobs:
274
310
  product: __PRODUCT__
275
311
  pipeline: checks
276
312
  status: ${{ steps.meta.outputs.state }}
313
+ - name: Reap the containers this job launched
314
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
315
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
316
+ # conventions/workflow-shell.ts); everything this job launched mounts its
317
+ # own test-temp, so that is what goes.
318
+ if: always()
319
+ run: |
320
+ base="${NORSK_CTL_TEST_TMP:-}"
321
+ tt="$GITHUB_WORKSPACE/test-temp"
322
+ s=""
323
+ for c in $(docker ps -aq); do
324
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
325
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
326
+ done
327
+ if [ -n "$s" ]; then
328
+ echo "::warning::removing containers this job left running:$s"
329
+ docker rm -f $s
330
+ fi
@@ -102,6 +102,24 @@ jobs:
102
102
  bun install --frozen-lockfile
103
103
  bun run check:drift
104
104
  '
105
+ - name: Reap the containers this job launched
106
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
107
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
108
+ # conventions/workflow-shell.ts); everything this job launched mounts its
109
+ # own test-temp, so that is what goes.
110
+ if: always()
111
+ run: |
112
+ base="${NORSK_CTL_TEST_TMP:-}"
113
+ tt="$GITHUB_WORKSPACE/test-temp"
114
+ s=""
115
+ for c in $(docker ps -aq); do
116
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
117
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
118
+ done
119
+ if [ -n "$s" ]; then
120
+ echo "::warning::removing containers this job left running:$s"
121
+ docker rm -f $s
122
+ fi
105
123
 
106
124
  quality:
107
125
  runs-on: x64
@@ -229,6 +247,24 @@ jobs:
229
247
  nix develop .#build --command bun \
230
248
  node_modules/@norskvideo/ctl-dev-kit/licence/check-product-name.ts \
231
249
  shared/src/product-template.ts "$product"
250
+ - name: Reap the containers this job launched
251
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
252
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
253
+ # conventions/workflow-shell.ts); everything this job launched mounts its
254
+ # own test-temp, so that is what goes.
255
+ if: always()
256
+ run: |
257
+ base="${NORSK_CTL_TEST_TMP:-}"
258
+ tt="$GITHUB_WORKSPACE/test-temp"
259
+ s=""
260
+ for c in $(docker ps -aq); do
261
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
262
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
263
+ done
264
+ if [ -n "$s" ]; then
265
+ echo "::warning::removing containers this job left running:$s"
266
+ docker rm -f $s
267
+ fi
232
268
 
233
269
  # Report this pipeline's result to the aggregated product CI dashboard
234
270
  # (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
@@ -313,3 +349,21 @@ jobs:
313
349
  product: __PRODUCT__
314
350
  pipeline: checks
315
351
  status: ${{ steps.meta.outputs.state }}
352
+ - name: Reap the containers this job launched
353
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
354
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
355
+ # conventions/workflow-shell.ts); everything this job launched mounts its
356
+ # own test-temp, so that is what goes.
357
+ if: always()
358
+ run: |
359
+ base="${NORSK_CTL_TEST_TMP:-}"
360
+ tt="$GITHUB_WORKSPACE/test-temp"
361
+ s=""
362
+ for c in $(docker ps -aq); do
363
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
364
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
365
+ done
366
+ if [ -n "$s" ]; then
367
+ echo "::warning::removing containers this job left running:$s"
368
+ docker rm -f $s
369
+ fi
@@ -76,9 +76,25 @@ On 2026-10-02 a reuters tier and a commentary tier each `compose up`ed
76
76
  older text here and in the reap steps that names a fixed 14321 is stale. Two
77
77
  concurrent runs of the SAME product that both `product add --image` still
78
78
  collide on the name.
79
- - Still banded, and still able to collide: the daemon's nginx proxy host port
80
- (`daemon+3`), which is published on the host. Docker-assigned proxy ports are
81
- in progress.
79
+ - The daemon's port and its nginx proxy's host port are assigned by the system
80
+ when the ctl under test advertises the `ephemeral-ports` daemon capability
81
+ (`norsk-ctl capabilities`, or `daemonCapabilities` in `/api/health`). The
82
+ harness then starts the daemon with `NORSK_CTL_PORT=0` and `init --proxy-port
83
+ 0`: the kernel picks the daemon's port, docker picks the proxy's host port
84
+ (nginx listens on 443 inside the container), and the daemon publishes both as
85
+ `<store>/daemon.port` and `<store>/proxy.port` (also `/api/proxy/status`).
86
+ Neither can collide. `runProductSmoke`, `runDemo`, `demo check --export-only`
87
+ and the dev-loop do this on their own. A `ProductHarness` you construct does
88
+ it only with `ephemeralPorts: true`, because your harness most likely keeps
89
+ its own `demoPorts(slug)` copy: switch every daemon/proxy port you read to
90
+ `harness.ports` (or `session.proxyBase`) first, then turn it on.
91
+ - Against a ctl without the capability (the released `latest` until it catches
92
+ up) both stay on the slug's band (`daemon`, `daemon+3`), exactly as before,
93
+ and can still collide.
94
+ - Still banded either way: the studio host port a launch publishes
95
+ (`daemon+2`, and `daemon+3+n` for further launches), and the dev backend's
96
+ port (bound inside the runner container, so not shared). In `direct` reach
97
+ mode with `--internal-only` the studio port is not published at all.
82
98
 
83
99
  ## Reaching an image product's control plane
84
100
 
@@ -387,6 +387,24 @@ jobs:
387
387
  fi
388
388
  done
389
389
  rm -f "$m" || true
390
+ - name: Reap the containers this job launched
391
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
392
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
393
+ # conventions/workflow-shell.ts); everything this job launched mounts its
394
+ # own test-temp, so that is what goes.
395
+ if: always()
396
+ run: |
397
+ base="${NORSK_CTL_TEST_TMP:-}"
398
+ tt="$GITHUB_WORKSPACE/test-temp"
399
+ s=""
400
+ for c in $(docker ps -aq); do
401
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
402
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
403
+ done
404
+ if [ -n "$s" ]; then
405
+ echo "::warning::removing containers this job left running:$s"
406
+ docker rm -f $s
407
+ fi
390
408
 
391
409
  # Report this pipeline's result to the aggregated product CI dashboard
392
410
  # (id3as/ci-workflows). !cancelled() so a real pass/fail reports, but a
@@ -469,6 +487,24 @@ jobs:
469
487
  product: __PRODUCT__
470
488
  pipeline: integration
471
489
  status: ${{ steps.meta.outputs.state }}
490
+ - name: Reap the containers this job launched
491
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
492
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
493
+ # conventions/workflow-shell.ts); everything this job launched mounts its
494
+ # own test-temp, so that is what goes.
495
+ if: always()
496
+ run: |
497
+ base="${NORSK_CTL_TEST_TMP:-}"
498
+ tt="$GITHUB_WORKSPACE/test-temp"
499
+ s=""
500
+ for c in $(docker ps -aq); do
501
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
502
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
503
+ done
504
+ if [ -n "$s" ]; then
505
+ echo "::warning::removing containers this job left running:$s"
506
+ docker rm -f $s
507
+ fi
472
508
 
473
509
  # Report the rc-candidate validation result to the norsk-ctl release gate. Only
474
510
  # on a ctl-candidate dispatch; release-collect tallies these and promotes
@@ -319,3 +319,21 @@ jobs:
319
319
  "https://api.github.com/repos/id3as/norsk-ctl/dispatches" -d "$body")"
320
320
  [ "$code" = "204" ] || { echo "::error::docs-published dispatch to norsk-ctl failed (HTTP $code)"; exit 1; }
321
321
  echo "dispatched product-docs-published -> id3as/norsk-ctl"
322
+ - name: Reap the containers this job launched
323
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
324
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
325
+ # conventions/workflow-shell.ts); everything this job launched mounts its
326
+ # own test-temp, so that is what goes.
327
+ if: always()
328
+ run: |
329
+ base="${NORSK_CTL_TEST_TMP:-}"
330
+ tt="$GITHUB_WORKSPACE/test-temp"
331
+ s=""
332
+ for c in $(docker ps -aq); do
333
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
334
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
335
+ done
336
+ if [ -n "$s" ]; then
337
+ echo "::warning::removing containers this job left running:$s"
338
+ docker rm -f $s
339
+ fi
@@ -326,6 +326,24 @@ jobs:
326
326
  path: ${{ runner.temp }}/norsk-logs-${{ github.run_id }}-${{ github.run_attempt }}
327
327
  retention-days: 7
328
328
  if-no-files-found: ignore
329
+ - name: Reap the containers this job launched
330
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
331
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
332
+ # conventions/workflow-shell.ts); everything this job launched mounts its
333
+ # own test-temp, so that is what goes.
334
+ if: always()
335
+ run: |
336
+ base="${NORSK_CTL_TEST_TMP:-}"
337
+ tt="$GITHUB_WORKSPACE/test-temp"
338
+ s=""
339
+ for c in $(docker ps -aq); do
340
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
341
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
342
+ done
343
+ if [ -n "$s" ]; then
344
+ echo "::warning::removing containers this job left running:$s"
345
+ docker rm -f $s
346
+ fi
329
347
 
330
348
  # Report this pipeline's result to the aggregated product CI dashboard
331
349
  # (id3as/ci-workflows). !cancelled() so a real pass/fail reports, but a
@@ -408,6 +426,24 @@ jobs:
408
426
  product: __PRODUCT__
409
427
  pipeline: smoke
410
428
  status: ${{ steps.meta.outputs.state }}
429
+ - name: Reap the containers this job launched
430
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
431
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
432
+ # conventions/workflow-shell.ts); everything this job launched mounts its
433
+ # own test-temp, so that is what goes.
434
+ if: always()
435
+ run: |
436
+ base="${NORSK_CTL_TEST_TMP:-}"
437
+ tt="$GITHUB_WORKSPACE/test-temp"
438
+ s=""
439
+ for c in $(docker ps -aq); do
440
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
441
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
442
+ done
443
+ if [ -n "$s" ]; then
444
+ echo "::warning::removing containers this job left running:$s"
445
+ docker rm -f $s
446
+ fi
411
447
 
412
448
  # Report the rc-candidate validation result to the norsk-ctl release gate. Only
413
449
  # on a ctl-candidate dispatch; release-collect tallies these and promotes
@@ -160,10 +160,13 @@ jobs:
160
160
  { echo "moved<<PINS"; printf '%s' "$moved"; echo "PINS"; } >> "$GITHUB_OUTPUT"
161
161
  fi
162
162
 
163
- - name: Reinstall and run the product gate
164
- id: gate
163
+ - name: Refresh the lockfiles against the new pins
165
164
  if: steps.bump.outputs.changed == '1'
166
- continue-on-error: true
165
+ # NOT continue-on-error, unlike the gate below. A refresh that dies
166
+ # part-way (the runner OOM-killed it on 2026-10-03) leaves bumped
167
+ # manifests beside the old lockfiles; carrying on then committed exactly
168
+ # that, and every frozen install on the PR failed. Fail here instead,
169
+ # before anything is committed.
167
170
  run: |
168
171
  nix develop .#build --command bash -c '
169
172
  set -euo pipefail
@@ -198,6 +201,15 @@ jobs:
198
201
  (cd "$(dirname "$lock")" && bun update "@norskvideo/$pkg") || true
199
202
  done
200
203
  done
204
+ '
205
+
206
+ - name: Run the product gate
207
+ id: gate
208
+ if: steps.bump.outputs.changed == '1'
209
+ continue-on-error: true
210
+ run: |
211
+ nix develop .#build --command bash -c '
212
+ set -euo pipefail
201
213
  bun run check:drift
202
214
  bun run lint
203
215
  bun run typecheck
@@ -276,3 +288,21 @@ jobs:
276
288
  curl "${curl_api[@]}" -X POST "$api" \
277
289
  --data "$(jq -nc --arg t "$title" --arg b "$body" --arg h "$branch" --argjson d "$draft" '{title:$t, body:$b, head:$h, base:"main", draft:$d}')" >/dev/null
278
290
  fi
291
+ - name: Reap the containers this job launched
292
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
293
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
294
+ # conventions/workflow-shell.ts); everything this job launched mounts its
295
+ # own test-temp, so that is what goes.
296
+ if: always()
297
+ run: |
298
+ base="${NORSK_CTL_TEST_TMP:-}"
299
+ tt="$GITHUB_WORKSPACE/test-temp"
300
+ s=""
301
+ for c in $(docker ps -aq); do
302
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
303
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
304
+ done
305
+ if [ -n "$s" ]; then
306
+ echo "::warning::removing containers this job left running:$s"
307
+ docker rm -f $s
308
+ fi
@@ -139,14 +139,25 @@ jobs:
139
139
  echo "old=$old_bare" >> "$GITHUB_OUTPUT"
140
140
  echo "new=$new" >> "$GITHUB_OUTPUT"
141
141
 
142
- - name: Reinstall, re-sync the copies from canonical, run the product gate
142
+ - name: Reinstall against the new pin
143
+ if: steps.sync.outputs.changed == '1'
144
+ # NOT continue-on-error, unlike the gate below. An install that dies
145
+ # part-way (the runner OOM-killed sync-ctl-packages' one on 2026-10-03)
146
+ # would otherwise read as a red gate and open a reconciliation PR around
147
+ # a stale lockfile. Fail here instead, before anything is committed.
148
+ run: |
149
+ nix develop .#build --command bash -c '
150
+ set -euo pipefail
151
+ bun install
152
+ '
153
+
154
+ - name: Re-sync the copies from canonical and run the product gate
143
155
  id: gate
144
156
  if: steps.sync.outputs.changed == '1'
145
157
  continue-on-error: true
146
158
  run: |
147
159
  nix develop .#build --command bash -c '
148
160
  set -euo pipefail
149
- bun install
150
161
  bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts
151
162
  bun run check:drift
152
163
  bun run lint
@@ -228,6 +239,24 @@ jobs:
228
239
  - name: Report "already fresh"
229
240
  if: steps.sync.outputs.changed != '1'
230
241
  run: echo "dev-kit pin already current -- nothing to sync."
242
+ - name: Reap the containers this job launched
243
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
244
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
245
+ # conventions/workflow-shell.ts); everything this job launched mounts its
246
+ # own test-temp, so that is what goes.
247
+ if: always()
248
+ run: |
249
+ base="${NORSK_CTL_TEST_TMP:-}"
250
+ tt="$GITHUB_WORKSPACE/test-temp"
251
+ s=""
252
+ for c in $(docker ps -aq); do
253
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
254
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
255
+ done
256
+ if [ -n "$s" ]; then
257
+ echo "::warning::removing containers this job left running:$s"
258
+ docker rm -f $s
259
+ fi
231
260
 
232
261
  # Report the sync result to the aggregated product CI dashboard
233
262
  # (id3as/ci-workflows). !cancelled() so a failed sync shows a sad pony but a
@@ -311,3 +340,21 @@ jobs:
311
340
  product: __PRODUCT__
312
341
  pipeline: dev-kit
313
342
  status: ${{ steps.meta.outputs.state }}
343
+ - name: Reap the containers this job launched
344
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
345
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
346
+ # conventions/workflow-shell.ts); everything this job launched mounts its
347
+ # own test-temp, so that is what goes.
348
+ if: always()
349
+ run: |
350
+ base="${NORSK_CTL_TEST_TMP:-}"
351
+ tt="$GITHUB_WORKSPACE/test-temp"
352
+ s=""
353
+ for c in $(docker ps -aq); do
354
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
355
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
356
+ done
357
+ if [ -n "$s" ]; then
358
+ echo "::warning::removing containers this job left running:$s"
359
+ docker rm -f $s
360
+ fi
@@ -375,6 +375,24 @@ jobs:
375
375
  - name: Report "already fresh"
376
376
  if: steps.bump.outputs.changed != '1'
377
377
  run: echo "Nightly pins already current — nothing to bump."
378
+ - name: Reap the containers this job launched
379
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
380
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
381
+ # conventions/workflow-shell.ts); everything this job launched mounts its
382
+ # own test-temp, so that is what goes.
383
+ if: always()
384
+ run: |
385
+ base="${NORSK_CTL_TEST_TMP:-}"
386
+ tt="$GITHUB_WORKSPACE/test-temp"
387
+ s=""
388
+ for c in $(docker ps -aq); do
389
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
390
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
391
+ done
392
+ if [ -n "$s" ]; then
393
+ echo "::warning::removing containers this job left running:$s"
394
+ docker rm -f $s
395
+ fi
378
396
 
379
397
  # Report the nightly bump result to the aggregated product CI dashboard
380
398
  # (id3as/ci-workflows). !cancelled() so a failed bump shows a sad pony (a
@@ -458,3 +476,21 @@ jobs:
458
476
  pipeline: nightly
459
477
  status: ${{ steps.meta.outputs.state }}
460
478
  detail: ${{ needs.bump.outputs.summary }}
479
+ - name: Reap the containers this job launched
480
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
481
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
482
+ # conventions/workflow-shell.ts); everything this job launched mounts its
483
+ # own test-temp, so that is what goes.
484
+ if: always()
485
+ run: |
486
+ base="${NORSK_CTL_TEST_TMP:-}"
487
+ tt="$GITHUB_WORKSPACE/test-temp"
488
+ s=""
489
+ for c in $(docker ps -aq); do
490
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
491
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
492
+ done
493
+ if [ -n "$s" ]; then
494
+ echo "::warning::removing containers this job left running:$s"
495
+ docker rm -f $s
496
+ fi
@@ -171,6 +171,100 @@ export function jobsMissingTestTempStep(yaml: string): string[] {
171
171
  return bad;
172
172
  }
173
173
 
174
+ /** The step every job that routes test-temp ends with, named so the guards
175
+ * below can find it. */
176
+ export const JOB_REAP_STEP = "Reap the containers this job launched";
177
+
178
+ // Why a job needs a reap of its own at the END, when the pre-checkout step
179
+ // already reaps. The pre-checkout reap only protects the NEXT job's checkout,
180
+ // and only from containers mounting the legacy in-workspace test-temp. A
181
+ // harness teardown that never finishes leaves the instance running on the
182
+ // shared Docker daemon until something else removes it: bun times a test out,
183
+ // kills the daemon with the test's other dangling processes, and the afterAll
184
+ // that should delete the instance has nothing left to ask, then times out
185
+ // itself. commentary's publish-docs left a composition guide's engine running
186
+ // that way for 2.6 hours on 2026-10-03, where it grew to 28 GB and the host
187
+ // OOM-killed the next morning's product CI. The job knows exactly what it
188
+ // launched -- everything mounting its own $NORSK_CTL_TEST_TMP -- so the job
189
+ // removes it, whatever happened before.
190
+ const JOB_REAP_REQUIREMENTS: { name: string; ok: (body: string) => boolean }[] = [
191
+ { name: "scope", ok: (b) => /\$\{?NORSK_CTL_TEST_TMP/.test(b) && /\.Mounts\b/.test(b) },
192
+ { name: "remove", ok: (b) => /docker rm -f/.test(b) },
193
+ ];
194
+
195
+ /** {@link JOB_REAP_STEP}s that would not reap, each reported at its `- name:`
196
+ * line with what it is missing: the `always()` guard (without it a failed test
197
+ * skips the step, which is exactly when it is needed) or a requirement above. */
198
+ export function jobReapStepProblems(yaml: string): { line: number; missing: string[] }[] {
199
+ const lines = yaml.split("\n");
200
+ const out: { line: number; missing: string[] }[] = [];
201
+ lines.forEach((text, i) => {
202
+ if (!text.includes(`- name: ${JOB_REAP_STEP}`)) return;
203
+ const missing: string[] = [];
204
+ const runAt = lines.findIndex((l, j) => j > i && /^\s*run: \|\s*$/.test(l));
205
+ const header = lines.slice(i + 1, runAt < 0 ? i + 4 : runAt);
206
+ if (!header.some((l) => /^\s*if: always\(\)\s*$/.test(l))) missing.push("always");
207
+ const body = stepBody(lines, i);
208
+ missing.push(...JOB_REAP_REQUIREMENTS.filter((r) => !r.ok(body)).map((r) => r.name));
209
+ if (missing.length > 0) out.push({ line: i + 1, missing });
210
+ });
211
+ return out;
212
+ }
213
+
214
+ /** Jobs that route test-temp (so may launch instances) but have no
215
+ * {@link JOB_REAP_STEP}. The presence half, per job for the same reason as
216
+ * {@link jobsMissingTestTempStep}. */
217
+ export function jobsMissingReapStep(yaml: string): string[] {
218
+ const lines = yaml.split("\n");
219
+ const bad: string[] = [];
220
+ let job: string | undefined;
221
+ let routes = 0;
222
+ let reaps = 0;
223
+ const settle = () => {
224
+ if (job !== undefined && routes > 0 && reaps === 0) bad.push(job);
225
+ };
226
+ for (const line of lines) {
227
+ const head = line.match(/^ {2}([A-Za-z_][\w-]*):\s*$/);
228
+ if (head) {
229
+ settle();
230
+ job = head[1];
231
+ routes = 0;
232
+ reaps = 0;
233
+ continue;
234
+ }
235
+ if (line.includes(`- name: ${TEST_TEMP_STEP}`)) routes++;
236
+ if (line.includes(`- name: ${JOB_REAP_STEP}`)) reaps++;
237
+ }
238
+ settle();
239
+ return bad;
240
+ }
241
+
242
+ /** Steps that rewrite a lockfile -- an unfrozen `bun install`, or a
243
+ * `bun update` -- yet are `continue-on-error`, by their `- name:` line.
244
+ *
245
+ * A gate may be allowed to fail: a red gate is information, and the sync
246
+ * workflows turn it into a draft or reconciliation PR. A lockfile refresh may
247
+ * not. One that dies part-way leaves bumped manifests beside the old
248
+ * lockfiles, and a later step commits that pair: on 2026-10-03 the runner
249
+ * OOM-killed sync-ctl-packages mid-install and its PR then failed every
250
+ * frozen install. Keep the refresh in a step of its own that must succeed. */
251
+ export function lockfileRefreshCanFail(yaml: string): number[] {
252
+ const lines = yaml.split("\n");
253
+ const starts = lines.flatMap((l, i) => (/^\s{6}- (name|uses|run)\b/.test(l) ? [i] : []));
254
+ const out: number[] = [];
255
+ starts.forEach((a, k) => {
256
+ const block = lines.slice(a, starts[k + 1] ?? lines.length);
257
+ const tolerated = block.some((l) => /^\s+continue-on-error:\s*true\b/.test(l));
258
+ const rewrites = block.some((l) => {
259
+ const t = l.trim();
260
+ if (t.startsWith("#") || t.startsWith('"')) return false;
261
+ return /\bbun (install|update)\b/.test(t) && !/--frozen-lockfile/.test(t);
262
+ });
263
+ if (tolerated && rewrites) out.push(a + 1);
264
+ });
265
+ return out;
266
+ }
267
+
174
268
  /** A `ctl-candidate` validation that another candidate, or its own retry, can
175
269
  * cancel.
176
270
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",