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