@norskvideo/ctl-dev-kit 0.2.64 → 0.2.66

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,808 @@
1
+ # The sdk-app variant of bump.yml: the one nightly bump for a bespoke-SDK
2
+ # product (no Studio -- norsk-relay). Kept in lockstep with bump.yml outside
3
+ # the pins step (bump-pins.sdk-app.test.ts); check-drift holds an sdk-app
4
+ # repo's copy to THIS file. See bump.yml for the whole story: one run, one
5
+ # lockfile refresh, one PR on bump/nightly that bot-prs merges once the PR's
6
+ # tiers are green, and the `components` input as the escape hatch.
7
+ #
8
+ # Its pins step differs: an sdk-app has no Studio, so no {engine, studio} pair
9
+ # and no manifest.seed.json. Its norsk family (norsk-sdk, norsk-api and the
10
+ # norskvideo/norsk engine image) is one version string, moved to the SDK's npm
11
+ # nightly once the API and the engine image are confirmed to exist at it.
12
+ #
13
+ # Single-sourced in @norskvideo/ctl-dev-kit (conventions/bump.sdk-app.yml) and
14
+ # copied verbatim into each sdk-app product repo as .github/workflows/bump.yml;
15
+ # the check:drift gate fails CI if a copy diverges. Only the `product:` values
16
+ # below are per-repo. Never hand-edit the copy -- edit the dev-kit source and
17
+ # re-sync.
18
+ name: bump
19
+
20
+ on:
21
+ schedule:
22
+ - cron: "17 4 * * *"
23
+ workflow_dispatch:
24
+ inputs:
25
+ components:
26
+ description: "What to move: all, or any of dev-kit, ctl-packages, pins (comma-separated). Leave a bad component out to land the rest."
27
+ default: all
28
+ base:
29
+ description: "The branch to bump and open the PR against. main, unless trialling a change on a throwaway branch."
30
+ default: main
31
+
32
+ permissions:
33
+ contents: write
34
+ pull-requests: write
35
+
36
+ concurrency:
37
+ # One bump at a time per branch. Never cancelled: a run cut short between its
38
+ # push and its PR leaves the branch and the PR disagreeing.
39
+ group: bump-${{ inputs.base || 'main' }}
40
+ cancel-in-progress: false
41
+
42
+ jobs:
43
+ bump:
44
+ runs-on: x64
45
+ outputs:
46
+ status: ${{ steps.night.outputs.status }}
47
+ summary: ${{ steps.night.outputs.summary }}
48
+ devkit_selected: ${{ steps.select.outputs.devkit }}
49
+ devkit_status: ${{ steps.night.outputs.devkit_status }}
50
+ devkit_detail: ${{ steps.night.outputs.devkit_detail }}
51
+ steps:
52
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
53
+ # out of the workspace for every later step in this job, and clears whatever
54
+ # the old repo-local default left behind on this runner. The body carries the
55
+ # why. It fails the job only when legacy litter survives -- which would take
56
+ # the checkout down a step later anyway, but report nothing useful.
57
+ - name: Route test-temp out of the workspace (pre-checkout)
58
+ run: |
59
+ set -uo pipefail
60
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
61
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
62
+ # host -- even when the launch passes --container-user (reproduced
63
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
64
+ # inotify constraint that binds macOS dev and nothing here, so every run
65
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
66
+ # cannot remove. That took commentary's checks AND integration red on
67
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
68
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
69
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
70
+ mkdir -p "$base"
71
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
72
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
73
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
74
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
75
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
76
+ done
77
+ # LEGACY: a runner that ran the old default still carries a workspace
78
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
79
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
80
+ # RESTARTING leaked container re-creates the path as root seconds after
81
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
82
+ # by construction, because a killed run labels nothing.
83
+ tt="$GITHUB_WORKSPACE/test-temp"
84
+ [ -d "$tt" ] || exit 0
85
+ stuck=""
86
+ for c in $(docker ps -aq 2>/dev/null || true); do
87
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
88
+ stuck="$stuck $c"
89
+ fi
90
+ done
91
+ if [ -n "$stuck" ]; then
92
+ echo "reaping containers holding mounts under $tt:$stuck"
93
+ docker rm -f $stuck || true
94
+ fi
95
+ # The DIRECTORY, not just its contents: an empty but root-owned base
96
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
97
+ # container unlink the leaf.
98
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
99
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
100
+ # Fail HERE if it survived. The checkout fails on it either way, but
101
+ # reports only an EACCES rmdir with no clue what was holding the path.
102
+ if [ -e "$tt" ]; then
103
+ echo "::error::$tt survived the pre-checkout clean"
104
+ ls -lan "$tt" || true
105
+ exit 1
106
+ fi
107
+ - uses: actions/checkout@v5
108
+ with:
109
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
110
+ ref: ${{ inputs.base || 'main' }}
111
+ clean: true
112
+
113
+ # The escape hatch. One bad component blocks the night's PR for the good
114
+ # ones: re-run with the others named to land them, and hold the bad one
115
+ # back until it is fixed upstream. Not split automatically on a red PR:
116
+ # that is costly and complex, and the filter covers the real cases.
117
+ - name: Choose the components to move
118
+ id: select
119
+ env:
120
+ COMPONENTS: ${{ inputs.components }}
121
+ run: |
122
+ set -euo pipefail
123
+ devkit=0; ctl=0; pins=0
124
+ for c in $(printf '%s' "${COMPONENTS:-all}" | tr ',' ' '); do
125
+ case "$c" in
126
+ all) devkit=1; ctl=1; pins=1 ;;
127
+ dev-kit) devkit=1 ;;
128
+ ctl-packages) ctl=1 ;;
129
+ pins) pins=1 ;;
130
+ *) echo "::error::unknown component '$c': name all, or any of dev-kit, ctl-packages, pins"; exit 1 ;;
131
+ esac
132
+ done
133
+ [ $((devkit + ctl + pins)) -gt 0 ] || { echo "::error::no component named: name all, or any of dev-kit, ctl-packages, pins"; exit 1; }
134
+ echo "moving: dev-kit=$devkit ctl-packages=$ctl pins=$pins"
135
+ { echo "devkit=$devkit"; echo "ctl=$ctl"; echo "pins=$pins"; } >> "$GITHUB_OUTPUT"
136
+
137
+ # DEV-KIT first: a new dev-kit can change the conventions and the gates
138
+ # themselves, this workflow included. Every tracked package.json, not just
139
+ # the root: a nested workspace can pin the dev-kit too, and a root-only
140
+ # rewrite left it behind (playout's tests/ sat on 0.1.x after the root
141
+ # moved to 0.2, ran a guide proxy that built the wrong Studio prefix, and
142
+ # first-light failed every night from 2026-09-07). The copied conventions
143
+ # are re-synced from the new dev-kit once it is installed, below.
144
+ - name: Move the dev-kit pin
145
+ id: devkit
146
+ if: steps.select.outputs.devkit == '1'
147
+ run: |
148
+ set -euo pipefail
149
+ reg="https://registry.npmjs.org"
150
+ pkg="@norskvideo/ctl-dev-kit"
151
+ refuse() {
152
+ echo "::warning::dev-kit refused: $1"
153
+ { echo "changed=0"; echo "summary="; echo "refused=$1"; } >> "$GITHUB_OUTPUT"
154
+ exit 0
155
+ }
156
+ # The runner has no npm; read the dist-tag off the registry with curl+jq.
157
+ new="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fctl-dev-kit" | jq -r '."dist-tags".latest // empty' || true)"
158
+ [ -n "$new" ] || refuse "could not resolve $pkg latest"
159
+ echo "new=$new" >> "$GITHUB_OUTPUT"
160
+
161
+ mapfile -t manifests < <(git ls-files '*package.json' ':!:**/node_modules/**')
162
+ moved=""
163
+ for m in "${manifests[@]}"; do
164
+ old="$(grep -oE "\"$pkg\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$m" | head -1 | sed -E 's/.*"([^"]*)"$/\1/' || true)"
165
+ # Absent here, or a spec that names no registry version: a workspace
166
+ # member, or a local tarball or checkout someone pinned on purpose
167
+ # (a trial of an unreleased dev-kit) -- moving it would undo that.
168
+ case "$old" in "" | workspace:* | file:* | link:*) continue ;; esac
169
+ old_bare="${old#^}"; old_bare="${old_bare#~}"
170
+ [ "$old_bare" != "$new" ] || continue
171
+ # Surgical single-line rewrite so the file keeps its formatting
172
+ # (a jq reserialize would reflow it and trip the formatter).
173
+ sed -i -E "s|(\"$pkg\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1^$new\2|" "$m"
174
+ echo "dev-kit: $m $old -> ^$new"
175
+ moved="$moved $old_bare"
176
+ done
177
+ if [ -z "$moved" ]; then
178
+ echo "every dev-kit pin is already on the newest dev-kit ($new)."
179
+ { echo "changed=0"; echo "summary="; echo "refused="; } >> "$GITHUB_OUTPUT"
180
+ exit 0
181
+ fi
182
+ # `old` names the lowest pin moved.
183
+ old_bare="$(printf '%s\n' $moved | sort -V | head -1)"
184
+ {
185
+ echo "changed=1"
186
+ echo "old=$old_bare"
187
+ echo "moved=- dev-kit: $old_bare -> $new"
188
+ echo "summary=dev-kit \`$new\`"
189
+ echo "refused="
190
+ } >> "$GITHUB_OUTPUT"
191
+
192
+ # CTL-* and MOQ-*: the test harness that drives smoke and integration, the
193
+ # SDK, the foundation helpers, the product-template schema, and the
194
+ # @norskvideo fork of moq-dev/moq the dashboards play through. Nothing used
195
+ # to move these, so the pins drifted silently: on 2026-08-30 studio carried
196
+ # a dev-kit pin bumped that morning beside an SDK three releases behind,
197
+ # and three repos ran three vintages of the same smoke runner. The moq-*
198
+ # members pin each other at one version, so all nine move together,
199
+ # including the ones a product gets only transitively. A minor bump of
200
+ # these (0.1 -> 0.2) is a breaking API change, which is what the red-gate
201
+ # draft PR is for. The dev-kit is deliberately absent: it has its own step.
202
+ #
203
+ # Every version is resolved before any pin is rewritten, so a refusal
204
+ # never leaves the family split.
205
+ - name: Move the ctl-* and moq-* pins
206
+ id: ctl
207
+ if: steps.select.outputs.ctl == '1'
208
+ run: |
209
+ set -euo pipefail
210
+ reg="https://registry.npmjs.org"
211
+ family="ctl-test-harness ctl-sdk ctl-foundation ctl-product-template-schema moq-signals moq-flate moq-pattern moq-net moq-json moq-loc moq-msf moq-hang moq-watch"
212
+ refuse() {
213
+ echo "::warning::ctl-packages refused: $1"
214
+ { echo "changed=0"; echo "summary="; echo "refused=$1"; } >> "$GITHUB_OUTPUT"
215
+ exit 0
216
+ }
217
+ declare -A latest=()
218
+ for short in $family; do
219
+ v="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2F$short" | jq -r '."dist-tags".latest // empty' || true)"
220
+ [ -n "$v" ] || refuse "could not resolve @norskvideo/$short latest"
221
+ latest[$short]="$v"
222
+ done
223
+
224
+ # Every tracked package.json, not just the root: a repo can pin the
225
+ # same package in a nested workspace (playout pins the harness twice),
226
+ # and a root-only rewrite would split the line across the two.
227
+ mapfile -t manifests < <(git ls-files '*package.json' ':!:**/node_modules/**')
228
+ moved=()
229
+ parts=()
230
+ moq=""
231
+ for short in $family; do
232
+ pkg="@norskvideo/$short"
233
+ new="${latest[$short]}"
234
+ hit=0
235
+ for m in "${manifests[@]}"; do
236
+ old="$(grep -oE "\"$pkg\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$m" | head -1 | sed -E 's/.*"([^"]*)"$/\1/' || true)"
237
+ # Absent, or no registry version (workspace, file: or link:).
238
+ case "$old" in "" | workspace:* | file:* | link:*) continue ;; esac
239
+ old_bare="${old#^}"; old_bare="${old_bare#~}"
240
+ [ "$old_bare" != "$new" ] || continue
241
+ sed -i -E "s|(\"$pkg\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1^$new\2|" "$m"
242
+ echo "$m: $pkg $old -> ^$new"
243
+ moved+=("- $pkg: $old -> ^$new ($m)")
244
+ hit=1
245
+ done
246
+ [ "$hit" = 1 ] || continue
247
+ # One summary entry per package, and one for the whole moq-* line
248
+ # when its members agree, which they do by construction.
249
+ case "$short" in
250
+ moq-*) case " $moq " in *" $new "*) ;; *) moq="$moq $new" ;; esac ;;
251
+ *) parts+=("$short \`$new\`") ;;
252
+ esac
253
+ done
254
+ for v in $moq; do parts+=("moq \`$v\`"); done
255
+
256
+ if [ ${#moved[@]} -eq 0 ]; then
257
+ echo "every ctl-* and moq-* pin is already on its newest published version."
258
+ { echo "changed=0"; echo "summary="; echo "refused="; } >> "$GITHUB_OUTPUT"
259
+ exit 0
260
+ fi
261
+ summary="$(printf '%s, ' "${parts[@]}")"
262
+ {
263
+ echo "changed=1"
264
+ echo "summary=${summary%, }"
265
+ echo "refused="
266
+ echo "moved<<BUMP_MOVED"
267
+ printf '%s\n' "${moved[@]}"
268
+ echo "BUMP_MOVED"
269
+ } >> "$GITHUB_OUTPUT"
270
+
271
+ # PINS last, the sdk-app way. An sdk-app has no Studio, so no {engine,
272
+ # studio} pair to take from norsk-studio's channel manifest and no
273
+ # manifest.seed.json to anchor on. Its whole norsk family is ONE version
274
+ # string: the @norskvideo/norsk-sdk and norsk-api npm packages and the
275
+ # norskvideo/norsk engine image are built from one commit and published
276
+ # under one version. The product's own exact norsk-sdk pin is the current
277
+ # value; the SDK's npm nightly is the new one, and the step confirms
278
+ # norsk-api and the engine image exist at it BEFORE rewriting anything.
279
+ # Then every occurrence of the old string moves, lockfile aside --
280
+ # including the engine image ref the product's template launches. It also
281
+ # moves flake.nix's ctlVersion, the norsk-ctl the product's CI tiers run,
282
+ # to the `latest` channel (ctl-pinned-in-repo.test.ts).
283
+ #
284
+ # A REFUSAL refuses the pins alone: the step says why and moves nothing,
285
+ # and the night's other components still reach their PR.
286
+ - name: Move the norsk family to the SDK nightly and the norsk-ctl pin
287
+ id: pins
288
+ if: steps.select.outputs.pins == '1'
289
+ run: |
290
+ set -euo pipefail
291
+ reg="https://registry.npmjs.org"
292
+ hub="https://hub.docker.com/v2/repositories/norskvideo/norsk/tags"
293
+ tag_re='^[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+$'
294
+ changed=0
295
+ parts=()
296
+ moved=()
297
+ refuse() {
298
+ echo "::warning::pins refused: $1"
299
+ { echo "changed=0"; echo "summary="; echo "refused=$1"; } >> "$GITHUB_OUTPUT"
300
+ exit 0
301
+ }
302
+
303
+ # NORSK-CTL: the daemon every CI tier runs; resolved up front, with
304
+ # everything else, so a refusal rewrites nothing.
305
+ ctl_base="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
306
+ old_ctl="$( { grep -oE 'ctlVersion = "[^"]+"' flake.nix 2>/dev/null || true; } | head -1 | sed -E 's/.*"(.*)"/\1/')"
307
+ [ -n "$old_ctl" ] || refuse "flake.nix pins no ctlVersion. CI runs the norsk-ctl it names, so add ctlVersion = \"<version>\"; to flake.nix."
308
+ new_ctl="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$ctl_base/latest" || true)"
309
+ [ -n "$new_ctl" ] || refuse "could not resolve norsk-ctl's latest channel"
310
+ # An sdk-app's flake fetches no daemon, so normally no hashes; any
311
+ # platform it does fetch is resolved now, before anything moves.
312
+ declare -A ctl_sri=()
313
+ if [ "$old_ctl" != "$new_ctl" ]; then
314
+ for p in linux-x64 linux-arm64 darwin-arm64 darwin-x64; do
315
+ grep -qF "plat = \"$p\";" flake.nix || continue
316
+ hex="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$ctl_base/$new_ctl/norsk-ctl-$new_ctl-$p.sha256" | awk '{print $1}' || true)"
317
+ [ -n "$hex" ] || refuse "norsk-ctl $new_ctl has no sha256 sidecar for $p"
318
+ ctl_sri[$p]="$(nix hash convert --hash-algo sha256 --to sri "$hex")"
319
+ done
320
+ fi
321
+
322
+ # CURRENT: the product's exact norsk-sdk pin, wherever a tracked
323
+ # package.json carries it. It is the one string the family shares, so
324
+ # exactly one distinct value: none means nothing to anchor on, two
325
+ # means a rewrite would leave half behind.
326
+ old="$(git grep -hoE '"@norskvideo/norsk-sdk"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+"' -- '*package.json' \
327
+ | sed -E 's/.*"([^"]+)"$/\1/' | sort -u || true)"
328
+ case "$(printf '%s' "$old" | grep -c .)" in
329
+ 1) ;;
330
+ 0) refuse "no exact @norskvideo/norsk-sdk nightly pin in any package.json: nothing to anchor the bump on" ;;
331
+ *) refuse "@norskvideo/norsk-sdk pins disagree across package.json files: $(printf '%s' "$old" | tr '\n' ' ')" ;;
332
+ esac
333
+
334
+ # NEW: the SDK's npm nightly. Not norsk-studio's channel manifest: that
335
+ # pairs the engine with a Studio image, and an sdk-app runs no Studio,
336
+ # so it would hold the product back whenever Studio's nightly stalls.
337
+ new="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fnorsk-sdk" | jq -r '."dist-tags".nightly // empty' || true)"
338
+ printf '%s' "$new" | grep -qE "$tag_re" || refuse "the norsk-sdk npm nightly is not a dated nightly version: '$new'"
339
+
340
+ # Everything resolved and checked BEFORE anything is rewritten, so a
341
+ # refusal never leaves the SDK moved and the API or the engine not.
342
+ api_has="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fnorsk-api" | jq -r --arg v "$new" '.versions[$v] | if . == null then "" else "yes" end' || true)"
343
+ [ "$api_has" = "yes" ] || refuse "@norskvideo/norsk-api has no $new: the SDK nightly is not a whole family yet"
344
+ curl -fsS --retry 3 --retry-delay 2 -o /dev/null "$hub/$new" ||
345
+ refuse "the engine image norskvideo/norsk:$new is not published: not pinning an engine nobody can pull"
346
+
347
+ # STRANDED: the rewrite below is a literal replace of the anchor, so a
348
+ # family pin that has drifted off it is invisible to this bump and to
349
+ # every later one. Refuse and name it rather than move it: a pin held
350
+ # back may be someone's decision.
351
+ stranded="$(git grep -hoE '"@norskvideo/norsk-(api|sdk|auto-manager|manager-sdk)"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+"' -- '*package.json' \
352
+ | sed -E 's/.*"([^"]+)"$/\1/' | sort -u | grep -vxF "$old" | grep -vxF "$new" || true)"
353
+ if [ -n "$stranded" ]; then
354
+ refuse "npm norsk pins are stranded off the family: $(printf '%s' "$stranded" | tr '\n' ' ')- the family (api, sdk, auto-manager, manager-sdk) and the engine image share one version, so every pin must read $new, and no later bump can reach these. Set them to $new."
355
+ fi
356
+
357
+ echo "norsk family: $old -> $new"
358
+ if [ "$old" != "$new" ]; then
359
+ git grep -lF "$old" -- . ':!bun.lock' | xargs -r sed -i "s|$old|$new|g"
360
+ changed=1
361
+ parts+=("media \`$new\`")
362
+ moved+=("- media: $old -> $new")
363
+ fi
364
+
365
+ # --- norsk-ctl pin (flake.nix) ------------------------------------
366
+ if [ "$old_ctl" != "$new_ctl" ]; then
367
+ for p in "${!ctl_sri[@]}"; do
368
+ sri="${ctl_sri[$p]}"
369
+ perl -0pi -e "s{(plat = \"$p\";\\s*hash = \")[^\"]*(\")}{\${1}$sri\${2}}" flake.nix
370
+ done
371
+ perl -0pi -e "s{(ctlVersion = \")[^\"]*(\")}{\${1}$new_ctl\${2}}" flake.nix
372
+ echo "ctl: $old_ctl -> $new_ctl"
373
+ changed=1
374
+ parts+=("norsk-ctl \`$new_ctl\`")
375
+ moved+=("- norsk-ctl: $old_ctl -> $new_ctl")
376
+ fi
377
+
378
+ summary=""
379
+ if [ ${#parts[@]} -gt 0 ]; then
380
+ summary="$(printf '%s, ' "${parts[@]}")"
381
+ summary="${summary%, }"
382
+ fi
383
+ {
384
+ echo "changed=$changed"
385
+ echo "summary=$summary"
386
+ echo "refused="
387
+ echo "moved<<BUMP_MOVED"
388
+ [ ${#moved[@]} -eq 0 ] || printf '%s\n' "${moved[@]}"
389
+ echo "BUMP_MOVED"
390
+ } >> "$GITHUB_OUTPUT"
391
+
392
+ # The night's one answer, from the three components': did anything move,
393
+ # what (one line per pin, from and to, for the PR body and the brief),
394
+ # what was refused and why, and what the dashboard's nightly and dev-kit
395
+ # cells say. Through env, never pasted into the script: the summaries wrap
396
+ # versions in backticks, and inside a script those run as commands.
397
+ - name: Gather what the night moved
398
+ id: night
399
+ env:
400
+ DEVKIT_SELECTED: ${{ steps.select.outputs.devkit }}
401
+ DEVKIT_CHANGED: ${{ steps.devkit.outputs.changed }}
402
+ DEVKIT_OLD: ${{ steps.devkit.outputs.old }}
403
+ DEVKIT_NEW: ${{ steps.devkit.outputs.new }}
404
+ DEVKIT_MOVED: ${{ steps.devkit.outputs.moved }}
405
+ DEVKIT_SUMMARY: ${{ steps.devkit.outputs.summary }}
406
+ DEVKIT_REFUSED: ${{ steps.devkit.outputs.refused }}
407
+ CTL_SELECTED: ${{ steps.select.outputs.ctl }}
408
+ CTL_CHANGED: ${{ steps.ctl.outputs.changed }}
409
+ CTL_MOVED: ${{ steps.ctl.outputs.moved }}
410
+ CTL_SUMMARY: ${{ steps.ctl.outputs.summary }}
411
+ CTL_REFUSED: ${{ steps.ctl.outputs.refused }}
412
+ PINS_SELECTED: ${{ steps.select.outputs.pins }}
413
+ PINS_CHANGED: ${{ steps.pins.outputs.changed }}
414
+ PINS_MOVED: ${{ steps.pins.outputs.moved }}
415
+ PINS_SUMMARY: ${{ steps.pins.outputs.summary }}
416
+ PINS_REFUSED: ${{ steps.pins.outputs.refused }}
417
+ run: |
418
+ set -euo pipefail
419
+ changed=0; names=(); moved=(); parts=(); refused=(); why=()
420
+ take() {
421
+ local name="$1" selected="$2" did="$3" lines="$4" summary="$5" refusal="$6"
422
+ [ "$selected" = 1 ] || return 0
423
+ if [ -n "$refusal" ]; then
424
+ refused+=("- $name: $refusal"); why+=("$name ($refusal)")
425
+ fi
426
+ [ "$did" = 1 ] || return 0
427
+ changed=1; names+=("$name")
428
+ [ -z "$lines" ] || moved+=("$lines")
429
+ [ -z "$summary" ] || parts+=("$summary")
430
+ }
431
+ take dev-kit "${DEVKIT_SELECTED:-0}" "${DEVKIT_CHANGED:-0}" "${DEVKIT_MOVED:-}" "${DEVKIT_SUMMARY:-}" "${DEVKIT_REFUSED:-}"
432
+ take ctl-packages "${CTL_SELECTED:-0}" "${CTL_CHANGED:-0}" "${CTL_MOVED:-}" "${CTL_SUMMARY:-}" "${CTL_REFUSED:-}"
433
+ take pins "${PINS_SELECTED:-0}" "${PINS_CHANGED:-0}" "${PINS_MOVED:-}" "${PINS_SUMMARY:-}" "${PINS_REFUSED:-}"
434
+
435
+ join() { local sep="$1" out; shift; out="$(printf "%s$sep" "$@")"; printf '%s' "${out%"$sep"}"; }
436
+ summary="$(join ', ' ${parts[@]+"${parts[@]}"})"
437
+ if [ ${#why[@]} -gt 0 ]; then
438
+ summary="${summary:+$summary; }refused: $(join ', ' "${why[@]}")"
439
+ status=failure
440
+ else
441
+ status=success
442
+ fi
443
+
444
+ if [ -n "${DEVKIT_REFUSED:-}" ]; then
445
+ devkit_status=failure; devkit_detail="refused: $DEVKIT_REFUSED"
446
+ elif [ "${DEVKIT_CHANGED:-0}" = 1 ]; then
447
+ devkit_status=success; devkit_detail="$DEVKIT_OLD -> $DEVKIT_NEW"
448
+ else
449
+ devkit_status=success; devkit_detail="current ${DEVKIT_NEW:-}"
450
+ fi
451
+
452
+ {
453
+ echo "changed=$changed"
454
+ echo "components=$(join ', ' ${names[@]+"${names[@]}"})"
455
+ echo "summary=$summary"
456
+ echo "status=$status"
457
+ echo "devkit_status=$devkit_status"
458
+ echo "devkit_detail=$devkit_detail"
459
+ echo "moved<<BUMP_MOVED"
460
+ [ ${#moved[@]} -eq 0 ] || printf '%s\n' "${moved[@]}"
461
+ echo "BUMP_MOVED"
462
+ echo "refused<<BUMP_REFUSED"
463
+ [ ${#refused[@]} -eq 0 ] || printf '%s\n' "${refused[@]}"
464
+ echo "BUMP_REFUSED"
465
+ } >> "$GITHUB_OUTPUT"
466
+
467
+ # ONE refresh, after every pin has moved: three bots each refreshing from
468
+ # main as it stood is what made their PRs conflict on bun.lock every
469
+ # night. NOT continue-on-error, unlike the gate below: a refresh that dies
470
+ # part-way (the runner OOM-killed one on 2026-10-03) leaves bumped
471
+ # manifests beside the old lockfiles, and carrying on committed exactly
472
+ # that. Fail here instead, before anything is committed.
473
+ - name: Refresh every lockfile against the new pins
474
+ if: steps.night.outputs.changed == '1'
475
+ env:
476
+ DEVKIT_CHANGED: ${{ steps.devkit.outputs.changed }}
477
+ CTL_CHANGED: ${{ steps.ctl.outputs.changed }}
478
+ run: |
479
+ nix develop .#build --command bash -c '
480
+ set -euo pipefail
481
+ # Per INSTALL ROOT, not per repo: the rewrites walk every tracked
482
+ # package.json, and a manifest outside the root workspace list (funke
483
+ # proxy/ui) owns a lockfile a root-only install never regenerates.
484
+ for lock in $(git ls-files "*bun.lock"); do
485
+ (cd "$(dirname "$lock")" && bun install)
486
+ # bun install keeps a locked version its range still admits, so a
487
+ # nested workspace could stay on an old dev-kit under a new pin.
488
+ # Only where the lockfile already holds it: bun update ADDS a
489
+ # package it does not find, and put the dev-kit into funke proxy/ui.
490
+ if [ "$DEVKIT_CHANGED" = 1 ] && grep -q "\"@norskvideo/ctl-dev-kit@" "$lock"; then
491
+ (cd "$(dirname "$lock")" && bun update @norskvideo/ctl-dev-kit)
492
+ fi
493
+ done
494
+ # A ctl-* package also arrives TRANSITIVELY, and those never move on
495
+ # their own: probe sat on ctl-foundation 0.1.14 from 2026-09-02 while
496
+ # 0.1.19 was current, through ctl-sdk and the schema, and the gate
497
+ # went red on an export the stale copy lacked. Same family as the ctl
498
+ # step, named again because the two are separate shells. Not every
499
+ # install root has every member; bun says so and this moves on.
500
+ if [ "$CTL_CHANGED" = 1 ]; then
501
+ for pkg in ctl-test-harness ctl-sdk ctl-foundation ctl-product-template-schema moq-signals moq-flate moq-pattern moq-net moq-json moq-loc moq-msf moq-hang moq-watch; do
502
+ for lock in $(git ls-files "*bun.lock"); do
503
+ (cd "$(dirname "$lock")" && bun update "@norskvideo/$pkg") || true
504
+ done
505
+ done
506
+ fi
507
+ '
508
+
509
+ # sync-drift re-derives each convention copy from the NEW dev-kit's
510
+ # canonical, keeping the per-repo bits the gate masks; it is how a
511
+ # convention change (this workflow included) reaches the product. Then
512
+ # the quick gate. continue-on-error, so a red gate still opens the PR --
513
+ # which is why the steps after it read `outcome`, not `conclusion`.
514
+ - name: Re-sync the conventions and run the product gate
515
+ id: gate
516
+ if: steps.night.outputs.changed == '1'
517
+ continue-on-error: true
518
+ env:
519
+ DEVKIT_CHANGED: ${{ steps.devkit.outputs.changed }}
520
+ run: |
521
+ nix develop .#build --command bash -c '
522
+ set -euo pipefail
523
+ if [ "$DEVKIT_CHANGED" = 1 ]; then
524
+ bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts
525
+ fi
526
+ bun run check:drift
527
+ bun run lint
528
+ bun run typecheck
529
+ bun run test:unit
530
+ '
531
+
532
+ # The night's only way to main is this PR. bot-prs merges it once every
533
+ # tier that runs on a bot PR -- checks, smoke, integration and
534
+ # build-image's guides gate, wherever this repo has them -- is green on
535
+ # its head (ci-workflows docs/dashboard/ci-fragility.md, proposals B and
536
+ # B2). A red quick gate opens it as a draft, and a later green run makes
537
+ # it ready again (draft is not settable by PATCH, so through GraphQL).
538
+ - name: Open (or update) the bump PR
539
+ if: steps.night.outputs.changed == '1'
540
+ env:
541
+ API_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
542
+ GATE: ${{ steps.gate.outcome }}
543
+ BASE: ${{ inputs.base || 'main' }}
544
+ # Through env, never pasted into the script: the summary wraps each
545
+ # version in backticks, and inside a double-quoted shell string those
546
+ # ran as commands ("1.3.1-...: command not found").
547
+ MOVED: ${{ steps.night.outputs.moved }}
548
+ REFUSED: ${{ steps.night.outputs.refused }}
549
+ SUMMARY: ${{ steps.night.outputs.summary }}
550
+ COMPONENTS: ${{ steps.night.outputs.components }}
551
+ DEVKIT_SELECTED: ${{ steps.select.outputs.devkit }}
552
+ CTL_SELECTED: ${{ steps.select.outputs.ctl }}
553
+ PINS_SELECTED: ${{ steps.select.outputs.pins }}
554
+ run: |
555
+ set -euo pipefail
556
+ branch="bump/nightly"
557
+ title="Nightly bump: $COMPONENTS"
558
+ repo="${{ github.repository }}"; owner="${repo%%/*}"
559
+ api="https://api.github.com/repos/$repo/pulls"
560
+ # This PR supersedes any older bump PR on the same base, and the old
561
+ # bots' PRs for every component this run covered: upgrade-latest's
562
+ # (pins), sync-dev-kit's (dev-kit) and sync-ctl-packages' (ctl-*).
563
+ # Left open they would sit red forever, with no bot left to rebuild
564
+ # them. A component the filter left out keeps its old PR. Only PRs on
565
+ # this run's base: a trial against a throwaway branch touches nothing
566
+ # on main. Best-effort: a failure here must not fail the bump.
567
+ families=("bump/")
568
+ [ "$DEVKIT_SELECTED" != 1 ] || families+=("sync-dev-kit/")
569
+ [ "$CTL_SELECTED" != 1 ] || families+=("chore/sync-ctl-packages")
570
+ [ "$PINS_SELECTED" != 1 ] || families+=("upgrade-latest/")
571
+ close_superseded() {
572
+ local keep="$1" n fam
573
+ fam="$(printf '%s\n' "${families[@]}" | jq -R . | jq -sc .)"
574
+ auth=(-H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
575
+ for n in $(curl -sS "${auth[@]}" "$api?state=open&per_page=100" \
576
+ | jq -r --arg repo "$repo" --arg base "$BASE" --argjson fam "$fam" --argjson keep "$keep" '
577
+ .[] | select(.head.repo.full_name == $repo and .base.ref == $base and .number != $keep)
578
+ | .head.ref as $ref
579
+ | select(any($fam[]; . as $prefix | if ($prefix | endswith("/")) then ($ref | startswith($prefix)) else $ref == $prefix end))
580
+ | .number'); do
581
+ curl -sS -o /dev/null "${auth[@]}" -X POST "https://api.github.com/repos/$repo/issues/$n/comments" \
582
+ -d "$(jq -nc --arg b "Superseded by #$keep, the nightly bump (bump.yml), which moves this component now." '{body:$b}')"
583
+ curl -sS -o /dev/null "${auth[@]}" -X PATCH "$api/$n" -d '{"state":"closed"}'
584
+ echo "closed superseded PR #$n"
585
+ done
586
+ }
587
+ git config user.name "github-actions[bot]"
588
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
589
+ git checkout -B "$branch"
590
+ git add -A
591
+ git commit -q -F - <<COMMIT
592
+ chore: nightly bump ($COMPONENTS)
593
+
594
+ $MOVED
595
+ COMMIT
596
+ # One branch, force-pushed from today's base -- but only when its tree
597
+ # changed. A red bump PR left standing is rebuilt by every run, and
598
+ # pushing the same tree again would re-run every slow tier for nothing.
599
+ if git fetch -q origin "$branch" 2>/dev/null && [ "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse 'FETCH_HEAD^{tree}')" ]; then
600
+ echo "$branch already holds this bump; not re-pushing it"
601
+ else
602
+ git push -f origin "$branch"
603
+ fi
604
+ if [ "$GATE" = "success" ]; then
605
+ verdict="Gate green: check:drift, lint, typecheck and unit tests pass on the bumped pins."
606
+ draft=false
607
+ else
608
+ verdict="**Gate red**: check:drift, lint, typecheck or unit tests fail on the bumped pins, so this is a draft. Run the gate on this branch to see which (\`bun install\`, \`bun run check:drift\`, \`bun run lint && bun run typecheck && bun run test:unit\`). Fix it here, or re-run bump.yml with the culprit left out of \`components\` to land the rest."
609
+ draft=true
610
+ fi
611
+ lines=(
612
+ "The nightly bump (bump.yml): one PR for every component that moved, tested together."
613
+ ""
614
+ "Moved, from and to:"
615
+ ""
616
+ "<!-- bump:moved -->"
617
+ "$MOVED"
618
+ "<!-- /bump:moved -->"
619
+ )
620
+ if [ -n "$REFUSED" ]; then
621
+ lines+=(
622
+ ""
623
+ "Refused, and left where they were (the run's log says more):"
624
+ ""
625
+ "<!-- bump:refused -->"
626
+ "$REFUSED"
627
+ "<!-- /bump:refused -->"
628
+ )
629
+ fi
630
+ lines+=(
631
+ ""
632
+ "$verdict"
633
+ ""
634
+ "bot-prs merges this once checks, smoke, integration and the guides gate"
635
+ "(build-image) have all passed on its head, wherever this repo runs them."
636
+ "Until then $BASE does not move. One bad component holds the night back: to land"
637
+ "the rest, run \`gh workflow run bump.yml -f components=<the others>\`."
638
+ )
639
+ body="$(printf '%s\n' "${lines[@]}")"
640
+ # gh is not on the self-hosted runner PATH (it lives only inside
641
+ # `nix develop`, and this step runs outside it), so create/update the PR
642
+ # straight against the REST API with the same PAT that pushed the branch.
643
+ # The PAT (not GITHUB_TOKEN) is what lets the PR's own workflows run,
644
+ # which is the whole point: the bump is judged before it lands.
645
+ curl_api=(--fail-with-body -sS -H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
646
+ standing="$(curl "${curl_api[@]}" "$api?head=$owner:$branch&base=$BASE&state=open")"
647
+ existing="$(jq -r '.[0].number // empty' <<<"$standing")"
648
+ if [ -n "$existing" ]; then
649
+ curl "${curl_api[@]}" -X PATCH "$api/$existing" \
650
+ --data "$(jq -nc --arg t "$title" --arg b "$body" '{title:$t, body:$b}')" > /dev/null
651
+ number="$existing"
652
+ echo "updated PR #$number"
653
+ is_draft="$(jq -r '.[0].draft // false' <<<"$standing")"
654
+ node="$(jq -r '.[0].node_id // empty' <<<"$standing")"
655
+ mutation=""
656
+ if [ "$draft" = true ] && [ "$is_draft" != true ]; then mutation=convertPullRequestToDraft; fi
657
+ if [ "$draft" = false ] && [ "$is_draft" = true ]; then mutation=markPullRequestReadyForReview; fi
658
+ if [ -n "$mutation" ] && [ -n "$node" ]; then
659
+ curl "${curl_api[@]}" -X POST "https://api.github.com/graphql" \
660
+ --data "$(jq -nc --arg m "$mutation" --arg id "$node" '{query: ("mutation($id: ID!) { " + $m + "(input: {pullRequestId: $id}) { pullRequest { isDraft } } }"), variables: {id: $id}}')" > /dev/null ||
661
+ echo "::warning::could not $mutation #$number"
662
+ echo "$mutation #$number"
663
+ fi
664
+ else
665
+ number="$(curl "${curl_api[@]}" -X POST "$api" \
666
+ --data "$(jq -nc --arg t "$title" --arg b "$body" --arg h "$branch" --arg base "$BASE" --argjson d "$draft" \
667
+ '{title:$t, body:$b, head:$h, base:$base, draft:$d}')" | jq -r .number)"
668
+ echo "opened PR #$number"
669
+ fi
670
+ close_superseded "$number" || true
671
+
672
+ - name: Report "nothing to bump"
673
+ if: steps.night.outputs.changed != '1'
674
+ run: echo "Every selected component is already current; nothing to bump."
675
+ - name: Reap the containers this job launched
676
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
677
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
678
+ # conventions/workflow-shell.ts); everything this job launched mounts its
679
+ # own test-temp, so that is what goes.
680
+ if: always()
681
+ run: |
682
+ base="${NORSK_CTL_TEST_TMP:-}"
683
+ tt="$GITHUB_WORKSPACE/test-temp"
684
+ s=""
685
+ for c in $(docker ps -aq); do
686
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
687
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
688
+ done
689
+ if [ -n "$s" ]; then
690
+ echo "::warning::removing containers this job left running:$s"
691
+ docker rm -f $s
692
+ fi
693
+
694
+ # Report to the aggregated product CI dashboard (id3as/ci-workflows): the
695
+ # night as the `nightly` pipeline, and the dev-kit component as the `dev-kit`
696
+ # one -- the board keeps that column, fed by this run. !cancelled() so a
697
+ # failed bump shows a sad pony while a cancelled run does not false-red the
698
+ # board; a run against any branch but main is a trial, and reports nothing.
699
+ # A refused component reports red although its PR opened: someone has to
700
+ # look at why.
701
+ notify:
702
+ needs: bump
703
+ if: ${{ !cancelled() && (inputs.base || 'main') == 'main' }}
704
+ runs-on: x64
705
+ steps:
706
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
707
+ # out of the workspace for every later step in this job, and clears whatever
708
+ # the old repo-local default left behind on this runner. The body carries the
709
+ # why. It fails the job only when legacy litter survives -- which would take
710
+ # the checkout down a step later anyway, but report nothing useful.
711
+ - name: Route test-temp out of the workspace (pre-checkout)
712
+ run: |
713
+ set -uo pipefail
714
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
715
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
716
+ # host -- even when the launch passes --container-user (reproduced
717
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
718
+ # inotify constraint that binds macOS dev and nothing here, so every run
719
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
720
+ # cannot remove. That took commentary's checks AND integration red on
721
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
722
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
723
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
724
+ mkdir -p "$base"
725
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
726
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
727
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
728
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
729
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
730
+ done
731
+ # LEGACY: a runner that ran the old default still carries a workspace
732
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
733
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
734
+ # RESTARTING leaked container re-creates the path as root seconds after
735
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
736
+ # by construction, because a killed run labels nothing.
737
+ tt="$GITHUB_WORKSPACE/test-temp"
738
+ [ -d "$tt" ] || exit 0
739
+ stuck=""
740
+ for c in $(docker ps -aq 2>/dev/null || true); do
741
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
742
+ stuck="$stuck $c"
743
+ fi
744
+ done
745
+ if [ -n "$stuck" ]; then
746
+ echo "reaping containers holding mounts under $tt:$stuck"
747
+ docker rm -f $stuck || true
748
+ fi
749
+ # The DIRECTORY, not just its contents: an empty but root-owned base
750
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
751
+ # container unlink the leaf.
752
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
753
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
754
+ # Fail HERE if it survived. The checkout fails on it either way, but
755
+ # reports only an EACCES rmdir with no clue what was holding the path.
756
+ if [ -e "$tt" ]; then
757
+ echo "::error::$tt survived the pre-checkout clean"
758
+ ls -lan "$tt" || true
759
+ exit 1
760
+ fi
761
+ - uses: actions/checkout@v5
762
+ with:
763
+ clean: true
764
+ - id: meta
765
+ env:
766
+ NIGHT: ${{ needs.bump.outputs.status }}
767
+ DEVKIT: ${{ needs.bump.outputs.devkit_status }}
768
+ run: |
769
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
770
+ echo "state=failure" >> "$GITHUB_OUTPUT"
771
+ echo "devkit=failure" >> "$GITHUB_OUTPUT"
772
+ else
773
+ echo "state=${NIGHT:-success}" >> "$GITHUB_OUTPUT"
774
+ echo "devkit=${DEVKIT:-success}" >> "$GITHUB_OUTPUT"
775
+ fi
776
+ - uses: ./.github/actions/ci-status-dispatch
777
+ with:
778
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
779
+ product: __PRODUCT__
780
+ pipeline: nightly
781
+ status: ${{ steps.meta.outputs.state }}
782
+ detail: ${{ needs.bump.outputs.summary }}
783
+ - if: needs.bump.outputs.devkit_selected == '1'
784
+ uses: ./.github/actions/ci-status-dispatch
785
+ with:
786
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
787
+ product: __PRODUCT__
788
+ pipeline: dev-kit
789
+ status: ${{ steps.meta.outputs.devkit }}
790
+ detail: ${{ needs.bump.outputs.devkit_detail }}
791
+ - name: Reap the containers this job launched
792
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
793
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
794
+ # conventions/workflow-shell.ts); everything this job launched mounts its
795
+ # own test-temp, so that is what goes.
796
+ if: always()
797
+ run: |
798
+ base="${NORSK_CTL_TEST_TMP:-}"
799
+ tt="$GITHUB_WORKSPACE/test-temp"
800
+ s=""
801
+ for c in $(docker ps -aq); do
802
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
803
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
804
+ done
805
+ if [ -n "$s" ]; then
806
+ echo "::warning::removing containers this job left running:$s"
807
+ docker rm -f $s
808
+ fi