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