@norskvideo/ctl-dev-kit 0.1.43 → 0.1.45
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/conventions/CLAUDE.core.md +7 -5
- package/conventions/check-drift.ts +25 -1
- package/conventions/smoke.yml +202 -0
- package/conventions/sync-drift.ts +27 -0
- package/package.json +1 -1
|
@@ -45,11 +45,13 @@
|
|
|
45
45
|
named capability in its manifest's `requires: [...]`; ctl refuses only if it
|
|
46
46
|
lacks that capability. Reach for a capability as rarely as possible — it is the
|
|
47
47
|
escape hatch, not the default.
|
|
48
|
-
- **Reloading a rebuilt image into a running daemon
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
- **Reloading a rebuilt image into a running daemon is `product reload` — a plain
|
|
49
|
+
rebuild never reaches launched instances.** The daemon launches from a STORED
|
|
50
|
+
template snapshot fetched once at `product add`. `product reload` pulls the
|
|
51
|
+
image, re-runs the control plane and re-renders every product-default template
|
|
52
|
+
no instance is using (an in-use one is reported skipped: delete or relaunch the
|
|
53
|
+
instance). `product pull` doesn't re-render. On a ctl that predates this
|
|
54
|
+
(reload refreshed only the manifest) the loop after `build:image` is:
|
|
53
55
|
`instance delete <id> --purge` -> `template remove <t>` -> `product remove <p>`
|
|
54
56
|
(plus `docker rm -f norsk-product-<p>` if the control-plane container lingers,
|
|
55
57
|
else its host port stays allocated) -> `product add --image <img> --license-file
|
|
@@ -60,6 +60,11 @@ export const PRODUCT_SENTINEL = "__PRODUCT__";
|
|
|
60
60
|
// unfilled-sentinel check.
|
|
61
61
|
export const PUBLISH_IMAGE_LINE = /^(\s*PUBLISH_IMAGE:\s*).*$/gm;
|
|
62
62
|
|
|
63
|
+
// smoke.yml carries a second per-repo line: the matrix `runner:` label array
|
|
64
|
+
// the journey runs on ('["x64"]' for the pool, '["ARM64","nvidia"]' for the GPU
|
|
65
|
+
// box). Masked like product:; any filled value is valid, so no sentinel check.
|
|
66
|
+
export const RUNNER_LINE = /^(\s*-?\s*runner:\s*).*$/gm;
|
|
67
|
+
|
|
63
68
|
function workflowProblem(
|
|
64
69
|
relPath: string,
|
|
65
70
|
canonicalRef: string,
|
|
@@ -74,7 +79,7 @@ function workflowProblem(
|
|
|
74
79
|
if (mask(actual) !== mask(canonical)) {
|
|
75
80
|
return `${relPath} has drifted from @norskvideo/ctl-dev-kit ${canonicalRef} (${firstDiffLine(mask(actual), mask(canonical))}). ${RESYNC}`;
|
|
76
81
|
}
|
|
77
|
-
if (
|
|
82
|
+
if (new RegExp(`^\\s*product:\\s*${PRODUCT_SENTINEL}\\s*$`, "m").test(actual)) {
|
|
78
83
|
return `${relPath} still has the ${PRODUCT_SENTINEL} placeholder — set \`product:\` to this repo's dashboard key.`;
|
|
79
84
|
}
|
|
80
85
|
return undefined;
|
|
@@ -239,6 +244,7 @@ export interface CanonicalBytes {
|
|
|
239
244
|
buildImageBootstrap: string;
|
|
240
245
|
gitignoreCore: string;
|
|
241
246
|
buildImage: string;
|
|
247
|
+
smoke: string;
|
|
242
248
|
}
|
|
243
249
|
|
|
244
250
|
// A file that must be a verbatim, byte-for-byte copy of its dev-kit canonical.
|
|
@@ -397,6 +403,23 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftRe
|
|
|
397
403
|
);
|
|
398
404
|
}
|
|
399
405
|
|
|
406
|
+
// smoke.yml is OPTIONAL until every product carries a tests/smoke.spec.ts
|
|
407
|
+
// (04b s7.2 sequences the required-file rule last). When present it is the
|
|
408
|
+
// shared, single-sourced workflow; only its `product:` dispatch key and the
|
|
409
|
+
// matrix `runner:` label array are per-repo.
|
|
410
|
+
const smokePath = join(repoRoot, ".github", "workflows", "smoke.yml");
|
|
411
|
+
if (existsSync(smokePath)) {
|
|
412
|
+
push(
|
|
413
|
+
workflowProblem(
|
|
414
|
+
".github/workflows/smoke.yml",
|
|
415
|
+
"conventions/smoke.yml",
|
|
416
|
+
readFileSync(smokePath, "utf8"),
|
|
417
|
+
canonical.smoke,
|
|
418
|
+
[PRODUCT_LINE, RUNNER_LINE],
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
400
423
|
// publish-docs.yml is OPTIONAL — a product carries it once it has an
|
|
401
424
|
// illustrated manual to publish nightly. Unlike the workflows above it has NO
|
|
402
425
|
// per-repo line: it is deliberately product-agnostic (per-repo bits live in
|
|
@@ -430,6 +453,7 @@ if (import.meta.main) {
|
|
|
430
453
|
buildImageBootstrap: readFileSync(join(import.meta.dir, "..", "build", "build-image.bootstrap.sh"), "utf8"),
|
|
431
454
|
gitignoreCore: readFileSync(join(import.meta.dir, "gitignore.core"), "utf8"),
|
|
432
455
|
buildImage: readFileSync(join(import.meta.dir, "build-image.yml"), "utf8"),
|
|
456
|
+
smoke: readFileSync(join(import.meta.dir, "smoke.yml"), "utf8"),
|
|
433
457
|
};
|
|
434
458
|
const report = checkDrift(repoRoot, canonical);
|
|
435
459
|
if (report.ok) {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# The smoke tier, single-sourced in @norskvideo/ctl-dev-kit (conventions/smoke.yml)
|
|
2
|
+
# and drift-checked — the two per-repo lines are the ci-status-dispatch
|
|
3
|
+
# `product:` key and the `runner:` label array.
|
|
4
|
+
#
|
|
5
|
+
# One journey per product (04b s7.2): launch from the stored template, accept
|
|
6
|
+
# the source the manifest advertises, produce the observable it advertises —
|
|
7
|
+
# tests/smoke.spec.ts over @norskvideo/ctl-test-harness/smoke. The image under
|
|
8
|
+
# test is built HERE (`bun run build:image`), on the same runner that holds the
|
|
9
|
+
# licence, so the tier runs on pull requests and needs no cross-workflow state;
|
|
10
|
+
# the bytes build-image pushed are covered by ctl's manifest-driven job from the
|
|
11
|
+
# published tag instead. The daemon is the RELEASED norsk-ctl binary, as a
|
|
12
|
+
# customer would run it.
|
|
13
|
+
#
|
|
14
|
+
# Runner selection is a one-entry matrix carrying a JSON label array, because a
|
|
15
|
+
# string like '[ARM64, nvidia]' in runs-on is ONE label, not two: the x64 pool
|
|
16
|
+
# is '["x64"]', the GPU box '["ARM64","nvidia"]'.
|
|
17
|
+
name: smoke
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
pull_request:
|
|
21
|
+
push:
|
|
22
|
+
branches: [main]
|
|
23
|
+
repository_dispatch:
|
|
24
|
+
types: [ctl-candidate, pins-bumped]
|
|
25
|
+
workflow_dispatch:
|
|
26
|
+
inputs:
|
|
27
|
+
ctl_channel:
|
|
28
|
+
description: "norsk-ctl S3 channel to test against (release-gate dispatches 'rc' to validate a candidate before promoting latest)."
|
|
29
|
+
type: string
|
|
30
|
+
default: latest
|
|
31
|
+
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
|
|
35
|
+
# Key the group on event_name so a push never cancels an in-flight ctl-candidate
|
|
36
|
+
# validation: pushes cancel pushes, candidate validations cancel only newer
|
|
37
|
+
# candidates. A cancelled validation would score as a red at release-collect.
|
|
38
|
+
concurrency:
|
|
39
|
+
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
|
|
40
|
+
cancel-in-progress: true
|
|
41
|
+
|
|
42
|
+
jobs:
|
|
43
|
+
smoke:
|
|
44
|
+
strategy:
|
|
45
|
+
matrix:
|
|
46
|
+
include:
|
|
47
|
+
- runner: '["x64"]'
|
|
48
|
+
runs-on: ${{ fromJSON(matrix.runner) }}
|
|
49
|
+
steps:
|
|
50
|
+
# BEFORE checkout: launched instances leave root-owned bind-mount target
|
|
51
|
+
# dirs under test-temp/ that the non-root runner user cannot remove, so
|
|
52
|
+
# actions/checkout's own cleanup would fail the job before anything runs.
|
|
53
|
+
# Nuke them from a throwaway root container first. Best-effort.
|
|
54
|
+
- name: Clear stale root-owned test-temp (pre-checkout)
|
|
55
|
+
run: |
|
|
56
|
+
set -uo pipefail
|
|
57
|
+
tt="$GITHUB_WORKSPACE/test-temp"
|
|
58
|
+
[ -d "$tt" ] || exit 0
|
|
59
|
+
docker run --rm --user 0:0 -v "$tt":/t alpine sh \
|
|
60
|
+
-c 'rm -rf /t/* /t/.[!.]* 2>/dev/null || true' || rm -rf "$tt"/* 2>/dev/null || true
|
|
61
|
+
|
|
62
|
+
- uses: actions/checkout@v5
|
|
63
|
+
with:
|
|
64
|
+
clean: false
|
|
65
|
+
|
|
66
|
+
# A containerised runner (docker-outside-of-docker) reaches host-published
|
|
67
|
+
# ports via the host-gateway alias and can join norsk-net for direct reach;
|
|
68
|
+
# a bare-metal host runner (the GPU box) must stay on localhost + publish.
|
|
69
|
+
# Detect rather than hardcode, so one byte-identical file serves both.
|
|
70
|
+
- name: Probe runner docker topology and set NORSK_TEST_HOST + NORSK_NET_REACH
|
|
71
|
+
run: |
|
|
72
|
+
set -uo pipefail
|
|
73
|
+
echo "hostname=$(hostname)"
|
|
74
|
+
if docker inspect "$(hostname)" >/dev/null 2>&1; then
|
|
75
|
+
echo "TOPO: hostname resolves to a container -> runner is containerized (DooD)"
|
|
76
|
+
echo "NORSK_TEST_HOST=host.docker.internal" >> "$GITHUB_ENV"
|
|
77
|
+
echo "NORSK_NET_REACH=direct" >> "$GITHUB_ENV"
|
|
78
|
+
else
|
|
79
|
+
echo "TOPO: hostname is not a container -> runner is host-based"
|
|
80
|
+
echo "NORSK_TEST_HOST=localhost" >> "$GITHUB_ENV"
|
|
81
|
+
echo "NORSK_NET_REACH=publish" >> "$GITHUB_ENV"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
- name: Write the Norsk license (from the org secret)
|
|
85
|
+
env:
|
|
86
|
+
NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
|
|
87
|
+
run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
|
|
88
|
+
|
|
89
|
+
# Pick the binary for THIS runner's architecture: the GPU box is aarch64,
|
|
90
|
+
# the x64 pool is x86_64, and the wrong one dies with "Exec format error"
|
|
91
|
+
# deep in the suite. No `|| true` on --version: a bad binary must fail here.
|
|
92
|
+
- name: Download the released norsk-ctl binary
|
|
93
|
+
run: |
|
|
94
|
+
set -euo pipefail
|
|
95
|
+
channel="${{ github.event.client_payload.channel || inputs.ctl_channel || 'latest' }}"
|
|
96
|
+
case "$(uname -m)" in
|
|
97
|
+
x86_64) arch=linux-x64 ;;
|
|
98
|
+
aarch64 | arm64) arch=linux-arm64 ;;
|
|
99
|
+
*) echo "unsupported runner arch: $(uname -m)" >&2; exit 1 ;;
|
|
100
|
+
esac
|
|
101
|
+
S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
|
|
102
|
+
ver="$(curl -fsSL "$S3/$channel")"
|
|
103
|
+
echo "norsk-ctl $channel channel -> $ver ($arch)"
|
|
104
|
+
curl -fsSL "$S3/$ver/norsk-ctl-$ver-$arch" -o "$RUNNER_TEMP/norsk-ctl"
|
|
105
|
+
chmod +x "$RUNNER_TEMP/norsk-ctl"
|
|
106
|
+
"$RUNNER_TEMP/norsk-ctl" --version
|
|
107
|
+
|
|
108
|
+
# Instance launch docker-composes the media + studio images; on a cold
|
|
109
|
+
# runner the first pull overruns the per-test timeout. Pre-pull them (tags
|
|
110
|
+
# from manifest.seed.json), plus whatever the repo lists in the optional
|
|
111
|
+
# scripts/smoke/prepull-extra.sh hook (a driver image, a sidecar).
|
|
112
|
+
- name: Pre-pull the media + studio images
|
|
113
|
+
run: |
|
|
114
|
+
set -euo pipefail
|
|
115
|
+
for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
|
|
116
|
+
echo "pre-pulling $img"
|
|
117
|
+
docker pull "$img"
|
|
118
|
+
done
|
|
119
|
+
if [ -x scripts/smoke/prepull-extra.sh ]; then
|
|
120
|
+
echo "running scripts/smoke/prepull-extra.sh"
|
|
121
|
+
./scripts/smoke/prepull-extra.sh
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# The image is built UNDER THE LICENSED REPO: a V2 licence entitles a
|
|
125
|
+
# product by image repo (norskvideo/norsk-<x>-product), so `product add`
|
|
126
|
+
# refuses a throwaway name. That repo is the PUBLISH_IMAGE line
|
|
127
|
+
# build-image.yml already carries per repo; a build-only product (empty)
|
|
128
|
+
# falls back to its build script default, which its own licence must name.
|
|
129
|
+
- name: Build the image and run the smoke journey against it
|
|
130
|
+
env:
|
|
131
|
+
NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
|
|
132
|
+
NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
|
|
133
|
+
run: |
|
|
134
|
+
nix develop .#build --command bash -c '
|
|
135
|
+
set -euo pipefail
|
|
136
|
+
# clean:false keeps node_modules between runs, and a frozen install
|
|
137
|
+
# does not reliably relink a hoisted package whose version moved: a
|
|
138
|
+
# stale test-harness once survived a bump and a new export was "not
|
|
139
|
+
# found" at import. Drop the harness copy so the pinned version runs.
|
|
140
|
+
rm -rf node_modules/@norskvideo/ctl-test-harness
|
|
141
|
+
bun install --frozen-lockfile
|
|
142
|
+
pub="$(sed -nE "s/^[[:space:]]*PUBLISH_IMAGE:[[:space:]]*\"?([^\" ]*)\"?[[:space:]]*$/\\1/p" .github/workflows/build-image.yml | head -1)"
|
|
143
|
+
def="$(sed -nE "s/.*IMAGE_TAG:-([^}]+)\\}.*/\\1/p" deployment/build-image.sh | head -1)"
|
|
144
|
+
export IMAGE_TAG="${pub:+$pub:smoke}"
|
|
145
|
+
export IMAGE_TAG="${IMAGE_TAG:-$def}"
|
|
146
|
+
echo "smoke image: $IMAGE_TAG"
|
|
147
|
+
bun run build:image
|
|
148
|
+
SMOKE_IMAGE_TAG="$IMAGE_TAG" bun run test:smoke
|
|
149
|
+
'
|
|
150
|
+
|
|
151
|
+
# Report this pipeline's result to the aggregated product CI dashboard
|
|
152
|
+
# (id3as/ci-workflows). !cancelled() so a real pass/fail reports, but a
|
|
153
|
+
# cancelled/superseded run does NOT — a cancellation dispatched as failure
|
|
154
|
+
# would false-red the board. Candidate validations report to ctl instead.
|
|
155
|
+
notify:
|
|
156
|
+
needs: smoke
|
|
157
|
+
if: ${{ !cancelled() && github.event_name != 'repository_dispatch' }}
|
|
158
|
+
runs-on: x64
|
|
159
|
+
steps:
|
|
160
|
+
- uses: actions/checkout@v5
|
|
161
|
+
with:
|
|
162
|
+
clean: false
|
|
163
|
+
- id: meta
|
|
164
|
+
run: |
|
|
165
|
+
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
|
|
166
|
+
echo "state=failure" >> "$GITHUB_OUTPUT"
|
|
167
|
+
else
|
|
168
|
+
echo "state=success" >> "$GITHUB_OUTPUT"
|
|
169
|
+
fi
|
|
170
|
+
- uses: ./.github/actions/ci-status-dispatch
|
|
171
|
+
with:
|
|
172
|
+
token: ${{ secrets.CI_DISPATCH_TOKEN }}
|
|
173
|
+
product: __PRODUCT__
|
|
174
|
+
pipeline: smoke
|
|
175
|
+
status: ${{ steps.meta.outputs.state }}
|
|
176
|
+
|
|
177
|
+
# Report the rc-candidate validation result to the norsk-ctl release gate. Only
|
|
178
|
+
# on a ctl-candidate dispatch; release-collect tallies these and promotes
|
|
179
|
+
# latest once every gating product is green on the candidate.
|
|
180
|
+
report-candidate:
|
|
181
|
+
needs: smoke
|
|
182
|
+
if: ${{ always() && github.event_name == 'repository_dispatch' && github.event.action == 'ctl-candidate' }}
|
|
183
|
+
runs-on: x64
|
|
184
|
+
steps:
|
|
185
|
+
- name: Fire ctl-candidate-result at norsk-ctl
|
|
186
|
+
env:
|
|
187
|
+
GH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
|
|
188
|
+
# Lowercase on purpose: the drift gate masks every `product:` line, so
|
|
189
|
+
# one per-repo key serves both the notify and the report jobs.
|
|
190
|
+
product: __PRODUCT__
|
|
191
|
+
VERSION: ${{ github.event.client_payload.version }}
|
|
192
|
+
RESULT: ${{ needs.smoke.result }}
|
|
193
|
+
run: |
|
|
194
|
+
set -euo pipefail
|
|
195
|
+
state=failure; [ "$RESULT" = "success" ] && state=success
|
|
196
|
+
body="$(jq -nc --arg p "$product" --arg v "$VERSION" --arg r "$state" \
|
|
197
|
+
'{event_type:"ctl-candidate-result",client_payload:{product:$p,version:$v,result:$r}}')"
|
|
198
|
+
code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
|
199
|
+
-H "Authorization: Bearer $GH_TOKEN" \
|
|
200
|
+
-H "Accept: application/vnd.github+json" \
|
|
201
|
+
https://api.github.com/repos/id3as/norsk-ctl/dispatches -d "$body")"
|
|
202
|
+
[ "$code" = "204" ] || { echo "::error::report to norsk-ctl failed (HTTP $code)"; exit 1; }
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
PRODUCT_LINE,
|
|
32
32
|
PRODUCT_SENTINEL,
|
|
33
33
|
PUBLISH_IMAGE_LINE,
|
|
34
|
+
RUNNER_LINE,
|
|
34
35
|
splitAtMarkers,
|
|
35
36
|
} from "./check-drift.ts";
|
|
36
37
|
|
|
@@ -91,6 +92,30 @@ function syncBuildImage(repoRoot: string, canonical: string, r: SyncReport): voi
|
|
|
91
92
|
writeIfChanged(path, out, rel, r.written);
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// smoke.yml: optional (a repo carries it once it has a smoke spec), canonical
|
|
96
|
+
// byte-for-byte except the notify/report `product:` key and the matrix
|
|
97
|
+
// `runner:` label array. Re-emit the canonical with both restored; the runner
|
|
98
|
+
// line goes back through a function replacer so a `$` in it is never read as a
|
|
99
|
+
// backreference.
|
|
100
|
+
function syncSmoke(repoRoot: string, canonical: string, r: SyncReport): void {
|
|
101
|
+
const rel = ".github/workflows/smoke.yml";
|
|
102
|
+
const path = join(repoRoot, rel);
|
|
103
|
+
if (!existsSync(path)) {
|
|
104
|
+
r.skipped.push(`${rel} (absent)`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const existing = readFileSync(path, "utf8");
|
|
108
|
+
const key = existing.match(/^\s*product:\s*(\S+)/m)?.[1];
|
|
109
|
+
if (!key || key === PRODUCT_SENTINEL) {
|
|
110
|
+
r.skipped.push(`${rel} (no product: key to preserve)`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const runnerLine = existing.match(/^\s*-?\s*runner:.*$/m)?.[0];
|
|
114
|
+
let out = canonical.replace(PRODUCT_LINE, `$1${key}`);
|
|
115
|
+
if (runnerLine !== undefined) out = out.replace(RUNNER_LINE, () => runnerLine);
|
|
116
|
+
writeIfChanged(path, out, rel, r.written);
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
// flake.nix is verbatim except the dev-only ctl pin (ctlVersion + the four
|
|
95
120
|
// per-platform hashes), which floats per repo. Re-emit canonical STRUCTURE with
|
|
96
121
|
// the repo's pin restored in order — so a structural edit (e.g. a stray comment)
|
|
@@ -180,6 +205,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
|
|
|
180
205
|
syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", canonical.upgradeLatest, r);
|
|
181
206
|
syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
|
|
182
207
|
syncBuildImage(repoRoot, canonical.buildImage, r);
|
|
208
|
+
syncSmoke(repoRoot, canonical.smoke, r);
|
|
183
209
|
|
|
184
210
|
// publish-docs.yml is optional and verbatim (no per-repo line) — only re-sync a
|
|
185
211
|
// repo that already carries it, unlike biome/tsconfig which every repo must have.
|
|
@@ -212,6 +238,7 @@ if (import.meta.main) {
|
|
|
212
238
|
buildImageBootstrap: readFileSync(join(dir, "..", "build", "build-image.bootstrap.sh"), "utf8"),
|
|
213
239
|
gitignoreCore: readFileSync(join(dir, "gitignore.core"), "utf8"),
|
|
214
240
|
buildImage: readFileSync(join(dir, "build-image.yml"), "utf8"),
|
|
241
|
+
smoke: readFileSync(join(dir, "smoke.yml"), "utf8"),
|
|
215
242
|
};
|
|
216
243
|
const report = syncDrift(repoRoot, canonical);
|
|
217
244
|
if (report.written.length === 0) {
|