@norskvideo/ctl-dev-kit 0.2.32 → 0.2.33

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.
@@ -290,6 +290,10 @@ export interface CanonicalBytes {
290
290
  * Studio licence step removed (it reads shared/src/version.ts +
291
291
  * product-template.ts). Optional for the same reason as flakeSdkApp. */
292
292
  checksSdkApp?: string;
293
+ /** conventions/upgrade-latest.sdk-app.yml — the upgrade-latest.yml variant for
294
+ * an sdk-app: no Studio pair or manifest.seed.json, the norsk family bumped off
295
+ * the SDK nightly. Optional for the same reason as flakeSdkApp. */
296
+ upgradeLatestSdkApp?: string;
293
297
  }
294
298
 
295
299
  // 05-demo s6: a product that has a demo (tests/demo.spec.ts) exposes it the
@@ -650,15 +654,20 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
650
654
 
651
655
  // upgrade-latest.yml is OPTIONAL — a product may ship without a nightly bump
652
656
  // (e.g. until it has an integration tier). But when present it is the shared,
653
- // single-sourced workflow and must not diverge.
657
+ // single-sourced workflow and must not diverge. Shape-selected like checks.yml:
658
+ // an sdk-app has no Studio pair to bump, so it carries its own variant.
654
659
  const workflowPath = join(repoRoot, ".github", "workflows", "upgrade-latest.yml");
655
660
  if (existsSync(workflowPath)) {
661
+ const upgradeCanon = shape === "sdk-app" ? canonical.upgradeLatestSdkApp : canonical.upgradeLatest;
662
+ if (upgradeCanon === undefined) {
663
+ throw new Error(`checkDrift: shape "${shape}" requires canonical.upgradeLatestSdkApp`);
664
+ }
656
665
  push(
657
666
  workflowProblem(
658
667
  ".github/workflows/upgrade-latest.yml",
659
- "conventions/upgrade-latest.yml",
668
+ shape === "sdk-app" ? "conventions/upgrade-latest.sdk-app.yml" : "conventions/upgrade-latest.yml",
660
669
  readFileSync(workflowPath, "utf8"),
661
- canonical.upgradeLatest,
670
+ upgradeCanon,
662
671
  ),
663
672
  );
664
673
  }
@@ -820,6 +829,7 @@ if (import.meta.main) {
820
829
  demoShim: readFileSync(join(import.meta.dir, "demo.sh"), "utf8"),
821
830
  flakeSdkApp: readFileSync(join(import.meta.dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
822
831
  checksSdkApp: readFileSync(join(import.meta.dir, "checks.sdk-app.yml"), "utf8"),
832
+ upgradeLatestSdkApp: readFileSync(join(import.meta.dir, "upgrade-latest.sdk-app.yml"), "utf8"),
823
833
  };
824
834
  const report = checkDrift(repoRoot, canonical, {
825
835
  repoName: resolveRepoName(repoRoot),
@@ -296,7 +296,10 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: Pr
296
296
  writeIfChanged(join(repoRoot, "tsconfig.base.json"), canonical.tsconfigBase, "tsconfig.base.json", r.written);
297
297
 
298
298
  syncWorkflow(repoRoot, ".github/workflows/checks.yml", checksCanon, r);
299
- syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", canonical.upgradeLatest, r);
299
+ // upgrade-latest too: an sdk-app has no Studio pair, so it has its own variant
300
+ const upgradeCanon = shape === "sdk-app" ? canonical.upgradeLatestSdkApp : canonical.upgradeLatest;
301
+ if (upgradeCanon === undefined) throw new Error(`syncDrift: shape "${shape}" requires canonical.upgradeLatestSdkApp`);
302
+ syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", upgradeCanon, r);
300
303
  syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
301
304
  // No per-repo line to preserve, so it is written whole -- and CREATED where
302
305
  // absent, unlike syncWorkflow's copies. That is how a repo acquires the
@@ -369,6 +372,7 @@ if (import.meta.main) {
369
372
  demoShim: readFileSync(join(dir, "demo.sh"), "utf8"),
370
373
  flakeSdkApp: readFileSync(join(dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
371
374
  checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"),
375
+ upgradeLatestSdkApp: readFileSync(join(dir, "upgrade-latest.sdk-app.yml"), "utf8"),
372
376
  };
373
377
  const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot));
374
378
  if (report.written.length === 0) {
@@ -0,0 +1,422 @@
1
+ # The sdk-app variant of upgrade-latest.yml: keep a bespoke-SDK product (no
2
+ # Studio -- norsk-relay) fresh against the Norsk "nightly" channel (RFC 0001 --
3
+ # product version freshness). Kept in lockstep with upgrade-latest.yml outside
4
+ # the bump step; check-drift holds an sdk-app repo's copy to THIS file.
5
+ #
6
+ # An sdk-app has no Studio, so no {engine, studio} pair to take from
7
+ # norsk-studio's channel manifest and no manifest.seed.json to anchor on. Its
8
+ # whole norsk family is ONE version string: the @norskvideo/norsk-sdk and
9
+ # norsk-api npm packages and the norskvideo/norsk engine image are built from
10
+ # one commit and published under one version. The product's own exact
11
+ # norsk-sdk pin is the current value; the SDK's npm nightly is the new one, and
12
+ # the step confirms norsk-api and the engine image exist at it BEFORE rewriting
13
+ # anything. Then every occurrence of the old string moves, lockfile aside (bun
14
+ # install regenerates it) -- including the engine image ref the product's
15
+ # template launches, which an sdk-app pins in its own source.
16
+ #
17
+ # Nightly cron + on demand. The bump runs the product's own gate (lint,
18
+ # typecheck, unit tests) BEFORE it lands, exactly as upgrade-latest.yml and
19
+ # sync-dev-kit do: green lands on main, red opens a reconciliation PR and main
20
+ # stays green. The push is made with CI_DISPATCH_TOKEN (a PAT), so the push to
21
+ # main fires the product's own workflows -- a GITHUB_TOKEN push would fire
22
+ # nothing. No norsk-ctl pin to refresh: the sdk-app flake carries none.
23
+ #
24
+ # Single-sourced in @norskvideo/ctl-dev-kit (conventions/upgrade-latest.sdk-app.yml)
25
+ # and copied verbatim into each sdk-app product repo as
26
+ # .github/workflows/upgrade-latest.yml; the check:drift gate fails CI if a copy
27
+ # diverges. Only the `product:` value below is per-repo (set it to this repo's
28
+ # dashboard key); everything else must match the dev-kit source. Never hand-edit
29
+ # the copy — edit the dev-kit source and re-sync.
30
+ name: upgrade-latest
31
+
32
+ on:
33
+ schedule:
34
+ - cron: "17 4 * * *"
35
+ workflow_dispatch:
36
+
37
+ permissions:
38
+ contents: write
39
+
40
+ jobs:
41
+ bump:
42
+ runs-on: x64
43
+ outputs:
44
+ changed: ${{ steps.bump.outputs.changed }}
45
+ summary: ${{ steps.bump.outputs.summary }}
46
+ steps:
47
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
48
+ # out of the workspace for every later step in this job, and clears whatever
49
+ # the old repo-local default left behind on this runner. The body carries the
50
+ # why. It fails the job only when legacy litter survives -- which would take
51
+ # the checkout down a step later anyway, but report nothing useful.
52
+ - name: Route test-temp out of the workspace (pre-checkout)
53
+ run: |
54
+ set -uo pipefail
55
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
56
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
57
+ # host -- even when the launch passes --container-user (reproduced
58
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
59
+ # inotify constraint that binds macOS dev and nothing here, so every run
60
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
61
+ # cannot remove. That took commentary's checks AND integration red on
62
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
63
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
64
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
65
+ mkdir -p "$base"
66
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
67
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
68
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
69
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
70
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
71
+ done
72
+ # LEGACY: a runner that ran the old default still carries a workspace
73
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
74
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
75
+ # RESTARTING leaked container re-creates the path as root seconds after
76
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
77
+ # by construction, because a killed run labels nothing.
78
+ tt="$GITHUB_WORKSPACE/test-temp"
79
+ [ -d "$tt" ] || exit 0
80
+ stuck=""
81
+ for c in $(docker ps -aq 2>/dev/null || true); do
82
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
83
+ stuck="$stuck $c"
84
+ fi
85
+ done
86
+ if [ -n "$stuck" ]; then
87
+ echo "reaping containers holding mounts under $tt:$stuck"
88
+ docker rm -f $stuck || true
89
+ fi
90
+ # The DIRECTORY, not just its contents: an empty but root-owned base
91
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
92
+ # container unlink the leaf.
93
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
94
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
95
+ # Fail HERE if it survived. The checkout fails on it either way, but
96
+ # reports only an EACCES rmdir with no clue what was holding the path.
97
+ if [ -e "$tt" ]; then
98
+ echo "::error::$tt survived the pre-checkout clean"
99
+ ls -lan "$tt" || true
100
+ exit 1
101
+ fi
102
+
103
+ - uses: actions/checkout@v5
104
+ with:
105
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
106
+ clean: true
107
+
108
+ - name: Resolve the SDK nightly and rewrite the norsk family
109
+ id: bump
110
+ run: |
111
+ set -euo pipefail
112
+ reg="https://registry.npmjs.org"
113
+ hub="https://hub.docker.com/v2/repositories/norskvideo/norsk/tags"
114
+ tag_re='^[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+$'
115
+ changed=0
116
+ summary=""
117
+
118
+ # CURRENT: the product's exact norsk-sdk pin, wherever a tracked
119
+ # package.json carries it (deps, devDeps or overrides). It is the one
120
+ # string the family shares, so exactly one distinct value: none means
121
+ # nothing to anchor on, two means a rewrite would leave half behind.
122
+ 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' \
123
+ | sed -E 's/.*"([^"]+)"$/\1/' | sort -u || true)"
124
+ case "$(printf '%s' "$old" | grep -c .)" in
125
+ 1) ;;
126
+ 0) echo "::error::no exact @norskvideo/norsk-sdk nightly pin in any package.json: nothing to anchor the bump on"; exit 1 ;;
127
+ *) echo "::error::@norskvideo/norsk-sdk pins disagree across package.json files:"; echo "$old"; exit 1 ;;
128
+ esac
129
+
130
+ # NEW: the SDK's npm nightly. Not norsk-studio's channel manifest: that
131
+ # pairs the engine with a Studio image, and an sdk-app runs no Studio,
132
+ # so it would hold the product back whenever Studio's nightly stalls.
133
+ new="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fnorsk-sdk" | jq -r '."dist-tags".nightly // empty')"
134
+ printf '%s' "$new" | grep -qE "$tag_re" || { echo "::error::the norsk-sdk npm nightly is not a dated nightly version: '$new'"; exit 1; }
135
+
136
+ # Everything resolved and checked BEFORE anything is rewritten, so a
137
+ # refusal never leaves the SDK moved and the API or the engine not.
138
+ 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')"
139
+ [ "$api_has" = "yes" ] || { echo "::error::@norskvideo/norsk-api has no $new: the SDK nightly is not a whole family yet"; exit 1; }
140
+ curl -fsS --retry 3 --retry-delay 2 -o /dev/null "$hub/$new" ||
141
+ { echo "::error::the engine image norskvideo/norsk:$new is not published: not pinning an engine nobody can pull"; exit 1; }
142
+
143
+ # STRANDED: the rewrite below is a literal replace of the anchor, so a
144
+ # family pin that has drifted off it is invisible to this bump and to
145
+ # every later one, while the job reports success. Fail and name it
146
+ # rather than move it: a pin held back may be someone's decision.
147
+ 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' \
148
+ | sed -E 's/.*"([^"]+)"$/\1/' | sort -u | grep -vxF "$old" | grep -vxF "$new" || true)"
149
+ if [ -n "$stranded" ]; then
150
+ echo "::error::npm norsk pins are stranded off the family: $(printf '%s' "$stranded" | tr '\n' ' ')"
151
+ echo "The family (api, sdk, auto-manager, manager-sdk) and the engine image share one version, so every pin"
152
+ echo "must read $new. These do not, and no later bump can reach them either. Set them to $new."
153
+ exit 1
154
+ fi
155
+
156
+ echo "norsk family: $old -> $new"
157
+ if [ "$old" != "$new" ]; then
158
+ git grep -lF "$old" -- . ':!bun.lock' | xargs -r sed -i "s|$old|$new|g"
159
+ changed=1
160
+ summary="media \`$new\`"
161
+ fi
162
+ {
163
+ echo "changed=$changed"
164
+ echo "summary=$summary"
165
+ } >> "$GITHUB_OUTPUT"
166
+
167
+ - name: Refresh the lockfile against the new pins
168
+ if: steps.bump.outputs.changed == '1'
169
+ # Per INSTALL ROOT: the rewrites above walk every tracked package.json,
170
+ # and a manifest outside the root workspace list owns a lockfile a
171
+ # root-only install never regenerates. See lockfile-reinstall.test.ts.
172
+ run: |
173
+ nix develop .#build --command bash -c '
174
+ set -euo pipefail
175
+ for lock in $(git ls-files "*bun.lock"); do
176
+ (cd "$(dirname "$lock")" && bun install)
177
+ done
178
+ '
179
+
180
+ - name: Run the product gate against the bumped pins
181
+ id: gate
182
+ if: steps.bump.outputs.changed == '1'
183
+ # continue-on-error so a red gate can open a PR below instead of failing
184
+ # the workflow -- which is why the steps after it read `outcome` and not
185
+ # `conclusion` (continue-on-error rewrites conclusion to success).
186
+ continue-on-error: true
187
+ run: |
188
+ nix develop .#build --command bash -c '
189
+ set -euo pipefail
190
+ bun run lint
191
+ bun run typecheck
192
+ bun run test:unit
193
+ '
194
+
195
+ - name: Land the bump on main (gate green)
196
+ if: steps.bump.outputs.changed == '1' && steps.gate.outcome == 'success'
197
+ env:
198
+ API_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
199
+ # Through env, never pasted into the script (see the PR step below).
200
+ SUMMARY: ${{ steps.bump.outputs.summary }}
201
+ run: |
202
+ set -euo pipefail
203
+ # This bump is about to land, which makes any upgrade-latest/* PR an earlier
204
+ # red run opened obsolete. Left open they piled up -- nine were closed
205
+ # by hand on 2026-10-03 -- so close them once the push has landed.
206
+ # Best-effort: a failure here must not fail the landing.
207
+ close_superseded() {
208
+ api="https://api.github.com/repos/$GITHUB_REPOSITORY"
209
+ auth=(-H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
210
+ for n in $(curl -sS "${auth[@]}" "$api/pulls?state=open&per_page=100" \
211
+ | jq -r --arg repo "$GITHUB_REPOSITORY" '.[] | select(.head.repo.full_name == $repo and (.head.ref | startswith("upgrade-latest/"))) | .number'); do
212
+ curl -sS -o /dev/null "${auth[@]}" -X POST "$api/issues/$n/comments" \
213
+ -d '{"body":"Superseded: a later upgrade-latest run landed its bump on main."}'
214
+ curl -sS -o /dev/null "${auth[@]}" -X PATCH "$api/pulls/$n" -d '{"state":"closed"}'
215
+ echo "closed superseded PR #$n"
216
+ done
217
+ }
218
+ git config user.name "github-actions[bot]"
219
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
220
+ git add -A
221
+ git commit -m "chore: bump the Norsk family to the SDK nightly
222
+
223
+ ${SUMMARY}
224
+
225
+ The norsk-sdk / norsk-api pins and the engine image ref moved together
226
+ via a global version-string replace; lockfile regenerated."
227
+ # main can advance under a long bump (lockfile regen + registry
228
+ # lookups run for minutes); a bare push then loses a non-fast-forward
229
+ # race and reds the nightly. Rebase-and-retry so an unrelated commit
230
+ # landing mid-run does not fail the bump; a genuine content conflict
231
+ # still aborts loudly rather than force-pushing over other work.
232
+ for attempt in 1 2 3 4 5; do
233
+ if git push origin HEAD:main; then close_superseded || true; exit 0; fi
234
+ echo "push rejected (attempt $attempt) - rebasing onto latest origin/main"
235
+ # --unshallow, not a plain fetch: these runners keep their .git
236
+ # between jobs, and a commit an earlier job fetched at depth 1 stays
237
+ # recorded as parentless. A plain fetch does not re-fetch an object it
238
+ # already holds, so main's tip can look like an unrelated root and the
239
+ # rebase replays our base commit over it -- add/add conflicts on every
240
+ # file it touches (playout + studio, 2026-10-03). The fallback covers
241
+ # a repo that is already complete, where --unshallow is refused.
242
+ git fetch --unshallow origin main 2>/dev/null || git fetch origin main
243
+ git rebase origin/main || { git rebase --abort; echo "rebase hit a real conflict; aborting"; exit 1; }
244
+ done
245
+ echo "push still failing after 5 rebase attempts"; exit 1
246
+
247
+ - name: Open a reconciliation PR (gate red)
248
+ if: steps.bump.outputs.changed == '1' && steps.gate.outcome == 'failure'
249
+ env:
250
+ API_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
251
+ # Through env, never pasted into the script: the summary wraps each
252
+ # version in backticks, and inside a double-quoted shell string those
253
+ # ran as commands ("1.3.1-...: command not found"), dropping every
254
+ # version from the commit message and PR body.
255
+ SUMMARY: ${{ steps.bump.outputs.summary }}
256
+ run: |
257
+ set -euo pipefail
258
+ branch="upgrade-latest/nightly"
259
+ git config user.name "github-actions[bot]"
260
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
261
+ git checkout -b "$branch"
262
+ git add -A
263
+ git commit -m "chore: bump the Norsk family to the SDK nightly (needs reconciliation)
264
+
265
+ ${SUMMARY}"
266
+ git push -f origin "$branch"
267
+ title="Bump the Norsk family to the SDK nightly"
268
+ body="$(printf '%s\n' \
269
+ "Automated nightly pin bump: the Norsk nightly channel moved and the coupled" \
270
+ "pins came with it -- but the product gate is **red**, so this bump needs a" \
271
+ "human before it reaches main." \
272
+ "" \
273
+ " ${SUMMARY}" \
274
+ "" \
275
+ "A red gate here usually means the new Norsk nightly is bad, or genuinely" \
276
+ "changed something the product depends on. Run it to see which:" \
277
+ "" \
278
+ " bun run lint && bun run typecheck && bun run test:unit" \
279
+ "" \
280
+ "Fix it on this branch and merge, or close this PR to skip the bump -- the" \
281
+ "next nightly run reopens it against whatever the channel holds then.")"
282
+ # gh is not on the self-hosted runner PATH (it lives only inside
283
+ # `nix develop`, and this step runs outside it), so create/update the PR
284
+ # straight against the REST API with the same PAT that pushed the branch.
285
+ # The PAT (not GITHUB_TOKEN) is what lets the PR's own checks run, which
286
+ # is the whole point: the bump is judged before it lands, not after.
287
+ repo="${{ github.repository }}"; owner="${repo%%/*}"
288
+ api="https://api.github.com/repos/$repo/pulls"
289
+ curl_api=(--fail-with-body -sS -H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
290
+ existing="$(curl "${curl_api[@]}" "$api?head=$owner:$branch&state=open" | jq -r '.[0].number // empty')"
291
+ if [ -n "$existing" ]; then
292
+ curl "${curl_api[@]}" -X PATCH "$api/$existing" \
293
+ --data "$(jq -nc --arg t "$title" --arg b "$body" '{title:$t, body:$b}')" > /dev/null
294
+ echo "updated PR #$existing"
295
+ else
296
+ curl "${curl_api[@]}" -X POST "$api" \
297
+ --data "$(jq -nc --arg t "$title" --arg b "$body" --arg h "$branch" \
298
+ '{title:$t, body:$b, head:$h, base:"main"}')" | jq -r '"opened PR #\(.number)"'
299
+ fi
300
+
301
+ - name: Report "already fresh"
302
+ if: steps.bump.outputs.changed != '1'
303
+ run: echo "Nightly pins already current — nothing to bump."
304
+ - name: Reap the containers this job launched
305
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
306
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
307
+ # conventions/workflow-shell.ts); everything this job launched mounts its
308
+ # own test-temp, so that is what goes.
309
+ if: always()
310
+ run: |
311
+ base="${NORSK_CTL_TEST_TMP:-}"
312
+ tt="$GITHUB_WORKSPACE/test-temp"
313
+ s=""
314
+ for c in $(docker ps -aq); do
315
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
316
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
317
+ done
318
+ if [ -n "$s" ]; then
319
+ echo "::warning::removing containers this job left running:$s"
320
+ docker rm -f $s
321
+ fi
322
+
323
+ # Report the nightly bump result to the aggregated product CI dashboard
324
+ # (id3as/ci-workflows). !cancelled() so a failed bump shows a sad pony (a
325
+ # cancelled/superseded run must not false-red the board); the summary rides
326
+ # along as the cell detail.
327
+ notify:
328
+ needs: bump
329
+ if: ${{ !cancelled() }}
330
+ runs-on: x64
331
+ steps:
332
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
333
+ # out of the workspace for every later step in this job, and clears whatever
334
+ # the old repo-local default left behind on this runner. The body carries the
335
+ # why. It fails the job only when legacy litter survives -- which would take
336
+ # the checkout down a step later anyway, but report nothing useful.
337
+ - name: Route test-temp out of the workspace (pre-checkout)
338
+ run: |
339
+ set -uo pipefail
340
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
341
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
342
+ # host -- even when the launch passes --container-user (reproduced
343
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
344
+ # inotify constraint that binds macOS dev and nothing here, so every run
345
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
346
+ # cannot remove. That took commentary's checks AND integration red on
347
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
348
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
349
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
350
+ mkdir -p "$base"
351
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
352
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
353
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
354
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
355
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
356
+ done
357
+ # LEGACY: a runner that ran the old default still carries a workspace
358
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
359
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
360
+ # RESTARTING leaked container re-creates the path as root seconds after
361
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
362
+ # by construction, because a killed run labels nothing.
363
+ tt="$GITHUB_WORKSPACE/test-temp"
364
+ [ -d "$tt" ] || exit 0
365
+ stuck=""
366
+ for c in $(docker ps -aq 2>/dev/null || true); do
367
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
368
+ stuck="$stuck $c"
369
+ fi
370
+ done
371
+ if [ -n "$stuck" ]; then
372
+ echo "reaping containers holding mounts under $tt:$stuck"
373
+ docker rm -f $stuck || true
374
+ fi
375
+ # The DIRECTORY, not just its contents: an empty but root-owned base
376
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
377
+ # container unlink the leaf.
378
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
379
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
380
+ # Fail HERE if it survived. The checkout fails on it either way, but
381
+ # reports only an EACCES rmdir with no clue what was holding the path.
382
+ if [ -e "$tt" ]; then
383
+ echo "::error::$tt survived the pre-checkout clean"
384
+ ls -lan "$tt" || true
385
+ exit 1
386
+ fi
387
+
388
+ - uses: actions/checkout@v5
389
+ with:
390
+ clean: true
391
+ - id: meta
392
+ run: |
393
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
394
+ echo "state=failure" >> "$GITHUB_OUTPUT"
395
+ else
396
+ echo "state=success" >> "$GITHUB_OUTPUT"
397
+ fi
398
+ - uses: ./.github/actions/ci-status-dispatch
399
+ with:
400
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
401
+ product: __PRODUCT__
402
+ pipeline: nightly
403
+ status: ${{ steps.meta.outputs.state }}
404
+ detail: ${{ needs.bump.outputs.summary }}
405
+ - name: Reap the containers this job launched
406
+ # Whatever the tests did. A teardown cut short by a test timeout leaves
407
+ # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
408
+ # conventions/workflow-shell.ts); everything this job launched mounts its
409
+ # own test-temp, so that is what goes.
410
+ if: always()
411
+ run: |
412
+ base="${NORSK_CTL_TEST_TMP:-}"
413
+ tt="$GITHUB_WORKSPACE/test-temp"
414
+ s=""
415
+ for c in $(docker ps -aq); do
416
+ src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
417
+ if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
418
+ done
419
+ if [ -n "$s" ]; then
420
+ echo "::warning::removing containers this job left running:$s"
421
+ docker rm -f $s
422
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",