@norskvideo/ctl-dev-kit 0.2.42 → 0.2.44

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,110 @@
1
+ # The shared pieces of every ctl product's dev shell. Single-sourced in
2
+ # @norskvideo/ctl-dev-kit (build/ctl-shell.nix), copied verbatim to
3
+ # nix/ctl-shell.nix in each product repo and drift-gated; norsk-ctl's own flake
4
+ # imports this file in place, so ctl and every product get the same browser.
5
+ # A product's flake.nix is its own: it imports this and adds what it alone needs.
6
+ #
7
+ # A plain function rather than a flake input: a flake only sees git-tracked
8
+ # files, so it cannot reach into node_modules, and a private-repo input would
9
+ # need GitHub auth in every shell and CI runner.
10
+ { pkgs, biome }:
11
+ let
12
+ # Playwright doc-guide browser. nix-pinned headless Chromium needs a real X
13
+ # display on a truly headless box (page.fill silently no-ops on plain HTML
14
+ # inputs; some pages crash the renderer). Ubuntu's apt Xvfb is too old for
15
+ # Chromium 148's input dispatch — the nix xorg-server works. A product's
16
+ # scripts/with-display.sh wraps playwright in xvfb-run iff DISPLAY is unset.
17
+ browserInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
18
+ pkgs.chromium
19
+ pkgs.xvfb-run
20
+ pkgs.xorg.xorgserver
21
+ ];
22
+ # Headless Chromium finds no system fonts on NixOS, so text renders at zero
23
+ # advance width — invisible, and every toBeVisible() on text times out. Point
24
+ # fontconfig at a minimal bundled set so glyphs have real metrics.
25
+ fontsConf = pkgs.makeFontsConf {
26
+ fontDirectories = [ pkgs.dejavu_fonts pkgs.liberation_ttf ];
27
+ };
28
+ browserHook = pkgs.lib.optionalString pkgs.stdenv.isLinux ''
29
+ export BROWSER_FOR_TESTING=${pkgs.chromium}/bin/chromium
30
+ export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
31
+ export FONTCONFIG_FILE=${fontsConf}
32
+ '' + pkgs.lib.optionalString pkgs.stdenv.isDarwin ''
33
+ # nixpkgs has no darwin chromium, so thread the ambient Chrome
34
+ # through the same variable everything already honours.
35
+ macos_chrome="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
36
+ if [ -z "''${BROWSER_FOR_TESTING:-}" ] && [ -x "$macos_chrome" ]; then
37
+ export BROWSER_FOR_TESTING="$macos_chrome"
38
+ export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
39
+ fi
40
+ unset macos_chrome
41
+ '';
42
+
43
+ buildTools = [
44
+ biome
45
+ pkgs.bun
46
+ pkgs.nodejs # @biomejs/biome's bin is a JS wrapper, not the binary — it is
47
+ # what reads BIOME_BINARY and execs the nix build. Something has
48
+ # to *parse* that wrapper first, so a modern node has to be on
49
+ # PATH or the host's is used: on an older distro node (Ubuntu
50
+ # noble ships v12) `bun run lint` dies on the wrapper's optional
51
+ # chaining before biome ever starts.
52
+ pkgs.cargo # builds the native product-signer addon (probe/native)
53
+ pkgs.rustc
54
+ pkgs.git
55
+ pkgs.ffmpeg # integration suites spawn a local ffmpeg (e.g. SRT egress capture)
56
+ pkgs.imagemagick # doc-guide manual assembly shells out to `magick` to embed captures
57
+ pkgs.gh # publish-docs uploads the built manual via `gh release`; runners have no system gh
58
+ ];
59
+
60
+ # The released norsk-ctl daemon, pinned by version + per-platform hash in the
61
+ # repo's own flake.nix (the nightly bumps it there). It is NOT built from
62
+ # source: a product dev consumes the shipped ctl exactly as a customer does.
63
+ mkCtl = { version, asset, base ? "https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl" }:
64
+ pkgs.stdenv.mkDerivation {
65
+ pname = "norsk-ctl";
66
+ inherit version;
67
+ src = pkgs.fetchurl {
68
+ url = "${base}/${version}/norsk-ctl-${version}-${asset.plat}";
69
+ hash = asset.hash;
70
+ };
71
+ dontUnpack = true;
72
+ # bun single-file executables embed a trailing payload; stripping
73
+ # corrupts them, and on Linux the raw binary needs its interpreter +
74
+ # libstdc++ rpath patched to the nix store.
75
+ dontStrip = true;
76
+ nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.autoPatchelfHook ];
77
+ buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ];
78
+ installPhase = ''
79
+ runHook preInstall
80
+ install -Dm755 "$src" "$out/bin/norsk-ctl"
81
+ runHook postInstall
82
+ '';
83
+ };
84
+
85
+ # Biome from its own pin so `bun run lint` works on NixOS: the node_modules
86
+ # @biomejs/biome binary is glibc-linked and can't run there. Each shell
87
+ # exports BIOME_BINARY so the npm wrapper execs this nix build instead.
88
+ shell = buildInputs: shellHook: pkgs.mkShell {
89
+ BIOME_BINARY = "${biome}/bin/biome";
90
+ inherit buildInputs shellHook;
91
+ };
92
+ in
93
+ {
94
+ inherit browserInputs browserHook buildTools mkCtl shell;
95
+
96
+ # The Studio-product shells:
97
+ # default / dev -> build tools + the pinned norsk-ctl daemon on PATH
98
+ # build -> build tools only; what CI (build-image / integration)
99
+ # uses, so image builds never depend on the ctl channel.
100
+ mkProductShells = { ctl, extraInputs ? [ ], extraShellHook ? "" }:
101
+ let
102
+ inputs = buildTools ++ browserInputs ++ extraInputs;
103
+ hook = browserHook + extraShellHook;
104
+ in
105
+ {
106
+ default = shell (inputs ++ [ ctl ]) hook;
107
+ dev = shell (inputs ++ [ ctl ]) hook;
108
+ build = shell inputs hook;
109
+ };
110
+ }
package/build/flake.nix CHANGED
@@ -1,20 +1,19 @@
1
- # Product build + dev shell — single-sourced in @norskvideo/ctl-dev-kit and
2
- # copied verbatim into each split product repo (nix needs a flake.nix at the repo
3
- # root for `nix develop`; it can't pull one from an npm package at CI bootstrap).
4
- # The copy is to be drift-gated (Workstream I; not yet enforced), same model as
5
- # the fenced CLAUDE.md core.
1
+ # This product's build + dev shells. The file is the product's own: add what
2
+ # this product alone needs through mkProductShells' extraInputs /
3
+ # extraShellHook. The shared pieces (build tools, the test browser, biome, the
4
+ # norsk-ctl fetch) come from nix/ctl-shell.nix — a verbatim, drift-gated copy of
5
+ # @norskvideo/ctl-dev-kit build/ctl-shell.nix; edit that in the dev-kit, never
6
+ # the copy. The dev-kit's build/flake.nix is the starter a new product begins
7
+ # from.
6
8
  #
7
9
  # Three shells:
8
- # nix develop -> build tools (bun, cargo/rustc, git) + the pinned
9
- # nix develop .#dev `norsk-ctl` daemon on PATH. The everyday product dev
10
- # shell; the two are equivalent.
10
+ # nix develop -> build tools + the pinned `norsk-ctl` daemon on PATH.
11
+ # nix develop .#dev The everyday product dev shell; the two are equivalent.
11
12
  # nix develop .#build -> build tools only, no ctl. What CI (build-image /
12
13
  # integration) uses, so image builds never depend on the
13
14
  # ctl binary channel.
14
15
  #
15
- # `norsk-ctl` is the released daemon binary pulled from the S3 channel and pinned
16
- # by version+hash (reproducible). It is NOT built from source here — a product
17
- # dev consumes the shipped ctl exactly as a customer does. Bump with:
16
+ # The norsk-ctl pin below is bumped nightly by upgrade-latest; by hand with
18
17
  # packages/dev-kit/build/refresh-ctl-pin.sh (writes ctlVersion + hashes)
19
18
  {
20
19
  description = "norsk-ctl product build + dev shell";
@@ -37,7 +36,6 @@
37
36
  # --- norsk-ctl channel pin -------------------------------------------
38
37
  # The released daemon, one build per platform. Bump these together.
39
38
  ctlVersion = "0.1.0-2026-09-15-81bbde8";
40
- ctlBase = "https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl";
41
39
  ctlAsset = {
42
40
  "x86_64-linux" = { plat = "linux-x64"; hash = "sha256-6Zm1iLs29o89tav+TEDfLLdq2E+blku7DSxZVAqEwF0="; };
43
41
  "aarch64-linux" = { plat = "linux-arm64"; hash = "sha256-ziH0YFLc8j7+xuYWArCU1JzWgq3NsFvfq67FqjlxdI8="; };
@@ -45,108 +43,18 @@
45
43
  "x86_64-darwin" = { plat = "darwin-x64"; hash = "sha256-GzlT0Rg9t8sAG6e7Deu+DzSbfSxcr+WpVmyOgit2Q4g="; };
46
44
  };
47
45
 
48
- mkCtl = system:
49
- let
50
- pkgs = import nixpkgs { inherit system; };
51
- asset = ctlAsset.${system};
52
- in
53
- pkgs.stdenv.mkDerivation {
54
- pname = "norsk-ctl";
55
- version = ctlVersion;
56
- src = pkgs.fetchurl {
57
- url = "${ctlBase}/${ctlVersion}/norsk-ctl-${ctlVersion}-${asset.plat}";
58
- hash = asset.hash;
59
- };
60
- dontUnpack = true;
61
- # bun single-file executables embed a trailing payload; stripping
62
- # corrupts them, and on Linux the raw binary needs its interpreter +
63
- # libstdc++ rpath patched to the nix store.
64
- dontStrip = true;
65
- nativeBuildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.autoPatchelfHook ];
66
- buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ];
67
- installPhase = ''
68
- runHook preInstall
69
- install -Dm755 "$src" "$out/bin/norsk-ctl"
70
- runHook postInstall
71
- '';
72
- };
46
+ ctlShell = system: import ./nix/ctl-shell.nix {
47
+ pkgs = import nixpkgs { inherit system; };
48
+ biome = (import nixpkgs-biome { inherit system; }).biome;
49
+ };
73
50
  in {
74
51
  packages = forAllSystems (system: {
75
- norsk-ctl = mkCtl system;
52
+ norsk-ctl = (ctlShell system).mkCtl { version = ctlVersion; asset = ctlAsset.${system}; };
76
53
  });
77
54
 
78
55
  devShells = forAllSystems (system:
79
- let
80
- pkgs = import nixpkgs { inherit system; };
81
- # Biome from its own pin so `bun run lint` works on NixOS: the
82
- # node_modules @biomejs/biome binary is glibc-linked and can't run
83
- # here. Each shell exports BIOME_BINARY so the npm wrapper execs this
84
- # nix build instead -- no patchelf, no per-command env var. Keep the
85
- # pin in step with package.json's @biomejs/biome version.
86
- biome = (import nixpkgs-biome { inherit system; }).biome;
87
- buildTools = [
88
- biome
89
- pkgs.bun
90
- pkgs.nodejs # @biomejs/biome's bin is a JS wrapper, not the binary —
91
- # it is what reads BIOME_BINARY below and execs the nix
92
- # build. Something has to *parse* that wrapper first, so a
93
- # modern node has to be on PATH or the host's is used: on
94
- # an older distro node (Ubuntu noble ships v12) `bun run
95
- # lint` dies on the wrapper's optional chaining before
96
- # biome ever starts. norsk-ctl carries nodejs for kubb and
97
- # so never hit this; the split-out products had nothing
98
- # putting node in the shell.
99
- pkgs.cargo # builds the native product-signer addon (probe/native)
100
- pkgs.rustc
101
- pkgs.git
102
- pkgs.ffmpeg # integration suites spawn a local ffmpeg (e.g. SRT egress capture)
103
- pkgs.imagemagick # doc-guide manual assembly shells out to `magick` to embed captures
104
- pkgs.gh # publish-docs uploads the built manual via `gh release`; runners have no system gh
105
- ];
106
-
107
- # Playwright doc-guide browser. nix-pinned headless Chromium needs a
108
- # real X display on a truly headless box (page.fill silently no-ops on
109
- # plain HTML inputs; some pages crash the renderer). Ubuntu's apt Xvfb
110
- # is too old for Chromium 148's input dispatch — the nix xorg-server
111
- # works. A product's scripts/with-display.sh wraps playwright in
112
- # xvfb-run iff DISPLAY is unset.
113
- linuxBrowser = pkgs.lib.optionals pkgs.stdenv.isLinux [
114
- pkgs.chromium
115
- pkgs.xvfb-run
116
- pkgs.xorg.xorgserver
117
- ];
118
- # Headless Chromium finds no system fonts on NixOS, so text renders at
119
- # zero advance width — invisible, and every toBeVisible() on text times
120
- # out. Point fontconfig at a minimal bundled set so glyphs have real
121
- # metrics.
122
- fontsConf = pkgs.makeFontsConf {
123
- fontDirectories = [ pkgs.dejavu_fonts pkgs.liberation_ttf ];
124
- };
125
- browserHook = pkgs.lib.optionalString pkgs.stdenv.isLinux ''
126
- export BROWSER_FOR_TESTING=${pkgs.chromium}/bin/chromium
127
- export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
128
- export FONTCONFIG_FILE=${fontsConf}
129
- '';
130
- in {
131
- # Everyday dev shell — build tools + the pinned norsk-ctl daemon.
132
- # `nix develop` and `nix develop .#dev` are equivalent.
133
- default = pkgs.mkShell {
134
- BIOME_BINARY = "${biome}/bin/biome";
135
- buildInputs = buildTools ++ linuxBrowser ++ [ (mkCtl system) ];
136
- shellHook = browserHook;
137
- };
138
- dev = pkgs.mkShell {
139
- BIOME_BINARY = "${biome}/bin/biome";
140
- buildInputs = buildTools ++ linuxBrowser ++ [ (mkCtl system) ];
141
- shellHook = browserHook;
142
- };
143
- # Lean shell for CI (build-image / integration) — no ctl fetch, so
144
- # image builds never depend on the ctl binary channel.
145
- build = pkgs.mkShell {
146
- BIOME_BINARY = "${biome}/bin/biome";
147
- buildInputs = buildTools ++ linuxBrowser;
148
- shellHook = browserHook;
149
- };
56
+ (ctlShell system).mkProductShells {
57
+ ctl = self.packages.${system}.norsk-ctl;
150
58
  });
151
59
  };
152
60
  }
@@ -1,17 +1,15 @@
1
1
  {
2
2
  # Dev shell for a ctl "sdk-app" product — a bespoke Norsk SDK application
3
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" }`.
4
+ # norsk-ctl). The file is the product's own; the dev-kit's
5
+ # build/flake.sdk-app.nix is the starter a new sdk-app begins from. The shared
6
+ # pieces (the test browser, biome) come from nix/ctl-shell.nix — a verbatim,
7
+ # drift-gated copy of @norskvideo/ctl-dev-kit build/ctl-shell.nix.
9
8
  #
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).
9
+ # Unlike the Studio-product flake this does NOT fetch the norsk-ctl daemon —
10
+ # an sdk-app's dev loop is its own SDK app plus a Norsk engine, not a
11
+ # ctl-launched product container. Decision: bun for tooling (install / build /
12
+ # test), Node for the shipped runtime (the SDK's blessed runtime).
15
13
  #
16
14
  # nixpkgs is pinned to the same rev the Studio flake uses, so bun matches the
17
15
  # fleet's 1.3.13.
@@ -39,25 +37,20 @@
39
37
  config.allowUnfree = true;
40
38
  };
41
39
  biome = (import nixpkgs-biome { inherit system; }).biome;
40
+ ctlShell = import ./nix/ctl-shell.nix { inherit pkgs biome; };
42
41
  ffmpegFull = pkgs.ffmpeg-full;
43
42
  tools = [
44
- biome # `bun run lint` execs this via BIOME_BINARY (see below)
43
+ biome # `bun run lint` execs this via BIOME_BINARY
45
44
  pkgs.bun # tooling: install / build / test (fleet parity, dev-kit)
46
45
  pkgs.nodejs # the shipped runtime (bin/run -> node lib/index.js); also
47
46
  # parses @biomejs/biome's JS wrapper that reads BIOME_BINARY
48
47
  pkgs.git
49
48
  ffmpegFull # integration suites spawn a local ffmpeg (e.g. SRT egress capture)
50
- ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.chromium ];
51
- shell = pkgs.mkShell {
52
- BIOME_BINARY = "${biome}/bin/biome";
53
- buildInputs = tools;
54
- shellHook = ''
55
- export NIXPKGS_ALLOW_UNFREE=1
56
- export FFMPEG_FULL=${ffmpegFull}
57
- '' + pkgs.lib.optionalString pkgs.stdenv.isLinux ''
58
- export BROWSER_FOR_TESTING=${pkgs.chromium}/bin/chromium
59
- '';
60
- };
49
+ ] ++ ctlShell.browserInputs;
50
+ shell = ctlShell.shell tools (''
51
+ export NIXPKGS_ALLOW_UNFREE=1
52
+ export FFMPEG_FULL=${ffmpegFull}
53
+ '' + ctlShell.browserHook);
61
54
  in {
62
55
  # One shell under every name the canonical workflows and the Studio
63
56
  # flake use: `build` is what CI enters (`nix develop .#build`). An
@@ -36,4 +36,4 @@ done
36
36
 
37
37
  perl -0pi -e "s{(ctlVersion = \")[^\"]*(\")}{\${1}$ver\${2}}" "$flake"
38
38
  echo "updated $flake"
39
- echo "next: copy this flake.nix into each split product repo root (or re-run the split)"
39
+ echo "this is the starter's pin; each product's own flake.nix is bumped nightly by upgrade-latest"
@@ -81,7 +81,7 @@
81
81
  stale until this loop re-runs; don't debug a ghost.
82
82
  - **The shared CI workflow is single-sourced too.** `.github/workflows/upgrade-latest.yml`
83
83
  is copied verbatim from `@norskvideo/ctl-dev-kit` (`conventions/upgrade-latest.yml`),
84
- same as this core block and `flake.nix`; `check:drift` fails CI if a copy
84
+ same as this core block and `nix/ctl-shell.nix`; `check:drift` fails CI if a copy
85
85
  diverges. Only the `product:` key is per-repo. Never hand-edit the copy — edit
86
86
  the dev-kit source and re-sync.
87
87
  - **A config-driven workflow needs a parity-checked invariant contract.** If this
@@ -289,6 +289,12 @@ jobs:
289
289
  gate-guides:
290
290
  needs: label
291
291
  runs-on: x64
292
+ # One guide run per repo at a time, nightly and publish gate alike: both
293
+ # launch the same instance ids on the x64 pool's shared docker daemon, and
294
+ # ctl names each compose project after its instance id. Queue, never cancel.
295
+ concurrency:
296
+ group: doc-guides-${{ github.repository }}
297
+ cancel-in-progress: false
292
298
  outputs:
293
299
  guides: ${{ steps.plan.outputs.guides }}
294
300
  reason: ${{ steps.plan.outputs.reason }}
@@ -1,11 +1,12 @@
1
1
  // Workstream I drift gate. Fails a product repo whose forced copies of the
2
2
  // shared conventions have diverged from this dev-kit's canonical source. Each
3
3
  // gated file uses the loosest mechanism that still pins what is shared:
4
- // - byte-verbatim: CLAUDE.md fenced core, flake.nix, biome.json,
4
+ // - byte-verbatim: CLAUDE.md fenced core, nix/ctl-shell.nix, biome.json,
5
5
  // tsconfig.base.json, the .gitignore core block, scripts/demo (only a
6
6
  // product with tests/demo.spec.ts is asked for it)
7
7
  // - masked per-repo line: checks.yml + upgrade-latest.yml (`product:` key)
8
8
  // - base + sanctioned extension block: dprint.json (repo-specific excludes)
9
+ // - must import the shared module: flake.nix (otherwise the product's own)
9
10
  // - structural: root tsconfig.json (shape-dependent includes stay free),
10
11
  // deployment/build-image.sh (per-product wrapper around a verbatim
11
12
  // bootstrap), manifest.seed.json (per-product pins, SDK-schema shape)
@@ -103,19 +104,19 @@ function workflowProblem(
103
104
  }
104
105
 
105
106
  // The dev-only ctl pin the nightly bumps in each repo's flake: the version
106
- // string and the four per-platform source hashes. CI downloads ctl directly, so
107
- // this pin only feeds the dev flake; it floats per repo and over time and is NOT
108
- // a shared convention. Mask it so the gate compares flake STRUCTURE, not the pin
109
- // — same idea as masking upgrade-latest's product: line.
107
+ // string and the four per-platform source hashes. It lives in the product's own
108
+ // flake.nix; sync-drift carries it across when it migrates a legacy flake.
110
109
  export const CTL_VERSION_LINE = /ctlVersion = "[^"]*";/;
111
110
  export const CTL_HASH_LINE = /hash = "sha256-[^"]*";/g;
112
111
 
113
- function flakeProblem(actual: string, canonical: string, canonicalRef = "build/flake.nix"): string | undefined {
114
- const mask = (s: string) =>
115
- s.replace(CTL_VERSION_LINE, 'ctlVersion = "__CTL__";').replace(CTL_HASH_LINE, 'hash = "sha256-__HASH__";');
116
- const [ma, mc] = [mask(actual), mask(canonical)];
117
- if (ma === mc) return undefined;
118
- return `flake.nix has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(ma, mc)}). ${RESYNC}`;
112
+ // The shared dev-shell module and how a product's flake.nix must reach it. The
113
+ // flake is otherwise the product's own, so a product can add what it alone
114
+ // needs; building from the module is what keeps the shared pieces shared.
115
+ export const CTL_SHELL_PATH = "nix/ctl-shell.nix";
116
+ export const CTL_SHELL_IMPORT = "import ./nix/ctl-shell.nix";
117
+
118
+ export function importsCtlShell(flake: string): boolean {
119
+ return flake.includes(CTL_SHELL_IMPORT);
119
120
  }
120
121
 
121
122
  // dprint.json is canonical-plus-extension: the shared config is verbatim, but a
@@ -250,7 +251,13 @@ function coreProblem(claude: string, canonicalCore: string): string | undefined
250
251
 
251
252
  export interface CanonicalBytes {
252
253
  core: string;
254
+ /** build/flake.nix — the starter flake a Studio product begins from, and what
255
+ * sync-drift migrates a legacy verbatim flake to. Not gated: the flake is the
256
+ * product's own. */
253
257
  flake: string;
258
+ /** build/ctl-shell.nix — the shared dev-shell module, gated verbatim at
259
+ * nix/ctl-shell.nix for every shape. */
260
+ ctlShell: string;
254
261
  upgradeLatest: string;
255
262
  syncDevKit: string;
256
263
  /** conventions/sync-ctl-packages.yml — the ctl-* pin freshness PR. No per-repo
@@ -281,10 +288,10 @@ export interface CanonicalBytes {
281
288
  * hand copy is how build-docs.yml came to fail in both turnkeys, which never
282
289
  * made one. */
283
290
  withDisplay: string;
284
- /** build/flake.sdk-app.nix — the flake canonical for a `shape: "sdk-app"`
291
+ /** build/flake.sdk-app.nix — the starter flake for a `shape: "sdk-app"`
285
292
  * product (bun tooling + node runtime, no norsk-ctl daemon fetch). Optional so
286
- * a studio-only caller (and the existing test fixtures) need not supply it;
287
- * required when a repo declares the sdk-app shape. */
293
+ * a studio-only caller need not supply it; sync-drift needs it to migrate a
294
+ * legacy sdk-app flake. */
288
295
  flakeSdkApp?: string;
289
296
  /** conventions/checks.sdk-app.yml — the checks.yml variant for an sdk-app: the
290
297
  * Studio licence step removed (it reads shared/src/version.ts +
@@ -530,21 +537,16 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
530
537
  push(coreProblem(readFileSync(claudePath, "utf8"), canonical.core));
531
538
  }
532
539
 
533
- // flake.nix is shape-selected: the studio flake fetches the norsk-ctl daemon,
534
- // the sdk-app flake does not. Both are gated the same way (ctl pins masked; the
535
- // sdk-app flake carries none, so it is effectively byte-verbatim).
540
+ push(verbatimProblem(repoRoot, CTL_SHELL_PATH, "build/ctl-shell.nix", canonical.ctlShell));
536
541
  const flakePath = join(repoRoot, "flake.nix");
537
- const flakeCanon = shape === "sdk-app" ? canonical.flakeSdkApp : canonical.flake;
538
- const flakeRef = shape === "sdk-app" ? "build/flake.sdk-app.nix" : "build/flake.nix";
539
- if (flakeCanon === undefined) {
540
- throw new Error(`checkDrift: shape "${shape}" requires canonical.${shape === "sdk-app" ? "flakeSdkApp" : "flake"}`);
541
- } else if (!existsSync(flakePath)) {
542
+ if (!existsSync(flakePath)) {
542
543
  problems.push(
543
- `flake.nix not found at repo root (${flakePath}). It is copied verbatim from @norskvideo/ctl-dev-kit ${flakeRef}.`,
544
+ `flake.nix not found at repo root (${flakePath}). Start from @norskvideo/ctl-dev-kit ${shape === "sdk-app" ? "build/flake.sdk-app.nix" : "build/flake.nix"}.`,
545
+ );
546
+ } else if (!importsCtlShell(readFileSync(flakePath, "utf8"))) {
547
+ problems.push(
548
+ `flake.nix does not build its shells from ${CTL_SHELL_PATH} (no \`${CTL_SHELL_IMPORT}\`). The shared shell pieces are single-sourced there; sync-drift migrates a legacy verbatim flake.`,
544
549
  );
545
- } else {
546
- const problem = flakeProblem(readFileSync(flakePath, "utf8"), flakeCanon, flakeRef);
547
- if (problem) problems.push(problem);
548
550
  }
549
551
  push(verbatimProblem(repoRoot, "biome.json", "conventions/biome.base.json", canonical.biome));
550
552
  push(verbatimProblem(repoRoot, "tsconfig.base.json", "conventions/tsconfig.base.json", canonical.tsconfigBase));
@@ -810,6 +812,7 @@ if (import.meta.main) {
810
812
  const canonical: CanonicalBytes = {
811
813
  core: readFileSync(join(import.meta.dir, "CLAUDE.core.md"), "utf8"),
812
814
  flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"),
815
+ ctlShell: readFileSync(join(import.meta.dir, "..", "build", "ctl-shell.nix"), "utf8"),
813
816
  upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
814
817
  syncDevKit: readFileSync(join(import.meta.dir, "sync-dev-kit.yml"), "utf8"),
815
818
  syncCtlPackages: readFileSync(join(import.meta.dir, "sync-ctl-packages.yml"), "utf8"),
@@ -8,7 +8,7 @@
8
8
  # Two jobs:
9
9
  # - drift: the RFC 0001 Workstream I shared-conventions drift-check. Fails if
10
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
11
+ # fenced CLAUDE.md core, nix/ctl-shell.nix, the shared config files, the manifest
12
12
  # seed. Copies exist so a human reads them on GitHub with no tooling; the
13
13
  # gate is the only thing keeping them true.
14
14
  # - quality: the repo's own lint + typecheck + unit tests + docs path lint,
@@ -4,7 +4,7 @@
4
4
  # Two jobs:
5
5
  # - drift: the RFC 0001 Workstream I shared-conventions drift-check. Fails if
6
6
  # this repo's forced copies have diverged from the pinned dev-kit — the
7
- # fenced CLAUDE.md core, flake.nix, the shared config files, the manifest
7
+ # fenced CLAUDE.md core, nix/ctl-shell.nix, the shared config files, the manifest
8
8
  # seed. Copies exist so a human reads them on GitHub with no tooling; the
9
9
  # gate is the only thing keeping them true.
10
10
  # - quality: the repo's own lint + typecheck + unit tests + docs path lint,
@@ -46,6 +46,12 @@ concurrency:
46
46
  jobs:
47
47
  publish:
48
48
  runs-on: x64
49
+ # One guide run per repo at a time, nightly and publish gate alike: both
50
+ # launch the same instance ids on the x64 pool's shared docker daemon, and
51
+ # ctl names each compose project after its instance id. Queue, never cancel.
52
+ concurrency:
53
+ group: doc-guides-${{ github.repository }}
54
+ cancel-in-progress: false
49
55
  steps:
50
56
  # Runs BEFORE checkout, and does two things: points the harness's temp base
51
57
  # out of the workspace for every later step in this job, and clears whatever
@@ -1,6 +1,6 @@
1
1
  # Keep this product's shared conventions fresh against @norskvideo/ctl-dev-kit.
2
2
  # The dev-kit single-sources the files every ctl product would otherwise
3
- # copy-and-drift (the fenced CLAUDE.md core, flake.nix, the biome/tsconfig/dprint
3
+ # copy-and-drift (the fenced CLAUDE.md core, nix/ctl-shell.nix, the biome/tsconfig/dprint
4
4
  # bases, the shared workflows, the build bootstrap). check:drift fails CI when a
5
5
  # copy diverges from the *installed* dev-kit — so a product goes stale two ways:
6
6
  # its pin lags the newest published dev-kit, and once the pin bumps its copies
@@ -19,7 +19,7 @@
19
19
  #
20
20
  # sync-drift re-derives each copy from the canonical (never a second opinion that
21
21
  # can disagree), preserving the per-repo bits the gate masks -- the product: keys,
22
- # the flake ctl pin, the dprint/gitignore/CLAUDE repo content. The gate runs AFTER
22
+ # the product's own flake.nix, the dprint/gitignore/CLAUDE repo content. The gate runs AFTER
23
23
  # it, so a bad rewrite fails check:drift and a breaking convention fails the build,
24
24
  # rather than landing silently. Structural per-repo files it never touches.
25
25
  #
@@ -8,7 +8,8 @@
8
8
  // judgement anywhere:
9
9
  // - verbatim (biome.json, tsconfig.base.json) -> overwrite with canonical
10
10
  // - masked workflows (checks / upgrade / sync) -> canonical, repo's product: key kept
11
- // - flake.nix -> canonical, repo's floating ctl pin kept
11
+ // - nix/ctl-shell.nix -> overwrite with canonical (created if absent)
12
+ // - flake.nix (legacy verbatim copy only) -> the starter, repo's floating ctl pin kept
12
13
  // - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest
13
14
  // Structural per-repo files (root tsconfig.json, deployment/build-image.sh,
14
15
  // manifest.seed.json) have NO canonical bytes to apply — the gate checks their
@@ -25,6 +26,7 @@ import {
25
26
  BEGIN,
26
27
  type CanonicalBytes,
27
28
  CTL_HASH_LINE,
29
+ CTL_SHELL_PATH,
28
30
  CTL_VERSION_LINE,
29
31
  carriesIntegrationMarker,
30
32
  DOCS_SITE_DIR,
@@ -32,6 +34,7 @@ import {
32
34
  GITIGNORE_BEGIN,
33
35
  GITIGNORE_END,
34
36
  hasDocsSite,
37
+ importsCtlShell,
35
38
  PRODUCT_LINE,
36
39
  PRODUCT_SENTINEL,
37
40
  type ProductShape,
@@ -149,21 +152,22 @@ function syncIntegration(repoRoot: string, canonical: string, r: SyncReport): vo
149
152
  writeIfChanged(path, out, rel, r.written);
150
153
  }
151
154
 
152
- // flake.nix is verbatim except the dev-only ctl pin (ctlVersion + the four
153
- // per-platform hashes), which floats per repo. Re-emit canonical STRUCTURE with
154
- // the repo's pin restored in order — so a structural edit (e.g. a stray comment)
155
- // is replaced while the pin is kept. Structure is identical across repos, so the
156
- // Nth hash line in canonical maps to the Nth in the repo.
157
- function syncFlake(repoRoot: string, canonical: string, r: SyncReport): void {
155
+ // flake.nix is the product's own once it builds from nix/ctl-shell.nix, so sync
156
+ // leaves it alone. One that does not can only be a verbatim dev-kit copy from
157
+ // before the shared pieces moved into the module (the gate allowed no product
158
+ // edits then), so it is replaced by the starter with the repo's floating ctl pin
159
+ // (ctlVersion + the four per-platform hashes) carried across in order.
160
+ function syncFlake(repoRoot: string, starter: string, r: SyncReport): void {
158
161
  const path = join(repoRoot, "flake.nix");
159
162
  if (!existsSync(path)) {
160
163
  r.skipped.push("flake.nix (absent)");
161
164
  return;
162
165
  }
163
166
  const existing = readFileSync(path, "utf8");
167
+ if (importsCtlShell(existing)) return;
164
168
  const version = existing.match(CTL_VERSION_LINE)?.[0];
165
169
  const hashes = existing.match(CTL_HASH_LINE) ?? [];
166
- let out = version ? canonical.replace(CTL_VERSION_LINE, version) : canonical;
170
+ let out = version ? starter.replace(CTL_VERSION_LINE, version) : starter;
167
171
  let i = 0;
168
172
  out = out.replace(CTL_HASH_LINE, (m) => hashes[i++] ?? m);
169
173
  writeIfChanged(path, out, "flake.nix", r.written);
@@ -336,6 +340,8 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: Pr
336
340
  writeIfChanged(join(repoRoot, buildDocsRel), canonical.buildDocs, buildDocsRel, r.written);
337
341
  }
338
342
 
343
+ mkdirSync(dirname(join(repoRoot, CTL_SHELL_PATH)), { recursive: true });
344
+ writeIfChanged(join(repoRoot, CTL_SHELL_PATH), canonical.ctlShell, CTL_SHELL_PATH, r.written);
339
345
  syncFlake(repoRoot, flakeCanon, r);
340
346
  syncClaude(repoRoot, canonical.core, r);
341
347
  syncGitignore(repoRoot, canonical.gitignoreCore, r);
@@ -353,6 +359,7 @@ if (import.meta.main) {
353
359
  const canonical: CanonicalBytes = {
354
360
  core: readFileSync(join(dir, "CLAUDE.core.md"), "utf8"),
355
361
  flake: readFileSync(join(dir, "..", "build", "flake.nix"), "utf8"),
362
+ ctlShell: readFileSync(join(dir, "..", "build", "ctl-shell.nix"), "utf8"),
356
363
  upgradeLatest: readFileSync(join(dir, "upgrade-latest.yml"), "utf8"),
357
364
  syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
358
365
  syncCtlPackages: readFileSync(join(dir, "sync-ctl-packages.yml"), "utf8"),
@@ -15,6 +15,8 @@ const buildDir = join(import.meta.dir, "..", "build");
15
15
  export interface Canon {
16
16
  core: string;
17
17
  flake: string;
18
+ /** build/ctl-shell.nix, emitted as nix/ctl-shell.nix — the flake imports it. */
19
+ ctlShell: string;
18
20
  flakeLock: string;
19
21
  upgradeLatest: string;
20
22
  syncDevKit: string;
@@ -41,6 +43,7 @@ export function loadCanon(): Canon {
41
43
  return {
42
44
  core: readFileSync(join(conventionsDir, "CLAUDE.core.md"), "utf8"),
43
45
  flake: readFileSync(join(buildDir, "flake.nix"), "utf8"),
46
+ ctlShell: readFileSync(join(buildDir, "ctl-shell.nix"), "utf8"),
44
47
  flakeLock: readFileSync(join(buildDir, "flake.lock"), "utf8"),
45
48
  upgradeLatest: readFileSync(join(conventionsDir, "upgrade-latest.yml"), "utf8"),
46
49
  syncDevKit: readFileSync(join(conventionsDir, "sync-dev-kit.yml"), "utf8"),
@@ -203,6 +203,7 @@ function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[]
203
203
  return [
204
204
  { path: "CLAUDE.md", content: `${shape.claudeHead(ctx)}\n${canon.core}\n${shape.claudeTail(ctx)}` },
205
205
  { path: "flake.nix", content: canon.flake },
206
+ { path: "nix/ctl-shell.nix", content: canon.ctlShell },
206
207
  { path: "flake.lock", content: canon.flakeLock },
207
208
  { path: "biome.json", content: canon.biome },
208
209
  { path: "tsconfig.base.json", content: canon.tsconfigBase },
@@ -9,7 +9,8 @@ test. This is the whole point of the split — you clone the product and go.
9
9
 
10
10
  `norsk-ctl` (the daemon + CLI that launches product instances) is **not** an npm
11
11
  dependency; it is the released binary, pinned by version+hash in the repo's
12
- `flake.nix` (single-sourced from `@norskvideo/ctl-dev-kit`). The dev shell puts
12
+ `flake.nix` (the shared shell pieces come from `nix/ctl-shell.nix`, single-sourced
13
+ from `@norskvideo/ctl-dev-kit`). The dev shell puts
13
14
  it on `PATH`:
14
15
 
15
16
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",