@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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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) {
|
package/create-product/canon.ts
CHANGED
|
@@ -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 },
|