mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-19 16:04:12 +00:00
fix(ci): fail releases that bundle native code for the wrong CPU (#54982)
This commit is contained in:
parent
126b7b633b
commit
3731aace4a
4 changed files with 130 additions and 4 deletions
|
|
@ -14,6 +14,7 @@ import { appArchiveAsset, mainConfig } from "../vite.node.config"
|
|||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact } from "./verify-artifact"
|
||||
import { NativeArch } from "../../../script/native-arch"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
|
@ -133,6 +134,7 @@ for (const target of targets) {
|
|||
await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`)
|
||||
run(builder, ["--build-sea", "dist-node/sea.json"])
|
||||
if (target.platform !== "win32") await chmod(output, 0o755)
|
||||
await NativeArch.verify(output, target.arch)
|
||||
if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output])
|
||||
if (target.platform === "darwin" && process.platform !== "darwin") {
|
||||
console.warn(`${output} must be signed on macOS before it can run`)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@openc
|
|||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
import { NativeArch } from "../../../script/native-arch"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode"
|
||||
|
|
@ -204,6 +205,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
|||
process.exit(1)
|
||||
}
|
||||
verifySimulationGraph(simulationInputs)
|
||||
await NativeArch.verify(path.join(outdir, name, "bin", item.os === "win32" ? `${binary}.exe` : binary), item.arch)
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, name, "package.json"),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { execFile } from "node:child_process"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
import type { CustomMacSignOptions } from "app-builder-lib"
|
||||
import type { Configuration } from "electron-builder"
|
||||
import { Arch, type Configuration } from "electron-builder"
|
||||
import { NativeArch } from "../../script/native-arch"
|
||||
import { CLI_BINARIES, getCurrentCli, nodePtyPackage } from "./scripts/utils"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
|
@ -116,28 +117,47 @@ const getBase = (appId: string): Configuration => {
|
|||
},
|
||||
],
|
||||
afterPack: async (context) => {
|
||||
const arch = Arch[context.arch]
|
||||
|
||||
// The --arm64 flag picks Electron while OPENCODE_CLI_TARGET picks the CLI and node-pty, so they can disagree.
|
||||
if (arch !== "x64" && arch !== "arm64") throw new Error(`Unsupported desktop architecture: ${arch}`)
|
||||
|
||||
if (arch !== target.cpu) throw new Error(`Packaging ${arch} Electron with the ${target.target} CLI`)
|
||||
const resources = context.packager.getResourcesDir(context.appOutDir)
|
||||
const cli = path.join(resources, context.electronPlatformName === "win32" ? "opencode-cli.exe" : "opencode-cli")
|
||||
|
||||
const file = await stat(cli)
|
||||
|
||||
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
|
||||
await NativeArch.verify(cli, arch)
|
||||
const version = path.join(path.dirname(cli), "opencode-cli.version")
|
||||
|
||||
if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`)
|
||||
|
||||
if (context.electronPlatformName !== "win32") return
|
||||
|
||||
const addon = path.join(
|
||||
const prebuilds = path.join(
|
||||
resources,
|
||||
"app.asar.unpacked/node_modules",
|
||||
nodePtyPackage(target.target),
|
||||
"prebuilds",
|
||||
`${target.os}-${target.cpu}`,
|
||||
"conpty.node",
|
||||
)
|
||||
|
||||
const addon = path.join(prebuilds, "conpty.node")
|
||||
|
||||
if ((await stat(addon)).size === 0) throw new Error(`Bundled node-pty must be a non-empty file: ${addon}`)
|
||||
const entries = await readdir(prebuilds, { recursive: true, withFileTypes: true })
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map(async (entry) => {
|
||||
const file = path.join(entry.parentPath, entry.name)
|
||||
|
||||
if (await NativeArch.isNative(file)) await NativeArch.verify(file, arch)
|
||||
}),
|
||||
)
|
||||
},
|
||||
mac: {
|
||||
category: "public.app-category.developer-tools",
|
||||
|
|
|
|||
102
script/native-arch.ts
Normal file
102
script/native-arch.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { readFile } from "node:fs/promises"
|
||||
|
||||
type Image = {
|
||||
readonly offset: number
|
||||
readonly format: "PE" | "ELF" | "Mach-O"
|
||||
readonly arches: readonly string[]
|
||||
// A universal Mach-O ends after its last slice; the thin slices inside it are not separate images.
|
||||
readonly end?: number
|
||||
}
|
||||
|
||||
const PE_MACHINES: Record<number, string> = { 0x8664: "x64", 0xaa64: "arm64", 0x014c: "x86", 0x01c4: "arm" }
|
||||
const ELF_MACHINES: Record<number, string> = { 62: "x64", 183: "arm64", 3: "x86", 40: "arm" }
|
||||
const MACHO_CPUS: Record<number, string> = { 0x01000007: "x64", 0x0100000c: "arm64", 7: "x86", 12: "arm" }
|
||||
|
||||
export namespace NativeArch {
|
||||
/** Every PE, ELF and Mach-O image in `bytes`, including the libraries Bun embeds in a compiled executable. */
|
||||
export function images(bytes: Buffer) {
|
||||
const universal = find(bytes, "\xca\xfe\xba\xbe", fat)
|
||||
const thin = find(bytes, "\xcf\xfa\xed\xfe", macho).filter(
|
||||
(image) => !universal.some((parent) => image.offset > parent.offset && image.offset < (parent.end ?? 0)),
|
||||
)
|
||||
return [...find(bytes, "MZ", pe), ...find(bytes, "\x7fELF", elf), ...thin, ...universal].sort(
|
||||
(left, right) => left.offset - right.offset,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails unless `file` is a native image for `arch` and every image embedded in it targets `arch` too. A
|
||||
* wrong-architecture library loads only on the machine that needs it, so it ships unnoticed: 2.0.27's ARM64
|
||||
* Windows CLI embedded an x64 rust_pty.dll and could not open a terminal.
|
||||
*/
|
||||
export async function verify(file: string, arch: "x64" | "arm64") {
|
||||
const found = images(await readFile(file))
|
||||
if (found[0]?.offset !== 0) throw new Error(`${file} is not a native executable or library`)
|
||||
const wrong = found.filter((image) => !image.arches.includes(arch))
|
||||
if (wrong.length === 0) return
|
||||
throw new Error(
|
||||
`${file} must contain only ${arch} code, but has ${wrong
|
||||
.map((image) => `${image.format} ${image.arches.join("+")} at byte ${image.offset}`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether `file` starts with a native image, so a package scan can skip scripts, data and debug symbols. */
|
||||
export async function isNative(file: string) {
|
||||
const bytes = await readFile(file)
|
||||
return images(bytes.subarray(0, Math.min(bytes.length, 64 * 1024))).some((image) => image.offset === 0)
|
||||
}
|
||||
}
|
||||
|
||||
function find(bytes: Buffer, magic: string, parse: (bytes: Buffer, offset: number) => Image | undefined) {
|
||||
const needle = Buffer.from(magic, "latin1")
|
||||
const found: Image[] = []
|
||||
for (let offset = bytes.indexOf(needle); offset !== -1; offset = bytes.indexOf(needle, offset + 1)) {
|
||||
const image = parse(bytes, offset)
|
||||
if (image) found.push(image)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function pe(bytes: Buffer, offset: number): Image | undefined {
|
||||
if (offset + 0x40 > bytes.length) return
|
||||
const header = offset + bytes.readUInt32LE(offset + 0x3c)
|
||||
if (header - offset < 0x40 || header - offset > 0x1000 || header + 6 > bytes.length) return
|
||||
if (bytes.readUInt32LE(header) !== 0x4550) return
|
||||
const machine = bytes.readUInt16LE(header + 4)
|
||||
return { offset, format: "PE", arches: [PE_MACHINES[machine] ?? `machine 0x${machine.toString(16)}`] }
|
||||
}
|
||||
|
||||
function elf(bytes: Buffer, offset: number): Image | undefined {
|
||||
if (offset + 24 > bytes.length) return
|
||||
const elfClass = bytes[offset + 4]
|
||||
const data = bytes[offset + 5]
|
||||
if ((elfClass !== 1 && elfClass !== 2) || (data !== 1 && data !== 2) || bytes[offset + 6] !== 1) return
|
||||
const machine = data === 1 ? bytes.readUInt16LE(offset + 18) : bytes.readUInt16BE(offset + 18)
|
||||
const version = data === 1 ? bytes.readUInt32LE(offset + 20) : bytes.readUInt32BE(offset + 20)
|
||||
if (version !== 1) return
|
||||
return { offset, format: "ELF", arches: [ELF_MACHINES[machine] ?? `machine ${machine}`] }
|
||||
}
|
||||
|
||||
function macho(bytes: Buffer, offset: number): Image | undefined {
|
||||
if (offset + 8 > bytes.length) return
|
||||
const arch = MACHO_CPUS[bytes.readUInt32LE(offset + 4)]
|
||||
if (!arch) return
|
||||
return { offset, format: "Mach-O", arches: [arch] }
|
||||
}
|
||||
|
||||
function fat(bytes: Buffer, offset: number): Image | undefined {
|
||||
if (offset + 8 > bytes.length) return
|
||||
const count = bytes.readUInt32BE(offset + 4)
|
||||
// Java class files share this magic; their version field reads as 45 or more here.
|
||||
if (count < 1 || count > 8 || offset + 8 + count * 20 > bytes.length) return
|
||||
const slices = Array.from({ length: count }, (_, index) => offset + 8 + index * 20)
|
||||
const arches = slices.map((slice) => MACHO_CPUS[bytes.readUInt32BE(slice)])
|
||||
if (arches.some((arch) => arch === undefined)) return
|
||||
return {
|
||||
offset,
|
||||
format: "Mach-O",
|
||||
arches: arches.filter((arch) => arch !== undefined),
|
||||
end: offset + Math.max(...slices.map((slice) => bytes.readUInt32BE(slice + 8) + bytes.readUInt32BE(slice + 12))),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue