@norskvideo/ctl-dev-kit 0.1.9 → 0.1.11
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.
|
@@ -45,5 +45,21 @@
|
|
|
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
|
+
- **The shared CI workflow is single-sourced too.** `.github/workflows/upgrade-latest.yml`
|
|
49
|
+
is copied verbatim from `@norskvideo/ctl-dev-kit` (`conventions/upgrade-latest.yml`),
|
|
50
|
+
same as this core block and `flake.nix`; `check:drift` fails CI if a copy
|
|
51
|
+
diverges. Only the `product:` key is per-repo. Never hand-edit the copy — edit
|
|
52
|
+
the dev-kit source and re-sync.
|
|
53
|
+
- **A config-driven workflow needs a parity-checked invariant contract.** If this
|
|
54
|
+
product emits its Studio graph dynamically from config, every config dimension's
|
|
55
|
+
rules are documented as stable-ID statements in
|
|
56
|
+
`shared/src/schemas/INVARIANTS.md`, and every ID has an executable rule in the
|
|
57
|
+
composer rule-sweep (`shared/src/workflow/composer-rules.test.ts`). A parity
|
|
58
|
+
guard in that suite fails CI on any drift — a documented ID with no rule, or a
|
|
59
|
+
rule with no doc ID. So a config change is never done until the invariant is in
|
|
60
|
+
BOTH the doc and its test (same ID, added together); a runtime-only invariant
|
|
61
|
+
the emitted graph can't show goes in the explicit untestable-IDs set
|
|
62
|
+
(`COMPOSER_UNTESTABLE_IDS`), still parity-checked. `norsk-ctl-product-playout`
|
|
63
|
+
and `-commentary` are the worked examples.
|
|
48
64
|
|
|
49
65
|
<!-- END ctl-shared-conventions v1 -->
|
|
@@ -36,6 +36,24 @@ function firstDiffLine(actual: string, expected: string): string {
|
|
|
36
36
|
return "content differs";
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// The one legitimately per-repo line in upgrade-latest.yml: the dashboard key
|
|
40
|
+
// the nightly result is dispatched under. Everything else is verbatim-shared.
|
|
41
|
+
const PRODUCT_LINE = /^(\s*product:\s*).*$/m;
|
|
42
|
+
const PRODUCT_SENTINEL = "__PRODUCT__";
|
|
43
|
+
|
|
44
|
+
function workflowProblem(actual: string, canonical: string): string | undefined {
|
|
45
|
+
// Compare structure with the product value masked so any real key passes, then
|
|
46
|
+
// separately reject a copy that still carries the unfilled sentinel.
|
|
47
|
+
const mask = (s: string) => s.replace(PRODUCT_LINE, `$1${PRODUCT_SENTINEL}`);
|
|
48
|
+
if (mask(actual) !== mask(canonical)) {
|
|
49
|
+
return `.github/workflows/upgrade-latest.yml has drifted from @norskvideo/ctl-dev-kit conventions/upgrade-latest.yml (${firstDiffLine(mask(actual), mask(canonical))}). ${RESYNC}`;
|
|
50
|
+
}
|
|
51
|
+
if (actual.match(/^\s*product:\s*(\S+)/m)?.[1] === PRODUCT_SENTINEL) {
|
|
52
|
+
return `.github/workflows/upgrade-latest.yml still has the ${PRODUCT_SENTINEL} placeholder — set \`product:\` to this repo's dashboard key.`;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
function coreProblem(claude: string, canonicalCore: string): string | undefined {
|
|
40
58
|
if (claude.includes(canonicalCore)) return undefined;
|
|
41
59
|
|
|
@@ -48,7 +66,10 @@ function coreProblem(claude: string, canonicalCore: string): string | undefined
|
|
|
48
66
|
return `CLAUDE.md fenced core has drifted from @norskvideo/ctl-dev-kit conventions/CLAUDE.core.md (${firstDiffLine(region, canonicalCore.trimEnd())}). ${RESYNC}`;
|
|
49
67
|
}
|
|
50
68
|
|
|
51
|
-
export function checkDrift(
|
|
69
|
+
export function checkDrift(
|
|
70
|
+
repoRoot: string,
|
|
71
|
+
canonical: { core: string; flake: string; upgradeLatest: string },
|
|
72
|
+
): DriftReport {
|
|
52
73
|
const problems: string[] = [];
|
|
53
74
|
|
|
54
75
|
const claudePath = join(repoRoot, "CLAUDE.md");
|
|
@@ -73,6 +94,15 @@ export function checkDrift(repoRoot: string, canonical: { core: string; flake: s
|
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
96
|
|
|
97
|
+
// upgrade-latest.yml is OPTIONAL — a product may ship without a nightly bump
|
|
98
|
+
// (e.g. until it has an integration tier). But when present it is the shared,
|
|
99
|
+
// single-sourced workflow and must not diverge.
|
|
100
|
+
const workflowPath = join(repoRoot, ".github", "workflows", "upgrade-latest.yml");
|
|
101
|
+
if (existsSync(workflowPath)) {
|
|
102
|
+
const problem = workflowProblem(readFileSync(workflowPath, "utf8"), canonical.upgradeLatest);
|
|
103
|
+
if (problem) problems.push(problem);
|
|
104
|
+
}
|
|
105
|
+
|
|
76
106
|
return { ok: problems.length === 0, problems };
|
|
77
107
|
}
|
|
78
108
|
|
|
@@ -81,10 +111,11 @@ if (import.meta.main) {
|
|
|
81
111
|
const canonical = {
|
|
82
112
|
core: readFileSync(join(import.meta.dir, "CLAUDE.core.md"), "utf8"),
|
|
83
113
|
flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"),
|
|
114
|
+
upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
|
|
84
115
|
};
|
|
85
116
|
const report = checkDrift(repoRoot, canonical);
|
|
86
117
|
if (report.ok) {
|
|
87
|
-
console.log("drift-check: CLAUDE.md core + flake.nix match @norskvideo/ctl-dev-kit.");
|
|
118
|
+
console.log("drift-check: CLAUDE.md core + flake.nix + upgrade-latest.yml match @norskvideo/ctl-dev-kit.");
|
|
88
119
|
process.exit(0);
|
|
89
120
|
}
|
|
90
121
|
console.error(
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Keep the product fresh against the Norsk "nightly" channel (RFC 0001 —
|
|
2
|
+
# product version freshness). The media + studio engine versions are pinned in
|
|
3
|
+
# two coupled places that MUST move together: the npm `overrides` in the root
|
|
4
|
+
# package.json (build-time libraries) and manifest.seed.json (the launched
|
|
5
|
+
# container image tags). Both use the identical version string, so a bump is a
|
|
6
|
+
# global string-replace of old->new across every tracked file — which also
|
|
7
|
+
# updates the version-pin guard test in lockstep, no drift. Also rebumps the
|
|
8
|
+
# dev flake's pinned norsk-ctl daemon to the `latest` channel (dev-only; CI
|
|
9
|
+
# downloads ctl directly, so a stale pin never affects CI or customers).
|
|
10
|
+
#
|
|
11
|
+
# Commits the bump straight to main (nightly cron + on demand). Note: a
|
|
12
|
+
# GITHUB_TOKEN push does not retrigger build-image/integration (Actions'
|
|
13
|
+
# recursion guard), so the next human push validates the bump; a sad pony on
|
|
14
|
+
# that run flags a bad nightly to revert. Runs nightly + on demand.
|
|
15
|
+
#
|
|
16
|
+
# Single-sourced in @norskvideo/ctl-dev-kit (conventions/upgrade-latest.yml) and
|
|
17
|
+
# copied verbatim into each product repo; the check:drift gate fails CI if a copy
|
|
18
|
+
# diverges. Only the `product:` value below is per-repo (set it to this repo's
|
|
19
|
+
# dashboard key); everything else must match the dev-kit source. Never hand-edit
|
|
20
|
+
# the copy — edit the dev-kit source and re-sync.
|
|
21
|
+
name: upgrade-latest
|
|
22
|
+
|
|
23
|
+
on:
|
|
24
|
+
schedule:
|
|
25
|
+
- cron: "17 4 * * *"
|
|
26
|
+
workflow_dispatch:
|
|
27
|
+
|
|
28
|
+
permissions:
|
|
29
|
+
contents: write
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
bump:
|
|
33
|
+
runs-on: x64
|
|
34
|
+
outputs:
|
|
35
|
+
changed: ${{ steps.bump.outputs.changed }}
|
|
36
|
+
summary: ${{ steps.bump.outputs.summary }}
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v5
|
|
39
|
+
|
|
40
|
+
- name: Resolve the nightly channel and rewrite the coupled pins
|
|
41
|
+
id: bump
|
|
42
|
+
run: |
|
|
43
|
+
set -euo pipefail
|
|
44
|
+
reg="https://registry.npmjs.org"
|
|
45
|
+
changed=0
|
|
46
|
+
|
|
47
|
+
# Accumulate a COMPACT summary: only the components that actually moved
|
|
48
|
+
# get an entry, so a no-op nightly (old == new) posts nothing. Was: the
|
|
49
|
+
# full media + studio-lib + studio-image triple every night for every
|
|
50
|
+
# product, even when nothing changed — pure dashboard noise.
|
|
51
|
+
parts=()
|
|
52
|
+
|
|
53
|
+
# MEDIA: the npm packages and the launched image are built from the SAME
|
|
54
|
+
# commit (their hash suffixes match), so one version string drives the
|
|
55
|
+
# whole family. norsk-sdk is the family reference; read its nightly
|
|
56
|
+
# dist-tag off the registry (curl+jq — the runner has no npm) and rewrite
|
|
57
|
+
# every occurrence (overrides, sub-package deps, manifest image ref,
|
|
58
|
+
# version-pin test) EXCEPT the lockfile, which bun install regenerates.
|
|
59
|
+
old_media="$(jq -r '.latest.media | split(":")[1]' manifest.seed.json)"
|
|
60
|
+
new_media="$(curl -fsSL "$reg/@norskvideo%2Fnorsk-sdk" | jq -r '."dist-tags".nightly')"
|
|
61
|
+
[ -n "$new_media" ] && [ "$new_media" != "null" ] || { echo "could not resolve norsk-sdk nightly"; exit 1; }
|
|
62
|
+
echo "media: $old_media -> $new_media"
|
|
63
|
+
if [ "$old_media" != "$new_media" ]; then
|
|
64
|
+
git grep -lF "$old_media" -- . ':!bun.lock' | xargs -r sed -i "s|$old_media|$new_media|g"
|
|
65
|
+
changed=1
|
|
66
|
+
parts+=("media \`$new_media\`")
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# STUDIO: unlike media, Norsk builds the studio npm packages and the
|
|
70
|
+
# studio Docker image from DIFFERENT commits — their hash suffixes never
|
|
71
|
+
# match — so one version string can't serve both: pinning the launched
|
|
72
|
+
# image to the npm nightly yields a tag that was never pushed (integration
|
|
73
|
+
# then dies on "manifest unknown"). Resolve the two apart: build libraries
|
|
74
|
+
# from npm, the launched image from Docker Hub's newest published tag ON
|
|
75
|
+
# THE SAME version line (so it's pullable AND matches the libs'
|
|
76
|
+
# major.minor.patch). Rewrite the image ref FIRST, then the bare library
|
|
77
|
+
# version — so even when the two happen to coincide, the image ref is
|
|
78
|
+
# already moved and the bare-version rewrite only touches the npm overrides
|
|
79
|
+
# (no dependence on which files hold each — some products have no
|
|
80
|
+
# version-pin test).
|
|
81
|
+
old_studio_lib="$(jq -r '.overrides["@norskvideo/norsk-studio"]' package.json)"
|
|
82
|
+
new_studio_lib="$(curl -fsSL "$reg/@norskvideo%2Fnorsk-studio" | jq -r '."dist-tags".nightly')"
|
|
83
|
+
[ -n "$new_studio_lib" ] && [ "$new_studio_lib" != "null" ] || { echo "could not resolve norsk-studio npm nightly"; exit 1; }
|
|
84
|
+
studio_line="${new_studio_lib%%-*}" # e.g. 1.28.0 — keep the image on this line
|
|
85
|
+
old_studio_img="$(jq -r '.latest.studio | split(":")[1]' manifest.seed.json)"
|
|
86
|
+
new_studio_img="$(curl -fsSL "https://hub.docker.com/v2/repositories/norskvideo/norsk-studio/tags?page_size=100&ordering=last_updated" \
|
|
87
|
+
| jq -r '.results[].name' \
|
|
88
|
+
| grep -E "^${studio_line}-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+$" | head -1 || true)"
|
|
89
|
+
[ -n "$new_studio_img" ] || { echo "no published norsk-studio image on the $studio_line line — npm/docker version lines have diverged"; exit 1; }
|
|
90
|
+
echo "studio lib: $old_studio_lib -> $new_studio_lib"
|
|
91
|
+
echo "studio image: $old_studio_img -> $new_studio_img"
|
|
92
|
+
if [ "$old_studio_img" != "$new_studio_img" ]; then
|
|
93
|
+
git grep -lF "norskvideo/norsk-studio:$old_studio_img" -- . ':!bun.lock' \
|
|
94
|
+
| xargs -r sed -i "s|norskvideo/norsk-studio:$old_studio_img|norskvideo/norsk-studio:$new_studio_img|g"
|
|
95
|
+
changed=1
|
|
96
|
+
fi
|
|
97
|
+
if [ "$old_studio_lib" != "$new_studio_lib" ]; then
|
|
98
|
+
git grep -lF "$old_studio_lib" -- . ':!bun.lock' | xargs -r sed -i "s|$old_studio_lib|$new_studio_lib|g"
|
|
99
|
+
changed=1
|
|
100
|
+
fi
|
|
101
|
+
# One studio entry, not two — the launched image tag is the runtime-
|
|
102
|
+
# relevant one; only fall back to the lib tag if the image held but the
|
|
103
|
+
# library advanced (npm nightly moved with no new image on the line yet).
|
|
104
|
+
if [ "$old_studio_img" != "$new_studio_img" ]; then
|
|
105
|
+
parts+=("studio \`$new_studio_img\`")
|
|
106
|
+
elif [ "$old_studio_lib" != "$new_studio_lib" ]; then
|
|
107
|
+
parts+=("studio-lib \`$new_studio_lib\`")
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# --- ctl daemon pin (flake.nix) -----------------------------------
|
|
111
|
+
# The pinned norsk-ctl in the dev flake is dev-only (CI downloads the
|
|
112
|
+
# released binary directly), but keep it fresh too. Bump ctlVersion +
|
|
113
|
+
# the four per-platform hashes to the `latest` channel. Mirrors
|
|
114
|
+
# packages/dev-kit/build/refresh-ctl-pin.sh in the ctl monorepo.
|
|
115
|
+
ctl_base="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
|
|
116
|
+
old_ctl="$(grep -oE 'ctlVersion = "[^"]+"' flake.nix | sed -E 's/.*"(.*)"/\1/')"
|
|
117
|
+
new_ctl="$(curl -fsSL "$ctl_base/latest")"
|
|
118
|
+
if [ -n "$new_ctl" ] && [ "$old_ctl" != "$new_ctl" ]; then
|
|
119
|
+
declare -A ctlplat=([x86_64-linux]=linux-x64 [aarch64-linux]=linux-arm64 [aarch64-darwin]=darwin-arm64 [x86_64-darwin]=darwin-x64)
|
|
120
|
+
for sys in "${!ctlplat[@]}"; do
|
|
121
|
+
p="${ctlplat[$sys]}"
|
|
122
|
+
hex="$(curl -fsSL "$ctl_base/$new_ctl/norsk-ctl-$new_ctl-$p.sha256" | awk '{print $1}')"
|
|
123
|
+
[ -n "$hex" ] || { echo "no sha256 sidecar for $p"; exit 1; }
|
|
124
|
+
sri="$(nix hash convert --hash-algo sha256 --to sri "$hex")"
|
|
125
|
+
perl -0pi -e "s{(plat = \"$p\";\\s*hash = \")[^\"]*(\")}{\${1}$sri\${2}}" flake.nix
|
|
126
|
+
done
|
|
127
|
+
perl -0pi -e "s{(ctlVersion = \")[^\"]*(\")}{\${1}$new_ctl\${2}}" flake.nix
|
|
128
|
+
echo "ctl: $old_ctl -> $new_ctl"
|
|
129
|
+
changed=1
|
|
130
|
+
parts+=("norsk-ctl \`$new_ctl\`")
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
# Join the moved components; empty when nothing changed (the dashboard
|
|
134
|
+
# then shows a clean green "nightly" cell with no detail).
|
|
135
|
+
summary=""
|
|
136
|
+
if [ ${#parts[@]} -gt 0 ]; then
|
|
137
|
+
summary="$(printf '%s, ' "${parts[@]}")"
|
|
138
|
+
summary="${summary%, }"
|
|
139
|
+
fi
|
|
140
|
+
{
|
|
141
|
+
echo "changed=$changed"
|
|
142
|
+
echo "summary=$summary"
|
|
143
|
+
} >> "$GITHUB_OUTPUT"
|
|
144
|
+
|
|
145
|
+
- name: Refresh the lockfile against the new pins
|
|
146
|
+
if: steps.bump.outputs.changed == '1'
|
|
147
|
+
run: nix develop .#build --command bash -c 'set -euo pipefail; bun install'
|
|
148
|
+
|
|
149
|
+
- name: Commit the bump to main
|
|
150
|
+
if: steps.bump.outputs.changed == '1'
|
|
151
|
+
run: |
|
|
152
|
+
set -euo pipefail
|
|
153
|
+
git config user.name "github-actions[bot]"
|
|
154
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
155
|
+
git add -A
|
|
156
|
+
git commit -m "chore: bump Norsk nightly + ctl pins to latest channel
|
|
157
|
+
|
|
158
|
+
${{ steps.bump.outputs.summary }}
|
|
159
|
+
|
|
160
|
+
Coupled Norsk pins (npm overrides + manifest.seed.json image tags +
|
|
161
|
+
version-pin guard test) moved together via a global version-string
|
|
162
|
+
replace; lockfile regenerated. Dev flake norsk-ctl pin (version + 4
|
|
163
|
+
platform hashes) rebumped to the latest channel."
|
|
164
|
+
# main can advance under a long bump (lockfile regen + registry
|
|
165
|
+
# lookups run for minutes); a bare push then loses a non-fast-forward
|
|
166
|
+
# race and reds the nightly. Rebase-and-retry so an unrelated commit
|
|
167
|
+
# landing mid-run does not fail the bump; a genuine content conflict
|
|
168
|
+
# still aborts loudly rather than force-pushing over other work.
|
|
169
|
+
for attempt in 1 2 3 4 5; do
|
|
170
|
+
if git push origin HEAD:main; then exit 0; fi
|
|
171
|
+
echo "push rejected (attempt $attempt) - rebasing onto latest origin/main"
|
|
172
|
+
git fetch origin main
|
|
173
|
+
git rebase origin/main || { git rebase --abort; echo "rebase hit a real conflict; aborting"; exit 1; }
|
|
174
|
+
done
|
|
175
|
+
echo "push still failing after 5 rebase attempts"; exit 1
|
|
176
|
+
|
|
177
|
+
- name: Report "already fresh"
|
|
178
|
+
if: steps.bump.outputs.changed != '1'
|
|
179
|
+
run: echo "Nightly pins already current — nothing to bump."
|
|
180
|
+
|
|
181
|
+
# Report the nightly bump result to the aggregated product CI dashboard
|
|
182
|
+
# (id3as/ci-workflows). always() so a failed bump shows a sad pony; the summary
|
|
183
|
+
# rides along as the cell detail.
|
|
184
|
+
notify:
|
|
185
|
+
needs: bump
|
|
186
|
+
if: always()
|
|
187
|
+
runs-on: x64
|
|
188
|
+
steps:
|
|
189
|
+
- uses: actions/checkout@v5
|
|
190
|
+
- id: meta
|
|
191
|
+
run: |
|
|
192
|
+
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
|
|
193
|
+
echo "state=failure" >> "$GITHUB_OUTPUT"
|
|
194
|
+
else
|
|
195
|
+
echo "state=success" >> "$GITHUB_OUTPUT"
|
|
196
|
+
fi
|
|
197
|
+
- uses: ./.github/actions/ci-status-dispatch
|
|
198
|
+
with:
|
|
199
|
+
token: ${{ secrets.CI_DISPATCH_TOKEN }}
|
|
200
|
+
product: __PRODUCT__
|
|
201
|
+
pipeline: nightly
|
|
202
|
+
status: ${{ steps.meta.outputs.state }}
|
|
203
|
+
detail: ${{ needs.bump.outputs.summary }}
|