@norskvideo/ctl-dev-kit 0.1.26 → 0.1.27

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,103 @@
1
+ # Build the product image on every push to main (and on demand), on the org's
2
+ # self-hosted x64 pool inside `nix develop` (bun + cargo/rustc from flake.nix).
3
+ # Proves the repo builds standalone against the published @norskvideo/ctl-* +
4
+ # @norskvideo/norsk-* packages. A deployed product sets PUBLISH_IMAGE to its
5
+ # Docker Hub repo (norskvideo org) and the built image is pushed there; a product
6
+ # with an empty PUBLISH_IMAGE builds as a standalone-build proof only and
7
+ # publishes nothing. Single-sourced in @norskvideo/ctl-dev-kit
8
+ # (conventions/build-image.yml) and drift-gated — edit the dev-kit source, never
9
+ # the copy. Only the PUBLISH_IMAGE env and the notify job's product: key are
10
+ # per-repo. Single-arch (amd64) to start; multi-arch is a follow-up. Repo-local
11
+ # for now; to be hoisted into id3as/ci-workflows as a reusable workflow
12
+ # (RFC 0001 Workstream E).
13
+ name: build-image
14
+
15
+ on:
16
+ push:
17
+ branches: [main]
18
+ workflow_dispatch:
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ concurrency:
24
+ group: ${{ github.workflow }}-${{ github.ref }}
25
+ cancel-in-progress: true
26
+
27
+ env:
28
+ # The Docker Hub repo this product publishes to (norskvideo org, e.g.
29
+ # norskvideo/norsk-<product>-product). Empty string = build-only proof, no
30
+ # publish (a product not yet deployed). This is the one per-repo env the drift
31
+ # gate masks; set it in the product repo, never in the dev-kit canonical.
32
+ PUBLISH_IMAGE: ""
33
+
34
+ jobs:
35
+ build:
36
+ runs-on: x64
37
+ steps:
38
+ # clean: false -- the integration suite (shared x64 pool) leaves root-owned
39
+ # bind-mount dirs under test-temp/ that a non-root git clean can't remove
40
+ # (EACCES). This job never needs them; skip the clean so it doesn't choke on
41
+ # another workflow's leftovers.
42
+ - uses: actions/checkout@v5
43
+ with:
44
+ clean: false
45
+
46
+ - name: Build product image (copy-only, via the dev-kit driver)
47
+ run: |
48
+ if [ -n "${PUBLISH_IMAGE}" ]; then
49
+ IMAGE_TAG="${PUBLISH_IMAGE}:${{ github.sha }}"
50
+ else
51
+ IMAGE_TAG="norsk-ctl-product:${{ github.sha }}"
52
+ fi
53
+ echo "IMAGE_TAG=${IMAGE_TAG}" >> "$GITHUB_ENV"
54
+ export IMAGE_TAG
55
+ nix develop .#build --command bash -c '
56
+ set -euo pipefail
57
+ bun install --frozen-lockfile
58
+ bun run build:image
59
+ '
60
+
61
+ - name: Log in to Docker Hub
62
+ if: ${{ env.PUBLISH_IMAGE != '' }}
63
+ uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
64
+ with:
65
+ username: norskvideo
66
+ password: ${{ secrets.DOCKER_PAT }}
67
+
68
+ - name: Push image to Docker Hub
69
+ if: ${{ env.PUBLISH_IMAGE != '' }}
70
+ run: |
71
+ docker tag "$IMAGE_TAG" "${PUBLISH_IMAGE}:main"
72
+ docker push "$IMAGE_TAG"
73
+ docker push "${PUBLISH_IMAGE}:main"
74
+
75
+ # Report this pipeline's result to the aggregated product CI dashboard
76
+ # (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
77
+ # the pony/emoji from the dispatched result. always() so a red or manual run
78
+ # still reports.
79
+ notify:
80
+ needs: build
81
+ if: ${{ !cancelled() }}
82
+ runs-on: x64
83
+ steps:
84
+ # clean: false -- the integration suite (shared x64 pool) leaves root-owned
85
+ # bind-mount dirs under test-temp/ that a non-root git clean can't remove
86
+ # (EACCES). This job never needs them; skip the clean so it doesn't choke on
87
+ # another workflow's leftovers.
88
+ - uses: actions/checkout@v5
89
+ with:
90
+ clean: false
91
+ - id: meta
92
+ run: |
93
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
94
+ echo "state=failure" >> "$GITHUB_OUTPUT"
95
+ else
96
+ echo "state=success" >> "$GITHUB_OUTPUT"
97
+ fi
98
+ - uses: ./.github/actions/ci-status-dispatch
99
+ with:
100
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
101
+ product: __PRODUCT__
102
+ pipeline: build-image
103
+ status: ${{ steps.meta.outputs.state }}
@@ -54,10 +54,23 @@ function firstDiffLine(actual: string, expected: string): string {
54
54
  export const PRODUCT_LINE = /^(\s*product:\s*).*$/gm;
55
55
  export const PRODUCT_SENTINEL = "__PRODUCT__";
56
56
 
57
- function workflowProblem(relPath: string, canonicalRef: string, actual: string, canonical: string): string | undefined {
58
- // Compare structure with the product value masked so any real key passes, then
59
- // separately reject a copy that still carries the unfilled sentinel.
60
- const mask = (s: string) => s.replace(PRODUCT_LINE, `$1${PRODUCT_SENTINEL}`);
57
+ // build-image.yml carries a second per-repo line: the Docker Hub repo the built
58
+ // image is published to (empty on a build-only product). Masked like product:,
59
+ // but unlike product: — an empty value is a valid filled state, so it has no
60
+ // unfilled-sentinel check.
61
+ export const PUBLISH_IMAGE_LINE = /^(\s*PUBLISH_IMAGE:\s*).*$/gm;
62
+
63
+ function workflowProblem(
64
+ relPath: string,
65
+ canonicalRef: string,
66
+ actual: string,
67
+ canonical: string,
68
+ maskLines: RegExp[] = [PRODUCT_LINE],
69
+ ): string | undefined {
70
+ // Compare structure with every per-repo value masked so any real value passes,
71
+ // then separately reject a copy that still carries the unfilled product:
72
+ // sentinel.
73
+ const mask = (s: string) => maskLines.reduce((acc, re) => acc.replace(re, `$1${PRODUCT_SENTINEL}`), s);
61
74
  if (mask(actual) !== mask(canonical)) {
62
75
  return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(mask(actual), mask(canonical))}). ${RESYNC}`;
63
76
  }
@@ -225,6 +238,7 @@ export interface CanonicalBytes {
225
238
  dprint: string;
226
239
  buildImageBootstrap: string;
227
240
  gitignoreCore: string;
241
+ buildImage: string;
228
242
  }
229
243
 
230
244
  // A file that must be a verbatim, byte-for-byte copy of its dev-kit canonical.
@@ -329,6 +343,29 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftRe
329
343
  push(workflowProblem(checksRel, "conventions/checks.yml", readFileSync(checksPath, "utf8"), canonical.checks));
330
344
  }
331
345
 
346
+ // build-image.yml is REQUIRED — every product builds its image in CI (a
347
+ // standalone-build proof), and a deployed one publishes it to Docker Hub. Two
348
+ // lines are per-repo: the notify job's `product:` dispatch key and the
349
+ // PUBLISH_IMAGE env (the Docker Hub repo, or empty for a build-only product);
350
+ // both are masked.
351
+ const buildImageYmlPath = join(repoRoot, ".github", "workflows", "build-image.yml");
352
+ const buildImageYmlRel = ".github/workflows/build-image.yml";
353
+ if (!existsSync(buildImageYmlPath)) {
354
+ problems.push(
355
+ `${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).`,
356
+ );
357
+ } else {
358
+ push(
359
+ workflowProblem(
360
+ buildImageYmlRel,
361
+ "conventions/build-image.yml",
362
+ readFileSync(buildImageYmlPath, "utf8"),
363
+ canonical.buildImage,
364
+ [PRODUCT_LINE, PUBLISH_IMAGE_LINE],
365
+ ),
366
+ );
367
+ }
368
+
332
369
  // upgrade-latest.yml is OPTIONAL — a product may ship without a nightly bump
333
370
  // (e.g. until it has an integration tier). But when present it is the shared,
334
371
  // single-sourced workflow and must not diverge.
@@ -392,6 +429,7 @@ if (import.meta.main) {
392
429
  dprint: readFileSync(join(import.meta.dir, "dprint.base.jsonc"), "utf8"),
393
430
  buildImageBootstrap: readFileSync(join(import.meta.dir, "..", "build", "build-image.bootstrap.sh"), "utf8"),
394
431
  gitignoreCore: readFileSync(join(import.meta.dir, "gitignore.core"), "utf8"),
432
+ buildImage: readFileSync(join(import.meta.dir, "build-image.yml"), "utf8"),
395
433
  };
396
434
  const report = checkDrift(repoRoot, canonical);
397
435
  if (report.ok) {
@@ -30,6 +30,7 @@ import {
30
30
  GITIGNORE_END,
31
31
  PRODUCT_LINE,
32
32
  PRODUCT_SENTINEL,
33
+ PUBLISH_IMAGE_LINE,
33
34
  splitAtMarkers,
34
35
  } from "./check-drift.ts";
35
36
 
@@ -64,6 +65,32 @@ function syncWorkflow(repoRoot: string, rel: string, canonical: string, r: SyncR
64
65
  writeIfChanged(path, canonical.replace(PRODUCT_LINE, `$1${key}`), rel, r.written);
65
66
  }
66
67
 
68
+ // build-image.yml: canonical byte-for-byte except two per-repo lines — the
69
+ // notify job's `product:` dispatch key and the PUBLISH_IMAGE env (the Docker Hub
70
+ // repo, or empty for a build-only product). Re-emit the canonical with both of
71
+ // the repo's own values restored. A copy with no product key to preserve (absent
72
+ // or still the sentinel) is left for the gate to flag. PUBLISH_IMAGE is restored
73
+ // via a function replacer so a value containing `$` can't be read as a
74
+ // replacement backreference.
75
+ function syncBuildImage(repoRoot: string, canonical: string, r: SyncReport): void {
76
+ const rel = ".github/workflows/build-image.yml";
77
+ const path = join(repoRoot, rel);
78
+ if (!existsSync(path)) {
79
+ r.skipped.push(`${rel} (absent)`);
80
+ return;
81
+ }
82
+ const existing = readFileSync(path, "utf8");
83
+ const key = existing.match(/^\s*product:\s*(\S+)/m)?.[1];
84
+ if (!key || key === PRODUCT_SENTINEL) {
85
+ r.skipped.push(`${rel} (no product: key to preserve)`);
86
+ return;
87
+ }
88
+ const publishLine = existing.match(/^\s*PUBLISH_IMAGE:.*$/m)?.[0];
89
+ let out = canonical.replace(PRODUCT_LINE, `$1${key}`);
90
+ if (publishLine !== undefined) out = out.replace(PUBLISH_IMAGE_LINE, () => publishLine);
91
+ writeIfChanged(path, out, rel, r.written);
92
+ }
93
+
67
94
  // flake.nix is verbatim except the dev-only ctl pin (ctlVersion + the four
68
95
  // per-platform hashes), which floats per repo. Re-emit canonical STRUCTURE with
69
96
  // the repo's pin restored in order — so a structural edit (e.g. a stray comment)
@@ -152,6 +179,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
152
179
  syncWorkflow(repoRoot, ".github/workflows/checks.yml", canonical.checks, r);
153
180
  syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", canonical.upgradeLatest, r);
154
181
  syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
182
+ syncBuildImage(repoRoot, canonical.buildImage, r);
155
183
 
156
184
  // publish-docs.yml is optional and verbatim (no per-repo line) — only re-sync a
157
185
  // repo that already carries it, unlike biome/tsconfig which every repo must have.
@@ -183,6 +211,7 @@ if (import.meta.main) {
183
211
  dprint: readFileSync(join(dir, "dprint.base.jsonc"), "utf8"),
184
212
  buildImageBootstrap: readFileSync(join(dir, "..", "build", "build-image.bootstrap.sh"), "utf8"),
185
213
  gitignoreCore: readFileSync(join(dir, "gitignore.core"), "utf8"),
214
+ buildImage: readFileSync(join(dir, "build-image.yml"), "utf8"),
186
215
  };
187
216
  const report = syncDrift(repoRoot, canonical);
188
217
  if (report.written.length === 0) {
@@ -23,6 +23,7 @@ export interface Canon {
23
23
  tsconfigBase: string;
24
24
  dprint: string;
25
25
  buildImageBootstrap: string;
26
+ buildImage: string;
26
27
  gitignoreCore: string;
27
28
  invariantsTemplate: string;
28
29
  }
@@ -39,6 +40,7 @@ export function loadCanon(): Canon {
39
40
  tsconfigBase: readFileSync(join(conventionsDir, "tsconfig.base.json"), "utf8"),
40
41
  dprint: readFileSync(join(conventionsDir, "dprint.base.jsonc"), "utf8"),
41
42
  buildImageBootstrap: readFileSync(join(buildDir, "build-image.bootstrap.sh"), "utf8"),
43
+ buildImage: readFileSync(join(conventionsDir, "build-image.yml"), "utf8"),
42
44
  gitignoreCore: readFileSync(join(conventionsDir, "gitignore.core"), "utf8"),
43
45
  invariantsTemplate: readFileSync(join(import.meta.dir, "..", "testing", "INVARIANTS.template.md"), "utf8"),
44
46
  };
@@ -111,6 +111,7 @@ function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[]
111
111
  { path: ".github/workflows/checks.yml", content: fillProduct(canon.checks) },
112
112
  { path: ".github/workflows/upgrade-latest.yml", content: fillProduct(canon.upgradeLatest) },
113
113
  { path: ".github/workflows/sync-dev-kit.yml", content: fillProduct(canon.syncDevKit) },
114
+ { path: ".github/workflows/build-image.yml", content: fillProduct(canon.buildImage) },
114
115
  { path: ".github/actions/ci-status-dispatch/action.yml", content: loadAsset("ci-status-dispatch.yml") },
115
116
  { path: "manifest.seed.json", content: seedJson(ctx) },
116
117
  { path: "deployment/build-image.sh", content: buildImageWrapper(ctx), executable: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",