@norskvideo/ctl-dev-kit 0.1.44 → 0.1.46

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.
@@ -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 (actual.match(/^\s*product:\s*(\S+)/m)?.[1] === PRODUCT_SENTINEL) {
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,207 @@
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 runner
16
+ # MUST run Docker natively: `product add --image` publishes the control plane on
17
+ # a host port and the daemon polls 127.0.0.1:<port> for readiness, so on a
18
+ # docker-outside-of-docker pool (the x64 runners are containers) that loopback
19
+ # is the docker host's, never the runner's, and registration times out. Default
20
+ # '["ar-arm-vm"]' (native Docker, arm64 — the image is built for it here); the
21
+ # GPU box is '["ARM64","nvidia"]'.
22
+ name: smoke
23
+
24
+ on:
25
+ pull_request:
26
+ push:
27
+ branches: [main]
28
+ repository_dispatch:
29
+ types: [ctl-candidate, pins-bumped]
30
+ workflow_dispatch:
31
+ inputs:
32
+ ctl_channel:
33
+ description: "norsk-ctl S3 channel to test against (release-gate dispatches 'rc' to validate a candidate before promoting latest)."
34
+ type: string
35
+ default: latest
36
+
37
+ permissions:
38
+ contents: read
39
+
40
+ # Key the group on event_name so a push never cancels an in-flight ctl-candidate
41
+ # validation: pushes cancel pushes, candidate validations cancel only newer
42
+ # candidates. A cancelled validation would score as a red at release-collect.
43
+ concurrency:
44
+ group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
45
+ cancel-in-progress: true
46
+
47
+ jobs:
48
+ smoke:
49
+ strategy:
50
+ matrix:
51
+ include:
52
+ - runner: '["ar-arm-vm"]'
53
+ runs-on: ${{ fromJSON(matrix.runner) }}
54
+ steps:
55
+ # BEFORE checkout: launched instances leave root-owned bind-mount target
56
+ # dirs under test-temp/ that the non-root runner user cannot remove, so
57
+ # actions/checkout's own cleanup would fail the job before anything runs.
58
+ # Nuke them from a throwaway root container first. Best-effort.
59
+ - name: Clear stale root-owned test-temp (pre-checkout)
60
+ run: |
61
+ set -uo pipefail
62
+ tt="$GITHUB_WORKSPACE/test-temp"
63
+ [ -d "$tt" ] || exit 0
64
+ docker run --rm --user 0:0 -v "$tt":/t alpine sh \
65
+ -c 'rm -rf /t/* /t/.[!.]* 2>/dev/null || true' || rm -rf "$tt"/* 2>/dev/null || true
66
+
67
+ - uses: actions/checkout@v5
68
+ with:
69
+ clean: false
70
+
71
+ # A containerised runner (docker-outside-of-docker) reaches host-published
72
+ # ports via the host-gateway alias and can join norsk-net for direct reach;
73
+ # a bare-metal host runner (the GPU box) must stay on localhost + publish.
74
+ # Detect rather than hardcode, so one byte-identical file serves both.
75
+ - name: Probe runner docker topology and set NORSK_TEST_HOST + NORSK_NET_REACH
76
+ run: |
77
+ set -uo pipefail
78
+ echo "hostname=$(hostname)"
79
+ if docker inspect "$(hostname)" >/dev/null 2>&1; then
80
+ echo "TOPO: hostname resolves to a container -> runner is containerized (DooD)"
81
+ echo "NORSK_TEST_HOST=host.docker.internal" >> "$GITHUB_ENV"
82
+ echo "NORSK_NET_REACH=direct" >> "$GITHUB_ENV"
83
+ else
84
+ echo "TOPO: hostname is not a container -> runner is host-based"
85
+ echo "NORSK_TEST_HOST=localhost" >> "$GITHUB_ENV"
86
+ echo "NORSK_NET_REACH=publish" >> "$GITHUB_ENV"
87
+ fi
88
+
89
+ - name: Write the Norsk license (from the org secret)
90
+ env:
91
+ NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
92
+ run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
93
+
94
+ # Pick the binary for THIS runner's architecture: the GPU box is aarch64,
95
+ # the x64 pool is x86_64, and the wrong one dies with "Exec format error"
96
+ # deep in the suite. No `|| true` on --version: a bad binary must fail here.
97
+ - name: Download the released norsk-ctl binary
98
+ run: |
99
+ set -euo pipefail
100
+ channel="${{ github.event.client_payload.channel || inputs.ctl_channel || 'latest' }}"
101
+ case "$(uname -m)" in
102
+ x86_64) arch=linux-x64 ;;
103
+ aarch64 | arm64) arch=linux-arm64 ;;
104
+ *) echo "unsupported runner arch: $(uname -m)" >&2; exit 1 ;;
105
+ esac
106
+ S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
107
+ ver="$(curl -fsSL "$S3/$channel")"
108
+ echo "norsk-ctl $channel channel -> $ver ($arch)"
109
+ curl -fsSL "$S3/$ver/norsk-ctl-$ver-$arch" -o "$RUNNER_TEMP/norsk-ctl"
110
+ chmod +x "$RUNNER_TEMP/norsk-ctl"
111
+ "$RUNNER_TEMP/norsk-ctl" --version
112
+
113
+ # Instance launch docker-composes the media + studio images; on a cold
114
+ # runner the first pull overruns the per-test timeout. Pre-pull them (tags
115
+ # from manifest.seed.json), plus whatever the repo lists in the optional
116
+ # scripts/smoke/prepull-extra.sh hook (a driver image, a sidecar).
117
+ - name: Pre-pull the media + studio images
118
+ run: |
119
+ set -euo pipefail
120
+ for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
121
+ echo "pre-pulling $img"
122
+ docker pull "$img"
123
+ done
124
+ if [ -x scripts/smoke/prepull-extra.sh ]; then
125
+ echo "running scripts/smoke/prepull-extra.sh"
126
+ ./scripts/smoke/prepull-extra.sh
127
+ fi
128
+
129
+ # The image is built UNDER THE LICENSED REPO: a V2 licence entitles a
130
+ # product by image repo (norskvideo/norsk-<x>-product), so `product add`
131
+ # refuses a throwaway name. That repo is the PUBLISH_IMAGE line
132
+ # build-image.yml already carries per repo; a build-only product (empty)
133
+ # falls back to its build script default, which its own licence must name.
134
+ - name: Build the image and run the smoke journey against it
135
+ env:
136
+ NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
137
+ NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
138
+ run: |
139
+ nix develop .#build --command bash -c '
140
+ set -euo pipefail
141
+ # clean:false keeps node_modules between runs, and a frozen install
142
+ # does not reliably relink a hoisted package whose version moved: a
143
+ # stale test-harness once survived a bump and a new export was "not
144
+ # found" at import. Drop the harness copy so the pinned version runs.
145
+ rm -rf node_modules/@norskvideo/ctl-test-harness
146
+ bun install --frozen-lockfile
147
+ pub="$(sed -nE "s/^[[:space:]]*PUBLISH_IMAGE:[[:space:]]*\"?([^\" ]*)\"?[[:space:]]*$/\\1/p" .github/workflows/build-image.yml | head -1)"
148
+ def="$(sed -nE "s/.*IMAGE_TAG:-([^}]+)\\}.*/\\1/p" deployment/build-image.sh | head -1)"
149
+ export IMAGE_TAG="${pub:+$pub:smoke}"
150
+ export IMAGE_TAG="${IMAGE_TAG:-$def}"
151
+ echo "smoke image: $IMAGE_TAG"
152
+ bun run build:image
153
+ SMOKE_IMAGE_TAG="$IMAGE_TAG" bun run test:smoke
154
+ '
155
+
156
+ # Report this pipeline's result to the aggregated product CI dashboard
157
+ # (id3as/ci-workflows). !cancelled() so a real pass/fail reports, but a
158
+ # cancelled/superseded run does NOT — a cancellation dispatched as failure
159
+ # would false-red the board. Candidate validations report to ctl instead.
160
+ notify:
161
+ needs: smoke
162
+ if: ${{ !cancelled() && github.event_name != 'repository_dispatch' }}
163
+ runs-on: x64
164
+ steps:
165
+ - uses: actions/checkout@v5
166
+ with:
167
+ clean: false
168
+ - id: meta
169
+ run: |
170
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
171
+ echo "state=failure" >> "$GITHUB_OUTPUT"
172
+ else
173
+ echo "state=success" >> "$GITHUB_OUTPUT"
174
+ fi
175
+ - uses: ./.github/actions/ci-status-dispatch
176
+ with:
177
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
178
+ product: __PRODUCT__
179
+ pipeline: smoke
180
+ status: ${{ steps.meta.outputs.state }}
181
+
182
+ # Report the rc-candidate validation result to the norsk-ctl release gate. Only
183
+ # on a ctl-candidate dispatch; release-collect tallies these and promotes
184
+ # latest once every gating product is green on the candidate.
185
+ report-candidate:
186
+ needs: smoke
187
+ if: ${{ always() && github.event_name == 'repository_dispatch' && github.event.action == 'ctl-candidate' }}
188
+ runs-on: x64
189
+ steps:
190
+ - name: Fire ctl-candidate-result at norsk-ctl
191
+ env:
192
+ GH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
193
+ # Lowercase on purpose: the drift gate masks every `product:` line, so
194
+ # one per-repo key serves both the notify and the report jobs.
195
+ product: __PRODUCT__
196
+ VERSION: ${{ github.event.client_payload.version }}
197
+ RESULT: ${{ needs.smoke.result }}
198
+ run: |
199
+ set -euo pipefail
200
+ state=failure; [ "$RESULT" = "success" ] && state=success
201
+ body="$(jq -nc --arg p "$product" --arg v "$VERSION" --arg r "$state" \
202
+ '{event_type:"ctl-candidate-result",client_payload:{product:$p,version:$v,result:$r}}')"
203
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
204
+ -H "Authorization: Bearer $GH_TOKEN" \
205
+ -H "Accept: application/vnd.github+json" \
206
+ https://api.github.com/repos/id3as/norsk-ctl/dispatches -d "$body")"
207
+ [ "$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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",