@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(repoRoot: string, canonical: { core: string; flake: string }): DriftReport {
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 }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json"