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