@norskvideo/ctl-dev-kit 0.1.26 → 0.1.28

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,184 @@
1
+ # Build + publish a PRODUCT container image, multi-arch (linux/amd64 +
2
+ # linux/arm64), to Docker Hub. Single-sourced in @norskvideo/ctl-dev-kit
3
+ # (conventions/build-image.yml) and drift-gated -- edit the dev-kit source, never
4
+ # the copy.
5
+ #
6
+ # Multi-arch house style: native build per arch (no QEMU), each arch pushes its
7
+ # own `:<label>-<arch>` tag to Docker Hub, then a publish job combines them with
8
+ # `docker buildx imagetools create` into the multi-arch manifest. Each publish
9
+ # writes two tags:
10
+ # <repo>:<label> immutable, label = YYYY-MM-DD-<sha8> (what deployments pin)
11
+ # <repo>:<channel> moving (latest | rc | nightly)
12
+ #
13
+ # The one per-repo value is the masked PUBLISH_IMAGE env: set to a product's
14
+ # Docker Hub repo (norskvideo org) it builds + publishes both arches; left empty
15
+ # the build runs as a standalone-build proof (both arches) and publishes nothing
16
+ # -- for a product that is not deployed yet.
17
+ #
18
+ # CHANNEL: push-to-main publishes `latest` (matches the retired in-monorepo
19
+ # pipeline, so :latest + the dated tags stay fresh for installers/deployments).
20
+ # workflow_dispatch can publish any channel. When the product channel model
21
+ # (rc -> latest promotion) lands, flip the push default to `nightly` and let the
22
+ # promotion gate move `latest`.
23
+ #
24
+ # Repo-local for now; to be hoisted into id3as/ci-workflows as a reusable
25
+ # workflow (RFC 0001 Workstream E).
26
+ name: build-image
27
+
28
+ on:
29
+ push:
30
+ branches: [main]
31
+ workflow_dispatch:
32
+ inputs:
33
+ channel:
34
+ description: "Moving channel tag to publish (latest | rc | nightly)."
35
+ type: string
36
+ default: latest
37
+ dry_run:
38
+ description: "Build + stage both arches, skip the Docker Hub publish."
39
+ type: boolean
40
+ default: false
41
+
42
+ permissions:
43
+ contents: read
44
+
45
+ concurrency:
46
+ group: ${{ github.workflow }}-${{ github.ref }}
47
+ cancel-in-progress: true
48
+
49
+ env:
50
+ # The Docker Hub repo this product publishes to (norskvideo org, e.g.
51
+ # norskvideo/norsk-<product>-product). Empty string = build-only proof, no
52
+ # publish (a product not yet deployed). This is the one per-repo env the drift
53
+ # gate masks; set it in the product repo, never in the dev-kit canonical.
54
+ PUBLISH_IMAGE: ""
55
+
56
+ jobs:
57
+ # Compute the immutable label ONCE, so both arch builds and the manifest share
58
+ # it (a per-job `date` could straddle midnight and disagree).
59
+ label:
60
+ runs-on: x64
61
+ outputs:
62
+ label: ${{ steps.gen.outputs.label }}
63
+ channel: ${{ steps.gen.outputs.channel }}
64
+ # `publish` is derived here (in a step, where the env context is readable)
65
+ # because the env context is NOT available in a job-level `if:` -- the
66
+ # publish job gates on this output instead of on PUBLISH_IMAGE directly.
67
+ publish: ${{ steps.gen.outputs.publish }}
68
+ steps:
69
+ - uses: actions/checkout@v5
70
+ with:
71
+ clean: false
72
+ - id: gen
73
+ run: |
74
+ label="$(date -u +%Y-%m-%d)-$(git rev-parse --short=8 HEAD)"
75
+ channel="${{ github.event.inputs.channel || 'latest' }}"
76
+ if [ -n "${PUBLISH_IMAGE}" ]; then publish=true; else publish=false; fi
77
+ echo "label=${label}" >> "$GITHUB_OUTPUT"
78
+ echo "channel=${channel}" >> "$GITHUB_OUTPUT"
79
+ echo "publish=${publish}" >> "$GITHUB_OUTPUT"
80
+ echo "label=${label} channel=${channel} publish=${publish}"
81
+
82
+ build:
83
+ needs: label
84
+ strategy:
85
+ fail-fast: false
86
+ matrix:
87
+ include:
88
+ - runner: x64
89
+ arch: amd64
90
+ - runner: ar-arm-vm
91
+ arch: arm64
92
+ runs-on: ${{ matrix.runner }}
93
+ steps:
94
+ # clean: false -- the integration suite (shared pool) leaves root-owned
95
+ # bind-mount dirs under test-temp/ that a non-root git clean can't remove
96
+ # (EACCES). This job never needs them; skip the clean so it doesn't choke on
97
+ # another workflow's leftovers.
98
+ - uses: actions/checkout@v5
99
+ with:
100
+ clean: false
101
+
102
+ - name: Build product image (copy-only, via the dev-kit driver, native ${{ matrix.arch }})
103
+ run: |
104
+ if [ -n "${PUBLISH_IMAGE}" ]; then
105
+ IMAGE_TAG="${PUBLISH_IMAGE}:${{ needs.label.outputs.label }}-${{ matrix.arch }}"
106
+ else
107
+ IMAGE_TAG="norsk-ctl-product:${{ needs.label.outputs.label }}-${{ matrix.arch }}"
108
+ fi
109
+ echo "IMAGE_TAG=${IMAGE_TAG}" >> "$GITHUB_ENV"
110
+ export IMAGE_TAG
111
+ nix develop .#build --command bash -c '
112
+ set -euo pipefail
113
+ bun install --frozen-lockfile
114
+ bun run build:image
115
+ '
116
+
117
+ - name: Log in to Docker Hub
118
+ if: ${{ env.PUBLISH_IMAGE != '' }}
119
+ uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
120
+ with:
121
+ username: norskvideo
122
+ password: ${{ secrets.DOCKER_PAT }}
123
+
124
+ - name: Push the per-arch image to Docker Hub
125
+ if: ${{ env.PUBLISH_IMAGE != '' }}
126
+ run: docker push "$IMAGE_TAG"
127
+
128
+ # Combine the per-arch images into a multi-arch manifest and write the two
129
+ # published tags. Skipped for a build-only product (empty PUBLISH_IMAGE) and on
130
+ # a dry run.
131
+ publish:
132
+ needs: [label, build]
133
+ if: ${{ needs.label.outputs.publish == 'true' && !inputs.dry_run }}
134
+ runs-on: x64
135
+ steps:
136
+ - uses: actions/checkout@v5
137
+ with:
138
+ clean: false
139
+
140
+ - name: Log in to Docker Hub
141
+ uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
142
+ with:
143
+ username: norskvideo
144
+ password: ${{ secrets.DOCKER_PAT }}
145
+
146
+ - name: Create + push the multi-arch manifest (dated label + channel)
147
+ run: |
148
+ label="${{ needs.label.outputs.label }}"
149
+ channel="${{ needs.label.outputs.channel }}"
150
+ # buildx imagetools create, not `docker manifest create`: the arm64
151
+ # runner's containerd image store pushes single-arch images wrapped in
152
+ # an OCI index, which `docker manifest create` refuses to nest. imagetools
153
+ # accepts index sources and writes every -t tag in one push.
154
+ docker buildx imagetools create \
155
+ -t "${PUBLISH_IMAGE}:${label}" \
156
+ -t "${PUBLISH_IMAGE}:${channel}" \
157
+ "${PUBLISH_IMAGE}:${label}-amd64" \
158
+ "${PUBLISH_IMAGE}:${label}-arm64"
159
+
160
+ # Report this pipeline's result to the aggregated product CI dashboard
161
+ # (id3as/ci-workflows) instead of posting its own pony -- the dashboard renders
162
+ # the pony/emoji from the dispatched result. always() so a red or manual run
163
+ # still reports.
164
+ notify:
165
+ needs: [build, publish]
166
+ if: ${{ !cancelled() }}
167
+ runs-on: x64
168
+ steps:
169
+ - uses: actions/checkout@v5
170
+ with:
171
+ clean: false
172
+ - id: meta
173
+ run: |
174
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
175
+ echo "state=failure" >> "$GITHUB_OUTPUT"
176
+ else
177
+ echo "state=success" >> "$GITHUB_OUTPUT"
178
+ fi
179
+ - uses: ./.github/actions/ci-status-dispatch
180
+ with:
181
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
182
+ product: __PRODUCT__
183
+ pipeline: build-image
184
+ 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.28",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",