@norskvideo/ctl-dev-kit 0.1.94 → 0.1.96

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,64 @@
1
+ {
2
+ # Dev shell for a ctl "sdk-app" product — a bespoke Norsk SDK application
3
+ # (its runtime is its own daemon, not a composed Studio workflow launched by
4
+ # norsk-ctl). Single-sourced in @norskvideo/ctl-dev-kit (build/flake.sdk-app.nix)
5
+ # and copied verbatim into each sdk-app product repo (nix needs a flake.nix at
6
+ # the repo root for `nix develop`; it can't pull one from an npm package at CI
7
+ # bootstrap). Drift-gated for products whose package.json declares
8
+ # `"ctlProduct": { "shape": "sdk-app" }`.
9
+ #
10
+ # Unlike the Studio-product flake (build/flake.nix) this does NOT fetch the
11
+ # norsk-ctl daemon — an sdk-app's dev loop is its own SDK app plus a Norsk
12
+ # engine, not a ctl-launched product container. Decision: bun for tooling
13
+ # (install / build / test), Node for the shipped runtime (the SDK's blessed
14
+ # runtime).
15
+ #
16
+ # nixpkgs is pinned to the same rev the Studio flake uses, so bun matches the
17
+ # fleet's 1.3.13.
18
+ description = "norsk-ctl sdk-app product dev shell (bun tooling + node runtime)";
19
+
20
+ inputs = {
21
+ nixpkgs.url = "github:NixOS/nixpkgs/d233902339c02a9c334e7e593de68855ad26c4cb"; # nixpkgs-unstable 2026-05-15, bun 1.3.13
22
+ # Biome must match the version pinned in package.json (@biomejs/biome 2.5.5)
23
+ # exactly; the node_modules biome binary is glibc-linked and can't run on
24
+ # NixOS, so `bun run lint` execs this nix build via BIOME_BINARY. Keep this
25
+ # pin in step with package.json's @biomejs/biome version (same rev the Studio
26
+ # flake uses).
27
+ nixpkgs-biome.url = "github:NixOS/nixpkgs/a5cbcfe954791221bfffe2307f7d1a1bf61a871e"; # biome 2.5.5
28
+ };
29
+
30
+ outputs = { self, nixpkgs, nixpkgs-biome }:
31
+ let
32
+ systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
33
+ forAllSystems = f: builtins.listToAttrs (map (s: { name = s; value = f s; }) systems);
34
+ in {
35
+ devShells = forAllSystems (system:
36
+ let
37
+ pkgs = import nixpkgs {
38
+ inherit system;
39
+ config.allowUnfree = true;
40
+ };
41
+ biome = (import nixpkgs-biome { inherit system; }).biome;
42
+ ffmpegFull = pkgs.ffmpeg-full;
43
+ tools = [
44
+ biome # `bun run lint` execs this via BIOME_BINARY (see below)
45
+ pkgs.bun # tooling: install / build / test (fleet parity, dev-kit)
46
+ pkgs.nodejs # the shipped runtime (bin/run -> node lib/index.js); also
47
+ # parses @biomejs/biome's JS wrapper that reads BIOME_BINARY
48
+ pkgs.git
49
+ ffmpegFull # integration suites spawn a local ffmpeg (e.g. SRT egress capture)
50
+ ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.chromium ];
51
+ in {
52
+ default = pkgs.mkShell {
53
+ BIOME_BINARY = "${biome}/bin/biome";
54
+ buildInputs = tools;
55
+ shellHook = ''
56
+ export NIXPKGS_ALLOW_UNFREE=1
57
+ export FFMPEG_FULL=${ffmpegFull}
58
+ '' + pkgs.lib.optionalString pkgs.stdenv.isLinux ''
59
+ export BROWSER_FOR_TESTING=${pkgs.chromium}/bin/chromium
60
+ '';
61
+ };
62
+ });
63
+ };
64
+ }
@@ -109,12 +109,12 @@ function workflowProblem(
109
109
  export const CTL_VERSION_LINE = /ctlVersion = "[^"]*";/;
110
110
  export const CTL_HASH_LINE = /hash = "sha256-[^"]*";/g;
111
111
 
112
- function flakeProblem(actual: string, canonical: string): string | undefined {
112
+ function flakeProblem(actual: string, canonical: string, canonicalRef = "build/flake.nix"): string | undefined {
113
113
  const mask = (s: string) =>
114
114
  s.replace(CTL_VERSION_LINE, 'ctlVersion = "__CTL__";').replace(CTL_HASH_LINE, 'hash = "sha256-__HASH__";');
115
115
  const [ma, mc] = [mask(actual), mask(canonical)];
116
116
  if (ma === mc) return undefined;
117
- return `flake.nix has drifted from @norskvideo/ctl-dev-kit build/flake.nix (${firstDiffLine(ma, mc)}). ${RESYNC}`;
117
+ return `flake.nix has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(ma, mc)}). ${RESYNC}`;
118
118
  }
119
119
 
120
120
  // dprint.json is canonical-plus-extension: the shared config is verbatim, but a
@@ -276,6 +276,15 @@ export interface CanonicalBytes {
276
276
  * hand copy is how build-docs.yml came to fail in both turnkeys, which never
277
277
  * made one. */
278
278
  withDisplay: string;
279
+ /** build/flake.sdk-app.nix — the flake canonical for a `shape: "sdk-app"`
280
+ * product (bun tooling + node runtime, no norsk-ctl daemon fetch). Optional so
281
+ * a studio-only caller (and the existing test fixtures) need not supply it;
282
+ * required when a repo declares the sdk-app shape. */
283
+ flakeSdkApp?: string;
284
+ /** conventions/checks.sdk-app.yml — the checks.yml variant for an sdk-app: the
285
+ * Studio licence step removed (it reads shared/src/version.ts +
286
+ * product-template.ts). Optional for the same reason as flakeSdkApp. */
287
+ checksSdkApp?: string;
279
288
  }
280
289
 
281
290
  // 05-demo s6: a product that has a demo (tests/demo.spec.ts) exposes it the
@@ -348,11 +357,35 @@ function verbatimProblem(
348
357
  return undefined;
349
358
  }
350
359
 
360
+ // A product's shape. `studio` (the default, and every product before this) is a
361
+ // control-plane backend composing a Norsk Studio workflow launched by the
362
+ // norsk-ctl daemon. `sdk-app` is a bespoke Norsk SDK application whose runtime is
363
+ // its own daemon — no Studio workflow, no {media,studio} seed, no ctl-daemon
364
+ // flake, no product-template licence handshake. The shape selects which canonical
365
+ // a shape-variant file is gated against and which studio-only checks are skipped;
366
+ // absent ⇒ `studio`, so existing repos are unaffected.
367
+ export type ProductShape = "studio" | "sdk-app";
368
+
351
369
  export interface CheckDriftOptions {
352
370
  /** The GitHub repo name (`norsk-ctl-product-playout`), which decides whether the
353
371
  * public docs variant may be carried. The CLI resolves it ({@link resolveRepoName});
354
372
  * a fixture passes it. Unknown is allowed only while nothing depends on it. */
355
373
  repoName?: string;
374
+ /** The product shape ({@link resolveShape}); a fixture passes it. Absent ⇒ `studio`. */
375
+ shape?: ProductShape;
376
+ }
377
+
378
+ /** A product's shape from `package.json` `ctlProduct.shape`; `studio` when the
379
+ * field (or the file) is absent, so every existing repo keeps its behaviour. */
380
+ export function resolveShape(repoRoot: string): ProductShape {
381
+ try {
382
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as {
383
+ ctlProduct?: { shape?: string };
384
+ };
385
+ return pkg.ctlProduct?.shape === "sdk-app" ? "sdk-app" : "studio";
386
+ } catch {
387
+ return "studio";
388
+ }
356
389
  }
357
390
 
358
391
  /** `owner/name` from GITHUB_REPOSITORY (Actions), else the last path segment of
@@ -405,6 +438,7 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
405
438
  const push = (problem: string | undefined) => {
406
439
  if (problem) problems.push(problem);
407
440
  };
441
+ const shape: ProductShape = opts.shape ?? "studio";
408
442
 
409
443
  const claudePath = join(repoRoot, "CLAUDE.md");
410
444
  if (!existsSync(claudePath)) {
@@ -413,13 +447,20 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
413
447
  push(coreProblem(readFileSync(claudePath, "utf8"), canonical.core));
414
448
  }
415
449
 
450
+ // flake.nix is shape-selected: the studio flake fetches the norsk-ctl daemon,
451
+ // the sdk-app flake does not. Both are gated the same way (ctl pins masked; the
452
+ // sdk-app flake carries none, so it is effectively byte-verbatim).
416
453
  const flakePath = join(repoRoot, "flake.nix");
417
- if (!existsSync(flakePath)) {
454
+ const flakeCanon = shape === "sdk-app" ? canonical.flakeSdkApp : canonical.flake;
455
+ const flakeRef = shape === "sdk-app" ? "build/flake.sdk-app.nix" : "build/flake.nix";
456
+ if (flakeCanon === undefined) {
457
+ throw new Error(`checkDrift: shape "${shape}" requires canonical.${shape === "sdk-app" ? "flakeSdkApp" : "flake"}`);
458
+ } else if (!existsSync(flakePath)) {
418
459
  problems.push(
419
- `flake.nix not found at repo root (${flakePath}). It is copied verbatim from @norskvideo/ctl-dev-kit build/flake.nix.`,
460
+ `flake.nix not found at repo root (${flakePath}). It is copied verbatim from @norskvideo/ctl-dev-kit ${flakeRef}.`,
420
461
  );
421
462
  } else {
422
- const problem = flakeProblem(readFileSync(flakePath, "utf8"), canonical.flake);
463
+ const problem = flakeProblem(readFileSync(flakePath, "utf8"), flakeCanon, flakeRef);
423
464
  if (problem) problems.push(problem);
424
465
  }
425
466
  push(verbatimProblem(repoRoot, "biome.json", "conventions/biome.base.json", canonical.biome));
@@ -443,13 +484,18 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
443
484
  push(dprintProblem(readFileSync(dprintPath, "utf8"), canonical.dprint));
444
485
  }
445
486
 
446
- const seedPath = join(repoRoot, "manifest.seed.json");
447
- if (!existsSync(seedPath)) {
448
- problems.push(
449
- `manifest.seed.json not found at repo root (${seedPath}). Every product pins its {media, studio} pair there (RFC 0001 Workstream C).`,
450
- );
451
- } else {
452
- push(seedProblem(readFileSync(seedPath, "utf8")));
487
+ // manifest.seed.json pins a {media, studio} image pair — a Studio-product
488
+ // concept. An sdk-app is its own runtime (no Studio image), so it carries no
489
+ // seed and the check does not apply.
490
+ if (shape === "studio") {
491
+ const seedPath = join(repoRoot, "manifest.seed.json");
492
+ if (!existsSync(seedPath)) {
493
+ problems.push(
494
+ `manifest.seed.json not found at repo root (${seedPath}). Every product pins its {media, studio} pair there (RFC 0001 Workstream C).`,
495
+ );
496
+ } else {
497
+ push(seedProblem(readFileSync(seedPath, "utf8")));
498
+ }
453
499
  }
454
500
 
455
501
  const gitignorePath = join(repoRoot, ".gitignore");
@@ -461,13 +507,18 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
461
507
  push(gitignoreProblem(readFileSync(gitignorePath, "utf8"), canonical.gitignoreCore));
462
508
  }
463
509
 
464
- const buildImagePath = join(repoRoot, "deployment", "build-image.sh");
465
- if (!existsSync(buildImagePath)) {
466
- problems.push(
467
- `deployment/build-image.sh not found (${buildImagePath}). Every product ships the thin wrapper over the dev-kit build; see build/build-image.bootstrap.sh.`,
468
- );
469
- } else {
470
- push(buildImageProblem(readFileSync(buildImagePath, "utf8"), canonical.buildImageBootstrap));
510
+ // deployment/build-image.sh wraps the dev-kit bundle-image build (a
511
+ // daemon-launched product container on port 4321). An sdk-app ships its own
512
+ // container, not the bundle image, so the wrapper does not apply.
513
+ if (shape === "studio") {
514
+ const buildImagePath = join(repoRoot, "deployment", "build-image.sh");
515
+ if (!existsSync(buildImagePath)) {
516
+ problems.push(
517
+ `deployment/build-image.sh not found (${buildImagePath}). Every product ships the thin wrapper over the dev-kit build; see build/build-image.bootstrap.sh.`,
518
+ );
519
+ } else {
520
+ push(buildImageProblem(readFileSync(buildImagePath, "utf8"), canonical.buildImageBootstrap));
521
+ }
471
522
  }
472
523
 
473
524
  // checks.yml is REQUIRED — it is the CI entry point that runs this very gate
@@ -476,12 +527,18 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
476
527
  // upgrade-latest.yml's.
477
528
  const checksPath = join(repoRoot, ".github", "workflows", "checks.yml");
478
529
  const checksRel = ".github/workflows/checks.yml";
479
- if (!existsSync(checksPath)) {
530
+ const checksCanon = shape === "sdk-app" ? canonical.checksSdkApp : canonical.checks;
531
+ const checksRef = shape === "sdk-app" ? "conventions/checks.sdk-app.yml" : "conventions/checks.yml";
532
+ if (checksCanon === undefined) {
533
+ throw new Error(
534
+ `checkDrift: shape "${shape}" requires canonical.${shape === "sdk-app" ? "checksSdkApp" : "checks"}`,
535
+ );
536
+ } else if (!existsSync(checksPath)) {
480
537
  problems.push(
481
- `${checksRel} not found (${checksPath}). Copy conventions/checks.yml from @norskvideo/ctl-dev-kit and set \`product:\` to this repo's dashboard key.`,
538
+ `${checksRel} not found (${checksPath}). Copy ${checksRef} from @norskvideo/ctl-dev-kit and set \`product:\` to this repo's dashboard key.`,
482
539
  );
483
540
  } else {
484
- push(workflowProblem(checksRel, "conventions/checks.yml", readFileSync(checksPath, "utf8"), canonical.checks));
541
+ push(workflowProblem(checksRel, checksRef, readFileSync(checksPath, "utf8"), checksCanon));
485
542
  }
486
543
 
487
544
  // build-image.yml is REQUIRED — every product builds its image in CI (a
@@ -489,22 +546,27 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
489
546
  // lines are per-repo: the notify job's `product:` dispatch key and the
490
547
  // PUBLISH_IMAGE env (the Docker Hub repo, or empty for a build-only product);
491
548
  // both are masked.
492
- const buildImageYmlPath = join(repoRoot, ".github", "workflows", "build-image.yml");
493
- const buildImageYmlRel = ".github/workflows/build-image.yml";
494
- if (!existsSync(buildImageYmlPath)) {
495
- problems.push(
496
- `${buildImageYmlRel} not found (${buildImageYmlPath}). Copy conventions/build-image.yml from @norskvideo/ctl-dev-kit, set \`product:\` to this repo's dashboard key, and set PUBLISH_IMAGE to its Docker Hub repo (empty for a build-only product).`,
497
- );
498
- } else {
499
- push(
500
- workflowProblem(
501
- buildImageYmlRel,
502
- "conventions/build-image.yml",
503
- readFileSync(buildImageYmlPath, "utf8"),
504
- canonical.buildImage,
505
- [PRODUCT_LINE, PUBLISH_IMAGE_LINE],
506
- ),
507
- );
549
+ // build-image.yml builds the bundle image in CI — studio-product-shaped, so
550
+ // required only for the studio shape. (An sdk-app's own container build is not
551
+ // the dev-kit bundle image; it is out of scope for the shared gate today.)
552
+ if (shape === "studio") {
553
+ const buildImageYmlPath = join(repoRoot, ".github", "workflows", "build-image.yml");
554
+ const buildImageYmlRel = ".github/workflows/build-image.yml";
555
+ if (!existsSync(buildImageYmlPath)) {
556
+ problems.push(
557
+ `${buildImageYmlRel} not found (${buildImageYmlPath}). Copy conventions/build-image.yml from @norskvideo/ctl-dev-kit, set \`product:\` to this repo's dashboard key, and set PUBLISH_IMAGE to its Docker Hub repo (empty for a build-only product).`,
558
+ );
559
+ } else {
560
+ push(
561
+ workflowProblem(
562
+ buildImageYmlRel,
563
+ "conventions/build-image.yml",
564
+ readFileSync(buildImageYmlPath, "utf8"),
565
+ canonical.buildImage,
566
+ [PRODUCT_LINE, PUBLISH_IMAGE_LINE],
567
+ ),
568
+ );
569
+ }
508
570
  }
509
571
 
510
572
  // upgrade-latest.yml is OPTIONAL — a product may ship without a nightly bump
@@ -661,8 +723,13 @@ if (import.meta.main) {
661
723
  smoke: readFileSync(join(import.meta.dir, "smoke.yml"), "utf8"),
662
724
  integration: readFileSync(join(import.meta.dir, "integration.yml"), "utf8"),
663
725
  demoShim: readFileSync(join(import.meta.dir, "demo.sh"), "utf8"),
726
+ flakeSdkApp: readFileSync(join(import.meta.dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
727
+ checksSdkApp: readFileSync(join(import.meta.dir, "checks.sdk-app.yml"), "utf8"),
664
728
  };
665
- const report = checkDrift(repoRoot, canonical, { repoName: resolveRepoName(repoRoot) });
729
+ const report = checkDrift(repoRoot, canonical, {
730
+ repoName: resolveRepoName(repoRoot),
731
+ shape: resolveShape(repoRoot),
732
+ });
666
733
  if (report.ok) {
667
734
  console.log("drift-check: all shared-convention copies match @norskvideo/ctl-dev-kit.");
668
735
  process.exit(0);
@@ -0,0 +1,276 @@
1
+ # Pre-merge gates for an "sdk-app" product, single-sourced in @norskvideo/ctl-dev-kit
2
+ # (conventions/checks.sdk-app.yml) and drift-checked — only the ci-status-dispatch
3
+ # `product:` key is per-repo. This is the checks.yml variant for a bespoke-SDK
4
+ # product: identical to the Studio checks.yml EXCEPT it omits the licence step
5
+ # (that step reads shared/src/version.ts + product-template.ts, which a
6
+ # non-Studio product does not have). Keep the two in lockstep otherwise.
7
+ #
8
+ # Two jobs:
9
+ # - drift: the RFC 0001 Workstream I shared-conventions drift-check. Fails if
10
+ # this repo's forced copies have diverged from the pinned dev-kit — the
11
+ # fenced CLAUDE.md core, flake.nix, the shared config files, the manifest
12
+ # seed. Copies exist so a human reads them on GitHub with no tooling; the
13
+ # gate is the only thing keeping them true.
14
+ # - quality: the repo's own lint + typecheck + unit tests + docs path lint,
15
+ # via the shared package.json script interface every product carries. The
16
+ # "whole project clean" convention in the CLAUDE core has no teeth without
17
+ # this; docs:check is what keeps README/CLAUDE/docs paths true.
18
+ #
19
+ # Both run the same proven way build-image does — inside `nix develop .#build`
20
+ # (which provides bun) on the x64 self-hosted pool — but skip the Docker image
21
+ # build, so they also gate pull requests. To re-sync after a legitimate dev-kit
22
+ # change: bump the @norskvideo/ctl-dev-kit pin, then copy the new files in.
23
+ name: checks
24
+
25
+ on:
26
+ push:
27
+ branches: [main]
28
+ pull_request:
29
+
30
+ permissions:
31
+ contents: read
32
+
33
+ jobs:
34
+ drift:
35
+ runs-on: x64
36
+ steps:
37
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
38
+ # out of the workspace for every later step in this job, and clears whatever
39
+ # the old repo-local default left behind on this runner. The body carries the
40
+ # why. It fails the job only when legacy litter survives -- which would take
41
+ # the checkout down a step later anyway, but report nothing useful.
42
+ - name: Route test-temp out of the workspace (pre-checkout)
43
+ run: |
44
+ set -uo pipefail
45
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
46
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
47
+ # host -- even when the launch passes --container-user (reproduced
48
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
49
+ # inotify constraint that binds macOS dev and nothing here, so every run
50
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
51
+ # cannot remove. That took commentary's checks AND integration red on
52
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
53
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
54
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
55
+ mkdir -p "$base"
56
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
57
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
58
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
59
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
60
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
61
+ done
62
+ # LEGACY: a runner that ran the old default still carries a workspace
63
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
64
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
65
+ # RESTARTING leaked container re-creates the path as root seconds after
66
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
67
+ # by construction, because a killed run labels nothing.
68
+ tt="$GITHUB_WORKSPACE/test-temp"
69
+ [ -d "$tt" ] || exit 0
70
+ stuck=""
71
+ for c in $(docker ps -aq 2>/dev/null || true); do
72
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
73
+ stuck="$stuck $c"
74
+ fi
75
+ done
76
+ if [ -n "$stuck" ]; then
77
+ echo "reaping containers holding mounts under $tt:$stuck"
78
+ docker rm -f $stuck || true
79
+ fi
80
+ # The DIRECTORY, not just its contents: an empty but root-owned base
81
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
82
+ # container unlink the leaf.
83
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
84
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
85
+ # Fail HERE if it survived. The checkout fails on it either way, but
86
+ # reports only an EACCES rmdir with no clue what was holding the path.
87
+ if [ -e "$tt" ]; then
88
+ echo "::error::$tt survived the pre-checkout clean"
89
+ ls -lan "$tt" || true
90
+ exit 1
91
+ fi
92
+
93
+ - uses: actions/checkout@v5
94
+ with:
95
+ clean: true
96
+ - name: Shared-conventions drift-check (Workstream I)
97
+ run: |
98
+ nix develop .#build --command bash -c '
99
+ set -euo pipefail
100
+ # Belt-and-braces now that the checkout cleans: a persisted
101
+ # node_modules would defeat a frozen install, which does NOT relink a
102
+ # dep whose pinned version moved (a dev-kit bump leaves a stale
103
+ # node_modules/@norskvideo/ctl-dev-kit, so check:drift would run the
104
+ # OLD checker against the new canonical). Cheap when already gone.
105
+ find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
106
+ bun install --frozen-lockfile
107
+ bun run check:drift
108
+ '
109
+
110
+ quality:
111
+ runs-on: x64
112
+ steps:
113
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
114
+ # out of the workspace for every later step in this job, and clears whatever
115
+ # the old repo-local default left behind on this runner. The body carries the
116
+ # why. It fails the job only when legacy litter survives -- which would take
117
+ # the checkout down a step later anyway, but report nothing useful.
118
+ - name: Route test-temp out of the workspace (pre-checkout)
119
+ run: |
120
+ set -uo pipefail
121
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
122
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
123
+ # host -- even when the launch passes --container-user (reproduced
124
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
125
+ # inotify constraint that binds macOS dev and nothing here, so every run
126
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
127
+ # cannot remove. That took commentary's checks AND integration red on
128
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
129
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
130
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
131
+ mkdir -p "$base"
132
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
133
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
134
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
135
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
136
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
137
+ done
138
+ # LEGACY: a runner that ran the old default still carries a workspace
139
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
140
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
141
+ # RESTARTING leaked container re-creates the path as root seconds after
142
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
143
+ # by construction, because a killed run labels nothing.
144
+ tt="$GITHUB_WORKSPACE/test-temp"
145
+ [ -d "$tt" ] || exit 0
146
+ stuck=""
147
+ for c in $(docker ps -aq 2>/dev/null || true); do
148
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
149
+ stuck="$stuck $c"
150
+ fi
151
+ done
152
+ if [ -n "$stuck" ]; then
153
+ echo "reaping containers holding mounts under $tt:$stuck"
154
+ docker rm -f $stuck || true
155
+ fi
156
+ # The DIRECTORY, not just its contents: an empty but root-owned base
157
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
158
+ # container unlink the leaf.
159
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
160
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
161
+ # Fail HERE if it survived. The checkout fails on it either way, but
162
+ # reports only an EACCES rmdir with no clue what was holding the path.
163
+ if [ -e "$tt" ]; then
164
+ echo "::error::$tt survived the pre-checkout clean"
165
+ ls -lan "$tt" || true
166
+ exit 1
167
+ fi
168
+
169
+ - uses: actions/checkout@v5
170
+ with:
171
+ clean: true
172
+ - name: Lint + typecheck + unit tests
173
+ run: |
174
+ nix develop .#build --command bash -c '
175
+ set -euo pipefail
176
+ # Belt-and-braces now that the checkout cleans: a persisted
177
+ # node_modules would defeat a frozen install, which does NOT relink a
178
+ # dep whose pinned version moved (a dev-kit bump leaves a stale
179
+ # node_modules/@norskvideo/ctl-dev-kit, so check:drift would run the
180
+ # OLD checker against the new canonical). Cheap when already gone.
181
+ find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
182
+ bun install --frozen-lockfile
183
+ bun run lint
184
+ bun run typecheck
185
+ bun run test:unit
186
+ bun run docs:check
187
+ # The tree must resolve ONE zod 4 (conventions/zod-singleton.ts).
188
+ # studio guards this in-repo but never shipped the guard; products
189
+ # are where studio/ctl/moq-watch zod ranges meet, so a split slips
190
+ # in here and breaks a schema handed across the instance boundary.
191
+ bun node_modules/@norskvideo/ctl-dev-kit/conventions/zod-singleton.ts
192
+ '
193
+
194
+ # Report this pipeline's result to the aggregated product CI dashboard
195
+ # (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
196
+ # the pony/emoji from the dispatched result. !cancelled() so a real pass/fail
197
+ # reports, but a cancelled/superseded run does NOT — a cancellation dispatched
198
+ # as failure would false-red the board on every drained or concurrency-
199
+ # superseded run.
200
+ notify:
201
+ needs: [drift, quality]
202
+ if: ${{ !cancelled() && github.event_name == 'push' }}
203
+ runs-on: x64
204
+ steps:
205
+ # Runs BEFORE checkout, and does two things: points the harness's temp base
206
+ # out of the workspace for every later step in this job, and clears whatever
207
+ # the old repo-local default left behind on this runner. The body carries the
208
+ # why. It fails the job only when legacy litter survives -- which would take
209
+ # the checkout down a step later anyway, but report nothing useful.
210
+ - name: Route test-temp out of the workspace (pre-checkout)
211
+ run: |
212
+ set -uo pipefail
213
+ # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
214
+ # HOST daemon has not seen before is created by dockerd, as ROOT, on the
215
+ # host -- even when the launch passes --container-user (reproduced
216
+ # 2026-09-04). The harness's default base is repo-local for an OrbStack
217
+ # inotify constraint that binds macOS dev and nothing here, so every run
218
+ # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
219
+ # cannot remove. That took commentary's checks AND integration red on
220
+ # 2026-09-03. Use the sanctioned override, per-run and outside the
221
+ # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
222
+ base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
223
+ mkdir -p "$base"
224
+ echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
225
+ # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
226
+ # EACCESes. They cannot wedge a checkout, but they do fill the disk.
227
+ for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
228
+ docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
229
+ done
230
+ # LEGACY: a runner that ran the old default still carries a workspace
231
+ # test-temp, and a checkout with clean:true dies on it. Reap the holders
232
+ # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
233
+ # RESTARTING leaked container re-creates the path as root seconds after
234
+ # any clean. Match on mount SOURCE, not a label: the holders are untracked
235
+ # by construction, because a killed run labels nothing.
236
+ tt="$GITHUB_WORKSPACE/test-temp"
237
+ [ -d "$tt" ] || exit 0
238
+ stuck=""
239
+ for c in $(docker ps -aq 2>/dev/null || true); do
240
+ if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
241
+ stuck="$stuck $c"
242
+ fi
243
+ done
244
+ if [ -n "$stuck" ]; then
245
+ echo "reaping containers holding mounts under $tt:$stuck"
246
+ docker rm -f $stuck || true
247
+ fi
248
+ # The DIRECTORY, not just its contents: an empty but root-owned base
249
+ # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
250
+ # container unlink the leaf.
251
+ docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
252
+ sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
253
+ # Fail HERE if it survived. The checkout fails on it either way, but
254
+ # reports only an EACCES rmdir with no clue what was holding the path.
255
+ if [ -e "$tt" ]; then
256
+ echo "::error::$tt survived the pre-checkout clean"
257
+ ls -lan "$tt" || true
258
+ exit 1
259
+ fi
260
+
261
+ - uses: actions/checkout@v5
262
+ with:
263
+ clean: true
264
+ - id: meta
265
+ run: |
266
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
267
+ echo "state=failure" >> "$GITHUB_OUTPUT"
268
+ else
269
+ echo "state=success" >> "$GITHUB_OUTPUT"
270
+ fi
271
+ - uses: ./.github/actions/ci-status-dispatch
272
+ with:
273
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
274
+ product: __PRODUCT__
275
+ pipeline: checks
276
+ status: ${{ steps.meta.outputs.state }}
@@ -31,8 +31,10 @@ import {
31
31
  GITIGNORE_END,
32
32
  PRODUCT_LINE,
33
33
  PRODUCT_SENTINEL,
34
+ type ProductShape,
34
35
  PUBLISH_IMAGE_LINE,
35
36
  RUNNER_LINE,
37
+ resolveShape,
36
38
  splitAtMarkers,
37
39
  } from "./check-drift.ts";
38
40
 
@@ -258,13 +260,20 @@ function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): vo
258
260
  chmodSync(path, 0o755);
259
261
  }
260
262
 
261
- export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncReport {
263
+ export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: ProductShape = "studio"): SyncReport {
262
264
  const r: SyncReport = { written: [], skipped: [] };
265
+ // Shape-selected canonicals: an sdk-app's flake + checks are its own variants
266
+ // (the gate holds it to those). Everything else is shared across shapes.
267
+ const checksCanon = shape === "sdk-app" ? canonical.checksSdkApp : canonical.checks;
268
+ const flakeCanon = shape === "sdk-app" ? canonical.flakeSdkApp : canonical.flake;
269
+ if (checksCanon === undefined || flakeCanon === undefined) {
270
+ throw new Error(`syncDrift: shape "${shape}" requires canonical.flakeSdkApp and canonical.checksSdkApp`);
271
+ }
263
272
 
264
273
  writeIfChanged(join(repoRoot, "biome.json"), canonical.biome, "biome.json", r.written);
265
274
  writeIfChanged(join(repoRoot, "tsconfig.base.json"), canonical.tsconfigBase, "tsconfig.base.json", r.written);
266
275
 
267
- syncWorkflow(repoRoot, ".github/workflows/checks.yml", canonical.checks, r);
276
+ syncWorkflow(repoRoot, ".github/workflows/checks.yml", checksCanon, r);
268
277
  syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", canonical.upgradeLatest, r);
269
278
  syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
270
279
  // No per-repo line to preserve, so it is written whole -- and CREATED where
@@ -276,7 +285,9 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
276
285
  ".github/workflows/sync-ctl-packages.yml",
277
286
  r.written,
278
287
  );
279
- syncBuildImage(repoRoot, canonical.buildImage, r);
288
+ // build-image.yml is the Studio bundle-image workflow; an sdk-app ships its
289
+ // own container, so it is not synced for that shape.
290
+ if (shape === "studio") syncBuildImage(repoRoot, canonical.buildImage, r);
280
291
  syncSmoke(repoRoot, canonical.smoke, r);
281
292
  syncIntegration(repoRoot, canonical.integration, r);
282
293
 
@@ -293,7 +304,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
293
304
  writeIfChanged(join(repoRoot, buildDocsRel), canonical.buildDocs, buildDocsRel, r.written);
294
305
  }
295
306
 
296
- syncFlake(repoRoot, canonical.flake, r);
307
+ syncFlake(repoRoot, flakeCanon, r);
297
308
  syncClaude(repoRoot, canonical.core, r);
298
309
  syncGitignore(repoRoot, canonical.gitignoreCore, r);
299
310
  syncDprint(repoRoot, canonical.dprint, r);
@@ -325,8 +336,10 @@ if (import.meta.main) {
325
336
  smoke: readFileSync(join(dir, "smoke.yml"), "utf8"),
326
337
  integration: readFileSync(join(dir, "integration.yml"), "utf8"),
327
338
  demoShim: readFileSync(join(dir, "demo.sh"), "utf8"),
339
+ flakeSdkApp: readFileSync(join(dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
340
+ checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"),
328
341
  };
329
- const report = syncDrift(repoRoot, canonical);
342
+ const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot));
330
343
  if (report.written.length === 0) {
331
344
  console.log("sync-drift: all shared-convention copies already match @norskvideo/ctl-dev-kit.");
332
345
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.94",
3
+ "version": "0.1.96",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",