@norskvideo/ctl-dev-kit 0.2.64 → 0.2.65

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.
@@ -1,567 +0,0 @@
1
- # Keep the product fresh against the Norsk "nightly" channel (RFC 0001 —
2
- # product version freshness). The {engine, studio image} pair comes from the
3
- # nightly entry of norsk-studio's channel manifest -- the pair studio TESTED
4
- # together -- never from the two channels separately. Version discovery of the
5
- # product's current pins anchors on manifest.seed.json
6
- # (the machine-writable source of truth for the launched {media, studio} image
7
- # pair — every product repo has one). Any npm pins on the same version string
8
- # (media build libraries, the @norskvideo/norsk-studio* family in whichever
9
- # tracked package.json actually depends on them) move in lockstep via a global
10
- # string-replace of old->new across every tracked file — which also updates the
11
- # version-pin guard test, no drift. A repo with no studio npm deps needs no
12
- # package.json `overrides` block at all. Also moves flake.nix's norsk-ctl pin
13
- # (ctlVersion + the per-platform hashes) to the `latest` channel. That pin is
14
- # not dev-only: every CI tier runs the norsk-ctl it names, so a product's result
15
- # belongs to a commit (ctl-pinned-in-repo.test.ts).
16
- #
17
- # Nightly cron + on demand. The bump runs the product's own gate (lint,
18
- # typecheck, unit tests), then ALWAYS opens (or updates) a PR on
19
- # upgrade-latest/nightly, as sync-ctl-packages does; it never pushes to main.
20
- # bot-prs merges the PR once checks, smoke, integration and the guides gate are
21
- # green on its head, so a bad Norsk nightly or norsk-ctl shows up as a red bot
22
- # PR and main stays green.
23
- #
24
- # History, because each step was a fix. It first committed straight to main and
25
- # let the push-triggered run be the verdict -- a RED `main` as the alarm, and
26
- # nobody watches a product repo's own `main`: playout sat red ~12h, then funke,
27
- # then probe, inside three days (fleet review 04c, class 8). Then it gated
28
- # first and landed on green. That kept lint/typecheck/unit failures off main,
29
- # but smoke, integration and the guides ran only after landing, so a bad
30
- # upstream release still reddened main in every repo that took it
31
- # (ci-workflows docs/dashboard/ci-fragility.md, problem 2 and proposal B).
32
- # Pinned for the class by conventions/gate-before-main.test.ts. The branch is
33
- # pushed with CI_DISPATCH_TOKEN (a PAT): a GITHUB_TOKEN push would fire none of
34
- # the PR's workflows (Actions' recursion guard).
35
- #
36
- # Single-sourced in @norskvideo/ctl-dev-kit (conventions/upgrade-latest.yml) and
37
- # copied verbatim into each product repo; the check:drift gate fails CI if a copy
38
- # diverges. Only the `product:` value below is per-repo (set it to this repo's
39
- # dashboard key); everything else must match the dev-kit source. Never hand-edit
40
- # the copy — edit the dev-kit source and re-sync.
41
- name: upgrade-latest
42
-
43
- on:
44
- schedule:
45
- - cron: "17 4 * * *"
46
- workflow_dispatch:
47
-
48
- permissions:
49
- contents: write
50
-
51
- jobs:
52
- bump:
53
- runs-on: x64
54
- outputs:
55
- changed: ${{ steps.bump.outputs.changed }}
56
- summary: ${{ steps.bump.outputs.summary }}
57
- steps:
58
- # Runs BEFORE checkout, and does two things: points the harness's temp base
59
- # out of the workspace for every later step in this job, and clears whatever
60
- # the old repo-local default left behind on this runner. The body carries the
61
- # why. It fails the job only when legacy litter survives -- which would take
62
- # the checkout down a step later anyway, but report nothing useful.
63
- - name: Route test-temp out of the workspace (pre-checkout)
64
- run: |
65
- set -uo pipefail
66
- # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
67
- # HOST daemon has not seen before is created by dockerd, as ROOT, on the
68
- # host -- even when the launch passes --container-user (reproduced
69
- # 2026-09-04). The harness's default base is repo-local for an OrbStack
70
- # inotify constraint that binds macOS dev and nothing here, so every run
71
- # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
72
- # cannot remove. That took commentary's checks AND integration red on
73
- # 2026-09-03. Use the sanctioned override, per-run and outside the
74
- # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
75
- base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
76
- mkdir -p "$base"
77
- echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
78
- # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
79
- # EACCESes. They cannot wedge a checkout, but they do fill the disk.
80
- for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
81
- docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
82
- done
83
- # LEGACY: a runner that ran the old default still carries a workspace
84
- # test-temp, and a checkout with clean:true dies on it. Reap the holders
85
- # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
86
- # RESTARTING leaked container re-creates the path as root seconds after
87
- # any clean. Match on mount SOURCE, not a label: the holders are untracked
88
- # by construction, because a killed run labels nothing.
89
- tt="$GITHUB_WORKSPACE/test-temp"
90
- [ -d "$tt" ] || exit 0
91
- stuck=""
92
- for c in $(docker ps -aq 2>/dev/null || true); do
93
- if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
94
- stuck="$stuck $c"
95
- fi
96
- done
97
- if [ -n "$stuck" ]; then
98
- echo "reaping containers holding mounts under $tt:$stuck"
99
- docker rm -f $stuck || true
100
- fi
101
- # The DIRECTORY, not just its contents: an empty but root-owned base
102
- # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
103
- # container unlink the leaf.
104
- docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
105
- sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
106
- # Fail HERE if it survived. The checkout fails on it either way, but
107
- # reports only an EACCES rmdir with no clue what was holding the path.
108
- if [ -e "$tt" ]; then
109
- echo "::error::$tt survived the pre-checkout clean"
110
- ls -lan "$tt" || true
111
- exit 1
112
- fi
113
-
114
- - uses: actions/checkout@v5
115
- with:
116
- token: ${{ secrets.CI_DISPATCH_TOKEN }}
117
- clean: true
118
-
119
- - name: Resolve the nightly channel and rewrite the coupled pins
120
- id: bump
121
- run: |
122
- set -euo pipefail
123
- reg="https://registry.npmjs.org"
124
- changed=0
125
-
126
- # NORSK-CTL: the version flake.nix's ctlVersion names is the daemon every
127
- # CI tier runs (ctl-pinned-in-repo.test.ts) as well as the one the dev
128
- # shell launches, so it moves here like the engine does and the bump
129
- # PR's tiers are what test it (ci-workflows docs/dashboard/
130
- # ci-fragility.md, proposal A). Resolved up front, with everything
131
- # else, so a refusal rewrites nothing.
132
- ctl_base="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
133
- old_ctl="$( { grep -oE 'ctlVersion = "[^"]+"' flake.nix 2>/dev/null || true; } | head -1 | sed -E 's/.*"(.*)"/\1/')"
134
- [ -n "$old_ctl" ] || { echo "::error::flake.nix pins no ctlVersion. CI runs the norsk-ctl it names, so add ctlVersion = \"<version>\"; to flake.nix."; exit 1; }
135
- new_ctl="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$ctl_base/latest")"
136
- [ -n "$new_ctl" ] || { echo "::error::could not resolve norsk-ctl's latest channel"; exit 1; }
137
-
138
- # Accumulate a COMPACT summary: only the components that actually moved
139
- # get an entry, so a no-op nightly (old == new) posts nothing. Was: the
140
- # full media + studio-lib + studio-image triple every night for every
141
- # product, even when nothing changed — pure dashboard noise.
142
- parts=()
143
-
144
- # ONE TESTED PAIR, NOT TWO CHANNELS. The engine and the studio image are
145
- # taken together from the nightly entry of norsk-studio's channel
146
- # manifest: the pair studio's publish built and tested against each
147
- # other (scripts/write-channel-manifest.sh in norsk-studio). Resolving
148
- # them apart -- the engine off norsk-sdk's nightly dist-tag, the image
149
- # off Docker Hub's nightly tag -- produced pairs nobody had tested
150
- # whenever one channel moved without the other. On 2026-10-04 studio's
151
- # nightly had been held by its test gate since 09-28 while the engine
152
- # walked on to 10-02, a breaking SDK change landed in between, and
153
- # commentary's audio mix produced nothing on the old-studio /
154
- # new-engine pair it was pinned to (norsk task 112).
155
- #
156
- # Consequence, deliberately: when studio's nightly stalls, the engine
157
- # stalls with it (or steps BACK to the pair), rather than running ahead
158
- # untested. Unblocking studio's nightly is how products move forward.
159
- #
160
- # Everything is resolved and checked BEFORE anything is rewritten, so a
161
- # refusal never leaves the engine moved and studio not.
162
- manifest_url="https://s3.eu-west-1.amazonaws.com/norsk.video/channel-manifest.json"
163
- pair="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$manifest_url" | jq -c '.channels.nightly // empty')"
164
- [ -n "$pair" ] || { echo "::error::no nightly pair in $manifest_url"; exit 1; }
165
- tag_re='^[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+$'
166
- new_media="$(jq -r '(.norsk // "") | split(":") | .[1] // ""' <<<"$pair")"
167
- new_studio_img="$(jq -r '(.studio // "") | split(":") | .[1] // ""' <<<"$pair")"
168
- printf '%s' "$new_media" | grep -qE "$tag_re" || { echo "::error::the manifest's nightly norsk image is not a dated nightly tag: $pair"; exit 1; }
169
- printf '%s' "$new_studio_img" | grep -qE "$tag_re" || { echo "::error::the manifest's nightly studio image is not a dated nightly tag: $pair"; exit 1; }
170
- echo "nightly pair: norsk $new_media + studio $new_studio_img"
171
-
172
- # MEDIA: the npm packages and the launched image are built from the SAME
173
- # commit (their hash suffixes match), so the pair's engine version drives
174
- # the whole family: overrides, sub-package deps, the manifest image ref,
175
- # the version-pin test -- every occurrence EXCEPT the lockfile, which bun
176
- # install regenerates.
177
- old_media="$(jq -r '.latest.media | split(":")[1]' manifest.seed.json)"
178
- # The rewrite below is a LITERAL replace of the value found in
179
- # manifest.seed.json, so it only ever touches pins that already equal the
180
- # seed. A pin that has drifted off it -- a hand edit, a merge, a
181
- # half-finished run -- is invisible to this bump and to every later one,
182
- # permanently, while the job still reports success. funke launched a
183
- # 2026-09-30 engine against 2026-09-05 npm pins for a month that way;
184
- # playout and probe sat two months back on 1.1.0-2026-08-02. The whole
185
- # point of the family is that ONE version string drives it, so a
186
- # divergent pin is a defect and the only thing missing was someone
187
- # saying so.
188
- #
189
- # Not auto-rewritten: a pin held back on purpose is someone's decision,
190
- # and silently moving it would be the same class of surprise in the
191
- # other direction. Fail, name them, and let a human fix the pin or the
192
- # intent. (Checked against the SEED, the family's current value: after
193
- # the rewrite every seed-equal pin reads the pair's engine.)
194
- 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' \
195
- | sed -E 's/.*"([^"]+)"$/\1/' | sort -u | grep -vxF "$old_media" | grep -vxF "$new_media" || true)"
196
- if [ -n "$stranded" ]; then
197
- echo "::error::npm norsk pins are stranded off the nightly channel: $(printf '%s' "$stranded" | tr '\n' ' ')"
198
- echo "The media family (api, sdk, auto-manager, manager-sdk) and the launched image are built from the same"
199
- echo "commit, so every pin must read $new_media. These do not, and because the rewrite above is a literal"
200
- echo "replace of the seed value, no later bump can reach them either. Set them to $new_media."
201
- exit 1
202
- fi
203
-
204
- # STUDIO: the studio npm packages and the studio image are built from
205
- # DIFFERENT commits -- their hash suffixes never match -- so the image
206
- # comes from the pair and the build libraries from npm's nightly. The
207
- # libraries must belong to the same pair: their norsk-sdk dependency has
208
- # to name the pair's engine, and their version line the image's. A
209
- # mismatch means the channels are inconsistent (studio published npm
210
- # without updating the manifest, or the reverse); refuse rather than pin
211
- # a combination nobody tested. (Studio's publish installs the SDK it
212
- # builds against as a DEV dependency -- `npm install --save-dev
213
- # @norskvideo/norsk-sdk@<channel>` -- so that is where the pairing lives.)
214
- #
215
- # The old npm pin is discovered from the repo's REAL studio deps: any
216
- # exact nightly on a @norskvideo/norsk-studio* package in a tracked
217
- # package.json (deps, devDeps, or overrides -- wherever it lives). A
218
- # repo with none skips the lib entirely. Distinct pins disagreeing
219
- # across files abort rather than half-rewrite.
220
- 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' \
221
- | sed -E 's/.*"([^"]+)"$/\1/' | sort -u || true)"
222
- if [ "$(printf '%s' "$old_studio_lib" | grep -c .)" -gt 1 ]; then
223
- echo "studio npm pins disagree across package.json files:"; echo "$old_studio_lib"; exit 1
224
- fi
225
- old_studio_img="$(jq -r '.latest.studio | split(":")[1]' manifest.seed.json)"
226
- new_studio_lib=""
227
- if [ -n "$old_studio_lib" ]; then
228
- studio_doc="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fnorsk-studio")"
229
- new_studio_lib="$(jq -r '."dist-tags".nightly // empty' <<<"$studio_doc")"
230
- [ -n "$new_studio_lib" ] || { echo "could not resolve norsk-studio npm nightly"; exit 1; }
231
- lib_sdk="$(jq -r --arg v "$new_studio_lib" \
232
- '.versions[$v] | ((.devDependencies // {}) + (.peerDependencies // {}) + (.dependencies // {}))["@norskvideo/norsk-sdk"] // empty' <<<"$studio_doc")"
233
- lib_sdk="${lib_sdk#^}"; lib_sdk="${lib_sdk#~}"
234
- if [ "$lib_sdk" != "$new_media" ]; then
235
- echo "::error::norsk-studio npm nightly $new_studio_lib was built against norsk-sdk ${lib_sdk:-<none declared>}, not the tested pair's engine $new_media."
236
- echo "The npm channel and the channel manifest disagree; not pinning a combination nobody tested."
237
- exit 1
238
- fi
239
- if [ "${new_studio_lib%%-*}" != "${new_studio_img%%-*}" ]; then
240
- echo "::error::norsk-studio npm nightly $new_studio_lib is on a different version line from the pair's image $new_studio_img."
241
- exit 1
242
- fi
243
- fi
244
-
245
- echo "media: $old_media -> $new_media"
246
- echo "studio lib: ${old_studio_lib:-<no npm pins>} -> ${new_studio_lib:-<unchanged>}"
247
- echo "studio image: $old_studio_img -> $new_studio_img"
248
- if [ "$old_media" != "$new_media" ]; then
249
- git grep -lF "$old_media" -- . ':!bun.lock' | xargs -r sed -i "s|$old_media|$new_media|g"
250
- changed=1
251
- parts+=("media \`$new_media\`")
252
- fi
253
- # Rewrite the image ref FIRST, then the bare library version -- so even
254
- # when the two coincide, the image ref has already moved and the
255
- # bare-version rewrite only touches the npm pins.
256
- if [ "$old_studio_img" != "$new_studio_img" ]; then
257
- git grep -lF "norskvideo/norsk-studio:$old_studio_img" -- . ':!bun.lock' \
258
- | xargs -r sed -i "s|norskvideo/norsk-studio:$old_studio_img|norskvideo/norsk-studio:$new_studio_img|g"
259
- # And the tag on its own: a product can carry it unprefixed (onair2026's
260
- # committed template input has `"studioTag": "<tag>"`), which the
261
- # prefixed rewrite never reached -- the engine moved, studio did not,
262
- # and the repo's own pin test went red (2026-10-04). Safe because the
263
- # image tag and the npm library version are different strings; if
264
- # they ever coincide, the bare rewrite would also hit the lib pins, so
265
- # it is skipped and the lib rewrite below owns that string.
266
- if [ "$old_studio_img" != "$old_studio_lib" ]; then
267
- # `|| true`: after the prefixed rewrite most repos have no bare
268
- # occurrence left, and git grep exits 1 on no match (pipefail).
269
- { git grep -lF "$old_studio_img" -- . ':!bun.lock' || true; } | xargs -r sed -i "s|$old_studio_img|$new_studio_img|g"
270
- fi
271
- changed=1
272
- fi
273
- if [ -n "$new_studio_lib" ] && [ "$old_studio_lib" != "$new_studio_lib" ]; then
274
- git grep -lF "$old_studio_lib" -- . ':!bun.lock' | xargs -r sed -i "s|$old_studio_lib|$new_studio_lib|g"
275
- changed=1
276
- fi
277
- # One studio entry, not two -- the launched image tag is the runtime-
278
- # relevant one; only fall back to the lib tag if the image held but the
279
- # library advanced.
280
- if [ "$old_studio_img" != "$new_studio_img" ]; then
281
- parts+=("studio \`$new_studio_img\`")
282
- elif [ -n "$new_studio_lib" ] && [ "$old_studio_lib" != "$new_studio_lib" ]; then
283
- parts+=("studio-lib \`$new_studio_lib\`")
284
- fi
285
-
286
- # --- norsk-ctl pin (flake.nix) ------------------------------------
287
- # ctlVersion, plus the per-platform hash of each platform the flake
288
- # fetches the daemon for. A Studio product's flake fetches all four; an
289
- # sdk-app's fetches none and carries the version alone. Mirrors
290
- # packages/dev-kit/build/refresh-ctl-pin.sh in the ctl monorepo.
291
- if [ "$old_ctl" != "$new_ctl" ]; then
292
- declare -A ctlplat=([x86_64-linux]=linux-x64 [aarch64-linux]=linux-arm64 [aarch64-darwin]=darwin-arm64 [x86_64-darwin]=darwin-x64)
293
- for sys in "${!ctlplat[@]}"; do
294
- p="${ctlplat[$sys]}"
295
- grep -qF "plat = \"$p\";" flake.nix || continue
296
- hex="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$ctl_base/$new_ctl/norsk-ctl-$new_ctl-$p.sha256" | awk '{print $1}')"
297
- [ -n "$hex" ] || { echo "no sha256 sidecar for $p"; exit 1; }
298
- sri="$(nix hash convert --hash-algo sha256 --to sri "$hex")"
299
- perl -0pi -e "s{(plat = \"$p\";\\s*hash = \")[^\"]*(\")}{\${1}$sri\${2}}" flake.nix
300
- done
301
- perl -0pi -e "s{(ctlVersion = \")[^\"]*(\")}{\${1}$new_ctl\${2}}" flake.nix
302
- echo "ctl: $old_ctl -> $new_ctl"
303
- changed=1
304
- parts+=("norsk-ctl \`$new_ctl\`")
305
- fi
306
-
307
- # Join the moved components; empty when nothing changed (the dashboard
308
- # then shows a clean green "nightly" cell with no detail).
309
- summary=""
310
- if [ ${#parts[@]} -gt 0 ]; then
311
- summary="$(printf '%s, ' "${parts[@]}")"
312
- summary="${summary%, }"
313
- fi
314
- {
315
- echo "changed=$changed"
316
- echo "summary=$summary"
317
- } >> "$GITHUB_OUTPUT"
318
-
319
- - name: Refresh the lockfile against the new pins
320
- if: steps.bump.outputs.changed == '1'
321
- # Per INSTALL ROOT: the rewrites above walk every tracked package.json,
322
- # and a manifest outside the root workspace list owns a lockfile a
323
- # root-only install never regenerates. See lockfile-reinstall.test.ts.
324
- run: |
325
- nix develop .#build --command bash -c '
326
- set -euo pipefail
327
- for lock in $(git ls-files "*bun.lock"); do
328
- (cd "$(dirname "$lock")" && bun install)
329
- done
330
- '
331
-
332
- - name: Run the product gate against the bumped pins
333
- id: gate
334
- if: steps.bump.outputs.changed == '1'
335
- # continue-on-error so a red gate can open a PR below instead of failing
336
- # the workflow -- which is why the steps after it read `outcome` and not
337
- # `conclusion` (continue-on-error rewrites conclusion to success).
338
- continue-on-error: true
339
- run: |
340
- nix develop .#build --command bash -c '
341
- set -euo pipefail
342
- bun run lint
343
- bun run typecheck
344
- bun run test:unit
345
- '
346
-
347
- # The bump's only way to main is this PR. bot-prs merges it once every tier
348
- # that runs on a bot PR -- checks, smoke, integration and build-image's
349
- # guides gate, wherever this repo has them -- is green on its head
350
- # (ci-workflows docs/dashboard/ci-fragility.md, proposal B). Landing on a
351
- # green gate kept lint, typecheck and unit failures off main, but the tiers
352
- # that break on an upstream change ran only after landing, so a bad
353
- # upstream release still turned main red in every repo that took it. The
354
- # gate above still runs: its verdict heads the PR body, and a red one
355
- # shows on the PR's own checks too. Never a draft: bot-prs reads red from
356
- # the tiers, and a draft could never become ready again by PATCH.
357
- - name: Open (or update) the bump PR
358
- if: steps.bump.outputs.changed == '1'
359
- env:
360
- API_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
361
- GATE: ${{ steps.gate.outcome }}
362
- # Through env, never pasted into the script: the summary wraps each
363
- # version in backticks, and inside a double-quoted shell string those
364
- # ran as commands ("1.3.1-...: command not found"), dropping every
365
- # version from the commit message and PR body.
366
- SUMMARY: ${{ steps.bump.outputs.summary }}
367
- run: |
368
- set -euo pipefail
369
- branch="upgrade-latest/nightly"
370
- prefix="upgrade-latest/"
371
- title="Bump Norsk nightly + ctl pins to latest channel"
372
- repo="${{ github.repository }}"; owner="${repo%%/*}"
373
- api="https://api.github.com/repos/$repo/pulls"
374
- # An older PR of this bot's family is obsolete once this one is open:
375
- # left open they piled up (nine were closed by hand on 2026-10-03).
376
- # Best-effort: a failure here must not fail the bump.
377
- close_superseded() {
378
- local keep="$1" n
379
- auth=(-H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
380
- for n in $(curl -sS "${auth[@]}" "$api?state=open&per_page=100" \
381
- | jq -r --arg repo "$repo" --arg prefix "$prefix" --argjson keep "$keep" \
382
- '.[] | select(.head.repo.full_name == $repo and (.head.ref | startswith($prefix)) and .number != $keep) | .number'); do
383
- curl -sS -o /dev/null "${auth[@]}" -X POST "https://api.github.com/repos/$repo/issues/$n/comments" \
384
- -d "$(jq -nc --arg b "Superseded by #$keep, a later run of this bot." '{body:$b}')"
385
- curl -sS -o /dev/null "${auth[@]}" -X PATCH "$api/$n" -d '{"state":"closed"}'
386
- echo "closed superseded PR #$n"
387
- done
388
- }
389
- git config user.name "github-actions[bot]"
390
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
391
- git checkout -B "$branch"
392
- git add -A
393
- git commit -m "chore: bump Norsk nightly + ctl pins to latest channel
394
-
395
- ${SUMMARY}
396
-
397
- Coupled Norsk pins (npm overrides + manifest.seed.json image tags +
398
- version-pin guard test) moved together via a global version-string
399
- replace; lockfile regenerated. flake.nix norsk-ctl pin (version + the
400
- per-platform hashes), which CI runs, moved to the latest channel."
401
- # One branch per bump, force-pushed from today's main -- but only when
402
- # its tree changed. A red bump PR left standing is rebuilt by every run
403
- # of this bot, and pushing the same tree again would re-run every slow
404
- # tier on it for nothing.
405
- if git fetch -q origin "$branch" 2>/dev/null && [ "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse 'FETCH_HEAD^{tree}')" ]; then
406
- echo "$branch already holds this bump; not re-pushing it"
407
- else
408
- git push -f origin "$branch"
409
- fi
410
- if [ "$GATE" = "success" ]; then
411
- verdict="Gate green: lint, typecheck and unit tests pass on the bumped pins."
412
- else
413
- verdict="**Gate red**: lint, typecheck or unit tests fail on the bumped pins. That usually means the new Norsk nightly is bad, or genuinely changed something the product depends on. Fix it on this branch, or close this PR to skip the bump -- the next nightly run reopens it against whatever the channel holds then."
414
- fi
415
- body="$(printf '%s\n' \
416
- "Automated nightly pin bump: the Norsk nightly channel and norsk-ctl latest moved, and the coupled pins came with them." \
417
- "" \
418
- " ${SUMMARY}" \
419
- "" \
420
- "$verdict" \
421
- "" \
422
- "bot-prs merges this once checks, smoke, integration and the guides gate" \
423
- "(build-image) have all passed on its head, wherever this repo runs them." \
424
- "Until then main does not move; a red tier keeps it open and reports it on" \
425
- "the product dashboard.")"
426
- # gh is not on the self-hosted runner PATH (it lives only inside
427
- # `nix develop`, and this step runs outside it), so create/update the PR
428
- # straight against the REST API with the same PAT that pushed the branch.
429
- # The PAT (not GITHUB_TOKEN) is what lets the PR's own workflows run,
430
- # which is the whole point: the bump is judged before it lands.
431
- curl_api=(--fail-with-body -sS -H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
432
- existing="$(curl "${curl_api[@]}" "$api?head=$owner:$branch&state=open" | jq -r '.[0].number // empty')"
433
- if [ -n "$existing" ]; then
434
- curl "${curl_api[@]}" -X PATCH "$api/$existing" \
435
- --data "$(jq -nc --arg t "$title" --arg b "$body" '{title:$t, body:$b}')" > /dev/null
436
- number="$existing"
437
- echo "updated PR #$number"
438
- else
439
- number="$(curl "${curl_api[@]}" -X POST "$api" \
440
- --data "$(jq -nc --arg t "$title" --arg b "$body" --arg h "$branch" \
441
- '{title:$t, body:$b, head:$h, base:"main"}')" | jq -r .number)"
442
- echo "opened PR #$number"
443
- fi
444
- close_superseded "$number" || true
445
-
446
- - name: Report "already fresh"
447
- if: steps.bump.outputs.changed != '1'
448
- run: echo "Nightly pins already current — nothing to bump."
449
- - name: Reap the containers this job launched
450
- # Whatever the tests did. A teardown cut short by a test timeout leaves
451
- # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
452
- # conventions/workflow-shell.ts); everything this job launched mounts its
453
- # own test-temp, so that is what goes.
454
- if: always()
455
- run: |
456
- base="${NORSK_CTL_TEST_TMP:-}"
457
- tt="$GITHUB_WORKSPACE/test-temp"
458
- s=""
459
- for c in $(docker ps -aq); do
460
- src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
461
- if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
462
- done
463
- if [ -n "$s" ]; then
464
- echo "::warning::removing containers this job left running:$s"
465
- docker rm -f $s
466
- fi
467
-
468
- # Report the nightly bump result to the aggregated product CI dashboard
469
- # (id3as/ci-workflows). !cancelled() so a failed bump shows a sad pony (a
470
- # cancelled/superseded run must not false-red the board); the summary rides
471
- # along as the cell detail.
472
- notify:
473
- needs: bump
474
- if: ${{ !cancelled() }}
475
- runs-on: x64
476
- steps:
477
- # Runs BEFORE checkout, and does two things: points the harness's temp base
478
- # out of the workspace for every later step in this job, and clears whatever
479
- # the old repo-local default left behind on this runner. The body carries the
480
- # why. It fails the job only when legacy litter survives -- which would take
481
- # the checkout down a step later anyway, but report nothing useful.
482
- - name: Route test-temp out of the workspace (pre-checkout)
483
- run: |
484
- set -uo pipefail
485
- # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
486
- # HOST daemon has not seen before is created by dockerd, as ROOT, on the
487
- # host -- even when the launch passes --container-user (reproduced
488
- # 2026-09-04). The harness's default base is repo-local for an OrbStack
489
- # inotify constraint that binds macOS dev and nothing here, so every run
490
- # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
491
- # cannot remove. That took commentary's checks AND integration red on
492
- # 2026-09-03. Use the sanctioned override, per-run and outside the
493
- # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
494
- base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
495
- mkdir -p "$base"
496
- echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
497
- # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
498
- # EACCESes. They cannot wedge a checkout, but they do fill the disk.
499
- for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
500
- docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
501
- done
502
- # LEGACY: a runner that ran the old default still carries a workspace
503
- # test-temp, and a checkout with clean:true dies on it. Reap the holders
504
- # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
505
- # RESTARTING leaked container re-creates the path as root seconds after
506
- # any clean. Match on mount SOURCE, not a label: the holders are untracked
507
- # by construction, because a killed run labels nothing.
508
- tt="$GITHUB_WORKSPACE/test-temp"
509
- [ -d "$tt" ] || exit 0
510
- stuck=""
511
- for c in $(docker ps -aq 2>/dev/null || true); do
512
- if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
513
- stuck="$stuck $c"
514
- fi
515
- done
516
- if [ -n "$stuck" ]; then
517
- echo "reaping containers holding mounts under $tt:$stuck"
518
- docker rm -f $stuck || true
519
- fi
520
- # The DIRECTORY, not just its contents: an empty but root-owned base
521
- # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
522
- # container unlink the leaf.
523
- docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
524
- sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
525
- # Fail HERE if it survived. The checkout fails on it either way, but
526
- # reports only an EACCES rmdir with no clue what was holding the path.
527
- if [ -e "$tt" ]; then
528
- echo "::error::$tt survived the pre-checkout clean"
529
- ls -lan "$tt" || true
530
- exit 1
531
- fi
532
-
533
- - uses: actions/checkout@v5
534
- with:
535
- clean: true
536
- - id: meta
537
- run: |
538
- if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
539
- echo "state=failure" >> "$GITHUB_OUTPUT"
540
- else
541
- echo "state=success" >> "$GITHUB_OUTPUT"
542
- fi
543
- - uses: ./.github/actions/ci-status-dispatch
544
- with:
545
- token: ${{ secrets.CI_DISPATCH_TOKEN }}
546
- product: __PRODUCT__
547
- pipeline: nightly
548
- status: ${{ steps.meta.outputs.state }}
549
- detail: ${{ needs.bump.outputs.summary }}
550
- - name: Reap the containers this job launched
551
- # Whatever the tests did. A teardown cut short by a test timeout leaves
552
- # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
553
- # conventions/workflow-shell.ts); everything this job launched mounts its
554
- # own test-temp, so that is what goes.
555
- if: always()
556
- run: |
557
- base="${NORSK_CTL_TEST_TMP:-}"
558
- tt="$GITHUB_WORKSPACE/test-temp"
559
- s=""
560
- for c in $(docker ps -aq); do
561
- src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
562
- if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
563
- done
564
- if [ -n "$s" ]; then
565
- echo "::warning::removing containers this job left running:$s"
566
- docker rm -f $s
567
- fi