| 1 | import { createHash } from "node:crypto"; |
| 2 | import { execFileSync } from "node:child_process"; |
| 3 | import { |
| 4 | copyFileSync, |
| 5 | cpSync, |
| 6 | mkdirSync, |
| 7 | mkdtempSync, |
| 8 | readFileSync, |
| 9 | rmSync, |
| 10 | writeFileSync, |
| 11 | } from "node:fs"; |
| 12 | import { tmpdir } from "node:os"; |
| 13 | import path from "node:path"; |
| 14 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 15 | |
| 16 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 17 | const ROOT = path.join(HERE, ".."); |
| 18 | const VERSION_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; |
| 19 | const SHA_RE = /^[0-9a-f]{40}$/; |
| 20 | export const targets = [ |
| 21 | { key: "darwin-arm64", npm: "darwin-arm64", goos: "darwin", goarch: "arm64" }, |
| 22 | { key: "darwin-amd64", npm: "darwin-x64", goos: "darwin", goarch: "amd64" }, |
| 23 | { key: "linux-arm64", npm: "linux-arm64", goos: "linux", goarch: "arm64" }, |
| 24 | { key: "linux-amd64", npm: "linux-x64", goos: "linux", goarch: "amd64" }, |
| 25 | { key: "windows-arm64", npm: "win32-arm64", goos: "windows", goarch: "arm64" }, |
| 26 | { key: "windows-amd64", npm: "win32-x64", goos: "windows", goarch: "amd64" }, |
| 27 | ]; |
| 28 | |
| 29 | function sha256(file) { |
| 30 | return createHash("sha256").update(readFileSync(file)).digest("hex"); |
| 31 | } |
| 32 | |
| 33 | export function platformPackage(target, version, candidateSha) { |
| 34 | return { |
| 35 | name: `@reasonix/cli-${target.npm}`, |
| 36 | version, |
| 37 | description: `reasonix prebuilt binary for ${target.npm}.`, |
| 38 | os: [target.goos === "windows" ? "win32" : target.goos], |
| 39 | cpu: [target.goarch === "amd64" ? "x64" : "arm64"], |
| 40 | files: ["bin/"], |
| 41 | license: "MIT", |
| 42 | repository: { type: "git", url: "git+https://github.com/esengine/DeepSeek-Reasonix.git" }, |
| 43 | reasonixCandidateSha: candidateSha, |
| 44 | gitHead: candidateSha, |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | export function renderHomebrewCask(version, sums) { |
| 49 | const requireSum = name => { |
| 50 | if (!/^[0-9a-f]{64}$/.test(sums[name] ?? "")) throw new Error(`missing checksum for ${name}`); |
| 51 | return sums[name]; |
| 52 | }; |
| 53 | return `# This file was generated by Reasonix release candidate preparation. DO NOT EDIT. |
| 54 | cask "reasonix" do |
| 55 | postflight_steps do |
| 56 | if OS.mac? |
| 57 | system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"] |
| 58 | end |
| 59 | end |
| 60 | |
| 61 | version "${version}" |
| 62 | |
| 63 | on_macos do |
| 64 | on_arm do |
| 65 | sha256 "${requireSum("reasonix-darwin-arm64.tar.gz")}" |
| 66 | url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-darwin-arm64.tar.gz" |
| 67 | end |
| 68 | on_intel do |
| 69 | sha256 "${requireSum("reasonix-darwin-amd64.tar.gz")}" |
| 70 | url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-darwin-amd64.tar.gz" |
| 71 | end |
| 72 | end |
| 73 | on_linux do |
| 74 | on_arm do |
| 75 | sha256 "${requireSum("reasonix-linux-arm64.tar.gz")}" |
| 76 | url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-linux-arm64.tar.gz" |
| 77 | end |
| 78 | on_intel do |
| 79 | sha256 "${requireSum("reasonix-linux-amd64.tar.gz")}" |
| 80 | url "https://github.com/esengine/DeepSeek-Reasonix/releases/download/v#{version}/reasonix-linux-amd64.tar.gz" |
| 81 | end |
| 82 | end |
| 83 | |
| 84 | name "reasonix" |
| 85 | desc "Cache-first DeepSeek coding agent for the terminal." |
| 86 | homepage "https://github.com/esengine/DeepSeek-Reasonix" |
| 87 | |
| 88 | livecheck do |
| 89 | skip "Auto-generated on release." |
| 90 | end |
| 91 | |
| 92 | binary "reasonix" |
| 93 | end |
| 94 | `; |
| 95 | } |
| 96 | |
| 97 | function run(command, args, options = {}) { |
| 98 | execFileSync(command, args, { stdio: "inherit", ...options }); |
| 99 | } |
| 100 | |
| 101 | function makeArchive(target, binary, cliDirectory, work) { |
| 102 | const stage = path.join(work, `cli-${target.key}`); |
| 103 | mkdirSync(stage); |
| 104 | for (const name of ["CHANGELOG.md", "LICENSE", "README.md", "README.zh-CN.md"]) copyFileSync(path.join(ROOT, name), path.join(stage, name)); |
| 105 | copyFileSync(binary, path.join(stage, target.goos === "windows" ? "reasonix.exe" : "reasonix")); |
| 106 | const extension = target.goos === "windows" ? "zip" : "tar.gz"; |
| 107 | const archive = path.join(cliDirectory, `reasonix-${target.key}.${extension}`); |
| 108 | if (target.goos === "windows") run("zip", ["-q", "-r", archive, "."], { cwd: stage }); |
| 109 | else run("tar", ["-czf", archive, "-C", stage, "."]); |
| 110 | return archive; |
| 111 | } |
| 112 | |
| 113 | function packNpm(target, binary, npmDirectory, work, version, candidateSha) { |
| 114 | const stage = path.join(work, `npm-${target.npm}`); |
| 115 | mkdirSync(path.join(stage, "bin"), { recursive: true }); |
| 116 | copyFileSync(binary, path.join(stage, "bin", target.goos === "windows" ? "reasonix.exe" : "reasonix")); |
| 117 | writeFileSync(path.join(stage, "package.json"), `${JSON.stringify(platformPackage(target, version, candidateSha), null, 2)}\n`); |
| 118 | run("npm", ["pack", stage, "--pack-destination", npmDirectory, "--silent"]); |
| 119 | } |
| 120 | |
| 121 | function packNpmRoot(npmDirectory, work, version, candidateSha) { |
| 122 | const stage = path.join(work, "npm-reasonix"); |
| 123 | mkdirSync(stage); |
| 124 | cpSync(path.join(ROOT, "npm", "reasonix", "bin"), path.join(stage, "bin"), { recursive: true }); |
| 125 | copyFileSync(path.join(ROOT, "README.md"), path.join(stage, "README.md")); |
| 126 | const pkg = JSON.parse(readFileSync(path.join(ROOT, "npm", "reasonix", "package.json"), "utf8")); |
| 127 | pkg.version = version; |
| 128 | pkg.reasonixCandidateSha = candidateSha; |
| 129 | pkg.gitHead = candidateSha; |
| 130 | for (const name of Object.keys(pkg.optionalDependencies)) pkg.optionalDependencies[name] = version; |
| 131 | writeFileSync(path.join(stage, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`); |
| 132 | run("npm", ["pack", stage, "--pack-destination", npmDirectory, "--silent"]); |
| 133 | } |
| 134 | |
| 135 | export function buildCandidate(version, output, env = process.env) { |
| 136 | if (!VERSION_RE.test(version)) throw new Error("candidate version must be MAJOR.MINOR.PATCH"); |
| 137 | const candidateSha = env.RELEASE_SOURCE_SHA || execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT, encoding: "utf8" }).trim(); |
| 138 | if (!SHA_RE.test(candidateSha)) throw new Error("candidate source SHA must be full length"); |
| 139 | const buildTime = env.RELEASE_BUILD_TIME; |
| 140 | if (!buildTime || !Number.isFinite(new Date(buildTime).valueOf())) throw new Error("RELEASE_BUILD_TIME must be an ISO timestamp"); |
| 141 | mkdirSync(output); |
| 142 | const cliDirectory = path.join(output, "cli"); |
| 143 | const npmDirectory = path.join(output, "npm"); |
| 144 | const binaries = path.join(output, "binaries"); |
| 145 | mkdirSync(cliDirectory); mkdirSync(npmDirectory); mkdirSync(binaries); |
| 146 | const work = mkdtempSync(path.join(tmpdir(), "reasonix-cli-candidate-")); |
| 147 | try { |
| 148 | for (const target of targets) { |
| 149 | const filename = target.goos === "windows" ? "reasonix.exe" : "reasonix"; |
| 150 | const directory = path.join(binaries, target.key); |
| 151 | mkdirSync(directory); |
| 152 | const binary = path.join(directory, filename); |
| 153 | const ldflags = `-s -w -X main.version=v${version} -X main.gitCommit=${candidateSha.slice(0, 12)} -X main.buildTimeUTC=${buildTime} -X reasonix/internal/productdocs.linkedVersion=v${version} -X reasonix/internal/productdocs.linkedRevision=${candidateSha}`; |
| 154 | run("go", ["build", "-trimpath", "-ldflags", ldflags, "-o", binary, "./cmd/reasonix"], { |
| 155 | cwd: ROOT, env: { ...env, CGO_ENABLED: "0", GOOS: target.goos, GOARCH: target.goarch }, |
| 156 | }); |
| 157 | makeArchive(target, binary, cliDirectory, work); |
| 158 | packNpm(target, binary, npmDirectory, work, version, candidateSha); |
| 159 | } |
| 160 | packNpmRoot(npmDirectory, work, version, candidateSha); |
| 161 | const assets = targets.map(target => `reasonix-${target.key}.${target.goos === "windows" ? "zip" : "tar.gz"}`).sort(); |
| 162 | const sums = Object.fromEntries(assets.map(name => [name, sha256(path.join(cliDirectory, name))])); |
| 163 | writeFileSync(path.join(cliDirectory, "SHA256SUMS"), `${assets.map(name => `${sums[name]} ${name}`).join("\n")}\n`); |
| 164 | writeFileSync(path.join(cliDirectory, "reasonix.rb"), renderHomebrewCask(version, sums)); |
| 165 | } finally { |
| 166 | rmSync(work, { recursive: true, force: true }); |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { |
| 171 | const [version, output] = process.argv.slice(2); |
| 172 | if (!version || !output) throw new Error("usage: build-release-cli-candidate.mjs VERSION OUTPUT"); |
| 173 | buildCandidate(version, path.resolve(output)); |
| 174 | } |
| 175 |