@norskvideo/ctl-dev-kit 0.1.95 → 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.
- package/build/flake.sdk-app.nix +64 -0
- package/conventions/check-drift.ts +106 -39
- package/conventions/checks.sdk-app.yml +276 -0
- package/conventions/sync-drift.ts +18 -5
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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"),
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
);
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
);
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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, {
|
|
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",
|
|
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
|
-
|
|
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,
|
|
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 {
|