@norskvideo/ctl-dev-kit 0.1.18 → 0.1.20

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.
@@ -25,8 +25,14 @@ export interface DriftReport {
25
25
  problems: string[];
26
26
  }
27
27
 
28
- const BEGIN = "<!-- BEGIN ctl-shared-conventions v1 -->";
29
- const END = "<!-- END ctl-shared-conventions v1 -->";
28
+ export const BEGIN = "<!-- BEGIN ctl-shared-conventions v1 -->";
29
+ export const END = "<!-- END ctl-shared-conventions v1 -->";
30
+
31
+ // The .gitignore shared block is delimited by these markers (see gitignore.core),
32
+ // so the sync writer can locate-and-replace it deterministically. The gate itself
33
+ // checks the whole marker-delimited block as a verbatim substring.
34
+ export const GITIGNORE_BEGIN = "# BEGIN ctl-shared-gitignore v1";
35
+ export const GITIGNORE_END = "# END ctl-shared-gitignore v1";
30
36
 
31
37
  const RESYNC = "Edit the dev-kit source and re-sync — never hand-edit the copy.";
32
38
 
@@ -45,8 +51,8 @@ function firstDiffLine(actual: string, expected: string): string {
45
51
 
46
52
  // The one legitimately per-repo line in the shared workflows: the dashboard key
47
53
  // the CI result is dispatched under. Everything else is verbatim-shared.
48
- const PRODUCT_LINE = /^(\s*product:\s*).*$/gm;
49
- const PRODUCT_SENTINEL = "__PRODUCT__";
54
+ export const PRODUCT_LINE = /^(\s*product:\s*).*$/gm;
55
+ export const PRODUCT_SENTINEL = "__PRODUCT__";
50
56
 
51
57
  function workflowProblem(relPath: string, canonicalRef: string, actual: string, canonical: string): string | undefined {
52
58
  // Compare structure with the product value masked so any real key passes, then
@@ -66,8 +72,8 @@ function workflowProblem(relPath: string, canonicalRef: string, actual: string,
66
72
  // this pin only feeds the dev flake; it floats per repo and over time and is NOT
67
73
  // a shared convention. Mask it so the gate compares flake STRUCTURE, not the pin
68
74
  // — same idea as masking upgrade-latest's product: line.
69
- const CTL_VERSION_LINE = /ctlVersion = "[^"]*";/;
70
- const CTL_HASH_LINE = /hash = "sha256-[^"]*";/g;
75
+ export const CTL_VERSION_LINE = /ctlVersion = "[^"]*";/;
76
+ export const CTL_HASH_LINE = /hash = "sha256-[^"]*";/g;
71
77
 
72
78
  function flakeProblem(actual: string, canonical: string): string | undefined {
73
79
  const mask = (s: string) =>
@@ -82,10 +88,10 @@ function flakeProblem(actual: string, canonical: string): string | undefined {
82
88
  // inside a sanctioned marker block at the head of `excludes`. Everything outside
83
89
  // the block is byte-compared; inside, only well-formed `"entry",` lines are
84
90
  // allowed so the block can't smuggle arbitrary config past the byte check.
85
- const DPRINT_BEGIN = "// BEGIN repo-specific excludes";
86
- const DPRINT_END = "// END repo-specific excludes";
91
+ export const DPRINT_BEGIN = "// BEGIN repo-specific excludes";
92
+ export const DPRINT_END = "// END repo-specific excludes";
87
93
 
88
- function splitAtMarkers(s: string): { prefix: string; body: string[]; suffix: string } | undefined {
94
+ export function splitAtMarkers(s: string): { prefix: string; body: string[]; suffix: string } | undefined {
89
95
  const lines = s.split("\n");
90
96
  const begin = lines.findIndex((l) => l.trim() === DPRINT_BEGIN);
91
97
  const end = lines.findIndex((l) => l.trim() === DPRINT_END);
@@ -54,11 +54,13 @@ jobs:
54
54
 
55
55
  # Report this pipeline's result to the aggregated product CI dashboard
56
56
  # (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
57
- # the pony/emoji from the dispatched result. always() so a red or manual run
58
- # still reports.
57
+ # the pony/emoji from the dispatched result. !cancelled() so a real pass/fail
58
+ # reports, but a cancelled/superseded run does NOT — a cancellation dispatched
59
+ # as failure would false-red the board on every drained or concurrency-
60
+ # superseded run.
59
61
  notify:
60
62
  needs: [drift, quality]
61
- if: always() && github.event_name == 'push'
63
+ if: ${{ !cancelled() && github.event_name == 'push' }}
62
64
  runs-on: x64
63
65
  steps:
64
66
  - uses: actions/checkout@v5
@@ -7,22 +7,21 @@
7
7
  # need re-syncing. This workflow closes the first gap and surfaces the second.
8
8
  #
9
9
  # It bumps the @norskvideo/ctl-dev-kit pin to the newest published version,
10
- # reinstalls, and runs this product's own gate (check:drift + lint + typecheck +
11
- # unit tests) against the new bytes:
10
+ # reinstalls, re-syncs this product's forced copies from the new canonical
11
+ # (sync-drift), then runs the gate (check:drift + lint + typecheck + unit tests):
12
12
  #
13
- # - gate green -> a clean freshness bump; commit straight to main, like
14
- # upgrade-latest.yml. The common case: a dev-kit release that doesn't touch
15
- # this product's copies (or only verbatim ones already reconciled).
16
- # - gate red -> the new dev-kit needs a human: a drifted copy to re-sync, or
17
- # a convention change that breaks this product. Push a branch and open a PR;
18
- # check:drift's own output names each drifted file and the first diffing line.
19
- # Re-sync on the branch until green, then merge.
13
+ # - gate green -> the bump plus any mechanical re-sync is correct; commit it all
14
+ # straight to main, hands-free. This is the common case.
15
+ # - gate red -> the new dev-kit genuinely does not fit this product: a
16
+ # convention that fails lint/typecheck/tests, or a copy sync-drift could not
17
+ # mechanically converge. Push a branch and open a PR for a human -- real
18
+ # engineering to adopt the convention, not a copy-paste.
20
19
  #
21
- # The re-sync is deliberately NOT automated. check:drift already emits the exact
22
- # fix, the masked / marker-block copies (the flake ctl pin, the CLAUDE core, the
23
- # dprint and gitignore blocks) need judgement, and a bot that blindly rewrote
24
- # them could commit a subtly-wrong splice. Detection plus a ready branch is the
25
- # leverage; the fix stays human.
20
+ # sync-drift re-derives each copy from the canonical (never a second opinion that
21
+ # can disagree), preserving the per-repo bits the gate masks -- the product: keys,
22
+ # the flake ctl pin, the dprint/gitignore/CLAUDE repo content. The gate runs AFTER
23
+ # it, so a bad rewrite fails check:drift and a breaking convention fails the build,
24
+ # rather than landing silently. Structural per-repo files it never touches.
26
25
  #
27
26
  # Single-sourced in @norskvideo/ctl-dev-kit (conventions/sync-dev-kit.yml) and
28
27
  # copied verbatim into each product repo; the check:drift gate fails CI if a copy
@@ -81,7 +80,7 @@ jobs:
81
80
  echo "old=$old_bare" >> "$GITHUB_OUTPUT"
82
81
  echo "new=$new" >> "$GITHUB_OUTPUT"
83
82
 
84
- - name: Reinstall and run the product gate against the new dev-kit
83
+ - name: Reinstall, re-sync the copies from canonical, run the product gate
85
84
  id: gate
86
85
  if: steps.sync.outputs.changed == '1'
87
86
  continue-on-error: true
@@ -89,6 +88,7 @@ jobs:
89
88
  nix develop .#build --command bash -c '
90
89
  set -euo pipefail
91
90
  bun install
91
+ bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts
92
92
  bun run check:drift
93
93
  bun run lint
94
94
  bun run typecheck
@@ -104,9 +104,10 @@ jobs:
104
104
  git add -A
105
105
  git commit -m "chore: sync dev-kit ${{ steps.sync.outputs.old }} -> ${{ steps.sync.outputs.new }}
106
106
 
107
- @norskvideo/ctl-dev-kit pin bumped to the newest published version;
108
- check:drift + lint + typecheck + unit tests green against the new bytes.
109
- Lockfile regenerated."
107
+ @norskvideo/ctl-dev-kit pin bumped to the newest published version and the
108
+ forced copies re-synced from the new canonical (sync-drift); check:drift +
109
+ lint + typecheck + unit tests green against the new bytes. Lockfile
110
+ regenerated."
110
111
  # A GITHUB_TOKEN push does not retrigger checks (Actions' recursion
111
112
  # guard) -- fine, the gate above already ran them. Rebase-and-retry so a
112
113
  # commit landing mid-run does not red the sync; a real conflict aborts.
@@ -134,18 +135,17 @@ jobs:
134
135
  title="Sync dev-kit ${{ steps.sync.outputs.old }} -> ${{ steps.sync.outputs.new }}"
135
136
  body="$(printf '%s\n' \
136
137
  "Automated dev-kit sync: the @norskvideo/ctl-dev-kit pin moved to the newest" \
137
- "published version, but this product's gate is **red** against the new bytes," \
138
- "so the sync needs a human." \
138
+ "published version and sync-drift re-synced the forced copies from the new" \
139
+ "canonical -- but the gate is **red**, so this bump needs a human." \
139
140
  "" \
140
- "Run the gate locally to see what to fix:" \
141
+ "The mechanical re-sync is already on this branch. A red gate here means the" \
142
+ "new dev-kit genuinely does not fit the product yet -- run the gate to see which:" \
141
143
  "" \
142
144
  " bun install" \
143
- " bun run check:drift # names each drifted copy and the first diffing line" \
144
- " bun run lint && bun run typecheck && bun run test:unit" \
145
+ " bun run check:drift # a copy sync-drift could not mechanically converge" \
146
+ " bun run lint && bun run typecheck && bun run test:unit # a convention that breaks real code" \
145
147
  "" \
146
- "Re-sync the named copies from node_modules/@norskvideo/ctl-dev-kit (never" \
147
- "hand-edit against memory), or resolve the convention change this pin" \
148
- "introduced, pushing to this branch until green -- then merge.")"
148
+ "Resolve whichever is failing, pushing to this branch until green -- then merge.")"
149
149
  if existing="$(gh pr list --head "$branch" --state open --json number -q '.[0].number')" && [ -n "$existing" ]; then
150
150
  gh pr edit "$existing" --title "$title" --body "$body"
151
151
  else
@@ -157,12 +157,13 @@ jobs:
157
157
  run: echo "dev-kit pin already current -- nothing to sync."
158
158
 
159
159
  # Report the sync result to the aggregated product CI dashboard
160
- # (id3as/ci-workflows). always() so a failed sync shows a sad pony. A gate-red
161
- # run that opened a PR is still a successful sync -- the red lives on the PR's
162
- # own checks run, not here.
160
+ # (id3as/ci-workflows). !cancelled() so a failed sync shows a sad pony but a
161
+ # cancelled/superseded run does not false-red the board. A gate-red run that
162
+ # opened a PR is still a successful sync -- the red lives on the PR's own checks
163
+ # run, not here.
163
164
  notify:
164
165
  needs: sync
165
- if: always()
166
+ if: ${{ !cancelled() }}
166
167
  runs-on: x64
167
168
  steps:
168
169
  - uses: actions/checkout@v5
@@ -0,0 +1,190 @@
1
+ // The writer half of the drift gate. check-drift.ts proves a product's forced
2
+ // copies match this dev-kit's canonical; sync-drift.ts *makes* them match, by
3
+ // re-deriving each copy from the same canonical bytes. It is the mechanical
4
+ // inverse of the checker and shares its constants (imported below) so the two
5
+ // cannot disagree about what "in sync" means.
6
+ //
7
+ // It only touches copies whose convergent form is mechanical — there is no
8
+ // judgement anywhere:
9
+ // - verbatim (biome.json, tsconfig.base.json) -> overwrite with canonical
10
+ // - masked workflows (checks / upgrade / sync) -> canonical, repo's product: key kept
11
+ // - flake.nix -> canonical, repo's floating ctl pin kept
12
+ // - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest
13
+ // Structural per-repo files (root tsconfig.json, deployment/build-image.sh,
14
+ // manifest.seed.json) have NO canonical bytes to apply — the gate checks their
15
+ // shape, not their content — so they are left untouched.
16
+ //
17
+ // The safety net is that the gate still runs AFTER sync: a copy this writer
18
+ // cannot mechanically converge (e.g. a workflow with no product key to preserve)
19
+ // is left for check-drift to flag, and a convention that genuinely breaks the
20
+ // product surfaces as a red lint/typecheck/test — not as a silent bad copy.
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import {
24
+ BEGIN,
25
+ type CanonicalBytes,
26
+ CTL_HASH_LINE,
27
+ CTL_VERSION_LINE,
28
+ END,
29
+ GITIGNORE_BEGIN,
30
+ GITIGNORE_END,
31
+ PRODUCT_LINE,
32
+ PRODUCT_SENTINEL,
33
+ splitAtMarkers,
34
+ } from "./check-drift.ts";
35
+
36
+ export interface SyncReport {
37
+ written: string[];
38
+ skipped: string[];
39
+ }
40
+
41
+ function writeIfChanged(path: string, content: string, rel: string, written: string[]): void {
42
+ const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
43
+ if (current !== content) {
44
+ writeFileSync(path, content);
45
+ written.push(rel);
46
+ }
47
+ }
48
+
49
+ // checks.yml / upgrade-latest.yml / sync-dev-kit.yml: canonical byte-for-byte
50
+ // except the one `product:` dispatch key, which the gate masks. Re-emit the
51
+ // canonical with the repo's own key filled back in. A workflow with no key to
52
+ // preserve (absent, or still the sentinel) is left for the gate to flag.
53
+ function syncWorkflow(repoRoot: string, rel: string, canonical: string, r: SyncReport): void {
54
+ const path = join(repoRoot, rel);
55
+ if (!existsSync(path)) {
56
+ r.skipped.push(`${rel} (absent)`);
57
+ return;
58
+ }
59
+ const key = readFileSync(path, "utf8").match(/^\s*product:\s*(\S+)/m)?.[1];
60
+ if (!key || key === PRODUCT_SENTINEL) {
61
+ r.skipped.push(`${rel} (no product: key to preserve)`);
62
+ return;
63
+ }
64
+ writeIfChanged(path, canonical.replace(PRODUCT_LINE, `$1${key}`), rel, r.written);
65
+ }
66
+
67
+ // flake.nix is verbatim except the dev-only ctl pin (ctlVersion + the four
68
+ // per-platform hashes), which floats per repo. Re-emit canonical STRUCTURE with
69
+ // the repo's pin restored in order — so a structural edit (e.g. a stray comment)
70
+ // is replaced while the pin is kept. Structure is identical across repos, so the
71
+ // Nth hash line in canonical maps to the Nth in the repo.
72
+ function syncFlake(repoRoot: string, canonical: string, r: SyncReport): void {
73
+ const path = join(repoRoot, "flake.nix");
74
+ if (!existsSync(path)) {
75
+ r.skipped.push("flake.nix (absent)");
76
+ return;
77
+ }
78
+ const existing = readFileSync(path, "utf8");
79
+ const version = existing.match(CTL_VERSION_LINE)?.[0];
80
+ const hashes = existing.match(CTL_HASH_LINE) ?? [];
81
+ let out = version ? canonical.replace(CTL_VERSION_LINE, version) : canonical;
82
+ let i = 0;
83
+ out = out.replace(CTL_HASH_LINE, (m) => hashes[i++] ?? m);
84
+ writeIfChanged(path, out, "flake.nix", r.written);
85
+ }
86
+
87
+ // Replace the region between two markers with the canonical block, preserving
88
+ // everything outside it. `canonicalBlock` starts at the BEGIN marker and ends at
89
+ // the END marker.
90
+ function replaceMarkerBlock(existing: string, begin: string, end: string, canonicalBlock: string): string | undefined {
91
+ const b = existing.indexOf(begin);
92
+ const e = existing.indexOf(end);
93
+ if (b === -1 || e === -1 || e < b) return undefined;
94
+ const after = existing.slice(e + end.length);
95
+ return existing.slice(0, b) + canonicalBlock.trimEnd() + (after.startsWith("\n") ? after : `\n${after}`);
96
+ }
97
+
98
+ // CLAUDE.md: the fenced conventions core lives between the markers; the repo's
99
+ // own intro/outro prose is outside and kept.
100
+ function syncClaude(repoRoot: string, core: string, r: SyncReport): void {
101
+ const path = join(repoRoot, "CLAUDE.md");
102
+ if (!existsSync(path)) {
103
+ r.skipped.push("CLAUDE.md (absent)");
104
+ return;
105
+ }
106
+ const out = replaceMarkerBlock(readFileSync(path, "utf8"), BEGIN, END, core);
107
+ if (out === undefined) {
108
+ r.skipped.push("CLAUDE.md (no marker block)");
109
+ return;
110
+ }
111
+ writeIfChanged(path, out, "CLAUDE.md", r.written);
112
+ }
113
+
114
+ // .gitignore: the shared block is marker-delimited; repo-specific ignores live
115
+ // outside it. Replace the block, or prepend it if the repo has none yet (the
116
+ // one-time migration case).
117
+ function syncGitignore(repoRoot: string, canonicalCore: string, r: SyncReport): void {
118
+ const path = join(repoRoot, ".gitignore");
119
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
120
+ const replaced = replaceMarkerBlock(existing, GITIGNORE_BEGIN, GITIGNORE_END, canonicalCore);
121
+ const out = replaced ?? `${canonicalCore.trimEnd()}\n${existing}`;
122
+ writeIfChanged(path, out, ".gitignore", r.written);
123
+ }
124
+
125
+ // dprint.json: canonical outside the repo-specific-excludes markers, the repo's
126
+ // own exclude entries kept between them.
127
+ function syncDprint(repoRoot: string, canonical: string, r: SyncReport): void {
128
+ const path = join(repoRoot, "dprint.json");
129
+ if (!existsSync(path)) {
130
+ r.skipped.push("dprint.json (absent)");
131
+ return;
132
+ }
133
+ const c = splitAtMarkers(canonical);
134
+ if (!c) throw new Error("canonical dprint.base.jsonc is missing its own marker block");
135
+ const a = splitAtMarkers(readFileSync(path, "utf8"));
136
+ if (!a) {
137
+ r.skipped.push("dprint.json (no marker block)");
138
+ return;
139
+ }
140
+ // splitAtMarkers keeps the file's trailing newline as an empty final line in
141
+ // suffix, so the join alone reproduces it — appending another would drift.
142
+ const out = [c.prefix, ...a.body, c.suffix].join("\n");
143
+ writeIfChanged(path, out, "dprint.json", r.written);
144
+ }
145
+
146
+ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncReport {
147
+ const r: SyncReport = { written: [], skipped: [] };
148
+
149
+ writeIfChanged(join(repoRoot, "biome.json"), canonical.biome, "biome.json", r.written);
150
+ writeIfChanged(join(repoRoot, "tsconfig.base.json"), canonical.tsconfigBase, "tsconfig.base.json", r.written);
151
+
152
+ syncWorkflow(repoRoot, ".github/workflows/checks.yml", canonical.checks, r);
153
+ syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", canonical.upgradeLatest, r);
154
+ syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
155
+
156
+ syncFlake(repoRoot, canonical.flake, r);
157
+ syncClaude(repoRoot, canonical.core, r);
158
+ syncGitignore(repoRoot, canonical.gitignoreCore, r);
159
+ syncDprint(repoRoot, canonical.dprint, r);
160
+
161
+ return r;
162
+ }
163
+
164
+ if (import.meta.main) {
165
+ const repoRoot = process.argv[2] ?? process.cwd();
166
+ const dir = import.meta.dir;
167
+ const canonical: CanonicalBytes = {
168
+ core: readFileSync(join(dir, "CLAUDE.core.md"), "utf8"),
169
+ flake: readFileSync(join(dir, "..", "build", "flake.nix"), "utf8"),
170
+ upgradeLatest: readFileSync(join(dir, "upgrade-latest.yml"), "utf8"),
171
+ syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
172
+ checks: readFileSync(join(dir, "checks.yml"), "utf8"),
173
+ biome: readFileSync(join(dir, "biome.base.json"), "utf8"),
174
+ tsconfigBase: readFileSync(join(dir, "tsconfig.base.json"), "utf8"),
175
+ dprint: readFileSync(join(dir, "dprint.base.jsonc"), "utf8"),
176
+ buildImageBootstrap: readFileSync(join(dir, "..", "build", "build-image.bootstrap.sh"), "utf8"),
177
+ gitignoreCore: readFileSync(join(dir, "gitignore.core"), "utf8"),
178
+ };
179
+ const report = syncDrift(repoRoot, canonical);
180
+ if (report.written.length === 0) {
181
+ console.log("sync-drift: all shared-convention copies already match @norskvideo/ctl-dev-kit.");
182
+ } else {
183
+ console.log(`sync-drift: rewrote ${report.written.length} copy(ies) from @norskvideo/ctl-dev-kit:`);
184
+ for (const w of report.written) console.log(` - ${w}`);
185
+ }
186
+ if (report.skipped.length > 0) {
187
+ console.log("left for check-drift / a human:");
188
+ for (const s of report.skipped) console.log(` - ${s}`);
189
+ }
190
+ }
@@ -192,11 +192,12 @@ jobs:
192
192
  run: echo "Nightly pins already current — nothing to bump."
193
193
 
194
194
  # Report the nightly bump result to the aggregated product CI dashboard
195
- # (id3as/ci-workflows). always() so a failed bump shows a sad pony; the summary
196
- # rides along as the cell detail.
195
+ # (id3as/ci-workflows). !cancelled() so a failed bump shows a sad pony (a
196
+ # cancelled/superseded run must not false-red the board); the summary rides
197
+ # along as the cell detail.
197
198
  notify:
198
199
  needs: bump
199
- if: always()
200
+ if: ${{ !cancelled() }}
200
201
  runs-on: x64
201
202
  steps:
202
203
  - uses: actions/checkout@v5
@@ -111,6 +111,7 @@ function rootPackageJson(ctx: ShapeContext): string {
111
111
  "test:unit": "bun test shared/src/ backend/src/ tests/unit/",
112
112
  "test:image": "bun test tests/image/",
113
113
  "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts",
114
+ "sync:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts",
114
115
  typecheck: "bunx tsc --noEmit -p shared && bunx tsc --noEmit -p backend && bunx tsc --noEmit -p .",
115
116
  build: "bun run lint && bun run typecheck && bun run build:no-lint",
116
117
  "build:no-lint": "bun run build:shared && bun run build:backend",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",