@norskvideo/ctl-dev-kit 0.1.65 → 0.1.67

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.
@@ -238,6 +238,9 @@ export interface CanonicalBytes {
238
238
  flake: string;
239
239
  upgradeLatest: string;
240
240
  syncDevKit: string;
241
+ /** conventions/sync-ctl-packages.yml — the ctl-* pin freshness PR. No per-repo
242
+ * line at all, so it is verbatim and sync-drift creates it where absent. */
243
+ syncCtlPackages: string;
241
244
  publishDocs: string;
242
245
  /** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */
243
246
  buildDocs: string;
@@ -513,6 +516,14 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
513
516
  );
514
517
  }
515
518
 
519
+ // sync-ctl-packages.yml is OPTIONAL until a repo has been synced once — it is
520
+ // NEW, and sync-drift creates it. Unlike its neighbours it carries no per-repo
521
+ // line (it dispatches to no dashboard), so the whole file is canonical.
522
+ const syncCtlPath = join(repoRoot, ".github", "workflows", "sync-ctl-packages.yml");
523
+ if (existsSync(syncCtlPath) && readFileSync(syncCtlPath, "utf8") !== canonical.syncCtlPackages) {
524
+ push(`.github/workflows/sync-ctl-packages.yml differs from conventions/sync-ctl-packages.yml. ${RESYNC}`);
525
+ }
526
+
516
527
  // smoke.yml is OPTIONAL until every product carries a tests/smoke.spec.ts
517
528
  // (04b s7.2 sequences the required-file rule last). When present it is the
518
529
  // shared, single-sourced workflow; only its `product:` dispatch key and the
@@ -569,6 +580,7 @@ if (import.meta.main) {
569
580
  flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"),
570
581
  upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
571
582
  syncDevKit: readFileSync(join(import.meta.dir, "sync-dev-kit.yml"), "utf8"),
583
+ syncCtlPackages: readFileSync(join(import.meta.dir, "sync-ctl-packages.yml"), "utf8"),
572
584
  publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
573
585
  buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
574
586
  checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
@@ -102,37 +102,47 @@ jobs:
102
102
  # gracefully, and this workflow cancels its own superseded runs -- a killed
103
103
  # daemon reaps nothing and the port stays held for every later job here.
104
104
  #
105
- # On 2026-08-30 something held it on one runner from 11:18 to ~16:00 and
105
+ # On 2026-08-30 something held it on ar-arm-vm from 11:18 to ~16:00 and
106
106
  # took out the next nine smoke and integration jobs across four repos.
107
107
  # What held it was never established: the job that last bound it PASSED
108
- # and shut down cleanly, so it may not have been a control-plane container
109
- # at all -- a stale docker-proxy or dangling endpoint reports the same
110
- # "already allocated". So this reaps what it can name AND names whatever
111
- # still holds the port, rather than exiting quiet and leaving the next
112
- # occurrence as much of a mystery as this one.
108
+ # and shut down cleanly, and every add-path failure already removes its
109
+ # own container -- so the holder may not have been a control plane at all.
110
+ # "already allocated" is also what a dangling endpoint or a stranded
111
+ # docker-proxy reports after an unclean removal.
113
112
  #
114
- # Gated on the daemon being this runner's own. The GPU box runs several
115
- # runners as sibling CONTAINERS against one host daemon, where a blanket
116
- # reap would kill a concurrent job's control plane.
113
+ # Hence: kill by PORT rather than by name, prune the endpoints a removal
114
+ # can strand, and then PROVE the port binds instead of assuming it. This
115
+ # branch only runs where the docker daemon is this runner's own and jobs
116
+ # are serial, so there is nothing else here to protect. The GPU box runs
117
+ # several runners as sibling CONTAINERS against one host daemon and is
118
+ # excluded -- there, a blanket reap would kill a concurrent job.
117
119
  - name: Reap a stranded product control plane
118
120
  if: ${{ env.NORSK_RUNNER_OWNS_DOCKER == 'true' }}
119
121
  run: |
120
122
  set -uo pipefail
121
- stale="$(docker ps -aq --filter 'name=^norsk-product-')"
122
- if [ -n "$stale" ]; then
123
- echo "::warning::reaping a stranded product control plane -- a previous job did not tear its own down"
124
- docker inspect --format '{{.Name}} {{.State.Status}} {{.Config.Image}}' $stale || true
125
- docker rm -f $stale || true
123
+ port=14321
124
+ bindable() { docker run --rm -p "127.0.0.1:$port:1" alpine true >/dev/null 2>&1; }
125
+
126
+ held="$(docker ps -a --filter "publish=$port" --format '{{.Names}} {{.Status}} {{.Image}}')"
127
+ if [ -n "$held" ]; then
128
+ echo "::warning::$port was held on entry by: $held"
129
+ docker rm -f $(docker ps -aq --filter "publish=$port") || true
126
130
  fi
127
- # Whatever is left on the port is the thing worth seeing: if this is
128
- # ever non-empty after the reap above, the holder is not a control
129
- # plane and `docker rm` was never going to clear it.
130
- holder="$(docker ps -a --filter 'publish=14321' --format '{{.Names}} {{.Status}} {{.Image}}')"
131
- if [ -n "$holder" ]; then
132
- echo "::warning::14321 is still published by: $holder"
133
- else
134
- echo "14321 free; no container publishes it"
131
+ # A control plane that is stopped, or one whose publish docker no
132
+ # longer reports, still gets removed: nothing here outlives a job.
133
+ leftover="$(docker ps -aq --filter 'name=^norsk-product-')"
134
+ [ -z "$leftover" ] || docker rm -f $leftover || true
135
+
136
+ if ! bindable; then
137
+ echo "::warning::$port still not bindable after removing containers -- pruning dangling endpoints"
138
+ docker network prune -f || true
139
+ fi
140
+ if ! bindable; then
141
+ echo "::error::cannot bind 127.0.0.1:$port; the journey would fail later and more obscurely"
142
+ docker ps -a --filter "publish=$port" || true
143
+ exit 1
135
144
  fi
145
+ echo "$port is bindable"
136
146
 
137
147
  - name: Write the Norsk license (from the org secret)
138
148
  env:
@@ -0,0 +1,162 @@
1
+ # Keep this product fresh against the ctl-* npm family: the test harness that
2
+ # drives its smoke and integration tiers, the SDK, the foundation helpers and
3
+ # the product-template schema.
4
+ #
5
+ # sync-dev-kit already does this for the conventions package, and upgrade-latest
6
+ # does it for the Norsk nightly line. Nothing did it for the rest, so the pins
7
+ # drifted silently: on 2026-08-30 studio carried a conventions pin bumped that
8
+ # morning beside an SDK three releases behind, and three repos ran three
9
+ # different vintages of the same smoke runner. The caret ranges admit the new
10
+ # versions -- `--frozen-lockfile` is what holds them back, and nothing ever
11
+ # regenerated the lockfile.
12
+ #
13
+ # Unlike sync-dev-kit this NEVER pushes to main. Its gate is drift + lint +
14
+ # typecheck + unit, and a bump to the test harness changes the code that drives
15
+ # smoke and integration -- neither of which that gate runs, because both need
16
+ # Docker and a licence. A green gate therefore proves strictly less here than it
17
+ # does for a conventions bump, so the bump goes to a human as a PR. A red gate
18
+ # still opens one, as a draft: adopting a breaking change is real engineering,
19
+ # not a copy-paste, and the PR is where that work belongs.
20
+ #
21
+ # Single-sourced in @norskvideo/ctl-dev-kit (conventions/sync-ctl-packages.yml)
22
+ # and copied verbatim into each product repo; the check:drift gate fails CI if a
23
+ # copy diverges. Never hand-edit the copy -- edit the source and re-sync.
24
+ name: sync-ctl-packages
25
+
26
+ on:
27
+ schedule:
28
+ # Clear of sync-dev-kit's 04:37 so the two never race the same lockfile.
29
+ - cron: "52 4 * * *"
30
+ workflow_dispatch:
31
+
32
+ permissions:
33
+ contents: write
34
+ pull-requests: write
35
+
36
+ concurrency:
37
+ group: sync-ctl-packages-${{ github.ref }}
38
+ cancel-in-progress: true
39
+
40
+ jobs:
41
+ sync:
42
+ runs-on: x64
43
+ steps:
44
+ # CI_DISPATCH_TOKEN, not the automatic GITHUB_TOKEN: a PR opened by
45
+ # GITHUB_TOKEN raises no workflow runs, so the product's own build, smoke
46
+ # and integration would never execute against the new pins -- which is the
47
+ # entire reason for opening it. clean: false for the same reason as the
48
+ # other shared jobs: the suites leave root-owned bind-mount dirs under
49
+ # test-temp/ that a non-root git clean cannot remove.
50
+ - uses: actions/checkout@v5
51
+ with:
52
+ token: ${{ secrets.CI_DISPATCH_TOKEN }}
53
+ clean: false
54
+
55
+ - name: Resolve the newest published ctl-* packages and rewrite the pins
56
+ id: bump
57
+ run: |
58
+ set -euo pipefail
59
+ reg="https://registry.npmjs.org"
60
+ # The conventions package is deliberately absent: sync-dev-kit owns it,
61
+ # lands it straight to main, and re-syncs the copied files with it.
62
+ family="ctl-test-harness ctl-sdk ctl-foundation ctl-product-template-schema"
63
+
64
+ # Every tracked package.json, not just the root: a repo can pin the
65
+ # same package in a nested workspace (playout pins the harness twice),
66
+ # and a root-only rewrite would split the line across the two.
67
+ mapfile -t manifests < <(git ls-files '*package.json' ':!:**/node_modules/**')
68
+
69
+ moved=""
70
+ for short in $family; do
71
+ pkg="@norskvideo/$short"
72
+ enc="@norskvideo%2F$short"
73
+ # The runner has no npm; read the dist-tag off the registry with
74
+ # curl+jq, as sync-dev-kit and upgrade-latest both do.
75
+ new="$(curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$reg/$enc" | jq -r '."dist-tags".latest')"
76
+ [ -n "$new" ] && [ "$new" != "null" ] || { echo "could not resolve $pkg latest"; exit 1; }
77
+
78
+ for m in "${manifests[@]}"; do
79
+ old="$(grep -oE "\"$pkg\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$m" | head -1 | sed -E 's/.*"([^"]*)"$/\1/' || true)"
80
+ # Absent here, or a workspace protocol spec that names no version.
81
+ case "$old" in "" | workspace:*) continue ;; esac
82
+ old_bare="${old#^}"; old_bare="${old_bare#~}"
83
+ [ "$old_bare" != "$new" ] || continue
84
+ # Surgical single-line rewrite so the file keeps its formatting;
85
+ # a jq reserialize would reflow it and trip the formatter.
86
+ sed -i -E "s|(\"$pkg\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")|\1^$new\2|" "$m"
87
+ echo "$m: $pkg $old -> ^$new"
88
+ moved="$moved- \`$pkg\` $old -> \`^$new\` ($m)"$'\n'
89
+ done
90
+ done
91
+
92
+ if [ -z "$moved" ]; then
93
+ echo "every ctl-* pin is already on its newest published version."
94
+ echo "changed=0" >> "$GITHUB_OUTPUT"
95
+ else
96
+ echo "changed=1" >> "$GITHUB_OUTPUT"
97
+ { echo "moved<<PINS"; printf '%s' "$moved"; echo "PINS"; } >> "$GITHUB_OUTPUT"
98
+ fi
99
+
100
+ - name: Reinstall and run the product gate
101
+ id: gate
102
+ if: steps.bump.outputs.changed == '1'
103
+ continue-on-error: true
104
+ run: |
105
+ nix develop .#build --command bash -c '
106
+ set -euo pipefail
107
+ bun install
108
+ bun run check:drift
109
+ bun run lint
110
+ bun run typecheck
111
+ bun run test:unit
112
+ '
113
+
114
+ - name: Open (or update) the pin-freshness PR
115
+ if: steps.bump.outputs.changed == '1'
116
+ env:
117
+ GH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
118
+ GATE: ${{ steps.gate.outcome }}
119
+ run: |
120
+ set -euo pipefail
121
+ branch="chore/sync-ctl-packages"
122
+ git config user.name "github-actions[bot]"
123
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
124
+ git checkout -B "$branch"
125
+ git add -A
126
+ git commit -m "chore: sync the ctl-* pins to their newest published versions"
127
+ # One branch, force-pushed: a daily schedule must refresh the standing
128
+ # PR rather than accumulate a new one every morning.
129
+ git push -f origin "$branch"
130
+
131
+ if [ "$GATE" = "success" ]; then
132
+ verdict="Gate green: check:drift, lint, typecheck and unit tests all pass on the new pins."
133
+ draft=""
134
+ else
135
+ verdict="**Gate red** — the new pins do not fit this product as-is. Adopting them is real engineering, not a merge."
136
+ draft="--draft"
137
+ fi
138
+
139
+ body="$(cat <<EOF
140
+ Automated by \`sync-ctl-packages\`.
141
+
142
+ ## Pins moved
143
+
144
+ ${{ steps.bump.outputs.moved }}
145
+
146
+ ## What the gate proved
147
+
148
+ $verdict
149
+
150
+ It did **not** run smoke or integration — both need Docker and a licence.
151
+ A test-harness bump changes the code that drives those tiers, so the
152
+ runs raised on this PR are the real evidence. Read them before merging.
153
+ EOF
154
+ )"
155
+
156
+ if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then
157
+ gh pr create $draft --head "$branch" --base main \
158
+ --title "chore: sync the ctl-* pins to their newest published versions" \
159
+ --body "$body"
160
+ else
161
+ gh pr edit "$branch" --body "$body"
162
+ fi
@@ -223,6 +223,15 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
223
223
  syncWorkflow(repoRoot, ".github/workflows/checks.yml", canonical.checks, r);
224
224
  syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", canonical.upgradeLatest, r);
225
225
  syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
226
+ // No per-repo line to preserve, so it is written whole -- and CREATED where
227
+ // absent, unlike syncWorkflow's copies. That is how a repo acquires the
228
+ // workflow at all: its next sync run installs it.
229
+ writeIfChanged(
230
+ join(repoRoot, ".github/workflows/sync-ctl-packages.yml"),
231
+ canonical.syncCtlPackages,
232
+ ".github/workflows/sync-ctl-packages.yml",
233
+ r.written,
234
+ );
226
235
  syncBuildImage(repoRoot, canonical.buildImage, r);
227
236
  syncSmoke(repoRoot, canonical.smoke, r);
228
237
 
@@ -256,6 +265,7 @@ if (import.meta.main) {
256
265
  flake: readFileSync(join(dir, "..", "build", "flake.nix"), "utf8"),
257
266
  upgradeLatest: readFileSync(join(dir, "upgrade-latest.yml"), "utf8"),
258
267
  syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
268
+ syncCtlPackages: readFileSync(join(dir, "sync-ctl-packages.yml"), "utf8"),
259
269
  publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
260
270
  buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
261
271
  checks: readFileSync(join(dir, "checks.yml"), "utf8"),
@@ -18,6 +18,7 @@ export interface Canon {
18
18
  flakeLock: string;
19
19
  upgradeLatest: string;
20
20
  syncDevKit: string;
21
+ syncCtlPackages: string;
21
22
  checks: string;
22
23
  biome: string;
23
24
  tsconfigBase: string;
@@ -39,6 +40,7 @@ export function loadCanon(): Canon {
39
40
  flakeLock: readFileSync(join(buildDir, "flake.lock"), "utf8"),
40
41
  upgradeLatest: readFileSync(join(conventionsDir, "upgrade-latest.yml"), "utf8"),
41
42
  syncDevKit: readFileSync(join(conventionsDir, "sync-dev-kit.yml"), "utf8"),
43
+ syncCtlPackages: readFileSync(join(conventionsDir, "sync-ctl-packages.yml"), "utf8"),
42
44
  checks: readFileSync(join(conventionsDir, "checks.yml"), "utf8"),
43
45
  buildDocs: readFileSync(join(conventionsDir, "build-docs.yml"), "utf8"),
44
46
  biome: readFileSync(join(conventionsDir, "biome.base.json"), "utf8"),
@@ -111,6 +111,7 @@ function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[]
111
111
  { path: ".github/workflows/checks.yml", content: fillProduct(canon.checks) },
112
112
  { path: ".github/workflows/upgrade-latest.yml", content: fillProduct(canon.upgradeLatest) },
113
113
  { path: ".github/workflows/sync-dev-kit.yml", content: fillProduct(canon.syncDevKit) },
114
+ { path: ".github/workflows/sync-ctl-packages.yml", content: canon.syncCtlPackages },
114
115
  { path: ".github/workflows/build-image.yml", content: fillProduct(canon.buildImage) },
115
116
  { path: ".github/actions/ci-status-dispatch/action.yml", content: loadAsset("ci-status-dispatch.yml") },
116
117
  { path: "manifest.seed.json", content: seedJson(ctx) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",