@norskvideo/ctl-dev-kit 0.1.84 → 0.1.85

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.
@@ -62,6 +62,20 @@ export const PRODUCT_SENTINEL = "__PRODUCT__";
62
62
  // unfilled-sentinel check.
63
63
  export const PUBLISH_IMAGE_LINE = /^(\s*PUBLISH_IMAGE:\s*).*$/gm;
64
64
 
65
+ // integration.yml (04c s9 #1) is universally present and was hand-owned in six
66
+ // shapes, so on the day it joined the conventions set the gate could not demand
67
+ // the canonical of every copy: sync-dev-kit commits straight to main when
68
+ // checks are green, and would have rewritten six tiers before the hooks their
69
+ // suites need existed. A copy therefore declares itself converged by carrying
70
+ // the canonical's own first line; one without it is hand-owned and ungated
71
+ // until its repo converges deliberately. When every repo carries the marker,
72
+ // this becomes unconditional like smoke.yml.
73
+ export const INTEGRATION_MARKER = "# ctl-dev-kit convention: integration";
74
+
75
+ export function carriesIntegrationMarker(text: string): boolean {
76
+ return text.split("\n")[0]?.trim() === INTEGRATION_MARKER;
77
+ }
78
+
65
79
  // smoke.yml carries a second per-repo line: the matrix `runner:` label array
66
80
  // the journey runs on ('["x64"]' for the pool, '["ARM64","nvidia"]' for the GPU
67
81
  // box). Masked like product:; any filled value is valid, so no sentinel check.
@@ -252,6 +266,9 @@ export interface CanonicalBytes {
252
266
  gitignoreCore: string;
253
267
  buildImage: string;
254
268
  smoke: string;
269
+ /** conventions/integration.yml — the integration tier. Gated only for a copy
270
+ * carrying INTEGRATION_MARKER on line 1; same two masked lines as smoke. */
271
+ integration: string;
255
272
  /** conventions/demo.sh — the scripts/demo shim a product with tests/demo.spec.ts carries. */
256
273
  demoShim: string;
257
274
  /** doc-guide/with-display.sh — the X-display wrapper BOTH docs workflows invoke
@@ -546,6 +563,25 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
546
563
  );
547
564
  }
548
565
 
566
+ // integration.yml: gated once a copy carries the convention marker (see
567
+ // INTEGRATION_MARKER for why not before); then it is smoke.yml's twin, with
568
+ // the `product:` key and the matrix `runner:` array as the per-repo lines.
569
+ const integrationPath = join(repoRoot, ".github", "workflows", "integration.yml");
570
+ if (existsSync(integrationPath)) {
571
+ const actual = readFileSync(integrationPath, "utf8");
572
+ if (carriesIntegrationMarker(actual)) {
573
+ push(
574
+ workflowProblem(
575
+ ".github/workflows/integration.yml",
576
+ "conventions/integration.yml",
577
+ actual,
578
+ canonical.integration,
579
+ [PRODUCT_LINE, RUNNER_LINE],
580
+ ),
581
+ );
582
+ }
583
+ }
584
+
549
585
  // publish-docs.yml is OPTIONAL — a product carries it once it has an
550
586
  // illustrated manual to publish nightly. Unlike the workflows above it has NO
551
587
  // per-repo line: it is deliberately product-agnostic (per-repo bits live in
@@ -623,6 +659,7 @@ if (import.meta.main) {
623
659
  gitignoreCore: readFileSync(join(import.meta.dir, "gitignore.core"), "utf8"),
624
660
  buildImage: readFileSync(join(import.meta.dir, "build-image.yml"), "utf8"),
625
661
  smoke: readFileSync(join(import.meta.dir, "smoke.yml"), "utf8"),
662
+ integration: readFileSync(join(import.meta.dir, "integration.yml"), "utf8"),
626
663
  demoShim: readFileSync(join(import.meta.dir, "demo.sh"), "utf8"),
627
664
  };
628
665
  const report = checkDrift(repoRoot, canonical, { repoName: resolveRepoName(repoRoot) });
@@ -138,3 +138,43 @@ clients that create media nodes (e.g. a WHIP driver) must answer the license
138
138
  creation with `10 ABORTED: License handshake required`. The `license is marked
139
139
  NON-PRODUCTION` warning is expected in CI, not an error. The media engine's
140
140
  `hugepages / rte_eal_init failed` line is **benign noise** — do not chase it.
141
+
142
+ ## The workflow: `integration.yml` is a convention
143
+
144
+ `.github/workflows/integration.yml` is single-sourced in this dev-kit
145
+ (`conventions/integration.yml`) and drift-gated like `smoke.yml`, with the same
146
+ two per-repo lines: the `product:` dashboard key and the matrix `runner:` label
147
+ array. Everything else a repo used to hand-edit into the workflow now lives in
148
+ three optional hooks the workflow calls if they are executable:
149
+
150
+ | Hook | When | What goes there |
151
+ | -------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
152
+ | `scripts/integration/prepull-extra.sh` | before the suite, outside nix | `docker pull` of images the suite launches beyond media + studio (a driver, a sink) |
153
+ | `scripts/integration/prepare.sh` | before `bun run test:integration` | builds the suite needs on disk — a frontend dist the daemon fetches, an image it launches |
154
+ | `scripts/integration/verify.sh` | after the suite | post-suite checks; absent, a repo with `tests/demo.spec.ts` gets `bun run demo -- check` |
155
+
156
+ `bun run test:integration` is the product's claim of what its tier is, and
157
+ ctl's release gate believes it: a `ctl-candidate` dispatch runs this workflow
158
+ and the reported result decides whether `latest` moves.
159
+
160
+ **Converging a repo.** The gate only takes over a copy whose first line is the
161
+ canonical's marker (`# ctl-dev-kit convention: integration`); a copy without it
162
+ is hand-owned and ungated, which is the state every repo started in. To
163
+ converge: move the repo's bespoke steps into the hooks above, replace the
164
+ workflow with the canonical (product and runner filled in), and prove it with
165
+ `gh workflow run integration.yml --ref <branch>` before merging — the tier
166
+ only fires on `main` and on dispatch, so a PR's checks never run it. Once the
167
+ marker is in, `sync-dev-kit` keeps the copy current and `check:drift` names any
168
+ hand edit.
169
+
170
+ **Why the pipe is shaped the way it is.** The suite runs inside a shell
171
+ function piped into `tee` so the log survives as an artifact, and the step's
172
+ first line is `set -euo pipefail`. GitHub's default `run:` shell is `bash -e
173
+ {0}` with no pipefail, so without it the step exits with tee's status and a
174
+ dead suite reads green; a `set -euo pipefail` inside the nested `bash -c` body
175
+ cannot reach the pipeline one shell up. Both turnkeys ran a void tier for weeks
176
+ that way. `tee-pipefail.test.ts` guards every convention workflow for it.
177
+
178
+ The workflow also uploads `test-results/` beside the log, and reaps the
179
+ instances the harness recorded in `$NORSK_LAUNCHED_MANIFEST` after a cancelled
180
+ or failed run, so one killed job cannot poison the next with a held port.
@@ -0,0 +1,346 @@
1
+ # ctl-dev-kit convention: integration
2
+ #
3
+ # The integration tier, single-sourced in @norskvideo/ctl-dev-kit
4
+ # (conventions/integration.yml) and drift-checked — the two per-repo lines are
5
+ # the ci-status-dispatch `product:` key and the `runner:` label array. The
6
+ # marker on line 1 is what puts a copy under the gate: a repo converges by
7
+ # replacing its hand-owned workflow with this file (product and runner filled
8
+ # in) plus whatever hooks below its suite needs, proven by a workflow_dispatch
9
+ # run on the branch. Until a copy carries the marker it is hand-owned and
10
+ # ungated — which is the state fleet review 04c s3.1 found every product in.
11
+ #
12
+ # What the tier is: the product's slow suite (daemon + Docker + licence) run
13
+ # against the RELEASED norsk-ctl binary, exactly as a customer would run it — a
14
+ # split product repo has no ctl source. `bun run test:integration` is the
15
+ # product's claim of what that suite is, and ctl's release gate believes it: a
16
+ # ctl-candidate dispatch runs this and the result decides whether `latest`
17
+ # moves.
18
+ #
19
+ # Per-repo variation lives in three optional hooks, not in this file:
20
+ # scripts/integration/prepull-extra.sh extra images the suite launches (a
21
+ # driver, a sink, a sidecar)
22
+ # scripts/integration/prepare.sh built before the suite runs (a
23
+ # frontend dist the daemon fetches, an
24
+ # image the workflow launches)
25
+ # scripts/integration/verify.sh after the suite; absent, a product
26
+ # with tests/demo.spec.ts gets
27
+ # `bun run demo -- check`
28
+ #
29
+ # The suite is piped into tee so its log survives as an artifact, and the step
30
+ # sets pipefail FIRST: the default `bash -e {0}` has none, so without it the
31
+ # step exits with tee's status and a dead suite reads green — both turnkeys
32
+ # ran a void tier for weeks that way, feeding a green candidate result to the
33
+ # release gate. tee-pipefail.test.ts guards the line; it ships with this file.
34
+ #
35
+ # Runner selection is a one-entry matrix carrying a JSON label array, because a
36
+ # string like '[ARM64, nvidia]' in runs-on is ONE label, not two. Default
37
+ # '["x64"]' — the docker-outside-of-docker pool, where launched containers are
38
+ # host siblings and the harness reaches them by compose name on norsk-net (the
39
+ # topology step below says so in NORSK_TEST_HOST/NORSK_TEST_NET); the GPU box
40
+ # is '["ARM64","nvidia"]'.
41
+ name: integration
42
+
43
+ on:
44
+ repository_dispatch:
45
+ types: [ctl-candidate]
46
+ push:
47
+ branches: [main]
48
+ workflow_dispatch:
49
+ inputs:
50
+ ctl_channel:
51
+ description: "norsk-ctl S3 channel to test against (release-gate dispatches 'rc' to validate a candidate before promoting latest)."
52
+ type: string
53
+ default: latest
54
+
55
+ permissions:
56
+ contents: read
57
+
58
+ # Key the group on event_name so a push never cancels an in-flight ctl-candidate
59
+ # validation: pushes cancel pushes, candidate validations cancel only newer
60
+ # candidates. A cancelled validation would score as a red at release-collect.
61
+ concurrency:
62
+ group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
63
+ cancel-in-progress: true
64
+
65
+ jobs:
66
+ integration:
67
+ strategy:
68
+ matrix:
69
+ include:
70
+ - runner: '["x64"]'
71
+ runs-on: ${{ fromJSON(matrix.runner) }}
72
+ steps:
73
+ # BEFORE checkout: launched instances leave root-owned bind-mount target
74
+ # dirs under test-temp/ that the non-root runner user cannot remove, so
75
+ # actions/checkout's own cleanup would fail the job before anything runs.
76
+ # Nuke them from a throwaway root container first. Best-effort.
77
+ - name: Clear stale root-owned test-temp (pre-checkout)
78
+ run: |
79
+ set -uo pipefail
80
+ tt="$GITHUB_WORKSPACE/test-temp"
81
+ [ -d "$tt" ] || exit 0
82
+ docker run --rm --user 0:0 -v "$tt":/t alpine sh \
83
+ -c 'rm -rf /t/* /t/.[!.]* 2>/dev/null || true' || rm -rf "$tt"/* 2>/dev/null || true
84
+
85
+ - uses: actions/checkout@v5
86
+ with:
87
+ clean: true
88
+
89
+ # A containerised runner (docker-outside-of-docker) reaches host-published
90
+ # ports via the host-gateway alias and can join norsk-net for direct reach;
91
+ # a bare-metal host runner (the GPU box) must stay on localhost + publish.
92
+ # Detect rather than hardcode, so one byte-identical file serves both.
93
+ # NORSK_CTL_PRODUCT_REACH rides along because this step has already
94
+ # established the fact the daemon would otherwise sniff from its own
95
+ # cgroup: `product add --image` loopback-publishes the control plane and
96
+ # polls it for readiness, and on a DooD runner that loopback is the docker
97
+ # HOST's, so registration times out at 60s with nothing in the log saying
98
+ # why. The daemon's own detection stays the default; here the probe that
99
+ # already knows simply says so.
100
+ - name: Probe runner docker topology and set NORSK_TEST_HOST + NORSK_TEST_NET
101
+ run: |
102
+ set -uo pipefail
103
+ echo "hostname=$(hostname)"
104
+ if docker inspect "$(hostname)" >/dev/null 2>&1; then
105
+ echo "TOPO: hostname resolves to a container -> runner is containerized (DooD)"
106
+ echo "NORSK_TEST_HOST=host.docker.internal" >> "$GITHUB_ENV"
107
+ echo "NORSK_TEST_NET=direct" >> "$GITHUB_ENV"
108
+ echo "NORSK_CTL_PRODUCT_REACH=container-address" >> "$GITHUB_ENV"
109
+ echo "NORSK_RUNNER_OWNS_DOCKER=false" >> "$GITHUB_ENV"
110
+ else
111
+ echo "TOPO: hostname is not a container -> runner is host-based"
112
+ echo "NORSK_TEST_HOST=localhost" >> "$GITHUB_ENV"
113
+ echo "NORSK_TEST_NET=publish" >> "$GITHUB_ENV"
114
+ echo "NORSK_CTL_PRODUCT_REACH=host" >> "$GITHUB_ENV"
115
+ echo "NORSK_RUNNER_OWNS_DOCKER=true" >> "$GITHUB_ENV"
116
+ fi
117
+
118
+ # `product add --image` publishes the control plane on 127.0.0.1:14321. The
119
+ # daemon means to reap it on shutdown (daemon.ts, model B) but only does so
120
+ # gracefully, and this workflow cancels its own superseded runs -- a killed
121
+ # daemon reaps nothing and the port stays held for every later job here.
122
+ # Kill by PORT rather than by name, prune the endpoints a removal can
123
+ # strand, and then PROVE the port binds instead of assuming it. Only where
124
+ # the docker daemon is this runner's own and jobs are serial; the GPU box
125
+ # runs several runners as sibling CONTAINERS against one host daemon and is
126
+ # excluded -- there, a blanket reap would kill a concurrent job.
127
+ - name: Reap a stranded product control plane
128
+ if: ${{ env.NORSK_RUNNER_OWNS_DOCKER == 'true' }}
129
+ run: |
130
+ set -uo pipefail
131
+ port=14321
132
+ bindable() { docker run --rm -p "127.0.0.1:$port:1" alpine true >/dev/null 2>&1; }
133
+
134
+ held="$(docker ps -a --filter "publish=$port" --format '{{.Names}} {{.Status}} {{.Image}}')"
135
+ if [ -n "$held" ]; then
136
+ echo "::warning::$port was held on entry by: $held"
137
+ docker rm -f $(docker ps -aq --filter "publish=$port") || true
138
+ fi
139
+ leftover="$(docker ps -aq --filter 'name=^norsk-product-')"
140
+ [ -z "$leftover" ] || docker rm -f $leftover || true
141
+
142
+ if ! bindable; then
143
+ echo "::warning::$port still not bindable after removing containers -- pruning dangling endpoints"
144
+ docker network prune -f || true
145
+ fi
146
+ if ! bindable; then
147
+ echo "::error::cannot bind 127.0.0.1:$port; the suite would fail later and more obscurely"
148
+ docker ps -a --filter "publish=$port" || true
149
+ exit 1
150
+ fi
151
+ echo "$port is bindable"
152
+
153
+ - name: Write the Norsk license (from the org secret)
154
+ env:
155
+ NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
156
+ run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
157
+
158
+ # Pick the binary for THIS runner's architecture: the GPU box is aarch64,
159
+ # the x64 pool is x86_64, and the wrong one dies with "Exec format error"
160
+ # deep in the suite. No `|| true` on --version: a bad binary must fail here.
161
+ - name: Download the released norsk-ctl binary
162
+ run: |
163
+ set -euo pipefail
164
+ channel="${{ github.event.client_payload.channel || inputs.ctl_channel || 'latest' }}"
165
+ case "$(uname -m)" in
166
+ x86_64) arch=linux-x64 ;;
167
+ aarch64 | arm64) arch=linux-arm64 ;;
168
+ *) echo "unsupported runner arch: $(uname -m)" >&2; exit 1 ;;
169
+ esac
170
+ S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
171
+ ver="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$channel")"
172
+ echo "norsk-ctl $channel channel -> $ver ($arch)"
173
+ curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-$arch" -o "$RUNNER_TEMP/norsk-ctl"
174
+ chmod +x "$RUNNER_TEMP/norsk-ctl"
175
+ "$RUNNER_TEMP/norsk-ctl" --version
176
+
177
+ # Everything below runs dev-kit and test-harness code out of node_modules,
178
+ # and `clean: true` on the checkout means this job starts without any.
179
+ - name: Install dependencies
180
+ run: nix develop .#build --command bun install --frozen-lockfile
181
+
182
+ # Before anything is built or launched: does this licence actually name
183
+ # this product? `product add` refuses one it does not, and that refusal
184
+ # reaches the log as a 60-second readiness timeout indistinguishable from
185
+ # a product that failed to start. reuters ran four "successful" hourly
186
+ # tiers dead at exactly that refusal (04c s3.1).
187
+ #
188
+ # The product name is the repo's own PRODUCT_NAME, not the short board key
189
+ # above (`funke` vs `funke-pegasus`): the licence names the product.
190
+ - name: Check the licence entitles this product
191
+ run: |
192
+ set -euo pipefail
193
+ product="$(sed -nE 's/.*PRODUCT_NAME[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' shared/src/version.ts | head -1)"
194
+ [ -n "$product" ] || { echo "::error::could not read PRODUCT_NAME from shared/src/version.ts"; exit 1; }
195
+ nix develop .#build --command bun \
196
+ node_modules/@norskvideo/ctl-dev-kit/licence/check-entitlement.ts \
197
+ "$RUNNER_TEMP/norsk-license.json" "$product"
198
+
199
+ # Instance launch docker-composes the media + studio images; on a cold
200
+ # runner the first pull overruns the per-test timeout. Pre-pull them (tags
201
+ # from manifest.seed.json), plus whatever the repo lists in the optional
202
+ # scripts/integration/prepull-extra.sh hook (a driver image, a sink).
203
+ - name: Pre-pull the media + studio images
204
+ run: |
205
+ set -euo pipefail
206
+ for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
207
+ echo "pre-pulling $img"
208
+ docker pull "$img"
209
+ done
210
+ if [ -x scripts/integration/prepull-extra.sh ]; then
211
+ echo "running scripts/integration/prepull-extra.sh"
212
+ ./scripts/integration/prepull-extra.sh
213
+ fi
214
+
215
+ # The launched-instances manifest is what the reap step below reads: the
216
+ # harness appends every instance id it launches, so a run that dies
217
+ # mid-suite still names what it left behind.
218
+ - name: Run integration suite
219
+ env:
220
+ NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
221
+ NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
222
+ NORSK_LAUNCHED_MANIFEST: ${{ runner.temp }}/norsk-launched-instances
223
+ run: |
224
+ set -euo pipefail
225
+ suite() {
226
+ nix develop .#build --command bash -c '
227
+ set -euo pipefail
228
+ # A frozen install does not reliably relink a hoisted package whose
229
+ # version moved: a stale test-harness once survived a bump and a new
230
+ # export was "not found" at import. Drop the harness copy so the
231
+ # pinned version runs.
232
+ rm -rf node_modules/@norskvideo/ctl-test-harness
233
+ bun install --frozen-lockfile
234
+ if [ -x scripts/integration/prepare.sh ]; then
235
+ echo "running scripts/integration/prepare.sh"
236
+ ./scripts/integration/prepare.sh
237
+ fi
238
+ bun run test:integration
239
+ if [ -x scripts/integration/verify.sh ]; then
240
+ echo "running scripts/integration/verify.sh"
241
+ ./scripts/integration/verify.sh
242
+ elif [ -f tests/demo.spec.ts ]; then
243
+ bun run demo -- check
244
+ fi
245
+ '
246
+ }
247
+ suite 2>&1 | tee "$RUNNER_TEMP/integration.log"
248
+
249
+ # The log AND the harness evidence. Playwright and the harness write
250
+ # screenshots, traces and captured container logs under test-results/;
251
+ # a workflow that uploads only its stdout leaves the reader to re-run the
252
+ # tier for evidence that already existed on the runner.
253
+ - name: Upload the run log and test evidence
254
+ if: always()
255
+ uses: actions/upload-artifact@v4
256
+ with:
257
+ name: integration-attempt-${{ github.run_attempt }}
258
+ path: |
259
+ ${{ runner.temp }}/integration.log
260
+ test-results/
261
+ retention-days: 7
262
+ if-no-files-found: warn
263
+
264
+ - name: Reap this run's leftover instance containers
265
+ if: always()
266
+ env:
267
+ NORSK_LAUNCHED_MANIFEST: ${{ runner.temp }}/norsk-launched-instances
268
+ run: |
269
+ set -uo pipefail
270
+ m="$NORSK_LAUNCHED_MANIFEST"
271
+ [ -f "$m" ] || { echo "no launched-instances manifest -- nothing to reap"; exit 0; }
272
+ sort -u "$m" | while IFS= read -r id; do
273
+ [ -n "$id" ] || continue
274
+ ids="$(docker ps -aq --filter "label=norsk-ctl.instance=$id" || true)"
275
+ if [ -n "$ids" ]; then
276
+ echo "reaping leftover instance '$id': $ids"
277
+ docker rm -f $ids || true
278
+ fi
279
+ done
280
+ rm -f "$m" || true
281
+
282
+ # Report this pipeline's result to the aggregated product CI dashboard
283
+ # (id3as/ci-workflows). !cancelled() so a real pass/fail reports, but a
284
+ # cancelled/superseded run does NOT — a cancellation dispatched as failure
285
+ # would false-red the board. Candidate validations report to ctl instead.
286
+ notify:
287
+ needs: integration
288
+ if: ${{ !cancelled() && github.event_name != 'repository_dispatch' }}
289
+ runs-on: x64
290
+ steps:
291
+ # Same reason as the first checkout in this file: clear root-owned leftovers
292
+ # from a root container so the checkout below can clean properly. Best-effort
293
+ # by construction -- no docker, no test-temp, or a failed run all fall through.
294
+ - name: Clear stale root-owned test-temp (pre-checkout)
295
+ run: |
296
+ set -uo pipefail
297
+ tt="$GITHUB_WORKSPACE/test-temp"
298
+ [ -d "$tt" ] || exit 0
299
+ docker run --rm --user 0:0 -v "$tt":/t alpine sh \
300
+ -c 'rm -rf /t/* /t/.[!.]* 2>/dev/null || true' || rm -rf "$tt"/* 2>/dev/null || true
301
+
302
+ - uses: actions/checkout@v5
303
+ with:
304
+ clean: true
305
+ - id: meta
306
+ run: |
307
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
308
+ echo "state=failure" >> "$GITHUB_OUTPUT"
309
+ else
310
+ echo "state=success" >> "$GITHUB_OUTPUT"
311
+ fi
312
+ - uses: ./.github/actions/ci-status-dispatch
313
+ with:
314
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
315
+ product: __PRODUCT__
316
+ pipeline: integration
317
+ status: ${{ steps.meta.outputs.state }}
318
+
319
+ # Report the rc-candidate validation result to the norsk-ctl release gate. Only
320
+ # on a ctl-candidate dispatch; release-collect tallies these and promotes
321
+ # latest once every gating product is green on the candidate.
322
+ report-candidate:
323
+ needs: integration
324
+ if: ${{ always() && github.event_name == 'repository_dispatch' && github.event.action == 'ctl-candidate' }}
325
+ runs-on: x64
326
+ steps:
327
+ - name: Fire ctl-candidate-result at norsk-ctl
328
+ env:
329
+ GH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
330
+ # Lowercase on purpose: the drift gate masks every `product:` line, so
331
+ # one per-repo key serves both the notify and the report jobs.
332
+ product: __PRODUCT__
333
+ VERSION: ${{ github.event.client_payload.version }}
334
+ RESULT: ${{ needs.integration.result }}
335
+ run: |
336
+ set -euo pipefail
337
+ state=failure; [ "$RESULT" = "success" ] && state=success
338
+ # `tier: integration`, named rather than defaulted: the release gate
339
+ # tallies per tier, and this is the one that gates (docs/release-gate.md).
340
+ body="$(jq -nc --arg p "$product" --arg v "$VERSION" --arg r "$state" \
341
+ '{event_type:"ctl-candidate-result",client_payload:{product:$p,version:$v,result:$r,tier:"integration"}}')"
342
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
343
+ -H "Authorization: Bearer $GH_TOKEN" \
344
+ -H "Accept: application/vnd.github+json" \
345
+ https://api.github.com/repos/id3as/norsk-ctl/dispatches -d "$body")"
346
+ [ "$code" = "204" ] || { echo "::error::report to norsk-ctl failed (HTTP $code)"; exit 1; }
@@ -25,6 +25,7 @@ import {
25
25
  type CanonicalBytes,
26
26
  CTL_HASH_LINE,
27
27
  CTL_VERSION_LINE,
28
+ carriesIntegrationMarker,
28
29
  END,
29
30
  GITIGNORE_BEGIN,
30
31
  GITIGNORE_END,
@@ -116,6 +117,33 @@ function syncSmoke(repoRoot: string, canonical: string, r: SyncReport): void {
116
117
  writeIfChanged(path, out, rel, r.written);
117
118
  }
118
119
 
120
+ // integration.yml: smoke.yml's twin, but only a copy that already carries the
121
+ // convention marker is rewritten — a hand-owned one is left untouched and named,
122
+ // because converging it is a deliberate per-repo change (hooks, a proving run),
123
+ // not something a nightly sync may do to a tier the release gate believes.
124
+ function syncIntegration(repoRoot: string, canonical: string, r: SyncReport): void {
125
+ const rel = ".github/workflows/integration.yml";
126
+ const path = join(repoRoot, rel);
127
+ if (!existsSync(path)) {
128
+ r.skipped.push(`${rel} (absent)`);
129
+ return;
130
+ }
131
+ const existing = readFileSync(path, "utf8");
132
+ if (!carriesIntegrationMarker(existing)) {
133
+ r.skipped.push(`${rel} (hand-owned: no convention marker on line 1 — converge it deliberately)`);
134
+ return;
135
+ }
136
+ const key = existing.match(/^\s*product:\s*(\S+)/m)?.[1];
137
+ if (!key || key === PRODUCT_SENTINEL) {
138
+ r.skipped.push(`${rel} (no product: key to preserve)`);
139
+ return;
140
+ }
141
+ const runnerLine = existing.match(/^\s*-?\s*runner:.*$/m)?.[0];
142
+ let out = canonical.replace(PRODUCT_LINE, `$1${key}`);
143
+ if (runnerLine !== undefined) out = out.replace(RUNNER_LINE, () => runnerLine);
144
+ writeIfChanged(path, out, rel, r.written);
145
+ }
146
+
119
147
  // flake.nix is verbatim except the dev-only ctl pin (ctlVersion + the four
120
148
  // per-platform hashes), which floats per repo. Re-emit canonical STRUCTURE with
121
149
  // the repo's pin restored in order — so a structural edit (e.g. a stray comment)
@@ -250,6 +278,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
250
278
  );
251
279
  syncBuildImage(repoRoot, canonical.buildImage, r);
252
280
  syncSmoke(repoRoot, canonical.smoke, r);
281
+ syncIntegration(repoRoot, canonical.integration, r);
253
282
 
254
283
  // publish-docs.yml is optional and verbatim (no per-repo line) — only re-sync a
255
284
  // repo that already carries it, unlike biome/tsconfig which every repo must have.
@@ -294,6 +323,7 @@ if (import.meta.main) {
294
323
  gitignoreCore: readFileSync(join(dir, "gitignore.core"), "utf8"),
295
324
  buildImage: readFileSync(join(dir, "build-image.yml"), "utf8"),
296
325
  smoke: readFileSync(join(dir, "smoke.yml"), "utf8"),
326
+ integration: readFileSync(join(dir, "integration.yml"), "utf8"),
297
327
  demoShim: readFileSync(join(dir, "demo.sh"), "utf8"),
298
328
  };
299
329
  const report = syncDrift(repoRoot, canonical);
@@ -25,3 +25,33 @@ export function bashBlockLines(yaml: string): Set<number> {
25
25
  });
26
26
  return inside;
27
27
  }
28
+
29
+ /** The `run: |` steps that pipe into `tee` without `pipefail` set on the
30
+ * block's first non-comment line — each reported at its `run: |` line. The
31
+ * default `bash -e {0}` has no pipefail, so such a step exits with TEE's
32
+ * status and a dead suite reads green; `set -euo pipefail` inside a nested
33
+ * `bash -c '...'` body cannot reach the pipeline one shell up. Read from the
34
+ * raw text, not a YAML parse: what matters is the ORDER of lines in the script
35
+ * the runner executes. */
36
+ export function teeWithoutPipefail(yaml: string): { line: number; text: string }[] {
37
+ const out: { line: number; text: string }[] = [];
38
+ const lines = yaml.split("\n");
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const head = lines[i] as string;
41
+ if (!/^\s*run: \|\s*$/.test(head)) continue;
42
+ const indent = (lines[i + 1] ?? "").match(/^\s*/)?.[0].length ?? 0;
43
+ const body: string[] = [];
44
+ for (let j = i + 1; j < lines.length; j++) {
45
+ const line = lines[j] as string;
46
+ if (line.trim() === "") continue;
47
+ if ((line.match(/^\s*/)?.[0].length ?? 0) < indent) break;
48
+ body.push(line.trim());
49
+ }
50
+ if (!body.some((l) => /\|\s*tee\b/.test(l))) continue;
51
+ const first = body.find((l) => !l.startsWith("#"));
52
+ const pipefailFirst =
53
+ first !== undefined && /^set\s+(-[a-zA-Z]*o\s+pipefail|-euo\s+pipefail|-o\s+pipefail)/.test(first);
54
+ if (!pipefailFirst) out.push({ line: i + 1, text: head });
55
+ }
56
+ return out;
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.84",
3
+ "version": "0.1.85",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",