| 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 |