返回 DeepSeek-Reasonix
release-cli-freeze.test.mjs
根目录 / scripts / release-cli-freeze.test.mjs
1 import assert from "node:assert/strict";
2 import test from "node:test";
3 import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import path from "node:path";
6 import { spawnSync } from "node:child_process";
7
8 const workflow = name => readFileSync(`.github/workflows/${name}`, "utf8");
9 const SWITCH = "CLI_PUBLISH_FROZEN";
10
11 // Evaluates a workflow expression with the JavaScript operators the release
12 // workflows use, so a default (variable unset) can be compared with a frozen run.
13 function evaluate(expression, { frozen, channel = "stable", token = "TAP-TOKEN" }) {
14 const vars = { [SWITCH]: String(frozen).toLowerCase() };
15 const needs = { resolve: { outputs: { channel } } };
16 const secrets = { HOMEBREW_TAP_TOKEN: token };
17 const js = expression.replace(/==/g, "===").replace(/!=/g, "!==");
18 return new Function("vars", "needs", "secrets", "inputs", `return (${js});`)(vars, needs, secrets, { candidate_artifact_name: "candidate", channel: "stable" });
19 }
20
21 function expressionAfter(file, anchor) {
22 const text = workflow(file);
23 const index = text.indexOf(anchor);
24 assert.ok(index >= 0, `${file} lost: ${anchor}`);
25 return text.slice(index + anchor.length).match(/\$\{\{ (.*?) \}\}/)[1];
26 }
27
28 test("every use of the switch is either a guard or a default-preserving selection", () => {
29 for (const file of ["release.yml", "release-npm.yml", "release-promote.yml", "release-stable.yml", "release-verify.yml"]) {
30 for (const line of workflow(file).split("\n").filter(item => item.includes(`vars.${SWITCH}`))) {
31 const allowed = [
32 /^\s+if: \$\{\{ .*vars\.CLI_PUBLISH_FROZEN != 'true'.* \}\}$/,
33 /^\s+NPM_STABLE_DIST_TAG: \$\{\{ vars\.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' \|\| 'latest' \}\}$/,
34 /^\s+args: release --clean\$\{\{ vars\.CLI_PUBLISH_FROZEN == 'true' && ' --skip=homebrew' \|\| '' \}\}$/,
35 /^\s+HOMEBREW_TAP_TOKEN: \$\{\{ .*vars\.CLI_PUBLISH_FROZEN != 'true' && secrets\.HOMEBREW_TAP_TOKEN \|\| '' \}\}$/,
36 /^\s+CLI_PUBLISH_FROZEN: \$\{\{ vars\.CLI_PUBLISH_FROZEN == 'true' \}\}$/,
37 ];
38 assert.ok(allowed.some(pattern => pattern.test(line)), `${file}: unexpected use of the switch: ${line.trim()}`);
39 }
40 }
41 });
42
43 const TOKEN = "HOMEBREW_TAP_TOKEN: ";
44 const CASK_IF = "- name: Publish prepared Homebrew cask\n if: ";
45 const ALIGN_IF = "- name: Align legacy aliases with the official release\n if: ";
46 const ARGS = "args: release --clean";
47 const DIST_TAG = "NPM_STABLE_DIST_TAG: ";
48
49 test("with the variable unset the CLI publication expressions evaluate as they always did", () => {
50 const unset = { frozen: "" };
51 assert.equal(evaluate(expressionAfter("release.yml", ARGS), unset), "");
52 assert.equal(evaluate(expressionAfter("release.yml", TOKEN), unset), "TAP-TOKEN");
53 assert.equal(evaluate(expressionAfter("release.yml", TOKEN), { ...unset, channel: "preview" }), "");
54 assert.equal(evaluate(expressionAfter("release.yml", CASK_IF), unset), true);
55 assert.equal(evaluate(expressionAfter("release-npm.yml", DIST_TAG), unset), "latest");
56 assert.equal(evaluate(expressionAfter("release-npm.yml", ALIGN_IF), unset), true);
57 });
58
59 test("with the variable set to true the CLI channels are skipped and npm moves to legacy-v1", () => {
60 const frozen = { frozen: "true" };
61 assert.equal(evaluate(expressionAfter("release.yml", ARGS), frozen), " --skip=homebrew");
62 assert.equal(evaluate(expressionAfter("release.yml", TOKEN), frozen), "");
63 assert.equal(evaluate(expressionAfter("release.yml", CASK_IF), frozen), false);
64 assert.equal(evaluate(expressionAfter("release-npm.yml", DIST_TAG), frozen), "legacy-v1");
65 assert.equal(evaluate(expressionAfter("release-npm.yml", ALIGN_IF), frozen), false);
66 for (const value of ["false", "1", "TRUE-ish", ""]) {
67 assert.equal(evaluate(expressionAfter("release-npm.yml", DIST_TAG), { frozen: value }), "latest", value);
68 }
69 });
70
71 test("the frozen npm publisher can never write latest, canary or next", () => {
72 const npm = workflow("release-npm.yml");
73 const align = npm.split("- name: Align legacy aliases with the official release")[1].split("\n - ")[0];
74 assert.match(align, /vars\.CLI_PUBLISH_FROZEN != 'true'/);
75 assert.equal((npm.match(/NPM_STABLE_DIST_TAG: /g) || []).length, 2);
76 const publishSteps = npm.split("- name: Publish or recover ").slice(1);
77 assert.equal(publishSteps.length, 2);
78 for (const step of publishSteps) assert.match(step.split("\n - ")[0], /NPM_STABLE_DIST_TAG/);
79 });
80
81 test("R2 pointers are guarded after the immutable record and before any pointer read or write", () => {
82 const step = workflow("release.yml").split("- name: Publish CLI release metadata to R2")[1].split("- name: Attach desktop manifest")[0];
83 const immutable = step.indexOf('immutable_key="cli/releases/${TAG}/latest.json"');
84 const guard = step.indexOf('if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then');
85 const pointerRead = step.indexOf('"s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json');
86 const pointerWrite = step.indexOf('aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/cli/${channel}/latest.json"');
87 assert.ok(immutable > 0 && immutable < guard && guard < pointerRead && guard < pointerWrite);
88 assert.match(step, /CLI_PUBLISH_FROZEN: \$\{\{ vars\.CLI_PUBLISH_FROZEN == 'true' \}\}/);
89 });
90
91 test("Desktop publication and the release orchestration do not depend on the switch", () => {
92 assert.equal(workflow("release-desktop.yml").includes(SWITCH), false);
93 for (const file of ["release-promote.yml", "release-stable.yml"]) {
94 const text = workflow(file);
95 for (const job of ["cli", "npm", "desktop"]) {
96 const block = text.split(new RegExp(`^ ${job}:\\n`, "m"))[1].split(/^ [a-z-]+:\n/m)[0];
97 assert.equal(block.includes(SWITCH), false, `${file} job ${job} must not be skipped by the switch`);
98 }
99 }
100 assert.match(workflow("release-promote.yml"), /CLI_PUBLISH_FROZEN: \$\{\{ vars\.CLI_PUBLISH_FROZEN == 'true' \}\}\n/);
101 assert.match(workflow("release-promote.yml"), /needs: \[preflight, cli, npm, desktop\]/);
102 });
103
104 const REQUIRED_CLI = ["SHA256SUMS", "reasonix-darwin-amd64.tar.gz", "reasonix-darwin-arm64.tar.gz", "reasonix-linux-amd64.tar.gz", "reasonix-linux-arm64.tar.gz", "reasonix-windows-amd64.zip", "reasonix-windows-arm64.zip"];
105 const DESKTOP = ["Reasonix-darwin-arm64.dmg", "Reasonix-darwin-amd64.dmg", "Reasonix-darwin-universal.dmg", "Reasonix-darwin-arm64.zip", "Reasonix-darwin-amd64.zip", "Reasonix-linux-amd64.deb", "Reasonix-linux-amd64.tar.gz", "Reasonix-windows-amd64-installer.exe", "Reasonix-windows-amd64.zip", "Reasonix-windows-arm64-installer.exe"];
106 const sha = "a".repeat(40);
107
108 function verifier(t, { latest = "1.2.3", legacy = "1.2.3" } = {}) {
109 const root = realpathSync(mkdtempSync(path.join(tmpdir(), "cli-freeze-")));
110 t.after(() => rmSync(root, { recursive: true, force: true }));
111 const bin = path.join(root, "bin");
112 mkdirSync(bin);
113 const asset = name => ({ name, size: 1 });
114 const cli = { isDraft: false, isPrerelease: false, assets: REQUIRED_CLI.map(asset) };
115 const desktop = { isDraft: false, isPrerelease: false, assets: [asset("latest.json"), ...DESKTOP.flatMap(name => [asset(name), asset(`${name}.minisig`)])] };
116 writeFileSync(path.join(root, "state.json"), JSON.stringify({ cli, desktop, latest, legacy }));
117 writeFileSync(path.join(bin, "git"), `#!/usr/bin/env node\nconsole.log('${sha}\\trefs/tags/x');`, { mode: 0o755 });
118 writeFileSync(path.join(bin, "gh"), `#!/usr/bin/env node
119 const fs=require('node:fs'), path=require('node:path');
120 const args=process.argv.slice(2), root=process.env.FIXTURE, state=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));
121 fs.appendFileSync(path.join(root,'calls'),JSON.stringify(['gh',...args])+'\\n');
122 if(args[0]==='release'&&args[1]==='view') console.log(JSON.stringify(args[2].startsWith('desktop-')?state.desktop:state.cli));
123 else if(args[0]==='api'&&args.join(' ').includes('homebrew-reasonix')) console.log('cask "reasonix" do\\n version "1.2.3"\\nend');
124 else process.exit(4);
125 `, { mode: 0o755 });
126 writeFileSync(path.join(bin, "npm"), `#!/usr/bin/env node
127 const fs=require('node:fs'), path=require('node:path');
128 const args=process.argv.slice(2), root=process.env.FIXTURE, state=JSON.parse(fs.readFileSync(path.join(root,'state.json'),'utf8'));
129 fs.appendFileSync(path.join(root,'calls'),JSON.stringify(['npm',...args])+'\\n');
130 const [spec]=args.slice(1); const name=spec.slice(0,spec.lastIndexOf('@'));
131 const out={name,version:'1.2.3',reasonixCandidateSha:'${sha}',gitHead:'${sha}','dist.integrity':'sha512-x'};
132 for (const field of args) if (field.startsWith('dist-tags.')) { const tag=field.slice(10); const value=tag==='latest'?state.latest:tag==='legacy-v1'?state.legacy:undefined; if(value) out[field]=value; }
133 console.log(JSON.stringify(out));
134 `, { mode: 0o755 });
135 writeFileSync(path.join(bin, "go"), `#!/usr/bin/env node
136 const args=process.argv.slice(2);
137 require('node:fs').writeFileSync(args[3], JSON.stringify({version:'v1.2.3',platforms:{'darwin-arm64':{url:'https://dl.reasonix.io/desktop-v1.2.3/x',sig:'s'}}}));
138 `, { mode: 0o755 });
139 const env = { ...process.env, FIXTURE: root, PATH: `${bin}:${process.env.PATH}`, RELEASE_REPOSITORY: "esengine/DeepSeek-Reasonix", RELEASE_VERSION: "1.2.3", CLI_TAG: "v1.2.3", DESKTOP_TAG: "desktop-v1.2.3", RELEASE_EXPECTED_SHA: sha, RELEASE_OPERATION: "publish", VERIFY_PUBLIC_POINTERS: "true", VERIFY_ATTEMPTS: "1", VERIFY_DELAY_SECONDS: "0", RELEASE_LEDGER_OUTPUT: path.join(root, "ledger.json") };
140 delete env[SWITCH];
141 return {
142 run: extra => spawnSync("bash", ["scripts/verify-stable-release-artifacts.sh"], { env: { ...env, ...extra }, encoding: "utf8" }),
143 calls: () => { try { return readFileSync(path.join(root, "calls"), "utf8"); } catch { return ""; } },
144 ledger: () => JSON.parse(readFileSync(env.RELEASE_LEDGER_OUTPUT, "utf8")),
145 };
146 }
147
148 test("postflight with the variable unset still requires npm latest and the Homebrew cask", t => {
149 const f = verifier(t), result = f.run({});
150 assert.equal(result.status, 0, result.stderr);
151 assert.match(f.calls(), /dist-tags\.latest/);
152 assert.doesNotMatch(f.calls(), /legacy-v1/);
153 assert.match(f.calls(), /homebrew-reasonix/);
154 const ledger = f.ledger();
155 assert.ok(ledger.surfaces.homebrew);
156 assert.ok(ledger.surfaces.npm.packages.every(item => item.latest === "1.2.3" && item.distTag === undefined));
157 const stale = verifier(t, { latest: "1.2.2" }).run({});
158 assert.notEqual(stale.status, 0);
159 });
160
161 test("postflight with the variable empty or false behaves as unset", t => {
162 for (const value of ["", "false"]) {
163 const f = verifier(t), result = f.run({ [SWITCH]: value });
164 assert.equal(result.status, 0, result.stderr);
165 assert.match(f.calls(), /homebrew-reasonix/);
166 }
167 });
168
169 test("frozen postflight verifies legacy-v1, ignores latest and the cask, and still verifies Desktop", t => {
170 const f = verifier(t, { latest: "2.0.0", legacy: "1.2.3" }), result = f.run({ [SWITCH]: "true" });
171 assert.equal(result.status, 0, result.stderr);
172 assert.match(f.calls(), /dist-tags\.legacy-v1/);
173 assert.doesNotMatch(f.calls(), /dist-tags\.latest/);
174 assert.doesNotMatch(f.calls(), /homebrew-reasonix/);
175 assert.match(f.calls(), /desktop-v1\.2\.3/);
176 const ledger = f.ledger();
177 assert.equal(ledger.surfaces.homebrew, undefined);
178 assert.ok(ledger.surfaces.stableManifest);
179 assert.ok(ledger.surfaces.desktop);
180 assert.ok(ledger.surfaces.npm.packages.every(item => item.distTag === "legacy-v1" && item.distTagVersion === "1.2.3"));
181 });
182
183 test("frozen postflight fails when the legacy-v1 tag did not land", t => {
184 const result = verifier(t, { legacy: "" }).run({ [SWITCH]: "true" });
185 assert.notEqual(result.status, 0);
186 });
187
188 test("the npm-v tag guard survives a freeze; only the latest poll is skipped", () => {
189 const step = workflow("release.yml").split("- name: Check npm latest dist-tag freshness")[1].split("\n - ")[0];
190 assert.doesNotMatch(step.split("run:")[0], /\bif:/);
191 const tagCheck = step.indexOf("git ls-remote --exit-code origin");
192 const freeze = step.indexOf('"${CLI_PUBLISH_FROZEN:-}" = "true"');
193 const poll = step.indexOf("npm view reasonix dist-tags.latest");
194 assert.ok(tagCheck > 0 && tagCheck < freeze && freeze < poll);
195 });
196
197 test("shell and workflow sides agree on every spelling of the variable", t => {
198 const envExpression = expressionAfter("release.yml", "- name: Check npm latest dist-tag freshness\n env:\n TAG: ${{ needs.resolve.outputs.tag }}\n CLI_PUBLISH_FROZEN: ");
199 const guard = expressionAfter("release.yml", "- name: Publish prepared Homebrew cask\n if: ");
200 for (const raw of ["true", "TRUE", "True", " true", "true ", "false", "", "1", "yes"]) {
201 const shell = String(evaluate(envExpression, { frozen: raw }));
202 const workflowFrozen = evaluate(guard, { frozen: raw }) === false;
203 assert.equal(shell === "true", workflowFrozen, JSON.stringify(raw));
204 const f = verifier(t), result = f.run({ [SWITCH]: shell });
205 assert.equal(result.status, 0, result.stderr);
206 assert.equal(/homebrew-reasonix/.test(f.calls()), !workflowFrozen, JSON.stringify(raw));
207 }
208 });
209
209 lines Plain Text