@norskvideo/ctl-dev-kit 0.2.63 → 0.2.65
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.
- package/build/flake.nix +1 -1
- package/build/flake.sdk-app.nix +2 -2
- package/build/refresh-ctl-pin.sh +1 -1
- package/conventions/CLAUDE.core.md +16 -7
- package/conventions/bot-prs.yml +91 -61
- package/conventions/build-docs.yml +27 -2
- package/conventions/build-image.yml +290 -22
- package/conventions/bump.sdk-app.yml +808 -0
- package/conventions/bump.yml +916 -0
- package/conventions/check-drift.ts +52 -60
- package/conventions/checks.sdk-app.yml +20 -1
- package/conventions/checks.yml +20 -1
- package/conventions/ctl-pin-agreement.ts +3 -4
- package/conventions/docs-site.md +3 -3
- package/conventions/integration-testing.md +1 -1
- package/conventions/integration.yml +31 -5
- package/conventions/publish-docs.yml +107 -12
- package/conventions/quarantine.ts +209 -0
- package/conventions/smoke.yml +27 -2
- package/conventions/sync-drift.ts +61 -24
- package/create-product/canon.ts +2 -6
- package/create-product/create-product.ts +1 -3
- package/create-product/turnkey.ts +2 -2
- package/doc-guide/engine-verdict.js +3 -3
- package/docs-theme/README.md +1 -1
- package/package.json +1 -1
- package/conventions/sync-ctl-packages.yml +0 -309
- package/conventions/sync-dev-kit.yml +0 -404
- package/conventions/upgrade-latest.sdk-app.yml +0 -456
- package/conventions/upgrade-latest.yml +0 -567
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Known-flaky tests, kept out of the gate but not out of the run (ci-fragility
|
|
4
|
+
// proposal E). A test that fails one run in three on a loaded runner turns the
|
|
5
|
+
// whole checks job red; a person re-runs it, and a real red hides among the
|
|
6
|
+
// flaky ones. Quarantine takes it out of the gating run, runs it on its own
|
|
7
|
+
// straight after, and reports the result without failing the job.
|
|
8
|
+
//
|
|
9
|
+
// The list is the repo's `.github/quarantine.json`, product-owned (not a
|
|
10
|
+
// drift-checked copy), so a reader on GitHub, the dashboard and the job all see
|
|
11
|
+
// the same thing. Every entry carries an owner, the date it went in and the
|
|
12
|
+
// condition for taking it out, and an entry older than STALE_DAYS is flagged on
|
|
13
|
+
// every run: quarantine is a holding pen, not a way to skip a test for ever.
|
|
14
|
+
//
|
|
15
|
+
// {"tests": [{"file": "tests/unit/x.test.ts", "name": "<test name>",
|
|
16
|
+
// "owner": "<who>", "since": "YYYY-MM-DD",
|
|
17
|
+
// "reason": "<what fails>", "exit": "<when it comes out>"}]}
|
|
18
|
+
//
|
|
19
|
+
// Run by its node_modules path from the checks quality job (conventions/
|
|
20
|
+
// checks.yml), like zod-singleton.ts:
|
|
21
|
+
// quarantine.ts check validate the list; exit 1 if it is malformed
|
|
22
|
+
// quarantine.ts bun-options the BUN_OPTIONS that leave the listed tests out
|
|
23
|
+
// quarantine.ts run run each listed test alone and report; exit 0
|
|
24
|
+
|
|
25
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
26
|
+
|
|
27
|
+
export const QUARANTINE_FILE = ".github/quarantine.json";
|
|
28
|
+
export const STALE_DAYS = 30;
|
|
29
|
+
|
|
30
|
+
export interface QuarantineEntry {
|
|
31
|
+
file: string;
|
|
32
|
+
name: string;
|
|
33
|
+
owner: string;
|
|
34
|
+
since: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
exit: string;
|
|
37
|
+
issue?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const REQUIRED = ["file", "name", "owner", "since", "reason", "exit"] as const;
|
|
41
|
+
|
|
42
|
+
export interface ParsedQuarantine {
|
|
43
|
+
entries: QuarantineEntry[];
|
|
44
|
+
errors: string[];
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DAY_MS = 86_400_000;
|
|
49
|
+
const isDate = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`));
|
|
50
|
+
|
|
51
|
+
/** `text` is the file's contents, undefined when the repo has none. */
|
|
52
|
+
export function parseQuarantine(
|
|
53
|
+
text: string | undefined,
|
|
54
|
+
opts: { today: string; exists: (file: string) => boolean },
|
|
55
|
+
): ParsedQuarantine {
|
|
56
|
+
const out: ParsedQuarantine = { entries: [], errors: [], warnings: [] };
|
|
57
|
+
if (text === undefined) return out;
|
|
58
|
+
let doc: unknown;
|
|
59
|
+
try {
|
|
60
|
+
doc = JSON.parse(text);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
out.errors.push(`${QUARANTINE_FILE} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
const tests = (doc as { tests?: unknown } | null)?.tests;
|
|
66
|
+
if (!Array.isArray(tests)) {
|
|
67
|
+
out.errors.push(`${QUARANTINE_FILE} has no "tests" array`);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
tests.forEach((raw, i) => {
|
|
72
|
+
const e = (raw ?? {}) as Record<string, unknown>;
|
|
73
|
+
const at = `${QUARANTINE_FILE} tests[${i}]`;
|
|
74
|
+
const missing = REQUIRED.filter((f) => typeof e[f] !== "string" || (e[f] as string).trim() === "");
|
|
75
|
+
if (missing.length > 0) {
|
|
76
|
+
out.errors.push(
|
|
77
|
+
`${at}: missing ${missing.join(", ")} (every entry needs an owner, a date and an exit condition)`,
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const entry = e as unknown as QuarantineEntry;
|
|
82
|
+
if (!isDate(entry.since)) {
|
|
83
|
+
out.errors.push(`${at}: since "${entry.since}" is not a YYYY-MM-DD date`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const age = Math.round((Date.parse(`${opts.today}T00:00:00Z`) - Date.parse(`${entry.since}T00:00:00Z`)) / DAY_MS);
|
|
87
|
+
if (age < 0) {
|
|
88
|
+
out.errors.push(`${at}: since ${entry.since} is in the future`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!opts.exists(entry.file)) {
|
|
92
|
+
out.errors.push(`${at}: ${entry.file} is not in the repo; drop the entry or fix the path`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const key = `${entry.file}\0${entry.name}`;
|
|
96
|
+
if (seen.has(key)) {
|
|
97
|
+
out.errors.push(`${at}: "${entry.name}" is listed twice`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
seen.add(key);
|
|
101
|
+
if (age > STALE_DAYS) {
|
|
102
|
+
out.warnings.push(
|
|
103
|
+
`"${entry.name}" has been quarantined since ${entry.since} (${age} days, owner ${entry.owner}): fix it and take it out, or delete it`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
out.entries.push(entry);
|
|
107
|
+
});
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
112
|
+
|
|
113
|
+
/** A regex matching the one test, wherever its describe blocks put it. */
|
|
114
|
+
export function namePattern(name: string): string {
|
|
115
|
+
return `${escapeRegex(name)}$`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** BUN_OPTIONS for the gating run: every test except the listed ones. Bun
|
|
119
|
+
* splits BUN_OPTIONS on whitespace and has no exclude-by-name flag, so it is
|
|
120
|
+
* one negative lookahead with each space written as \s. */
|
|
121
|
+
export function bunOptions(entries: readonly QuarantineEntry[]): string {
|
|
122
|
+
if (entries.length === 0) return "";
|
|
123
|
+
const names = entries.map((e) => namePattern(e.name).replace(/\s/g, "\\s")).join("|");
|
|
124
|
+
return `--test-name-pattern=^(?!.*(?:${names})).*`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type RunResult = "passed" | "failed" | "missing";
|
|
128
|
+
|
|
129
|
+
/** A run that matched nothing means the test was renamed or deleted, and the
|
|
130
|
+
* entry is stale; that is not a pass, whatever the exit code. */
|
|
131
|
+
export function classifyRun(exitCode: number, output: string): RunResult {
|
|
132
|
+
if (/matched 0 tests/.test(output) || /Ran 0 tests/.test(output)) return "missing";
|
|
133
|
+
return exitCode === 0 ? "passed" : "failed";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function summary(results: readonly { entry: QuarantineEntry; result: RunResult }[]): string {
|
|
137
|
+
const lines = [
|
|
138
|
+
`### Quarantined tests (run and reported, not gating)`,
|
|
139
|
+
"",
|
|
140
|
+
`From \`${QUARANTINE_FILE}\`. A failure here does not fail the job; it is the flake the entry is waiting on.`,
|
|
141
|
+
"",
|
|
142
|
+
"| Result | Test | File | Owner | Since | Exit condition |",
|
|
143
|
+
"|---|---|---|---|---|---|",
|
|
144
|
+
];
|
|
145
|
+
for (const { entry, result } of results) {
|
|
146
|
+
const cell = (s: string) => s.replace(/\|/g, "\\|");
|
|
147
|
+
lines.push(
|
|
148
|
+
`| ${result} | ${cell(entry.name)} | \`${entry.file}\` | ${cell(entry.owner)} | ${entry.since} | ${cell(entry.exit)} |`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return `${lines.join("\n")}\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function load(): ParsedQuarantine {
|
|
155
|
+
const text = existsSync(QUARANTINE_FILE) ? readFileSync(QUARANTINE_FILE, "utf8") : undefined;
|
|
156
|
+
return parseQuarantine(text, { today: new Date().toISOString().slice(0, 10), exists: existsSync });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function report(parsed: ParsedQuarantine): void {
|
|
160
|
+
for (const w of parsed.warnings) console.log(`::warning title=quarantine::${w}`);
|
|
161
|
+
for (const e of parsed.errors) console.log(`::error title=quarantine::${e}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function main(cmd: string | undefined): Promise<number> {
|
|
165
|
+
const parsed = load();
|
|
166
|
+
if (cmd === "check") {
|
|
167
|
+
report(parsed);
|
|
168
|
+
console.log(`${parsed.entries.length} quarantined test(s) in ${QUARANTINE_FILE}`);
|
|
169
|
+
return parsed.errors.length > 0 ? 1 : 0;
|
|
170
|
+
}
|
|
171
|
+
if (cmd === "bun-options") {
|
|
172
|
+
if (parsed.errors.length > 0) return 1;
|
|
173
|
+
process.stdout.write(bunOptions(parsed.entries));
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
if (cmd === "run") {
|
|
177
|
+
report(parsed);
|
|
178
|
+
const results: { entry: QuarantineEntry; result: RunResult }[] = [];
|
|
179
|
+
for (const entry of parsed.entries) {
|
|
180
|
+
console.log(`== quarantined: ${entry.name} (${entry.file})`);
|
|
181
|
+
const proc = Bun.spawn(["bun", "test", entry.file, `--test-name-pattern=${namePattern(entry.name)}`], {
|
|
182
|
+
stdout: "pipe",
|
|
183
|
+
stderr: "pipe",
|
|
184
|
+
});
|
|
185
|
+
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
186
|
+
const code = await proc.exited;
|
|
187
|
+
process.stdout.write(out);
|
|
188
|
+
process.stdout.write(err);
|
|
189
|
+
const result = classifyRun(code, `${out}${err}`);
|
|
190
|
+
results.push({ entry, result });
|
|
191
|
+
if (result !== "passed") {
|
|
192
|
+
console.log(`::warning title=quarantined test ${result} (not gating)::${entry.name} (${entry.file})`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const failed = results.filter((r) => r.result !== "passed").length;
|
|
196
|
+
if (process.env.GITHUB_STEP_SUMMARY && results.length > 0) {
|
|
197
|
+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary(results));
|
|
198
|
+
}
|
|
199
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
200
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `quarantined=${results.length}\nquarantine_failed=${failed}\n`);
|
|
201
|
+
}
|
|
202
|
+
console.log(`${results.length} quarantined test(s) run, ${failed} not passing (not gating)`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
console.error("usage: quarantine.ts check | bun-options | run");
|
|
206
|
+
return 2;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (import.meta.main) process.exit(await main(process.argv[2]));
|
package/conventions/smoke.yml
CHANGED
|
@@ -193,12 +193,37 @@ jobs:
|
|
|
193
193
|
NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
|
|
194
194
|
run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
|
|
195
195
|
|
|
196
|
+
# Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
|
|
197
|
+
# told not to, and nothing removes it: it is a node singleton, and the reap
|
|
198
|
+
# step only takes containers that mount this job's test-temp. So the first
|
|
199
|
+
# test daemon on a shared runner left one, and every later one adopted it
|
|
200
|
+
# (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
|
|
201
|
+
# to it). This job's daemons run without the node relay and discovery, so
|
|
202
|
+
# they neither start nor adopt one, and whatever is already on the host is
|
|
203
|
+
# named here. It reports and never fails: the leftover may be someone's.
|
|
204
|
+
- name: Isolate this job from node singletons other jobs left on this host
|
|
205
|
+
run: |
|
|
206
|
+
set -uo pipefail
|
|
207
|
+
echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
|
|
208
|
+
echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
|
|
209
|
+
left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
|
|
210
|
+
if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
|
|
211
|
+
echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
|
|
212
|
+
echo "$left"
|
|
213
|
+
while IFS= read -r line; do
|
|
214
|
+
role="$(awk '{print $2}' <<<"$line")"
|
|
215
|
+
case "$role" in
|
|
216
|
+
moq-relay|discovery)
|
|
217
|
+
echo "::warning title=node singleton left on this host::$line -- left by an earlier job (a daemon's auto-start, or a run killed before its reap). This job's daemons run without it and will not adopt it; remove it with docker rm -f if nothing on this host needs it." ;;
|
|
218
|
+
esac
|
|
219
|
+
done <<<"$left"
|
|
220
|
+
|
|
196
221
|
# Pick the binary for THIS runner's architecture: the GPU box is aarch64,
|
|
197
222
|
# the x64 pool is x86_64, and the wrong one dies with "Exec format error"
|
|
198
223
|
# deep in the suite. No `|| true` on --version: a bad binary must fail here.
|
|
199
224
|
#
|
|
200
225
|
# WHICH binary: the version this commit pins, flake.nix ctlVersion -- the
|
|
201
|
-
# daemon the dev shell launches, moved by
|
|
226
|
+
# daemon the dev shell launches, moved by bump.yml's nightly PR. It used
|
|
202
227
|
# to be S3's moving `latest`, read at run time, so a result could change
|
|
203
228
|
# with no product commit and a re-run was never a real repeat (funke went
|
|
204
229
|
# red and green again on 2026-10-09 without a commit either time;
|
|
@@ -221,7 +246,7 @@ jobs:
|
|
|
221
246
|
echo "norsk-ctl $CTL_CHANNEL channel -> $ver ($arch), in place of the repo pin"
|
|
222
247
|
else
|
|
223
248
|
ver="$( { sed -nE 's/^[[:space:]]*ctlVersion = "([^"]+)";.*/\1/p' flake.nix 2>/dev/null || true; } | head -1)"
|
|
224
|
-
[ -n "$ver" ] || { echo "::error::flake.nix pins no ctlVersion. CI runs the norsk-ctl this commit pins (
|
|
249
|
+
[ -n "$ver" ] || { echo "::error::flake.nix pins no ctlVersion. CI runs the norsk-ctl this commit pins (the nightly bump.yml moves it); add ctlVersion = \"<version>\"; to flake.nix."; exit 1; }
|
|
225
250
|
echo "norsk-ctl pinned by flake.nix ctlVersion -> $ver ($arch)"
|
|
226
251
|
fi
|
|
227
252
|
curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-$arch" -o "$RUNNER_TEMP/norsk-ctl"
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
// It only touches copies whose convergent form is mechanical — there is no
|
|
8
8
|
// judgement anywhere:
|
|
9
9
|
// - verbatim (biome.json, tsconfig.base.json) -> overwrite with canonical
|
|
10
|
-
// - masked workflows (checks /
|
|
10
|
+
// - masked workflows (checks / bump / -> canonical, repo's product: key kept
|
|
11
11
|
// publish-docs)
|
|
12
|
+
// - bump.yml -> CREATED where absent, under the key the
|
|
13
|
+
// retired bump bots carried; then those
|
|
14
|
+
// three (upgrade-latest / sync-dev-kit /
|
|
15
|
+
// sync-ctl-packages) are DELETED
|
|
12
16
|
// - nix/ctl-shell.nix -> overwrite with canonical (created if absent)
|
|
13
17
|
// - flake.nix (legacy verbatim copy only) -> the starter, repo's floating ctl pin kept
|
|
14
18
|
// - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest
|
|
@@ -22,11 +26,12 @@
|
|
|
22
26
|
// cannot mechanically converge (e.g. a workflow with no product key to preserve)
|
|
23
27
|
// is left for check-drift to flag, and a convention that genuinely breaks the
|
|
24
28
|
// product surfaces as a red lint/typecheck/test — not as a silent bad copy.
|
|
25
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
26
30
|
import { dirname, join } from "node:path";
|
|
27
31
|
import { checkTheme, syncTheme } from "../docs-theme/sync.ts";
|
|
28
32
|
import {
|
|
29
33
|
BEGIN,
|
|
34
|
+
BUMP_WORKFLOW,
|
|
30
35
|
type CanonicalBytes,
|
|
31
36
|
CTL_HASH_LINE,
|
|
32
37
|
CTL_SHELL_PATH,
|
|
@@ -43,6 +48,7 @@ import {
|
|
|
43
48
|
PRODUCT_SENTINEL,
|
|
44
49
|
type ProductShape,
|
|
45
50
|
PUBLISH_IMAGE_LINE,
|
|
51
|
+
RETIRED_BUMP_WORKFLOWS,
|
|
46
52
|
RUNNER_LINE,
|
|
47
53
|
resolveRepoName,
|
|
48
54
|
resolveShape,
|
|
@@ -69,7 +75,7 @@ function writeIfChanged(path: string, content: string, rel: string, written: str
|
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
// checks.yml /
|
|
78
|
+
// checks.yml / publish-docs.yml: canonical byte-for-byte
|
|
73
79
|
// except the one `product:` dispatch key, which the gate masks. Re-emit the
|
|
74
80
|
// canonical with the repo's own key filled back in. A workflow with no key to
|
|
75
81
|
// preserve (absent, or still the sentinel) is left for the gate to flag.
|
|
@@ -87,6 +93,48 @@ function syncWorkflow(repoRoot: string, rel: string, canonical: string, r: SyncR
|
|
|
87
93
|
writeIfChanged(path, canonical.replace(PRODUCT_LINE, `$1${key}`), rel, r.written);
|
|
88
94
|
}
|
|
89
95
|
|
|
96
|
+
const productKey = (path: string): string | undefined => {
|
|
97
|
+
if (!existsSync(path)) return undefined;
|
|
98
|
+
const key = readFileSync(path, "utf8").match(/^\s*product:\s*(\S+)/m)?.[1];
|
|
99
|
+
return key && key !== PRODUCT_SENTINEL ? key : undefined;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// bump.yml, the one nightly bump (ci-fragility proposal B2), and the end of
|
|
103
|
+
// the three bots it replaced. This is how it reaches a product: the old
|
|
104
|
+
// sync-dev-kit.yml installs a dev-kit that has it and runs this writer, which
|
|
105
|
+
// creates bump.yml under the dashboard key the old bots reported under and
|
|
106
|
+
// deletes them -- sync-dev-kit.yml included, in the very PR it is opening. The
|
|
107
|
+
// old bot-prs on main still triages that PR (its branch is sync-dev-kit/*),
|
|
108
|
+
// and the new integration and build-image it brings still run on that branch,
|
|
109
|
+
// so the transition lands like any other bump. Once landed, bump.yml owns the
|
|
110
|
+
// night and the old three are gone.
|
|
111
|
+
//
|
|
112
|
+
// The key comes from bump.yml itself once it has one; before that from
|
|
113
|
+
// upgrade-latest.yml (the nightly column's reporter), sync-dev-kit.yml, and
|
|
114
|
+
// last checks.yml (required, always filled). With no key at all nothing is
|
|
115
|
+
// created and nothing removed: deleting the old bots before their replacement
|
|
116
|
+
// exists would leave the product with no nightly bump, silently. The gate then
|
|
117
|
+
// names the missing bump.yml.
|
|
118
|
+
function syncBump(repoRoot: string, canonical: string, r: SyncReport): void {
|
|
119
|
+
const wf = (rel: string) => join(repoRoot, rel);
|
|
120
|
+
const key =
|
|
121
|
+
productKey(wf(BUMP_WORKFLOW)) ??
|
|
122
|
+
productKey(wf(".github/workflows/upgrade-latest.yml")) ??
|
|
123
|
+
productKey(wf(".github/workflows/sync-dev-kit.yml")) ??
|
|
124
|
+
productKey(wf(".github/workflows/checks.yml"));
|
|
125
|
+
if (key === undefined) {
|
|
126
|
+
r.skipped.push(`${BUMP_WORKFLOW} (no product: key to give it; the retired bump workflows are left in place)`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
mkdirSync(dirname(wf(BUMP_WORKFLOW)), { recursive: true });
|
|
130
|
+
writeIfChanged(wf(BUMP_WORKFLOW), canonical.replace(PRODUCT_LINE, `$1${key}`), BUMP_WORKFLOW, r.written);
|
|
131
|
+
for (const rel of RETIRED_BUMP_WORKFLOWS) {
|
|
132
|
+
if (!existsSync(wf(rel))) continue;
|
|
133
|
+
rmSync(wf(rel));
|
|
134
|
+
r.written.push(rel);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
90
138
|
// publish-docs.yml: optional, and masked like checks.yml (only re-synced where
|
|
91
139
|
// the repo already carries it). Every copy predating the mask was written
|
|
92
140
|
// verbatim with the sentinel, so a copy with no key of its own takes the one
|
|
@@ -307,7 +355,7 @@ function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): vo
|
|
|
307
355
|
// carry one. It is CREATED only where the gate would accept it: a repo with a
|
|
308
356
|
// site (there is something to publish), named, and not a turnkey (a turnkey has
|
|
309
357
|
// no public site, ADR-0012). That is how a product that grows a site acquires
|
|
310
|
-
// its publish: the next
|
|
358
|
+
// its publish: the next nightly bump installs it, green until the Pages
|
|
311
359
|
// project exists. A name this writer cannot determine creates nothing, since
|
|
312
360
|
// the gate could only refuse the result.
|
|
313
361
|
const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
|
|
@@ -338,7 +386,7 @@ function syncPublishDocsSite(repoRoot: string, canonical: string, repoName: stri
|
|
|
338
386
|
// Only for a repo that HAS a site: sync never creates one (a site is a nested
|
|
339
387
|
// standalone package with its own install, not a file this writer can conjure),
|
|
340
388
|
// it only keeps an existing one's copies in step. That is what makes a theme
|
|
341
|
-
// change roll out through
|
|
389
|
+
// change roll out through bump.yml like any other convention.
|
|
342
390
|
function syncDocsTheme(repoRoot: string, r: SyncReport): void {
|
|
343
391
|
if (!hasDocsSite(repoRoot)) {
|
|
344
392
|
r.skipped.push(`${DOCS_SITE_DIR}/ docs theme (no docs site)`);
|
|
@@ -372,21 +420,12 @@ export function syncDrift(
|
|
|
372
420
|
writeIfChanged(join(repoRoot, "tsconfig.base.json"), canonical.tsconfigBase, "tsconfig.base.json", r.written);
|
|
373
421
|
|
|
374
422
|
syncWorkflow(repoRoot, ".github/workflows/checks.yml", checksCanon, r);
|
|
375
|
-
//
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
// absent, unlike syncWorkflow's copies. That is how a repo acquires the
|
|
382
|
-
// workflow at all: its next sync run installs it.
|
|
383
|
-
writeIfChanged(
|
|
384
|
-
join(repoRoot, ".github/workflows/sync-ctl-packages.yml"),
|
|
385
|
-
canonical.syncCtlPackages,
|
|
386
|
-
".github/workflows/sync-ctl-packages.yml",
|
|
387
|
-
r.written,
|
|
388
|
-
);
|
|
389
|
-
// bot-prs.yml likewise: verbatim, and created where absent.
|
|
423
|
+
// bump.yml: an sdk-app has no Studio pair, so it has its own variant.
|
|
424
|
+
const bumpCanon = shape === "sdk-app" ? canonical.bumpSdkApp : canonical.bump;
|
|
425
|
+
if (bumpCanon === undefined) throw new Error(`syncDrift: shape "${shape}" requires canonical.bumpSdkApp`);
|
|
426
|
+
syncBump(repoRoot, bumpCanon, r);
|
|
427
|
+
// bot-prs.yml: no per-repo line to preserve, so it is written whole -- and
|
|
428
|
+
// CREATED where absent. That is how a repo acquires it: its next sync installs it.
|
|
390
429
|
writeIfChanged(
|
|
391
430
|
join(repoRoot, ".github/workflows/bot-prs.yml"),
|
|
392
431
|
canonical.botPrs,
|
|
@@ -428,9 +467,7 @@ if (import.meta.main) {
|
|
|
428
467
|
core: readFileSync(join(dir, "CLAUDE.core.md"), "utf8"),
|
|
429
468
|
flake: readFileSync(join(dir, "..", "build", "flake.nix"), "utf8"),
|
|
430
469
|
ctlShell: readFileSync(join(dir, "..", "build", "ctl-shell.nix"), "utf8"),
|
|
431
|
-
|
|
432
|
-
syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
|
|
433
|
-
syncCtlPackages: readFileSync(join(dir, "sync-ctl-packages.yml"), "utf8"),
|
|
470
|
+
bump: readFileSync(join(dir, "bump.yml"), "utf8"),
|
|
434
471
|
botPrs: readFileSync(join(dir, "bot-prs.yml"), "utf8"),
|
|
435
472
|
publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
|
|
436
473
|
buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
|
|
@@ -448,7 +485,7 @@ if (import.meta.main) {
|
|
|
448
485
|
demoShim: readFileSync(join(dir, "demo.sh"), "utf8"),
|
|
449
486
|
flakeSdkApp: readFileSync(join(dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
|
|
450
487
|
checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"),
|
|
451
|
-
|
|
488
|
+
bumpSdkApp: readFileSync(join(dir, "bump.sdk-app.yml"), "utf8"),
|
|
452
489
|
};
|
|
453
490
|
const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot), { repoName: resolveRepoName(repoRoot) });
|
|
454
491
|
if (report.written.length === 0) {
|
package/create-product/canon.ts
CHANGED
|
@@ -18,9 +18,7 @@ export interface Canon {
|
|
|
18
18
|
/** build/ctl-shell.nix, emitted as nix/ctl-shell.nix — the flake imports it. */
|
|
19
19
|
ctlShell: string;
|
|
20
20
|
flakeLock: string;
|
|
21
|
-
|
|
22
|
-
syncDevKit: string;
|
|
23
|
-
syncCtlPackages: string;
|
|
21
|
+
bump: string;
|
|
24
22
|
botPrs: string;
|
|
25
23
|
checks: string;
|
|
26
24
|
biome: string;
|
|
@@ -45,9 +43,7 @@ export function loadCanon(): Canon {
|
|
|
45
43
|
flake: readFileSync(join(buildDir, "flake.nix"), "utf8"),
|
|
46
44
|
ctlShell: readFileSync(join(buildDir, "ctl-shell.nix"), "utf8"),
|
|
47
45
|
flakeLock: readFileSync(join(buildDir, "flake.lock"), "utf8"),
|
|
48
|
-
|
|
49
|
-
syncDevKit: readFileSync(join(conventionsDir, "sync-dev-kit.yml"), "utf8"),
|
|
50
|
-
syncCtlPackages: readFileSync(join(conventionsDir, "sync-ctl-packages.yml"), "utf8"),
|
|
46
|
+
bump: readFileSync(join(conventionsDir, "bump.yml"), "utf8"),
|
|
51
47
|
botPrs: readFileSync(join(conventionsDir, "bot-prs.yml"), "utf8"),
|
|
52
48
|
checks: readFileSync(join(conventionsDir, "checks.yml"), "utf8"),
|
|
53
49
|
buildDocs: readFileSync(join(conventionsDir, "build-docs.yml"), "utf8"),
|
|
@@ -220,9 +220,7 @@ function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[]
|
|
|
220
220
|
},
|
|
221
221
|
{ path: ".gitignore", content: `${canon.gitignoreCore}\n${shape.gitignoreTail(ctx)}` },
|
|
222
222
|
{ path: ".github/workflows/checks.yml", content: fillProduct(canon.checks) },
|
|
223
|
-
{ path: ".github/workflows/
|
|
224
|
-
{ path: ".github/workflows/sync-dev-kit.yml", content: fillProduct(canon.syncDevKit) },
|
|
225
|
-
{ path: ".github/workflows/sync-ctl-packages.yml", content: canon.syncCtlPackages },
|
|
223
|
+
{ path: ".github/workflows/bump.yml", content: fillProduct(canon.bump) },
|
|
226
224
|
{ path: ".github/workflows/bot-prs.yml", content: canon.botPrs },
|
|
227
225
|
{ path: ".github/workflows/build-image.yml", content: fillProduct(canon.buildImage) },
|
|
228
226
|
{ path: ".github/actions/ci-status-dispatch/action.yml", content: loadAsset("ci-status-dispatch.yml") },
|
|
@@ -228,7 +228,7 @@ machine-writable source of truth — RFC 0001 Workstream C) via
|
|
|
228
228
|
\`shared/src/version.ts\`, which validates the seed through the SDK's
|
|
229
229
|
\`parseManifestSeed\` at module load. \`version.test.ts\` fails if the seed and
|
|
230
230
|
the \`norsk-sdk\`/\`norsk-studio*\` npm pins in \`shared/package.json\` drift
|
|
231
|
-
apart — the shared \`
|
|
231
|
+
apart — the shared \`bump.yml\` bumps them all in lockstep nightly.
|
|
232
232
|
|
|
233
233
|
## The stored-template trap
|
|
234
234
|
|
|
@@ -537,7 +537,7 @@ describe("npm pins stay coupled to the seed", () => {
|
|
|
537
537
|
// the studio npm packages and the studio Docker image from different commits,
|
|
538
538
|
// so the image rides the newest published Docker Hub tag on the same
|
|
539
539
|
// major.minor.patch line as the npm nightly (exactly how the shared
|
|
540
|
-
//
|
|
540
|
+
// bump.yml advances the pair).
|
|
541
541
|
it("studio image stays on the npm pin's major.minor.patch line", () => {
|
|
542
542
|
expect(STUDIO_IMAGE_TAG.split("-")[0]).toBe(studioNpmPin.split("-")[0]);
|
|
543
543
|
});
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* product's scripts/doc-guide/regen-manual.sh asks this, from the tier's own
|
|
6
6
|
* output, instead of "are the capture files on disk": that rule passed a
|
|
7
7
|
* composition guide that hung after two captures (2026-10-03) and passed any
|
|
8
|
-
* guide whose assertions failed after its captures were written. The
|
|
9
|
-
* tier
|
|
10
|
-
*
|
|
8
|
+
* guide whose assertions failed after its captures were written. The
|
|
9
|
+
* conventions' engine-tier step (build-image.yml guides-engine, publish-docs.yml)
|
|
10
|
+
* asks it the same way; that tier reports but does not gate (norsk-ctl task 57).
|
|
11
11
|
*
|
|
12
12
|
* Forgiven: every result line a test passed, apart from unnamed hook failures
|
|
13
13
|
* in guides that also passed a test. Anything else -- a named test failing, a
|
package/docs-theme/README.md
CHANGED
|
@@ -85,7 +85,7 @@ a class attribute on every page of the site.
|
|
|
85
85
|
site: `hasDocsSite()` tests for `website/astro.config.mjs`, and
|
|
86
86
|
`docsThemeProblems()` routes this directory's `checkTheme` into the same report as
|
|
87
87
|
`biome.json` and the shared workflows. `conventions/sync-drift.ts` is the writer
|
|
88
|
-
half, so `
|
|
88
|
+
half, so `bump.yml` rolls a theme change out to every product like any
|
|
89
89
|
other convention, through a bot PR that `bot-prs.yml` merges once its tiers pass. The site directory is `website/` across the fleet
|
|
90
90
|
(`DOCS_SITE_DIR`).
|
|
91
91
|
|