@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
|
-
|
|
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 },
|