返回 CodeWhale
release-workflows.test.js
根目录 / .github / scripts / release-workflows.test.js
1 #!/usr/bin/env node
2
3 const assert = require("node:assert/strict");
4 const fs = require("node:fs");
5 const path = require("node:path");
6 const vm = require("node:vm");
7 const { execFileSync } = require("node:child_process");
8
9 const repoRoot = path.resolve(__dirname, "..", "..");
10 const {
11 allAssetNames,
12 allReleaseAssetNames,
13 BUNDLE_ASSET_NAMES,
14 LEGACY_TUI_BRIDGE_ASSET_NAMES,
15 } = require(path.join(repoRoot, "npm", "codewhale", "scripts", "artifacts"));
16
17 function read(relativePath) {
18 return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
19 }
20
21 function valuesForKey(source, key) {
22 const expression = new RegExp(`^\\s+${key}:\\s+([^#\\s]+)\\s*$`, "gm");
23 return [...source.matchAll(expression)].map((match) => match[1]);
24 }
25
26 function namedStep(source, name) {
27 const marker = ` - name: ${name}\n`;
28 const start = source.indexOf(marker);
29 assert.notEqual(start, -1, `missing workflow step: ${name}`);
30 const next = source.indexOf("\n - ", start + marker.length);
31 return source.slice(start, next === -1 ? source.length : next);
32 }
33
34 const ci = read(".github/workflows/ci.yml");
35 const nightly = read(".github/workflows/nightly.yml");
36 const candidate = read(".github/workflows/release-candidate.yml");
37 const artifacts = read(".github/workflows/release-artifacts.yml");
38 const release = read(".github/workflows/release.yml");
39 const parityWorkflow = read(".github/workflows/release-parity.yml");
40 const republish = read(".github/workflows/release-republish.yml");
41 const releaseDockerfile = read("packaging/docker/Dockerfile.release");
42 const cnb = read(".cnb.yml");
43 const cnbSync = read(".github/workflows/sync-cnb.yml");
44 const bundles = read("scripts/release/create-release-bundles.sh");
45 const archiveInstaller = read("scripts/release/install.sh");
46 const cliDispatcher = read("crates/cli/src/lib.rs");
47 const runbook = read("docs/RELEASE_RUNBOOK.md");
48
49 const ciTestJob = ci.slice(ci.indexOf("\n test:\n"));
50 assert.match(
51 ciTestJob,
52 /matrix\.os == 'ubuntu-latest'.*github\.event_name == 'pull_request'/,
53 "heavy pull requests must select the real Ubuntu test lane",
54 );
55 assert.match(
56 ciTestJob,
57 /cargo nextest run --workspace --all-features --locked --profile ci/,
58 "the Ubuntu pull-request lane must run workspace nextest",
59 );
60 assert.match(
61 ciTestJob,
62 /name: Linux test location \(CNB\)/,
63 "the non-PR CNB fallback must be named explicitly",
64 );
65
66 assert.match(
67 namedStep(ciTestJob, "Build canonical executable for acceptance tests"),
68 /cargo build -p codewhale-cli --bin codewhale --all-features --locked/,
69 "Engine acceptance must build the canonical executable first",
70 );
71 const npmSmokeJob = ci.match(/^ npm-wrapper-smoke:\n([\s\S]*?)(?=^ \S)/m)?.[1];
72 assert.ok(npmSmokeJob, "CI must retain the required npm-wrapper job");
73 assert.match(
74 namedStep(npmSmokeJob, "Build wrapper binaries"),
75 /run: cargo build --release --locked -p codewhale-cli --bin codewhale/,
76 );
77 assert.match(
78 namedStep(npmSmokeJob, "Smoke wrapper install and delegated entrypoints"),
79 /run: node scripts\/release\/npm-wrapper-smoke\.js/,
80 );
81 const npmSmokeSteps = npmSmokeJob.split(/(?=^ - )/m).slice(1);
82 // Exercise the workflow's actual Boolean guards. A successful location echo
83 // must never substitute for the build/install smoke on a heavy pull request.
84 const npmSmokeCases = [
85 // name, event, heavy, OS, trusted, cache success, execute, Linux deps, CNB
86 ["own PR", "pull_request", true, "ubuntu-latest", true, true, true, true, false],
87 ["fork PR", "pull_request", true, "ubuntu-latest", false, true, true, true, false],
88 ["PR cache failure", "pull_request", true, "ubuntu-latest", false, false, true, true, false],
89 ["light PR", "pull_request", false, "ubuntu-latest", true, true, false, false, false],
90 ["manual Ubuntu", "workflow_dispatch", true, "ubuntu-latest", true, true, true, true, false],
91 ["main Ubuntu", "push", true, "ubuntu-latest", true, true, false, false, true],
92 ["manual macOS", "workflow_dispatch", true, "macos-latest", true, true, true, false, false],
93 ["main Windows", "push", true, "windows-latest", true, true, true, false, false],
94 ["main cache failure", "push", true, "windows-latest", true, false, true, false, false],
95 ["light main", "push", false, "ubuntu-latest", true, true, false, false, false],
96 ["schedule", "schedule", true, "ubuntu-latest", true, true, false, false, false],
97 ];
98 // The sccache GitHub Actions backend is main-only, mirroring rust-cache's
99 // save-if: pull requests never install or enable it (cache bloat, PLAN D).
100 const sccacheInstallStep = "mozilla-actions/sccache-action@v0.0.11";
101 for (const [label, event, heavy, os, trusted, cache, execute, linuxDeps, cnb] of npmSmokeCases) {
102 const ref = event === "pull_request" ? "refs/pull/1/merge" : "refs/heads/main";
103 const onMain = ref === "refs/heads/main";
104 const installed = execute && onMain;
105 const context = {
106 needs: { changes: { outputs: { heavy: String(heavy), trusted: String(trusted) } } },
107 github: { event_name: event, ref },
108 matrix: { os },
109 steps: { sccache: { outcome: installed ? (cache ? "success" : "failure") : "skipped" } },
110 };
111 const jobGuard = npmSmokeJob.match(/^ if: (.+)$/m)?.[1];
112 assert.ok(jobGuard, "the wrapper job must retain its event guard");
113 const jobEnabled = vm.runInNewContext(jobGuard, context);
114 for (const step of npmSmokeSteps) {
115 const name = step.match(/^ - (?:name|uses): (.+)$/m)?.[1];
116 const guard = step.match(/^ if: (.+)$/m)?.[1];
117 assert.ok(name && guard, "every wrapper step must have an explicit guard");
118 let expected = execute;
119 if (name === "Skip npm wrapper smoke for light change") expected = !heavy;
120 else if (name === "Install Linux system dependencies") expected = linuxDeps;
121 else if (name === "Linux smoke location") expected = cnb;
122 else if (name === sccacheInstallStep) expected = installed;
123 else if (name === "Enable sccache" || name === "sccache stats") expected = installed && cache;
124 assert.equal(
125 Boolean(jobEnabled && vm.runInNewContext(guard, context)),
126 expected,
127 `${label}: ${name} must ${expected ? "execute" : "stay skipped"}`,
128 );
129 }
130 }
131 // The matrix itself: pull requests keep the single required Ubuntu context,
132 // main pushes skip the hosted-macOS leg (5-job concurrency cap), and a manual
133 // full-CI dispatch still covers all three platforms.
134 const npmSmokeMatrix = npmSmokeJob.match(/^ os: \$\{\{ fromJSON\((.+)\) \}\}$/m)?.[1];
135 assert.ok(npmSmokeMatrix, "the wrapper job must keep an event-keyed OS matrix");
136 for (const [event, expected] of [
137 ["pull_request", ["ubuntu-latest"]],
138 ["push", ["ubuntu-latest", "windows-latest"]],
139 ["workflow_dispatch", ["ubuntu-latest", "macos-latest", "windows-latest"]],
140 ]) {
141 assert.deepEqual(
142 JSON.parse(vm.runInNewContext(npmSmokeMatrix, { github: { event_name: event } })),
143 expected,
144 `npm wrapper smoke matrix for ${event}`,
145 );
146 }
147 // Change detection: a path Rust embeds or its tests read must never classify
148 // light, or a "docs-only" edit skips the Rust gates that consume it. Run the
149 // workflow's own `case` block in bash against every include_str!/include_bytes!
150 // target in the tree plus the files Rust tests read at runtime.
151 const heavyCase = ci.match(
152 /\n( +)case "\$\{path\}" in\n[\s\S]*?\n\1 \*\)\n\1 heavy=true\n\1 ;;\n\1esac\n/,
153 )?.[0];
154 assert.ok(heavyCase, "ci.yml must keep the heavy/light change classification case block");
155 function classify(paths) {
156 const script = `while IFS= read -r path; do heavy=false\n${heavyCase}\nprintf '%s\\t%s\\n' "$heavy" "$path"; done`;
157 const out = execFileSync("bash", ["-c", script], { input: `${paths.join("\n")}\n`, encoding: "utf8" });
158 return new Map(out.trim().split("\n").map((line) => line.split("\t").reverse()));
159 }
160 const rustSources = execFileSync("git", ["ls-files", "-z", "--", "*.rs"], { cwd: repoRoot, encoding: "utf8" })
161 .split("\0")
162 .filter((file) => file && fs.existsSync(path.join(repoRoot, file)));
163 const includeTargets = new Set();
164 const includePattern =
165 /include_(?:str|bytes)!\s*\(\s*(?:concat!\s*\(\s*(?:env!\s*\(\s*"(\w+)"\s*\)\s*,\s*)?)?"([^"]+)"/g;
166 for (const file of rustSources) {
167 const text = fs.readFileSync(path.join(repoRoot, file), "utf8");
168 for (const [, env, target] of text.matchAll(includePattern)) {
169 let base = path.dirname(file);
170 if (env === "CARGO_MANIFEST_DIR") {
171 while (base !== "." && !fs.existsSync(path.join(repoRoot, base, "Cargo.toml"))) base = path.dirname(base);
172 } else if (env) {
173 continue; // OUT_DIR and friends are build outputs, not tracked inputs.
174 }
175 const resolved = path.posix.normalize(path.posix.join(base, target.replace(/^\//, "")));
176 if (!resolved.endsWith(".rs") && fs.existsSync(path.join(repoRoot, resolved))) includeTargets.add(resolved);
177 }
178 }
179 assert.ok(includeTargets.size > 60, `include_str! scan found only ${includeTargets.size} targets`);
180 assert.ok(
181 [...includeTargets].some((target) => !target.startsWith("crates/")),
182 "include_str! scan must see the targets outside crates/ (docs/HOOKS.md, ...)",
183 );
184 // Read with fs at test time, not embedded; keep in sync with the ci.yml arm.
185 const rustTestReads = [
186 "docs/FLEET_WORKFLOW_TUTORIAL.md",
187 "docs/examples/fleet-dogfood.toml",
188 "docs/2512.24601v2.pdf",
189 ];
190 const mustBeHeavy = [...includeTargets, ...rustTestReads];
191 const heavyVerdicts = classify(mustBeHeavy);
192 for (const target of mustBeHeavy) {
193 assert.equal(heavyVerdicts.get(target), "true", `${target} feeds Rust and must classify heavy`);
194 }
195 const lightVerdicts = classify(["README.md", "docs/ARCHITECTURE.md", "CHANGELOG.md", ".github/ISSUE_TEMPLATE/bug.yml"]);
196 for (const [target, verdict] of lightVerdicts) {
197 assert.equal(verdict, "false", `${target} is docs-only and must stay light`);
198 }
199 console.log(`Change detection OK: ${mustBeHeavy.length} Rust-consumed non-.rs paths classify heavy.`);
200
201 console.log(`Wrapper CI guards OK: ${npmSmokeCases.length} event cases, ${npmSmokeSteps.length} steps each.`);
202
203 assert.match(ci, /^ workflow_dispatch:\n inputs:\n expected_sha:/m);
204 const manualForceBlock = ci.match(
205 /if \[\[ "\$\{EVENT_NAME\}" == "workflow_dispatch" \]\]; then([\s\S]*?)\n\s+if \[\[ "\$\{EVENT_NAME\}" == "schedule" \]\]; then/,
206 );
207 assert.ok(manualForceBlock, "CI must have a dedicated manual-dispatch force-full branch");
208 for (const output of ["heavy", "workflow", "mobile", "actions"]) {
209 assert.match(manualForceBlock[1], new RegExp(`echo "${output}=true"`));
210 }
211 assert.match(manualForceBlock[1], /#EXPECTED_SHA.*-ne 40/s);
212 assert.match(manualForceBlock[1], /actual.*EXPECTED_SHA/s);
213 const expectedNightlyTargets = [
214 "x86_64-unknown-linux-gnu",
215 "aarch64-unknown-linux-musl",
216 "x86_64-apple-darwin",
217 "aarch64-apple-darwin",
218 "x86_64-pc-windows-msvc",
219 "aarch64-pc-windows-msvc",
220 ].sort();
221 assert.deepEqual([...new Set(valuesForKey(nightly, "target"))].sort(), expectedNightlyTargets);
222 assert.deepEqual(
223 [
224 ...valuesForKey(nightly, "primary_artifact"),
225 ...valuesForKey(nightly, "alias_artifact"),
226 ].sort(),
227 [
228 "codewhale-linux-x64",
229 "codew-linux-x64",
230 "codewhale-linux-arm64",
231 "codew-linux-arm64",
232 "codewhale-macos-x64",
233 "codew-macos-x64",
234 "codewhale-macos-arm64",
235 "codew-macos-arm64",
236 "codewhale-windows-x64.exe",
237 "codew-windows-x64.exe",
238 "codewhale-windows-arm64.exe",
239 "codew-windows-arm64.exe",
240 ].sort(),
241 );
242 assert.match(
243 nightly,
244 /cargo build --release --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
245 );
246 assert.match(nightly, /startsWith\(matrix\.target, 'x86_64-'\).*runner\.arch == 'X64'/s);
247 assert.match(nightly, /startsWith\(matrix\.target, 'aarch64-'\).*runner\.arch == 'ARM64'/s);
248 const nightlyArmMuslSetup = namedStep(nightly, "Install Linux ARM64 musl toolchain");
249 assert.match(nightlyArmMuslSetup, /matrix\.target == 'aarch64-unknown-linux-musl'/);
250 assert.match(nightlyArmMuslSetup, /apt-get install -y binutils musl-tools/);
251 assert.match(nightlyArmMuslSetup, /rustup target add --toolchain stable aarch64-unknown-linux-musl/);
252 const nightlyArmStaticSmoke = namedStep(
253 nightly,
254 "Verify static Linux ARM64 binary and launch",
255 );
256 assert.match(
257 nightlyArmStaticSmoke,
258 /matrix\.target == 'aarch64-unknown-linux-musl' && runner\.arch == 'ARM64'/,
259 );
260 assert.match(nightlyArmStaticSmoke, /readelf -l "\$\{bin_path\}"/);
261 assert.match(nightlyArmStaticSmoke, /grep -Fq 'INTERP'/);
262 assert.match(nightlyArmStaticSmoke, /"\$\{bin_path\}" --version/);
263 assert.doesNotMatch(nightly, /codewhale-tui/);
264 assert.doesNotMatch(nightly, /target\/[^\n]*\/codew(?:\.exe)?/);
265 assert.match(nightly, /cp "\$\{bin_path\}" "\$\{dir\}\/\$\{artifact\}"/);
266 assert.match(nightly, /cmp -s[\s\S]*nightly-primary[\s\S]*nightly-alias/);
267 assert.equal((nightly.match(/retention-days: 14/g) || []).length, 2);
268
269 assert.match(candidate, /^ workflow_dispatch:\n inputs:\n expected_sha:/m);
270 assert.doesNotMatch(candidate, /^ (push|pull_request|schedule):/m);
271 assert.match(candidate, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
272 assert.match(candidate, /source_sha: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
273 assert.match(candidate, /^ web:\n/m);
274 assert.doesNotMatch(
275 candidate,
276 /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
277 "candidate jobs must checkout GITHUB_SHA, not interpolate the dispatch SHA into ref",
278 );
279 assert.match(candidate, /cache-dependency-path: web\/package-lock\.json/);
280 assert.match(candidate, /package-manager-cache: false/);
281 assert.match(candidate, /working-directory: web/);
282 for (const workflow of [candidate, read(".github/workflows/web.yml")]) {
283 for (const command of ["npm ci", "npm test", "npm run check"]) {
284 assert.ok(workflow.includes(`run: ${command}\n`), `missing web gate: ${command}`);
285 }
286 }
287 // Both workflows use this gate. Preserve every check and its order: checking
288 // committed facts after prebuild could silently repair drift before testing it.
289 assert.deepEqual(JSON.parse(read("web/package.json")).scripts.check.split(" && "), [
290 "npm run check:facts",
291 "npm run check:latest-release",
292 "npm run prebuild",
293 "npm run check:docs",
294 "npm run check:tokens",
295 "npm run lint",
296 "tsc --noEmit",
297 "npm run build",
298 ]);
299 assert.match(candidate, /^ needs: \[resolve, web\]$/m);
300 assert.match(candidate, /needs\.web\.result == 'success'/);
301
302 for (const [label, workflow] of [
303 ["release candidate", candidate],
304 ["shared artifact", artifacts],
305 ]) {
306 for (const forbidden of [
307 /contents:\s*write/,
308 /packages:\s*write/,
309 /softprops\/action-gh-release/,
310 /docker\/login-action/,
311 /docker\/build-push-action/,
312 /\bgh release\b/,
313 /\bnpm publish\b/,
314 /\bcargo publish\b/,
315 /\bgit push\b/,
316 ]) {
317 assert.doesNotMatch(workflow, forbidden, `${label} workflow contains publication capability`);
318 }
319 }
320
321 for (const [label, workflow] of [
322 ["release candidate", candidate],
323 ["shared artifact", artifacts],
324 ["public release", release],
325 ["release republish", republish],
326 ]) {
327 const remoteActions = [...workflow.matchAll(/^\s+(?:-\s+)?uses:\s+([^@\s]+)@([^#\s]+)/gm)]
328 .map((match) => ({ action: match[1], ref: match[2] }))
329 .filter(({ action }) => !action.startsWith("./"));
330 assert.ok(remoteActions.length > 0, `${label} workflow must exercise pinned actions`);
331 for (const { action, ref } of remoteActions) {
332 assert.match(
333 ref,
334 /^[0-9a-f]{40}$/,
335 `${label} action ${action} must use an audited full commit SHA`,
336 );
337 }
338 }
339
340 const republishHomebrewJob = republish.match(/\n homebrew:\n([\s\S]*)$/);
341 assert.ok(republishHomebrewJob, "republish must retain a Homebrew recovery job");
342 const republishHomebrewCheckout = namedStep(
343 republishHomebrewJob[0],
344 "Checkout release infrastructure",
345 );
346 assert.match(
347 republishHomebrewCheckout,
348 /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/,
349 "Homebrew recovery must use the repaired default-branch infrastructure",
350 );
351 assert.doesNotMatch(
352 republishHomebrewCheckout,
353 /needs\.resolve\.outputs\.sha/,
354 "Homebrew recovery must not resurrect release-tag infrastructure",
355 );
356 assert.match(republishHomebrewJob[0], /gh release download "\$\{\{ needs\.resolve\.outputs\.tag \}\}"/);
357 assert.match(republishHomebrewJob[0], /MANIFEST: \/tmp\/codewhale-artifacts-sha256\.txt/);
358
359 assert.match(artifacts, /^ workflow_call:/m);
360 assert.match(artifacts, /^permissions:\n contents: read$/m);
361 const expectedTargets = [
362 "x86_64-unknown-linux-musl",
363 "aarch64-unknown-linux-musl",
364 "aarch64-linux-android",
365 "x86_64-apple-darwin",
366 "aarch64-apple-darwin",
367 "x86_64-pc-windows-msvc",
368 "aarch64-pc-windows-msvc",
369 ].sort();
370 assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expectedTargets);
371
372 const releaseMuslBuild = namedStep(artifacts, "Build static Linux binaries (musl)");
373 assert.match(releaseMuslBuild, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
374 assert.match(releaseMuslBuild, /apt-get install -y binutils musl-tools/);
375 assert.match(releaseMuslBuild, /rustup target add --toolchain stable \$\{\{ matrix\.target \}\}/);
376 assert.match(
377 releaseMuslBuild,
378 /cargo build --profile dist --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
379 );
380 const releaseStaticSmoke = namedStep(
381 artifacts,
382 "Verify static Linux binaries and launch on matching native runners",
383 );
384 assert.match(releaseStaticSmoke, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
385 assert.match(
386 releaseStaticSmoke,
387 /startsWith\(matrix\.target, 'aarch64-'\) && runner\.arch == 'ARM64'/,
388 );
389 assert.match(releaseStaticSmoke, /readelf -l "\$\{bin_path\}"/);
390 assert.match(releaseStaticSmoke, /grep -Fq 'INTERP'/);
391 assert.match(releaseStaticSmoke, /"\$\{bin_path\}" --version/);
392
393 const builtAssetNames = [
394 ...valuesForKey(artifacts, "cli_artifact"),
395 ...valuesForKey(artifacts, "shim_artifact"),
396 ...valuesForKey(artifacts, "compat_tui_artifact"),
397 ];
398 assert.equal(builtAssetNames.length, 21);
399 assert.deepEqual(
400 [...new Set(builtAssetNames)].sort(),
401 [
402 ...allAssetNames().filter((name) => name !== "codewhale.bat"),
403 ...LEGACY_TUI_BRIDGE_ASSET_NAMES,
404 ].sort(),
405 );
406 assert.match(
407 artifacts,
408 /stage_binary "\$\{\{ matrix\.cli_binary \}\}" "\$\{\{ matrix\.compat_tui_artifact \}\}"/,
409 "legacy TUI bridge assets must be staged from the one compiled codewhale binary",
410 );
411 const bundleInvocations = [...bundles.matchAll(
412 /^bundle (\S+) \\\n\s+\S+ \S+ (tar\.gz|zip) (""|portable)$/gm,
413 )].map((match) => {
414 const variant = match[3] === "portable" ? "-portable" : "";
415 return `codewhale-${match[1]}${variant}.${match[2]}`;
416 });
417 assert.deepEqual(bundleInvocations.sort(), [...BUNDLE_ASSET_NAMES].sort());
418 assert.match(artifacts, /aarch64-pc-windows-msvc/);
419 assert.match(artifacts, /aarch64-linux-android/);
420 assert.match(artifacts, /codew-windows-arm64\.exe/);
421 assert.match(artifacts, /CodeWhaleSetup\.exe/);
422 assert.match(artifacts, /assemble-release-assets\.js --verify release-assets/);
423 assert.match(artifacts, /CODEWHALE_SMOKE_ASSETS_DIR/);
424 assert.match(artifacts, /^ pin:\n/m);
425 assert.match(artifacts, /Require source_sha equals github\.sha/);
426 assert.doesNotMatch(
427 artifacts,
428 /ref: \$\{\{ inputs\.source_sha \}\}/,
429 "artifact jobs must checkout GITHUB_SHA, not interpolate the caller SHA into ref",
430 );
431 assert.match(artifacts, /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
432 assert.equal(
433 (artifacts.match(/package-manager-cache: false/g) || []).length,
434 2,
435 "assemble and smoke must disable setup-node's implicit npm cache",
436 );
437 const bundleStep = namedStep(artifacts, "Create and checksum platform archives");
438 assert.match(bundleStep, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
439 assert.match(bundleStep, /git show -s --format=%ct "\$\{SOURCE_SHA\}"/);
440 assert.match(
441 bundleStep,
442 /SOURCE_DATE_EPOCH="\$\{source_date_epoch\}"[\s\\]+bash scripts\/release\/create-release-bundles\.sh artifacts bundles/,
443 );
444 assert.doesNotMatch(bundleStep, /inputs\.source_sha/);
445 assert.doesNotMatch(bundleStep, /\bdate\b/, "bundle timestamps must come from the pinned source commit, not wall-clock time");
446
447 const rustCacheBlocks = [...artifacts.matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
448 (match) => match[0],
449 );
450 assert.ok(rustCacheBlocks.length >= 1, "shared artifact workflow must pin rust-cache");
451 for (const block of rustCacheBlocks) {
452 assert.doesNotMatch(block, /github\.(event|ref|sha)|inputs\./);
453 }
454
455 // One parity gate, called by the release candidate and the public release,
456 // and the release refuses a tag without a green RC receipt for its exact SHA.
457 // The release gate must lint at least as strictly as the merge gate.
458 function clippyAllows(source, label) {
459 const command = source.match(/cargo clippy --workspace --all-targets --all-features --locked -- \\\n([\s\S]*?)\n(?! +-)/)?.[1];
460 assert.ok(command, `${label} must run workspace all-targets clippy`);
461 return [...command.matchAll(/-A (clippy::\w+)/g)].map((match) => match[1]).sort();
462 }
463 assert.deepEqual(
464 clippyAllows(parityWorkflow, "release-parity.yml"),
465 clippyAllows(ci, "ci.yml"),
466 "release-parity.yml clippy allowances must match ci.yml",
467 );
468 const parity = parityWorkflow.match(/\n parity:\n([\s\S]*)$/);
469 assert.ok(parity, "release-parity.yml must define the parity job");
470 assert.match(parityWorkflow, /^on:\n workflow_call:\n/m, "parity must be a reusable workflow");
471 for (const [name, source] of [["release.yml", release], ["release-candidate.yml", candidate]]) {
472 const caller = source.match(/\n parity:\n([\s\S]*?)\n\n/);
473 assert.ok(caller, `${name} must run the parity job`);
474 assert.match(caller[1], /name: Parity\n/, `${name}: the RC receipt check matches the "Parity" job name`);
475 assert.match(caller[1], /uses: \.\/\.github\/workflows\/release-parity\.yml/, `${name} must call the shared parity gate`);
476 }
477 assert.match(
478 namedStep(release, "Require a green release-candidate receipt for this exact SHA"),
479 /require-rc-receipt\.sh "\$\{GITHUB_REPOSITORY\}" "\$\{SHA\}"/,
480 );
481 assert.match(release, /^ resolve:\n(?:.*\n)*? actions: read\n/m, "resolve needs actions: read for the RC receipt");
482 assert.doesNotMatch(
483 parity[1],
484 /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
485 "parity must checkout GITHUB_SHA after resolve, not interpolate the tag SHA into ref",
486 );
487 assert.match(parity[1], /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
488 const parityRustCache = [...parity[1].matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
489 (match) => match[0],
490 );
491 assert.equal(parityRustCache.length, 1, "parity must pin exactly one rust-cache");
492 assert.doesNotMatch(parityRustCache[0], /github\.(event|ref|sha)|inputs\./);
493
494 assert.equal(allReleaseAssetNames().length, 34);
495 assert.match(release, /^ artifacts:\n/m);
496 assert.match(release, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
497 assert.doesNotMatch(release, /^ (build|bundle|windows-installer):/m);
498 assert.match(release, /name: codewhale-release-assets\n\s+path: artifacts/);
499 assert.match(release, /files: artifacts\/\*/);
500 assert.equal(
501 (release.match(/ensure-release-assets-absent\.js/g) || []).length,
502 2,
503 "public release must refuse existing assets before work and immediately before upload",
504 );
505 assert.match(release, /overwrite_files:\s*false/);
506 assert.match(release, /fail_on_unmatched_files:\s*true/);
507 // Assets land in a draft; the whole verified set becomes public at once, and
508 // derived channels (the tagged container manifest) follow the canonical release.
509 assert.match(release, /files: artifacts\/\*\n\s+draft: true\n/);
510 assert.match(
511 namedStep(release, "Verify the draft's exact asset set, then publish it at once"),
512 /verify-release-inventory\.js \\\n\s+--draft --asset-dir artifacts --publish/,
513 );
514 const releaseJob = release.match(/\n release:\n([\s\S]*?)\n npm:\n/);
515 assert.ok(releaseJob, "public release must retain its release job");
516 assert.match(releaseJob[1], /^ needs: \[artifacts, docker-build, resolve\]$/m);
517 const dockerJob = release.match(/\n docker:\n([\s\S]*?)\n release:\n/);
518 assert.ok(dockerJob, "public release must retain its container manifest job");
519 assert.match(dockerJob[1], /^ needs: \[docker-build, release, resolve\]$/m);
520 assert.match(dockerJob[1], /needs\.release\.result == 'success'/);
521 const cnbJob = release.match(/\n cnb:\n([\s\S]*?)\n npm:\n/);
522 assert.ok(cnbJob, "CNB tag publication must follow the canonical release");
523 assert.match(cnbJob[1], /^ needs: \[release, resolve\]$/m);
524 assert.match(cnbJob[1], /needs\.release\.result == 'success'/);
525 assert.match(cnbJob[1], /uses: \.\/\.github\/workflows\/sync-cnb\.yml/);
526 assert.doesNotMatch(cnbSync, /^\s+tags:/m, "a tag push alone must not start CNB publication");
527 assert.match(cnbSync, /^ workflow_call:/m);
528 const cnbPush = namedStep(cnbSync, "Push triggering ref to CNB");
529 const cnbPublicGate = cnbPush.indexOf("node scripts/release/verify-release-inventory.js --manifest");
530 const cnbTagPush = cnbPush.indexOf('push_with_retry "tag ${TAG}"');
531 assert.ok(cnbPublicGate >= 0 && cnbTagPush > cnbPublicGate,
532 "manual and called tag mirrors must verify the published inventory before pushing");
533 assert.match(cnbPush, /verify-remote-tag\.sh/);
534 assert.doesNotMatch(cnbPush, /\+refs\/tags\//, "published mirror tags must not be rewritten");
535 assert.match(
536 namedStep(republish, "Require a complete published release"),
537 /verify-release-inventory\.js --manifest/,
538 );
539
540 assert.match(release, /^ docker-build:\n/m);
541 assert.match(release, /^ docker:\n/m);
542 assert.match(release, /runner: ubuntu-latest\n\s+platform: linux\/amd64/);
543 assert.match(release, /runner: ubuntu-24\.04-arm\n\s+platform: linux\/arm64/);
544 assert.match(release, /cli_artifact: codewhale-linux-x64/);
545 assert.match(release, /cli_artifact: codewhale-linux-arm64/);
546 assert.match(release, /shim_artifact: codew-linux-x64/);
547 assert.match(release, /shim_artifact: codew-linux-arm64/);
548 assert.doesNotMatch(
549 release,
550 /docker\/setup-qemu-action/,
551 "public container publication must not funnel both architectures through QEMU",
552 );
553 const releaseDockerBytes = namedStep(release, "Verify native release bytes");
554 assert.match(releaseDockerBytes, /CLI_ARTIFACT: \$\{\{ matrix\.cli_artifact \}\}/);
555 assert.match(releaseDockerBytes, /SHIM_ARTIFACT: \$\{\{ matrix\.shim_artifact \}\}/);
556 assert.match(
557 releaseDockerBytes,
558 /mv -- "docker-context\/bin\/\$\{CLI_ARTIFACT\}" docker-context\/bin\/codewhale/,
559 );
560 assert.match(
561 releaseDockerBytes,
562 /mv -- "docker-context\/bin\/\$\{SHIM_ARTIFACT\}" docker-context\/bin\/codew/,
563 );
564 assert.match(releaseDockerBytes, /cmp docker-context\/bin\/codewhale docker-context\/bin\/codew/);
565 const releaseDockerBuild = namedStep(release, "Assemble and push native image by digest");
566 assert.match(releaseDockerBuild, /context: docker-context/);
567 assert.match(releaseDockerBuild, /file: infra\/packaging\/docker\/Dockerfile\.release/);
568 assert.match(releaseDockerBuild, /platforms: \$\{\{ matrix\.platform \}\}/);
569 assert.match(releaseDockerBuild, /provenance: mode=max/);
570 assert.match(releaseDockerBuild, /sbom: true/);
571 assert.match(releaseDockerBuild, /push-by-digest=true/);
572 const releaseDockerManifest = namedStep(release, "Publish multi-architecture manifest");
573 assert.match(releaseDockerManifest, /Expected exactly two native image digests/);
574 assert.match(releaseDockerManifest, /docker buildx imagetools create/);
575 const releaseDockerSmoke = namedStep(release, "Verify and smoke published container");
576 assert.match(releaseDockerSmoke, /linux\/amd64/);
577 assert.match(releaseDockerSmoke, /linux\/arm64/);
578 assert.match(releaseDockerSmoke, /--entrypoint codewhale/);
579 assert.match(releaseDockerSmoke, /--entrypoint codew/);
580
581 const npmJob = release.match(/\n npm:\n([\s\S]*?)\n homebrew:\n/);
582 assert.ok(npmJob, "public release must retain a dedicated npm publication job");
583 assert.match(npmJob[1], /^ needs: \[release, resolve\]$/m);
584 assert.match(npmJob[1], /needs\.release\.result == 'success'/);
585 assert.match(npmJob[1], /^ contents: read$/m);
586 assert.match(npmJob[1], /^ id-token: write$/m);
587 assert.match(npmJob[1], /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
588 assert.match(npmJob[1], /fetch-depth: 0/);
589 assert.match(npmJob[1], /node-version: 24/);
590 assert.match(npmJob[1], /registry-url: https:\/\/registry\.npmjs\.org/);
591 assert.match(npmJob[1], /package-manager-cache: false/);
592 assert.match(npmJob[1], /npm install --global npm@12\.0\.2/);
593 const npmTagGate = namedStep(release, "Revalidate release tag before npm publish");
594 const npmAssetGate = namedStep(release, "Revalidate public release assets");
595 const npmPublish = namedStep(release, "Publish npm wrapper with trusted publishing");
596 assert.match(npmTagGate, /verify-remote-tag\.sh/);
597 assert.match(npmAssetGate, /verify-release-assets\.sh/);
598 assert.match(npmAssetGate, /GH_TOKEN: \$\{\{ github\.token \}\}/);
599 assert.match(npmPublish, /working-directory: npm\/codewhale/);
600 assert.match(npmPublish, /GH_TOKEN: \$\{\{ github\.token \}\}/);
601 assert.match(npmPublish, /npm publish --access public/);
602 assert.doesNotMatch(npmJob[1], /NPM_TOKEN|NODE_AUTH_TOKEN|secrets\./);
603 assert.ok(
604 release.indexOf("Revalidate public release assets") <
605 release.indexOf("Publish npm wrapper with trusted publishing"),
606 "npm publication must follow the public exact-asset gate",
607 );
608
609 assert.match(releaseDockerfile, /^FROM debian:bookworm-slim$/m);
610 assert.match(releaseDockerfile, /ca-certificates/);
611 assert.match(releaseDockerfile, /libdbus-1-3/);
612 assert.match(releaseDockerfile, /COPY .*bin\/codewhale \/usr\/local\/bin\/codewhale/);
613 assert.match(releaseDockerfile, /COPY .*bin\/codew \/usr\/local\/bin\/codew/);
614 assert.match(releaseDockerfile, /^USER codewhale$/m);
615 assert.doesNotMatch(
616 releaseDockerfile,
617 /\bcargo\s+build\b|^FROM\s+rust:/m,
618 "release container assembly must reuse the already-verified release binaries",
619 );
620
621 assert.match(runbook, /release[- ]candidate/i);
622 assert.match(runbook, /expected_sha/);
623 assert.match(runbook, /34/);
624 assert.match(runbook, /does not create a tag/i);
625 assert.match(runbook, /explicit.*approval/i);
626 assert.match(runbook, /last[- ]useful[- ]log/i, "runbook must document the last-useful-log rule (#5496)");
627 assert.match(runbook, /404 logs/i, "runbook must document the 404-log cancellation rule (#5496)");
628
629 const cnbRustGates = cnb.match(
630 /\.rust_workspace_gates_stage: &rust_workspace_gates_stage([\s\S]*?)\n\.linux_rust_gates:/,
631 );
632 assert.ok(cnbRustGates, "CNB must retain the shared Rust workspace gate");
633 assert.match(
634 cnbRustGates[1],
635 /timeout: 45m[\s\S]*export CARGO_BUILD_JOBS=1[\s\S]*export CARGO_PROFILE_TEST_DEBUG=0[\s\S]*cargo check --workspace --all-targets --locked[\s\S]*cargo clippy --workspace --all-targets --all-features --locked -- -D warnings[\s\S]*RUST_MIN_STACK=16777216 sh scripts\/with-hermetic-test-home.sh cargo test --workspace --all-features --locked/,
636 "CNB must serialize the memory-heavy Rust gate and preserve the workspace test stack contract",
637 );
638 assert.doesNotMatch(
639 cnbRustGates[1],
640 /export (?:HOME|USERPROFILE|CODEWHALE_HOME)=/,
641 "CNB must reuse the shared test-home boundary without overriding legacy migration fixtures",
642 );
643
644 // Cover every test invocation, including named parity and narrow crate gates.
645 // These launchers protect production dependencies as well as cfg(test) code.
646 // `release parity` is 4 rather than 3: parity runs the workspace under nextest
647 // for the same one-process-per-test isolation CI's lanes use, and keeps a
648 // separate doctest invocation because nextest does not run doctests. release.yml
649 // itself runs none: its parity job calls release-parity.yml.
650 let hermeticInvocations = 0;
651 for (const [label, workflow, expected] of [
652 ["CI", ci, 6],
653 ["release", release, 0],
654 ["release parity", parityWorkflow, 4],
655 ["CNB", cnb, 3],
656 ]) {
657 const commands = workflow.split("\n").filter((line) =>
658 !line.trimStart().startsWith("#") &&
659 // Exclude only this standalone quoted receipt argument. A printf line
660 // with command substitution or any appended execution still counts.
661 line.trim() !== "'command=sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked -- --format=pretty'" &&
662 /\bcargo (?:test|nextest run)\b/.test(line),
663 );
664 assert.equal(commands.length, expected, `${label} must retain every Rust test invocation`);
665 for (const command of commands) {
666 assert.match(command, /sh scripts\/with-hermetic-test-home.sh cargo (?:test|nextest run)\b/,
667 `${label} Rust tests must use the shared test-home boundary`);
668 }
669 hermeticInvocations += commands.length;
670 }
671 for (const name of ["Run tests", "Run doctests"]) {
672 const step = namedStep(ciTestJob, name);
673 assert.match(step, /shell: bash/, `${name} must invoke the POSIX helper on Windows too`);
674 assert.match(step, /RUST_MIN_STACK: '16777216'/);
675 }
676 console.log(`Hermetic Rust workflow invocations OK: ${hermeticInvocations} checks passed.`);
677
678 const nextest = read(".config/nextest.toml");
679 const integrationGroup = nextest.search(/^filter = 'binary\(integration\)'$/m);
680 const telemetryGroup = nextest.indexOf(
681 "filter = 'binary(integration) & test(/^telemetry_contract::/)'",
682 );
683 const execGroup = nextest.indexOf(
684 "filter = 'binary(integration) & test(/^exec_persistent_service::/)'",
685 );
686 assert.ok(integrationGroup >= 0, "nextest must bound the integration binary");
687 assert.ok(
688 telemetryGroup >= 0 && telemetryGroup < integrationGroup,
689 "telemetry-contract override must precede binary(integration); first matching group wins",
690 );
691 assert.ok(
692 execGroup >= 0 && execGroup < integrationGroup,
693 "exec_persistent_service override must precede binary(integration); first matching group wins",
694 );
695 assert.match(nextest, /exec-persistent-service = \{ max-threads = 1 \}/);
696 assert.equal(
697 (cnb.match(/^\s+- \*rust_workspace_gates_stage$/gm) || []).length,
698 2,
699 "both CNB Rust pipelines must reuse the constrained workspace gate",
700 );
701
702 const cnbPreflight = cnb.match(
703 /\.linux_release_preflight: &linux_release_preflight([\s\S]*?)\nmain:/,
704 );
705 assert.ok(cnbPreflight, "CNB must retain a dedicated release preflight");
706 const cnbBuild = cnbPreflight[1].indexOf(
707 "cargo build --jobs 2 --release --locked -p codewhale-cli",
708 );
709 const cnbAlias = cnbPreflight[1].indexOf(
710 "cp target/release/codewhale target/release/codew",
711 );
712 const cnbSmoke = cnbPreflight[1].indexOf("node scripts/release/npm-wrapper-smoke.js");
713 assert.ok(cnbBuild >= 0, "CNB release preflight must build the consolidated runtime");
714 assert.ok(cnbAlias > cnbBuild, "CNB release preflight must materialize codew after the build");
715 assert.ok(cnbSmoke > cnbAlias, "CNB release preflight must materialize codew before smoke");
716
717 const cnbTagRelease = cnb.match(/\$:\n tag_push:\n([\s\S]*)$/);
718 assert.ok(cnbTagRelease, "CNB must retain a tag release pipeline");
719 const cnbTagStamp = cnbTagRelease[1].indexOf(
720 'export CODEWHALE_BUILD_SHA="$commit_sha"',
721 );
722 const cnbTagBuild = cnbTagRelease[1].indexOf(
723 "cargo build --jobs 2 --release --locked \\",
724 );
725 const cnbTagVersionCheck = cnbTagRelease[1].indexOf(
726 "./scripts/release/check-versions.sh --require-dated-release",
727 );
728 assert.ok(cnbTagVersionCheck >= 0, "CNB publication must reject undated source candidates");
729 for (const [label, workflow] of [["release-candidate.yml", candidate], ["release.yml", release]]) {
730 assert.match(
731 workflow,
732 /\.\/scripts\/release\/check-versions\.sh --require-dated-release/,
733 `${label} must reject undated source candidates before building`,
734 );
735 }
736 assert.ok(cnbTagVersionCheck < cnbTagBuild, "CNB must validate release notes before building public assets");
737 assert.match(cnbTagRelease[1], /checkout_sha="\$\(git rev-parse 'HEAD\^\{commit\}'\)"/);
738 assert.match(cnbTagRelease[1], /commit_sha="\$\{CNB_COMMIT:-\$\{checkout_sha\}\}"/);
739 assert.match(cnbTagRelease[1], /CNB_COMMIT[\s\S]*does not match checkout[\s\S]*exit 1/);
740 assert.ok(cnbTagStamp >= 0, "CNB tag releases must stamp the consolidated runtime");
741 assert.ok(cnbTagBuild > cnbTagStamp, "CNB tag releases must stamp before compiling");
742
743 assert.doesNotMatch(
744 archiveInstaller,
745 /cargo install codewhale --locked/,
746 "glibc recovery must name the published codewhale-cli crate",
747 );
748 assert.equal(
749 (archiveInstaller.match(/cargo install codewhale-cli --locked/g) || []).length,
750 2,
751 "both glibc recovery branches must name codewhale-cli",
752 );
753 // The archive installer never overwrites an existing command: it validates the
754 // retired TUI path against the consolidated bytes and leaves upgrades to
755 // `codewhale update`, which migrates `codewhale-tui` beside the canonical pair.
756 assert.match(
757 archiveInstaller,
758 /legacy_tui="\$BIN_DIR\/codewhale-tui"[\s\S]*check_destination "\$SCRIPT_DIR\/codewhale" "\$legacy_tui"/,
759 "archive installs must validate the retired TUI path against consolidated bytes",
760 );
761 assert.doesNotMatch(
762 archiveInstaller,
763 /install_binary "\$SCRIPT_DIR\/codewhale" "\$legacy_tui"/,
764 "archive installs must not overwrite an existing retired TUI command",
765 );
766 assert.doesNotMatch(
767 cliDispatcher,
768 /codewhale_config::auto_model::classify/,
769 "the CLI dispatcher must leave auto routing to the provider-aware runtime",
770 );
771
772 // #5496: every release-lane job carries an explicit `timeout-minutes`.
773 //
774 // GitHub's default is 360 minutes, so an assigned-but-dead runner sits for six
775 // hours before anything reclaims it — observed on the v0.9.9 train as a job
776 // stuck `in_progress` with 404 logs. Timeouts are containment, not recovery:
777 // the runbook keeps the 404-log cancel/rerun rule for infrastructure failures.
778 //
779 // A job that calls a reusable workflow (`uses:`) cannot carry the key at all —
780 // GitHub rejects it — so the callee owns its own caps. That is why the artifact
781 // bounds live in release-artifacts.yml rather than in its callers.
782 function jobsWithoutTimeout(source) {
783 const lines = source.split("\n");
784 const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
785 assert.notEqual(jobsAt, -1, "workflow must declare jobs");
786 const offenders = [];
787 for (let i = jobsAt + 1; i < lines.length; i += 1) {
788 const header = lines[i].match(/^ ([A-Za-z0-9_-]+):\s*$/);
789 if (!header) continue;
790 let reusable = false;
791 let capped = false;
792 for (let j = i + 1; j < lines.length; j += 1) {
793 if (/^ [A-Za-z0-9_-]+:\s*$/.test(lines[j])) break;
794 if (/^ uses:/.test(lines[j])) reusable = true;
795 if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
796 }
797 if (!reusable && !capped) offenders.push(header[1]);
798 }
799 return offenders;
800 }
801
802 assert.deepEqual(
803 jobsWithoutTimeout("jobs:\n uncapped:\n runs-on: ubuntu-latest\n"),
804 ["uncapped"],
805 "jobsWithoutTimeout must detect an uncapped job",
806 );
807 assert.deepEqual(
808 jobsWithoutTimeout("jobs:\n reusable:\n uses: ./.github/workflows/reusable.yml\n"),
809 [],
810 "jobsWithoutTimeout must skip reusable workflow callers",
811 );
812 assert.deepEqual(
813 jobsWithoutTimeout("jobs:\n capped:\n runs-on: ubuntu-latest\n timeout-minutes: 15\n"),
814 [],
815 "jobsWithoutTimeout must accept a capped job",
816 );
817
818 for (const [name, source] of [
819 ["release-candidate.yml", candidate],
820 ["release-artifacts.yml", artifacts],
821 ["release.yml", release],
822 ["release-parity.yml", parityWorkflow],
823 ["release-republish.yml", republish],
824 ["ci.yml", ci],
825 ["nightly.yml", nightly],
826 ]) {
827 assert.deepEqual(
828 jobsWithoutTimeout(source),
829 [],
830 `${name}: every job must set timeout-minutes (#5496)`,
831 );
832 }
833
834 // The Windows artifact build historically runs 40-45 minutes, so its cap has to
835 // keep real margin — a tight bound here fails healthy releases.
836 const buildTimeout = artifacts.match(/^ build:\n(?:.*\n)*? timeout-minutes: (\d+)$/m);
837 assert.ok(buildTimeout, "release-artifacts build job must be capped");
838 assert.ok(
839 Number(buildTimeout[1]) >= 60,
840 `artifact build cap ${buildTimeout[1]}m leaves no margin over a healthy 40-45m Windows build`,
841 );
842
843 function jobTimeout(source, job) {
844 const match = source.match(
845 new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
846 );
847 assert.ok(match, `${job} must declare timeout-minutes`);
848 return Number(match[1]);
849 }
850
851 // Pin the measured release-lane budget: fast setup and packaging fail quickly,
852 // while cross-platform compilation keeps real margin over the 40-45m Windows
853 // build observed on the release train.
854 assert.equal(jobTimeout(candidate, "resolve"), 10);
855 assert.equal(jobTimeout(candidate, "web"), 15);
856 assert.equal(jobTimeout(artifacts, "pin"), 10);
857 assert.equal(jobTimeout(artifacts, "build"), 90);
858 for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
859 assert.equal(jobTimeout(artifacts, job), 15, `${job} must keep the 15m packaging cap`);
860 }
861 assert.equal(jobTimeout(nightly, "build"), 90);
862 assert.equal(jobTimeout(release, "resolve"), 10);
863 // The v0.9.12 tag push finished every parity step and was then cancelled at
864 // 20 minutes inside rust-cache's post-run save; 45 keeps that margin.
865 assert.equal(jobTimeout(parityWorkflow, "parity"), 45);
866
867 console.log(
868 "Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 7-target/34-asset release candidate.",
869 );
870
870 lines JAVASCRIPT