返回 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
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
158 lines JAVASCRIPT