@norskvideo/ctl-dev-kit 0.1.8 → 0.1.10

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.
@@ -18,9 +18,16 @@
18
18
  #
19
19
  # Everything else is auto-detected off PRODUCT_DIR, so a product opts in just by
20
20
  # having the directory/script — no per-product flag to keep in sync:
21
+ # frontend/dist the frontend workspace's build output
22
+ # dashboards/ the dashboard workspace's build output
23
+ # components/lib the components workspace's build output
21
24
  # native/ native addon(s) (probe's signer) -> beside bundle
22
25
  # assets/ product-template assets (funke's PNGs)
23
26
  # scripts/runtime-images.ts -> emits the norsk-ctl.runtime-images label
27
+ #
28
+ # Only backend/dist is required. A product with no frontend/dashboard/components
29
+ # workspace (reuters) stages those empty and builds the same as one that has all
30
+ # three (funke/probe/commentary).
24
31
  set -euo pipefail
25
32
 
26
33
  : "${PRODUCT_DIR:?build-bundle-image.sh: PRODUCT_DIR must be set by the wrapper}"
@@ -43,11 +50,26 @@ stage="$(mktemp -d)"
43
50
  trap 'rm -rf "${stage}"' EXIT
44
51
 
45
52
  echo "==> staging artifacts"
46
- mkdir -p "${stage}/backend" "${stage}/frontend" "${stage}/components" "${stage}/native" "${stage}/assets"
53
+ mkdir -p "${stage}/backend" "${stage}/frontend/dist" "${stage}/components/lib" \
54
+ "${stage}/dashboards" "${stage}/native" "${stage}/assets"
55
+ # The backend bundle is the one non-negotiable payload — every bundle product has
56
+ # one, so a missing dist is a broken build, not an absent workspace.
47
57
  cp -R "${PRODUCT_DIR}/backend/dist" "${stage}/backend/dist"
48
- cp -R "${PRODUCT_DIR}/frontend/dist" "${stage}/frontend/dist"
49
- cp -R "${PRODUCT_DIR}/dashboards" "${stage}/dashboards"
50
- cp -R "${PRODUCT_DIR}/components/lib" "${stage}/components/lib"
58
+
59
+ # Frontend / dashboards / components are produced by the workspaces of the same
60
+ # name, so a product that has no such workspace never produces them (reuters:
61
+ # backend-only). Staged empty in that case, exactly as native/ and assets/ below
62
+ # — Dockerfile.bundle COPYs all six unconditionally, so the path must exist even
63
+ # when there is nothing to put in it.
64
+ if [ -d "${PRODUCT_DIR}/frontend/dist" ]; then
65
+ cp -R "${PRODUCT_DIR}/frontend/dist/." "${stage}/frontend/dist/"
66
+ fi
67
+ if [ -d "${PRODUCT_DIR}/dashboards" ]; then
68
+ cp -R "${PRODUCT_DIR}/dashboards/." "${stage}/dashboards/"
69
+ fi
70
+ if [ -d "${PRODUCT_DIR}/components/lib" ]; then
71
+ cp -R "${PRODUCT_DIR}/components/lib/." "${stage}/components/lib/"
72
+ fi
51
73
 
52
74
  # Native addon(s): staged beside the bundle. Strip the build host's RUNPATH (nix
53
75
  # store / repo-local paths) so the shipped .node carries no build-host paths; its
@@ -45,5 +45,10 @@
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.
48
53
 
49
54
  <!-- 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.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json"