@norskvideo/ctl-dev-kit 0.2.25 → 0.2.27

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.
@@ -0,0 +1,167 @@
1
+ # Land the pin bots' green PRs; put the red ones on the product dashboard.
2
+ #
3
+ # Three bots keep a product fresh: upgrade-latest (the Norsk nightly line),
4
+ # sync-dev-kit (this package) and sync-ctl-packages (the ctl-* / moq-* npm
5
+ # family). The first two land on main when their gate is green and open a PR
6
+ # when it is not; sync-ctl-packages ALWAYS opens a PR, because its gate cannot
7
+ # run the smoke tier its packages drive. Nothing then acted on those PRs: green
8
+ # ones sat unmerged (studio's ctl-sdk was twenty releases behind on 2026-10-03,
9
+ # its PR open since 09-03) and red ones were invisible, because the bot that
10
+ # opened them reports success. With a product count that only goes up, "someone
11
+ # will notice" is not a process.
12
+ #
13
+ # So, for every open PR whose branch a bot owns:
14
+ # - every check on its head has passed and it merges cleanly -> squash-merge it
15
+ # (the PR's checks + smoke ARE the verdict; integration then runs on main);
16
+ # - a check failed, it conflicts with main, or it is a draft (the bot's own
17
+ # gate was red) -> list it, and report this repo's `bot-prs` cell RED on the
18
+ # product dashboard, which is the Discord message people actually read;
19
+ # - checks still running -> leave it for the next trigger.
20
+ # The cell goes green again once nothing is red.
21
+ #
22
+ # Runs when a PR's checks or smoke finish (the prompt path), hourly as a sweep,
23
+ # and on demand. No checkout: it only talks to the GitHub API, so it needs none
24
+ # of the runner-workspace steps the building workflows carry.
25
+ #
26
+ # Single-sourced in @norskvideo/ctl-dev-kit (conventions/bot-prs.yml) and copied
27
+ # verbatim into each product repo -- it reads its dashboard key from this repo's
28
+ # upgrade-latest.yml rather than carrying one -- so sync-drift creates it where
29
+ # absent and check:drift fails a copy that diverges. Never hand-edit the copy.
30
+ name: bot-prs
31
+
32
+ on:
33
+ workflow_run:
34
+ workflows: [checks, smoke]
35
+ types: [completed]
36
+ schedule:
37
+ - cron: "23 * * * *"
38
+ workflow_dispatch:
39
+
40
+ permissions:
41
+ contents: read
42
+ checks: read
43
+ pull-requests: read
44
+
45
+ concurrency:
46
+ # One at a time per repo, and never cancelled: a merge half-done by a run that
47
+ # was cancelled is worse than a second run finding nothing left to do.
48
+ group: bot-prs-${{ github.repository }}
49
+ cancel-in-progress: false
50
+
51
+ jobs:
52
+ triage:
53
+ runs-on: x64
54
+ steps:
55
+ - name: Merge green bot PRs, report red ones
56
+ id: triage
57
+ env:
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 }}
65
+ REPO: ${{ github.repository }}
66
+ run: |
67
+ set -euo pipefail
68
+ api="https://api.github.com/repos/$REPO"
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
+ }
80
+
81
+ # The bots own exactly these branches (see each workflow's PR step).
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" '
85
+ [.[] | select(.head.repo.full_name == $repo)
86
+ | select(.head.ref | test("^(upgrade-latest/|sync-dev-kit/|chore/sync-ctl-packages$)"))
87
+ | {number, ref: .head.ref, sha: .head.sha, draft, url: .html_url}]' <<<"$pulls")"
88
+
89
+ red=()
90
+ merged=()
91
+ while IFS= read -r pr; do
92
+ [ -n "$pr" ] || continue
93
+ n="$(jq -r .number <<<"$pr")"; ref="$(jq -r .ref <<<"$pr")"; sha="$(jq -r .sha <<<"$pr")"
94
+ runs="$(gh_api "$api/commits/$sha/check-runs?per_page=100")"
95
+ expect "reading checks for #$n" '.check_runs | type == "array"' "$runs"
96
+ failed="$(jq -r '[.check_runs[] | select(.conclusion | IN("failure","cancelled","timed_out","action_required","startup_failure")) | .name] | unique | join(", ")' <<<"$runs")"
97
+ pending="$(jq -r '[.check_runs[] | select(.status != "completed")] | length' <<<"$runs")"
98
+ passed="$(jq -r '[.check_runs[] | select(.conclusion == "success")] | length' <<<"$runs")"
99
+ if [ -n "$failed" ]; then red+=("#$n $ref: failing $failed"); continue; fi
100
+ if [ "$(jq -r .draft <<<"$pr")" = "true" ]; then red+=("#$n $ref: draft (its bot's gate was red)"); continue; fi
101
+ if [ "$pending" -gt 0 ] || [ "$passed" -eq 0 ]; then echo "#$n $ref: checks not finished; next time"; continue; fi
102
+ # GitHub computes mergeability lazily; ask until it has an answer.
103
+ mergeable=null
104
+ for _ in 1 2 3 4 5; do
105
+ mergeable="$(gh_api "$api/pulls/$n" | jq -r .mergeable)"
106
+ [ "$mergeable" != "null" ] && break
107
+ sleep 3
108
+ done
109
+ if [ "$mergeable" != "true" ]; then red+=("#$n $ref: does not merge cleanly with main"); continue; fi
110
+ # sha pins the merge to what was checked: a bot force-pushing the
111
+ # branch meanwhile makes this refuse rather than land unchecked code.
112
+ res="$(gh_write -X PUT "$api/pulls/$n/merge" -d "$(jq -nc --arg sha "$sha" '{merge_method:"squash", sha:$sha}')")"
113
+ if [ "$(jq -r '.merged // false' <<<"$res")" = "true" ]; then
114
+ merged+=("#$n")
115
+ echo "merged #$n ($ref)"
116
+ # The bots recreate their branch on the next bump.
117
+ gh_write -X DELETE "$api/git/refs/heads/$ref" -o /dev/null || true
118
+ else
119
+ red+=("#$n $ref: merge refused: $(jq -r '.message // "unknown"' <<<"$res")")
120
+ fi
121
+ done < <(jq -c '.[]' <<<"$prs")
122
+
123
+ if [ "${#red[@]}" -gt 0 ]; then
124
+ state=failure
125
+ detail="$(printf '%s; ' "${red[@]}")"
126
+ for r in "${red[@]}"; do echo "::warning::bot PR needs a human: $r"; done
127
+ else
128
+ state=success
129
+ detail="${merged[*]:+merged ${merged[*]}}"
130
+ fi
131
+ echo "state=$state" >> "$GITHUB_OUTPUT"
132
+ echo "detail=${detail%; }" >> "$GITHUB_OUTPUT"
133
+
134
+ - name: Report to the product dashboard
135
+ # Always: a triage that broke is exactly what nobody would otherwise see.
136
+ if: always()
137
+ env:
138
+ TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
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' }}
142
+ SHA: ${{ github.sha }}
143
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
144
+ REPO: ${{ github.repository }}
145
+ run: |
146
+ set -euo pipefail
147
+ # The dashboard key is the one upgrade-latest.yml reports under, read
148
+ # from it so this file can stay byte-identical across products.
149
+ product="$(curl -sS -H "Authorization: Bearer $READ_TOKEN" -H "Accept: application/vnd.github.raw" \
150
+ "https://api.github.com/repos/$REPO/contents/.github/workflows/upgrade-latest.yml" \
151
+ | sed -nE 's/^[[:space:]]*product:[[:space:]]*([^[:space:]]+).*/\1/p' | head -1)"
152
+ if [ -z "$product" ] || [ "$product" = "__PRODUCT__" ]; then
153
+ echo "::warning::no product key in upgrade-latest.yml; not reporting to the dashboard"
154
+ exit 0
155
+ fi
156
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
157
+ payload="$(jq -cn \
158
+ --arg product "$product" --arg pipeline bot-prs --arg conclusion "$STATUS" \
159
+ --arg sha "$SHA" --arg runUrl "$RUN_URL" --arg ts "$ts" --arg detail "$DETAIL" \
160
+ '{event_type: "ci-status", client_payload: (
161
+ {product: $product, pipeline: $pipeline, conclusion: $conclusion, sha: $sha, runUrl: $runUrl, ts: $ts}
162
+ + (if $detail == "" then {} else {detail: $detail} end)
163
+ )}')"
164
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
165
+ -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" \
166
+ https://api.github.com/repos/id3as/ci-workflows/dispatches -d "$payload")"
167
+ [ "$code" = "204" ] || echo "::warning::dashboard dispatch returned HTTP $code"
@@ -256,6 +256,10 @@ export interface CanonicalBytes {
256
256
  /** conventions/sync-ctl-packages.yml — the ctl-* pin freshness PR. No per-repo
257
257
  * line at all, so it is verbatim and sync-drift creates it where absent. */
258
258
  syncCtlPackages: string;
259
+ /** conventions/bot-prs.yml -- merges the pin bots' green PRs and reports red
260
+ * ones. Verbatim (it reads its dashboard key at run time), so sync-drift
261
+ * creates it where absent, like sync-ctl-packages. */
262
+ botPrs: string;
259
263
  publishDocs: string;
260
264
  /** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */
261
265
  buildDocs: string;
@@ -683,6 +687,13 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
683
687
  push(`.github/workflows/sync-ctl-packages.yml differs from conventions/sync-ctl-packages.yml. ${RESYNC}`);
684
688
  }
685
689
 
690
+ // bot-prs.yml: the same shape -- optional until a repo's first sync creates
691
+ // it, verbatim once present.
692
+ const botPrsPath = join(repoRoot, ".github", "workflows", "bot-prs.yml");
693
+ if (existsSync(botPrsPath) && readFileSync(botPrsPath, "utf8") !== canonical.botPrs) {
694
+ push(`.github/workflows/bot-prs.yml differs from conventions/bot-prs.yml. ${RESYNC}`);
695
+ }
696
+
686
697
  // smoke.yml is OPTIONAL until every product carries a tests/smoke.spec.ts
687
698
  // (04b s7.2 sequences the required-file rule last). When present it is the
688
699
  // shared, single-sourced workflow; only its `product:` dispatch key and the
@@ -793,6 +804,7 @@ if (import.meta.main) {
793
804
  upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
794
805
  syncDevKit: readFileSync(join(import.meta.dir, "sync-dev-kit.yml"), "utf8"),
795
806
  syncCtlPackages: readFileSync(join(import.meta.dir, "sync-ctl-packages.yml"), "utf8"),
807
+ botPrs: readFileSync(join(import.meta.dir, "bot-prs.yml"), "utf8"),
796
808
  publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
797
809
  buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
798
810
  withDisplay: readFileSync(join(import.meta.dir, "..", "doc-guide", "with-display.sh"), "utf8"),
@@ -167,8 +167,25 @@ jobs:
167
167
 
168
168
  - name: Land the freshness bump on main (gate green)
169
169
  if: steps.sync.outputs.changed == '1' && steps.gate.outcome == 'success'
170
+ env:
171
+ API_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
170
172
  run: |
171
173
  set -euo pipefail
174
+ # This bump is about to land, which makes any sync-dev-kit/* PR an earlier
175
+ # red run opened obsolete. Left open they piled up -- nine were closed
176
+ # by hand on 2026-10-03 -- so close them once the push has landed.
177
+ # Best-effort: a failure here must not fail the landing.
178
+ close_superseded() {
179
+ api="https://api.github.com/repos/$GITHUB_REPOSITORY"
180
+ auth=(-H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
181
+ for n in $(curl -sS "${auth[@]}" "$api/pulls?state=open&per_page=100" \
182
+ | jq -r --arg repo "$GITHUB_REPOSITORY" '.[] | select(.head.repo.full_name == $repo and (.head.ref | startswith("sync-dev-kit/"))) | .number'); do
183
+ curl -sS -o /dev/null "${auth[@]}" -X POST "$api/issues/$n/comments" \
184
+ -d '{"body":"Superseded: a later sync-dev-kit run landed its bump on main."}'
185
+ curl -sS -o /dev/null "${auth[@]}" -X PATCH "$api/pulls/$n" -d '{"state":"closed"}'
186
+ echo "closed superseded PR #$n"
187
+ done
188
+ }
172
189
  git config user.name "github-actions[bot]"
173
190
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
174
191
  git add -A
@@ -184,7 +201,7 @@ jobs:
184
201
  # is schedule/dispatch-only, so no loop). Rebase-and-retry so a commit
185
202
  # landing mid-run does not red the sync; a real conflict aborts.
186
203
  for attempt in 1 2 3 4 5; do
187
- if git push origin HEAD:main; then exit 0; fi
204
+ if git push origin HEAD:main; then close_superseded || true; exit 0; fi
188
205
  echo "push rejected (attempt $attempt) - rebasing onto latest origin/main"
189
206
  # --unshallow, not a plain fetch: these runners keep their .git
190
207
  # between jobs, and a commit an earlier job fetched at depth 1 stays
@@ -307,6 +307,13 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: Pr
307
307
  ".github/workflows/sync-ctl-packages.yml",
308
308
  r.written,
309
309
  );
310
+ // bot-prs.yml likewise: verbatim, and created where absent.
311
+ writeIfChanged(
312
+ join(repoRoot, ".github/workflows/bot-prs.yml"),
313
+ canonical.botPrs,
314
+ ".github/workflows/bot-prs.yml",
315
+ r.written,
316
+ );
310
317
  // build-image.yml is the Studio bundle-image workflow; an sdk-app ships its
311
318
  // own container, so it is not synced for that shape.
312
319
  if (shape === "studio") syncBuildImage(repoRoot, canonical.buildImage, r);
@@ -346,6 +353,7 @@ if (import.meta.main) {
346
353
  upgradeLatest: readFileSync(join(dir, "upgrade-latest.yml"), "utf8"),
347
354
  syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
348
355
  syncCtlPackages: readFileSync(join(dir, "sync-ctl-packages.yml"), "utf8"),
356
+ botPrs: readFileSync(join(dir, "bot-prs.yml"), "utf8"),
349
357
  publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
350
358
  buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
351
359
  withDisplay: readFileSync(join(dir, "..", "doc-guide", "with-display.sh"), "utf8"),
@@ -297,8 +297,25 @@ jobs:
297
297
 
298
298
  - name: Land the bump on main (gate green)
299
299
  if: steps.bump.outputs.changed == '1' && steps.gate.outcome == 'success'
300
+ env:
301
+ API_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
300
302
  run: |
301
303
  set -euo pipefail
304
+ # This bump is about to land, which makes any upgrade-latest/* PR an earlier
305
+ # red run opened obsolete. Left open they piled up -- nine were closed
306
+ # by hand on 2026-10-03 -- so close them once the push has landed.
307
+ # Best-effort: a failure here must not fail the landing.
308
+ close_superseded() {
309
+ api="https://api.github.com/repos/$GITHUB_REPOSITORY"
310
+ auth=(-H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
311
+ for n in $(curl -sS "${auth[@]}" "$api/pulls?state=open&per_page=100" \
312
+ | jq -r --arg repo "$GITHUB_REPOSITORY" '.[] | select(.head.repo.full_name == $repo and (.head.ref | startswith("upgrade-latest/"))) | .number'); do
313
+ curl -sS -o /dev/null "${auth[@]}" -X POST "$api/issues/$n/comments" \
314
+ -d '{"body":"Superseded: a later upgrade-latest run landed its bump on main."}'
315
+ curl -sS -o /dev/null "${auth[@]}" -X PATCH "$api/pulls/$n" -d '{"state":"closed"}'
316
+ echo "closed superseded PR #$n"
317
+ done
318
+ }
302
319
  git config user.name "github-actions[bot]"
303
320
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
304
321
  git add -A
@@ -316,7 +333,7 @@ jobs:
316
333
  # landing mid-run does not fail the bump; a genuine content conflict
317
334
  # still aborts loudly rather than force-pushing over other work.
318
335
  for attempt in 1 2 3 4 5; do
319
- if git push origin HEAD:main; then exit 0; fi
336
+ if git push origin HEAD:main; then close_superseded || true; exit 0; fi
320
337
  echo "push rejected (attempt $attempt) - rebasing onto latest origin/main"
321
338
  # --unshallow, not a plain fetch: these runners keep their .git
322
339
  # between jobs, and a commit an earlier job fetched at depth 1 stays
@@ -19,6 +19,7 @@ export interface Canon {
19
19
  upgradeLatest: string;
20
20
  syncDevKit: string;
21
21
  syncCtlPackages: string;
22
+ botPrs: string;
22
23
  checks: string;
23
24
  biome: string;
24
25
  tsconfigBase: string;
@@ -44,6 +45,7 @@ export function loadCanon(): Canon {
44
45
  upgradeLatest: readFileSync(join(conventionsDir, "upgrade-latest.yml"), "utf8"),
45
46
  syncDevKit: readFileSync(join(conventionsDir, "sync-dev-kit.yml"), "utf8"),
46
47
  syncCtlPackages: readFileSync(join(conventionsDir, "sync-ctl-packages.yml"), "utf8"),
48
+ botPrs: readFileSync(join(conventionsDir, "bot-prs.yml"), "utf8"),
47
49
  checks: readFileSync(join(conventionsDir, "checks.yml"), "utf8"),
48
50
  buildDocs: readFileSync(join(conventionsDir, "build-docs.yml"), "utf8"),
49
51
  withDisplay: readFileSync(join(conventionsDir, "..", "doc-guide", "with-display.sh"), "utf8"),
@@ -222,6 +222,7 @@ function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[]
222
222
  { path: ".github/workflows/upgrade-latest.yml", content: fillProduct(canon.upgradeLatest) },
223
223
  { path: ".github/workflows/sync-dev-kit.yml", content: fillProduct(canon.syncDevKit) },
224
224
  { path: ".github/workflows/sync-ctl-packages.yml", content: canon.syncCtlPackages },
225
+ { path: ".github/workflows/bot-prs.yml", content: canon.botPrs },
225
226
  { path: ".github/workflows/build-image.yml", content: fillProduct(canon.buildImage) },
226
227
  { path: ".github/actions/ci-status-dispatch/action.yml", content: loadAsset("ci-status-dispatch.yml") },
227
228
  { 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.2.25",
3
+ "version": "0.2.27",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",