| 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 | |
| 7 | const repoRoot = path.resolve(__dirname, "..", ".."); |
| 8 | const { |
| 9 | allAssetNames, |
| 10 | allReleaseAssetNames, |
| 11 | BUNDLE_ASSET_NAMES, |
| 12 | } = require(path.join(repoRoot, "npm", "codewhale", "scripts", "artifacts")); |
| 13 | |
| 14 | function read(relativePath) { |
| 15 | return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); |
| 16 | } |
| 17 | |
| 18 | function valuesForKey(source, key) { |
| 19 | const expression = new RegExp(`^\\s+${key}:\\s+([^#\\s]+)\\s*$`, "gm"); |
| 20 | return [...source.matchAll(expression)].map((match) => match[1]); |
| 21 | } |
| 22 | |
| 23 | const ci = read(".github/workflows/ci.yml"); |
| 24 | const candidate = read(".github/workflows/release-candidate.yml"); |
| 25 | const artifacts = read(".github/workflows/release-artifacts.yml"); |
| 26 | const release = read(".github/workflows/release.yml"); |
| 27 | const bundles = read("scripts/release/create-release-bundles.sh"); |
| 28 | const runbook = read("docs/RELEASE_RUNBOOK.md"); |
| 29 | |
| 30 | assert.match(ci, /^ workflow_dispatch:\n inputs:\n expected_sha:/m); |
| 31 | const manualForceBlock = ci.match( |
| 32 | /if \[\[ "\$\{EVENT_NAME\}" == "workflow_dispatch" \]\]; then([\s\S]*?)\n\s+if \[\[ "\$\{EVENT_NAME\}" == "schedule" \]\]; then/, |
| 33 | ); |
| 34 | assert.ok(manualForceBlock, "CI must have a dedicated manual-dispatch force-full branch"); |
| 35 | for (const output of ["heavy", "workflow", "mobile", "actions"]) { |
| 36 | assert.match(manualForceBlock[1], new RegExp(`echo "${output}=true"`)); |
| 37 | } |
| 38 | assert.match(manualForceBlock[1], /#EXPECTED_SHA.*-ne 40/s); |
| 39 | assert.match(manualForceBlock[1], /actual.*EXPECTED_SHA/s); |
| 40 | |
| 41 | assert.match(candidate, /^ workflow_dispatch:\n inputs:\n expected_sha:/m); |
| 42 | assert.doesNotMatch(candidate, /^ (push|pull_request|schedule):/m); |
| 43 | assert.match(candidate, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/); |
| 44 | assert.match(candidate, /source_sha: \$\{\{ needs\.resolve\.outputs\.sha \}\}/); |
| 45 | assert.match(candidate, /^ web:\n/m); |
| 46 | assert.match(candidate, /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/); |
| 47 | assert.match(candidate, /working-directory: web/); |
| 48 | for (const command of [ |
| 49 | "npm ci", |
| 50 | "npm run check:facts", |
| 51 | "npm run prebuild", |
| 52 | "npm run check:docs", |
| 53 | "npm test", |
| 54 | "npm run lint", |
| 55 | "npx tsc --noEmit", |
| 56 | "npm run build", |
| 57 | ]) { |
| 58 | assert.match(candidate, new RegExp(`run: ${command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); |
| 59 | } |
| 60 | assert.match(candidate, /^ needs: \[resolve, web\]$/m); |
| 61 | assert.match(candidate, /needs\.web\.result == 'success'/); |
| 62 | |
| 63 | for (const [label, workflow] of [ |
| 64 | ["release candidate", candidate], |
| 65 | ["shared artifact", artifacts], |
| 66 | ]) { |
| 67 | for (const forbidden of [ |
| 68 | /contents:\s*write/, |
| 69 | /packages:\s*write/, |
| 70 | /softprops\/action-gh-release/, |
| 71 | /docker\/login-action/, |
| 72 | /docker\/build-push-action/, |
| 73 | /\bgh release\b/, |
| 74 | /\bnpm publish\b/, |
| 75 | /\bcargo publish\b/, |
| 76 | /\bgit push\b/, |
| 77 | ]) { |
| 78 | assert.doesNotMatch(workflow, forbidden, `${label} workflow contains publication capability`); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | for (const [label, workflow] of [ |
| 83 | ["release candidate", candidate], |
| 84 | ["shared artifact", artifacts], |
| 85 | ["public release", release], |
| 86 | ]) { |
| 87 | const remoteActions = [...workflow.matchAll(/^\s+(?:-\s+)?uses:\s+([^@\s]+)@([^#\s]+)/gm)] |
| 88 | .map((match) => ({ action: match[1], ref: match[2] })) |
| 89 | .filter(({ action }) => !action.startsWith("./")); |
| 90 | assert.ok(remoteActions.length > 0, `${label} workflow must exercise pinned actions`); |
| 91 | for (const { action, ref } of remoteActions) { |
| 92 | assert.match( |
| 93 | ref, |
| 94 | /^[0-9a-f]{40}$/, |
| 95 | `${label} action ${action} must use an audited full commit SHA`, |
| 96 | ); |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | assert.match(artifacts, /^ workflow_call:/m); |
| 101 | assert.match(artifacts, /^permissions:\n contents: read$/m); |
| 102 | const expectedTargets = [ |
| 103 | "x86_64-unknown-linux-musl", |
| 104 | "aarch64-unknown-linux-gnu", |
| 105 | "aarch64-linux-android", |
| 106 | "x86_64-apple-darwin", |
| 107 | "aarch64-apple-darwin", |
| 108 | "x86_64-pc-windows-msvc", |
| 109 | "aarch64-pc-windows-msvc", |
| 110 | ].sort(); |
| 111 | assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expectedTargets); |
| 112 | |
| 113 | const builtAssetNames = [ |
| 114 | ...valuesForKey(artifacts, "cli_artifact"), |
| 115 | ...valuesForKey(artifacts, "shim_artifact"), |
| 116 | ...valuesForKey(artifacts, "tui_artifact"), |
| 117 | ]; |
| 118 | assert.equal(builtAssetNames.length, 21); |
| 119 | assert.deepEqual( |
| 120 | [...new Set(builtAssetNames)].sort(), |
| 121 | allAssetNames().filter((name) => name !== "codewhale.bat").sort(), |
| 122 | ); |
| 123 | const bundleInvocations = [...bundles.matchAll( |
| 124 | /^bundle (\S+) \\\n\s+\S+ \S+ \S+ (tar\.gz|zip) (""|portable)$/gm, |
| 125 | )].map((match) => { |
| 126 | const variant = match[3] === "portable" ? "-portable" : ""; |
| 127 | return `codewhale-${match[1]}${variant}.${match[2]}`; |
| 128 | }); |
| 129 | assert.deepEqual(bundleInvocations.sort(), [...BUNDLE_ASSET_NAMES].sort()); |
| 130 | assert.match(artifacts, /aarch64-pc-windows-msvc/); |
| 131 | assert.match(artifacts, /aarch64-linux-android/); |
| 132 | assert.match(artifacts, /codew-windows-arm64\.exe/); |
| 133 | assert.match(artifacts, /CodeWhaleSetup\.exe/); |
| 134 | assert.match(artifacts, /assemble-release-assets\.js --verify release-assets/); |
| 135 | assert.match(artifacts, /CODEWHALE_SMOKE_ASSETS_DIR/); |
| 136 | |
| 137 | assert.equal(allReleaseAssetNames().length, 34); |
| 138 | assert.match(release, /^ artifacts:\n/m); |
| 139 | assert.match(release, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/); |
| 140 | assert.doesNotMatch(release, /^ (build|bundle|windows-installer):/m); |
| 141 | assert.match(release, /name: codewhale-release-assets\n\s+path: artifacts/); |
| 142 | assert.match(release, /files: artifacts\/\*/); |
| 143 | assert.equal( |
| 144 | (release.match(/ensure-release-assets-absent\.js/g) || []).length, |
| 145 | 2, |
| 146 | "public release must refuse existing assets before work and immediately before upload", |
| 147 | ); |
| 148 | assert.match(release, /overwrite_files:\s*false/); |
| 149 | assert.match(release, /fail_on_unmatched_files:\s*true/); |
| 150 | |
| 151 | assert.match(runbook, /release[- ]candidate/i); |
| 152 | assert.match(runbook, /expected_sha/); |
| 153 | assert.match(runbook, /34/); |
| 154 | assert.match(runbook, /does not create a tag/i); |
| 155 | assert.match(runbook, /explicit.*approval/i); |
| 156 | |
| 157 | console.log("Release workflow contracts OK: exact-head full CI and 7-target/34-asset non-publishing candidate."); |
| 158 |