@norskvideo/ctl-dev-kit 0.2.26 → 0.2.28

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.
@@ -38,8 +38,9 @@ on:
38
38
  workflow_dispatch:
39
39
 
40
40
  permissions:
41
- contents: write
42
- pull-requests: write
41
+ contents: read
42
+ checks: read
43
+ pull-requests: read
43
44
 
44
45
  concurrency:
45
46
  # One at a time per repo, and never cancelled: a merge half-done by a run that
@@ -54,18 +55,36 @@ jobs:
54
55
  - name: Merge green bot PRs, report red ones
55
56
  id: triage
56
57
  env:
57
- GH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
58
+ # Reads go through the job's own token, which the permissions above
59
+ # scope to exactly what is read; CI_DISPATCH_TOKEN (a PAT) has no
60
+ # Checks access, and the check-runs call returned an error object.
61
+ # Merges and branch deletes go through the PAT, because a push made
62
+ # with GITHUB_TOKEN does not trigger main's own workflows.
63
+ READ_TOKEN: ${{ github.token }}
64
+ WRITE_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
58
65
  REPO: ${{ github.repository }}
59
66
  run: |
60
67
  set -euo pipefail
61
68
  api="https://api.github.com/repos/$REPO"
62
- gh_api() { curl -sS --retry 3 --retry-all-errors -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$@"; }
69
+ curl_as() { local tok="$1"; shift; curl -sS --retry 3 --retry-all-errors -H "Authorization: Bearer $tok" -H "Accept: application/vnd.github+json" "$@"; }
70
+ gh_api() { curl_as "$READ_TOKEN" "$@"; }
71
+ gh_write() { curl_as "$WRITE_TOKEN" "$@"; }
72
+ # An API error is a JSON object with a message; say what it was rather
73
+ # than letting jq fail later on a missing field.
74
+ expect() {
75
+ local what="$1" filter="$2" body="$3"
76
+ jq -e "$filter" >/dev/null 2>&1 <<<"$body" && return 0
77
+ echo "::error::$what: $(jq -r '.message // .' <<<"$body" 2>/dev/null || echo "$body")"
78
+ exit 1
79
+ }
63
80
 
64
81
  # The bots own exactly these branches (see each workflow's PR step).
65
- prs="$(gh_api "$api/pulls?state=open&per_page=100" | jq -c --arg repo "$REPO" '
82
+ pulls="$(gh_api "$api/pulls?state=open&per_page=100")"
83
+ expect "listing open PRs" 'type == "array"' "$pulls"
84
+ prs="$(jq -c --arg repo "$REPO" '
66
85
  [.[] | select(.head.repo.full_name == $repo)
67
86
  | select(.head.ref | test("^(upgrade-latest/|sync-dev-kit/|chore/sync-ctl-packages$)"))
68
- | {number, ref: .head.ref, sha: .head.sha, draft, url: .html_url}]')"
87
+ | {number, ref: .head.ref, sha: .head.sha, draft, url: .html_url}]' <<<"$pulls")"
69
88
 
70
89
  red=()
71
90
  merged=()
@@ -73,6 +92,7 @@ jobs:
73
92
  [ -n "$pr" ] || continue
74
93
  n="$(jq -r .number <<<"$pr")"; ref="$(jq -r .ref <<<"$pr")"; sha="$(jq -r .sha <<<"$pr")"
75
94
  runs="$(gh_api "$api/commits/$sha/check-runs?per_page=100")"
95
+ expect "reading checks for #$n" '.check_runs | type == "array"' "$runs"
76
96
  failed="$(jq -r '[.check_runs[] | select(.conclusion | IN("failure","cancelled","timed_out","action_required","startup_failure")) | .name] | unique | join(", ")' <<<"$runs")"
77
97
  pending="$(jq -r '[.check_runs[] | select(.status != "completed")] | length' <<<"$runs")"
78
98
  passed="$(jq -r '[.check_runs[] | select(.conclusion == "success")] | length' <<<"$runs")"
@@ -89,12 +109,12 @@ jobs:
89
109
  if [ "$mergeable" != "true" ]; then red+=("#$n $ref: does not merge cleanly with main"); continue; fi
90
110
  # sha pins the merge to what was checked: a bot force-pushing the
91
111
  # branch meanwhile makes this refuse rather than land unchecked code.
92
- res="$(gh_api -X PUT "$api/pulls/$n/merge" -d "$(jq -nc --arg sha "$sha" '{merge_method:"squash", sha:$sha}')")"
112
+ res="$(gh_write -X PUT "$api/pulls/$n/merge" -d "$(jq -nc --arg sha "$sha" '{merge_method:"squash", sha:$sha}')")"
93
113
  if [ "$(jq -r '.merged // false' <<<"$res")" = "true" ]; then
94
114
  merged+=("#$n")
95
115
  echo "merged #$n ($ref)"
96
116
  # The bots recreate their branch on the next bump.
97
- gh_api -X DELETE "$api/git/refs/heads/$ref" -o /dev/null || true
117
+ gh_write -X DELETE "$api/git/refs/heads/$ref" -o /dev/null || true
98
118
  else
99
119
  red+=("#$n $ref: merge refused: $(jq -r '.message // "unknown"' <<<"$res")")
100
120
  fi
@@ -112,10 +132,13 @@ jobs:
112
132
  echo "detail=${detail%; }" >> "$GITHUB_OUTPUT"
113
133
 
114
134
  - name: Report to the product dashboard
135
+ # Always: a triage that broke is exactly what nobody would otherwise see.
136
+ if: always()
115
137
  env:
116
138
  TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
117
- STATUS: ${{ steps.triage.outputs.state }}
118
- DETAIL: ${{ steps.triage.outputs.detail }}
139
+ READ_TOKEN: ${{ github.token }}
140
+ STATUS: ${{ steps.triage.outputs.state || 'failure' }}
141
+ DETAIL: ${{ steps.triage.outputs.detail || 'the bot-PR triage itself failed; see the run' }}
119
142
  SHA: ${{ github.sha }}
120
143
  RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
121
144
  REPO: ${{ github.repository }}
@@ -123,7 +146,7 @@ jobs:
123
146
  set -euo pipefail
124
147
  # The dashboard key is the one upgrade-latest.yml reports under, read
125
148
  # from it so this file can stay byte-identical across products.
126
- product="$(curl -sS -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github.raw" \
149
+ product="$(curl -sS -H "Authorization: Bearer $READ_TOKEN" -H "Accept: application/vnd.github.raw" \
127
150
  "https://api.github.com/repos/$REPO/contents/.github/workflows/upgrade-latest.yml" \
128
151
  | sed -nE 's/^[[:space:]]*product:[[:space:]]*([^[:space:]]+).*/\1/p' | head -1)"
129
152
  if [ -z "$product" ] || [ "$product" = "__PRODUCT__" ]; then
@@ -1,5 +1,8 @@
1
1
  # Keep the product fresh against the Norsk "nightly" channel (RFC 0001 —
2
- # product version freshness). Version discovery anchors on manifest.seed.json
2
+ # product version freshness). The {engine, studio image} pair comes from the
3
+ # nightly entry of norsk-studio's channel manifest -- the pair studio TESTED
4
+ # together -- never from the two channels separately. Version discovery of the
5
+ # product's current pins anchors on manifest.seed.json
3
6
  # (the machine-writable source of truth for the launched {media, studio} image
4
7
  # pair — every product repo has one). Any npm pins on the same version string
5
8
  # (media build libraries, the @norskvideo/norsk-studio* family in whichever
@@ -119,22 +122,41 @@ jobs:
119
122
  # product, even when nothing changed — pure dashboard noise.
120
123
  parts=()
121
124
 
125
+ # ONE TESTED PAIR, NOT TWO CHANNELS. The engine and the studio image are
126
+ # taken together from the nightly entry of norsk-studio's channel
127
+ # manifest: the pair studio's publish built and tested against each
128
+ # other (scripts/write-channel-manifest.sh in norsk-studio). Resolving
129
+ # them apart -- the engine off norsk-sdk's nightly dist-tag, the image
130
+ # off Docker Hub's nightly tag -- produced pairs nobody had tested
131
+ # whenever one channel moved without the other. On 2026-10-04 studio's
132
+ # nightly had been held by its test gate since 09-28 while the engine
133
+ # walked on to 10-02, a breaking SDK change landed in between, and
134
+ # commentary's audio mix produced nothing on the old-studio /
135
+ # new-engine pair it was pinned to (norsk task 112).
136
+ #
137
+ # Consequence, deliberately: when studio's nightly stalls, the engine
138
+ # stalls with it (or steps BACK to the pair), rather than running ahead
139
+ # untested. Unblocking studio's nightly is how products move forward.
140
+ #
141
+ # Everything is resolved and checked BEFORE anything is rewritten, so a
142
+ # refusal never leaves the engine moved and studio not.
143
+ manifest_url="https://s3.eu-west-1.amazonaws.com/norsk.video/channel-manifest.json"
144
+ pair="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$manifest_url" | jq -c '.channels.nightly // empty')"
145
+ [ -n "$pair" ] || { echo "::error::no nightly pair in $manifest_url"; exit 1; }
146
+ tag_re='^[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+$'
147
+ new_media="$(jq -r '(.norsk // "") | split(":") | .[1] // ""' <<<"$pair")"
148
+ new_studio_img="$(jq -r '(.studio // "") | split(":") | .[1] // ""' <<<"$pair")"
149
+ printf '%s' "$new_media" | grep -qE "$tag_re" || { echo "::error::the manifest's nightly norsk image is not a dated nightly tag: $pair"; exit 1; }
150
+ printf '%s' "$new_studio_img" | grep -qE "$tag_re" || { echo "::error::the manifest's nightly studio image is not a dated nightly tag: $pair"; exit 1; }
151
+ echo "nightly pair: norsk $new_media + studio $new_studio_img"
152
+
122
153
  # MEDIA: the npm packages and the launched image are built from the SAME
123
- # commit (their hash suffixes match), so one version string drives the
124
- # whole family. norsk-sdk is the family reference; read its nightly
125
- # dist-tag off the registry (curl+jq — the runner has no npm) and rewrite
126
- # every occurrence (overrides, sub-package deps, manifest image ref,
127
- # version-pin test) EXCEPT the lockfile, which bun install regenerates.
154
+ # commit (their hash suffixes match), so the pair's engine version drives
155
+ # the whole family: overrides, sub-package deps, the manifest image ref,
156
+ # the version-pin test -- every occurrence EXCEPT the lockfile, which bun
157
+ # install regenerates.
128
158
  old_media="$(jq -r '.latest.media | split(":")[1]' manifest.seed.json)"
129
- new_media="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fnorsk-sdk" | jq -r '."dist-tags".nightly')"
130
- [ -n "$new_media" ] && [ "$new_media" != "null" ] || { echo "could not resolve norsk-sdk nightly"; exit 1; }
131
- echo "media: $old_media -> $new_media"
132
- if [ "$old_media" != "$new_media" ]; then
133
- git grep -lF "$old_media" -- . ':!bun.lock' | xargs -r sed -i "s|$old_media|$new_media|g"
134
- changed=1
135
- parts+=("media \`$new_media\`")
136
- fi
137
- # The rewrite above is a LITERAL replace of the value found in
159
+ # The rewrite below is a LITERAL replace of the value found in
138
160
  # manifest.seed.json, so it only ever touches pins that already equal the
139
161
  # seed. A pin that has drifted off it -- a hand edit, a merge, a
140
162
  # half-finished run -- is invisible to this bump and to every later one,
@@ -148,9 +170,10 @@ jobs:
148
170
  # Not auto-rewritten: a pin held back on purpose is someone's decision,
149
171
  # and silently moving it would be the same class of surprise in the
150
172
  # other direction. Fail, name them, and let a human fix the pin or the
151
- # intent.
173
+ # intent. (Checked against the SEED, the family's current value: after
174
+ # the rewrite every seed-equal pin reads the pair's engine.)
152
175
  stranded="$(git grep -hoE '"@norskvideo/norsk-(api|sdk|auto-manager|manager-sdk)"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+"' -- '*package.json' \
153
- | sed -E 's/.*"([^"]+)"$/\1/' | sort -u | grep -vxF "$new_media" || true)"
176
+ | sed -E 's/.*"([^"]+)"$/\1/' | sort -u | grep -vxF "$old_media" | grep -vxF "$new_media" || true)"
154
177
  if [ -n "$stranded" ]; then
155
178
  echo "::error::npm norsk pins are stranded off the nightly channel: $(printf '%s' "$stranded" | tr '\n' ' ')"
156
179
  echo "The media family (api, sdk, auto-manager, manager-sdk) and the launched image are built from the same"
@@ -159,76 +182,73 @@ jobs:
159
182
  exit 1
160
183
  fi
161
184
 
162
- # STUDIO: unlike media, Norsk builds the studio npm packages and the
163
- # studio Docker image from DIFFERENT commits — their hash suffixes never
164
- # match — so one version string can't serve both: pinning the launched
165
- # image to the npm nightly yields a tag that was never pushed (integration
166
- # then dies on "manifest unknown"). Resolve the two apart: build libraries
167
- # from npm, the launched image from Docker Hub's newest published tag ON
168
- # THE SAME version line (so it's pullable AND matches the libs'
169
- # major.minor.patch). Rewrite the image ref FIRST, then the bare library
170
- # version — so even when the two happen to coincide, the image ref is
171
- # already moved and the bare-version rewrite only touches the npm pins
172
- # (no dependence on which files hold each — some products have no
173
- # version-pin test).
185
+ # STUDIO: the studio npm packages and the studio image are built from
186
+ # DIFFERENT commits -- their hash suffixes never match -- so the image
187
+ # comes from the pair and the build libraries from npm's nightly. The
188
+ # libraries must belong to the same pair: their norsk-sdk dependency has
189
+ # to name the pair's engine, and their version line the image's. A
190
+ # mismatch means the channels are inconsistent (studio published npm
191
+ # without updating the manifest, or the reverse); refuse rather than pin
192
+ # a combination nobody tested. (Studio's publish installs the SDK it
193
+ # builds against as a DEV dependency -- `npm install --save-dev
194
+ # @norskvideo/norsk-sdk@<channel>` -- so that is where the pairing lives.)
174
195
  #
175
196
  # The old npm pin is discovered from the repo's REAL studio deps: any
176
197
  # exact nightly on a @norskvideo/norsk-studio* package in a tracked
177
- # package.json (deps, devDeps, or overrides — wherever it lives). A
178
- # repo with none skips the lib rewrite; nothing forces phantom
179
- # `overrides` entries just to give this script an anchor. Distinct
180
- # pins disagreeing across files abort rather than half-rewrite.
198
+ # package.json (deps, devDeps, or overrides -- wherever it lives). A
199
+ # repo with none skips the lib entirely. Distinct pins disagreeing
200
+ # across files abort rather than half-rewrite.
181
201
  old_studio_lib="$(git grep -hoE '"@norskvideo/norsk-studio[^"]*"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+"' -- '*package.json' \
182
202
  | sed -E 's/.*"([^"]+)"$/\1/' | sort -u || true)"
183
203
  if [ "$(printf '%s' "$old_studio_lib" | grep -c .)" -gt 1 ]; then
184
204
  echo "studio npm pins disagree across package.json files:"; echo "$old_studio_lib"; exit 1
185
205
  fi
186
- new_studio_lib="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fnorsk-studio" | jq -r '."dist-tags".nightly')"
187
- [ -n "$new_studio_lib" ] && [ "$new_studio_lib" != "null" ] || { echo "could not resolve norsk-studio npm nightly"; exit 1; }
188
- studio_line="${new_studio_lib%%-*}" # e.g. 1.28.0 — keep the image on this line
189
206
  old_studio_img="$(jq -r '.latest.studio | split(":")[1]' manifest.seed.json)"
190
- # FOLLOW THE NIGHTLY CHANNEL — do not pick the newest tag NAME on the
191
- # line. Newest-name selects on publish recency, so any build landing a
192
- # <line>-<date>-<sha> tag wins, including one off a feature branch. Not
193
- # hypothetical: a norsk-studio `reasoning` build took both the nightly
194
- # tag and the newest-name slot on 2026-08-25, this job pinned it at
195
- # 04:29, and every signed probe workflow was refused at launch that
196
- # morning because the fix on main was not in it. norsk-studio now
197
- # refuses to publish `nightly` from anything but main, so the channel is
198
- # the trustworthy signal — and this follows it rather than guessing.
199
- #
200
- # Resolve nightly's digest, then name the version-specific tag carrying
201
- # the same digest. The moving tag is not pinnable itself: it moves under
202
- # you, and a launch has to record the exact image it ran.
203
- hub="https://hub.docker.com/v2/repositories/norskvideo/norsk-studio"
204
- nightly_digest="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$hub/tags/nightly" | jq -r '.digest // empty')"
205
- [ -n "$nightly_digest" ] || { echo "could not resolve the norsk-studio nightly tag's digest"; exit 1; }
206
- new_studio_img="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$hub/tags?page_size=100&ordering=last_updated" \
207
- | jq -r --arg d "$nightly_digest" '.results[] | select(.digest == $d) | .name' \
208
- | grep -E "^${studio_line}-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]+$" | head -1 || true)"
209
- [ -n "$new_studio_img" ] || {
210
- echo "norsk-studio nightly ($nightly_digest) has no version-specific tag on the $studio_line line."
211
- echo "Either the npm and docker version lines have diverged, or nightly has moved to a"
212
- echo "different line than the npm nightly ($new_studio_lib). Not guessing — fix the channel."
213
- exit 1
214
- }
215
- echo "studio lib: ${old_studio_lib:-<no npm pins>} -> $new_studio_lib"
207
+ new_studio_lib=""
208
+ if [ -n "$old_studio_lib" ]; then
209
+ studio_doc="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/@norskvideo%2Fnorsk-studio")"
210
+ new_studio_lib="$(jq -r '."dist-tags".nightly // empty' <<<"$studio_doc")"
211
+ [ -n "$new_studio_lib" ] || { echo "could not resolve norsk-studio npm nightly"; exit 1; }
212
+ lib_sdk="$(jq -r --arg v "$new_studio_lib" \
213
+ '.versions[$v] | ((.devDependencies // {}) + (.peerDependencies // {}) + (.dependencies // {}))["@norskvideo/norsk-sdk"] // empty' <<<"$studio_doc")"
214
+ lib_sdk="${lib_sdk#^}"; lib_sdk="${lib_sdk#~}"
215
+ if [ "$lib_sdk" != "$new_media" ]; then
216
+ echo "::error::norsk-studio npm nightly $new_studio_lib was built against norsk-sdk ${lib_sdk:-<none declared>}, not the tested pair's engine $new_media."
217
+ echo "The npm channel and the channel manifest disagree; not pinning a combination nobody tested."
218
+ exit 1
219
+ fi
220
+ if [ "${new_studio_lib%%-*}" != "${new_studio_img%%-*}" ]; then
221
+ echo "::error::norsk-studio npm nightly $new_studio_lib is on a different version line from the pair's image $new_studio_img."
222
+ exit 1
223
+ fi
224
+ fi
225
+
226
+ echo "media: $old_media -> $new_media"
227
+ echo "studio lib: ${old_studio_lib:-<no npm pins>} -> ${new_studio_lib:-<unchanged>}"
216
228
  echo "studio image: $old_studio_img -> $new_studio_img"
229
+ if [ "$old_media" != "$new_media" ]; then
230
+ git grep -lF "$old_media" -- . ':!bun.lock' | xargs -r sed -i "s|$old_media|$new_media|g"
231
+ changed=1
232
+ parts+=("media \`$new_media\`")
233
+ fi
234
+ # Rewrite the image ref FIRST, then the bare library version -- so even
235
+ # when the two coincide, the image ref has already moved and the
236
+ # bare-version rewrite only touches the npm pins.
217
237
  if [ "$old_studio_img" != "$new_studio_img" ]; then
218
238
  git grep -lF "norskvideo/norsk-studio:$old_studio_img" -- . ':!bun.lock' \
219
239
  | xargs -r sed -i "s|norskvideo/norsk-studio:$old_studio_img|norskvideo/norsk-studio:$new_studio_img|g"
220
240
  changed=1
221
241
  fi
222
- if [ -n "$old_studio_lib" ] && [ "$old_studio_lib" != "$new_studio_lib" ]; then
242
+ if [ -n "$new_studio_lib" ] && [ "$old_studio_lib" != "$new_studio_lib" ]; then
223
243
  git grep -lF "$old_studio_lib" -- . ':!bun.lock' | xargs -r sed -i "s|$old_studio_lib|$new_studio_lib|g"
224
244
  changed=1
225
245
  fi
226
- # One studio entry, not two — the launched image tag is the runtime-
246
+ # One studio entry, not two -- the launched image tag is the runtime-
227
247
  # relevant one; only fall back to the lib tag if the image held but the
228
- # library advanced (npm nightly moved with no new image on the line yet).
248
+ # library advanced.
229
249
  if [ "$old_studio_img" != "$new_studio_img" ]; then
230
250
  parts+=("studio \`$new_studio_img\`")
231
- elif [ -n "$old_studio_lib" ] && [ "$old_studio_lib" != "$new_studio_lib" ]; then
251
+ elif [ -n "$new_studio_lib" ] && [ "$old_studio_lib" != "$new_studio_lib" ]; then
232
252
  parts+=("studio-lib \`$new_studio_lib\`")
233
253
  fi
234
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",