Files
quak/test/packaging/build-bin.test.ts
T
clawbot c9c9e3e49d
check / check (push) Successful in 3m14s
make build-bin: on macOS, build with bun's pinned release build (closes #251)
nixpkgs points its macOS bun at an ICU in the nix store, and bun build
--compile copied that reference into bin/quak, so nix-collect-garbage could
break the cron backup. script/build-bin now downloads bun 1.3.13's release
archive on macOS too, Intel (baseline) or Apple silicon, checks its SHA-256,
and compiles with it. Those builds load only /usr/lib libraries. nix-shell is
no longer used; any system without a bun release build stops with an error.
The hash check falls back to shasum where sha256sum is missing.

Model: opus-5-5
2026-11-11 23:44:29 +00:00

179 lines
6.9 KiB
TypeScript

// `script/build-bin` compiles `bin/quak`, the single binary that the README's
// cron setup runs. bun compiles it from a copy of its own executable, so the
// binary loads the same libraries as the bun that built it. nix's bun loads
// libraries from the nix store, which garbage collection can delete: on Linux
// its C library, on macOS its ICU. The script therefore downloads bun's own
// release build, which loads only the system's libraries.
//
// What the script promises, and why each test matters:
//
// - Each system, machine and C library that bun publishes a build for gets its
// own archive: Linux on x86-64 or arm64, with glibc or with musl, as on
// Alpine, and macOS on Intel or Apple silicon. A bun built for another
// system, machine or C library does not start, and `make install` fails.
// - The archive's SHA-256 must match the one in the script before anything in
// the archive runs. Without that check, a tampered download would build the
// binary that cron runs.
// - Any other system or machine stops with an error, and never falls back to
// nix's bun. Building with nix's bun would bring back the dependency on the
// nix store.
//
// Each test copies the script into a fresh directory, which the script then
// treats as the checkout, and runs it there. Stubs first on PATH stand in for
// `uname`, `ldd`, `curl`, `unzip` and `nix-shell`, so no test downloads
// anything or runs a real bun.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
// The stubs, keyed by the command each replaces. `uname` and `ldd` describe the
// system that runBuildBin passes in the environment. `curl`, `unzip` and
// `nix-shell` write the arguments they were given, one per line, to a file
// named after the command in $STUB_LOG_DIR, which also shows that they ran.
const stubs: Record<string, string> = {
uname: `
case "$1" in
-s) echo "$STUB_UNAME_S" ;;
-m) echo "$STUB_UNAME_M" ;;
esac`,
// The script looks only at the start of the line: "musl libc" or not.
ldd: `
if [ "$STUB_LIBC" = musl ]; then
echo "musl libc (x86_64)"
else
echo "ldd (GNU libc) 2.36"
fi`,
// Writes a file that is not the archive to the path after -o.
curl: `
printf '%s\\n' "$@" > "$STUB_LOG_DIR/curl"
while [ $# -gt 1 ]; do
if [ "$1" = -o ]; then echo "not the archive" > "$2"; fi
shift
done`,
// Unpacks a stub bun where the real archive puts bun: in a directory named
// after the archive, inside the directory after -d. That bun writes
// bin/quak, as a real compile would.
unzip: `
printf '%s\\n' "$@" > "$STUB_LOG_DIR/unzip"
for arg in "$@"; do
case "$arg" in *.zip) archive="$arg" ;; esac
if [ "$previous" = -d ]; then directory="$arg"; fi
previous="$arg"
done
bun="$directory/$(basename "$archive" .zip)/bun"
mkdir -p "$(dirname "$bun")"
echo '#!/bin/sh' > "$bun"
echo 'mkdir -p bin && echo binary > bin/quak' >> "$bun"
chmod +x "$bun"`,
"nix-shell": `
printf '%s\\n' "$@" > "$STUB_LOG_DIR/nix-shell"`,
};
let checkout = "";
const writeScript = (path: string, body: string): void => {
writeFileSync(path, `#!/bin/sh\n${body}\n`);
chmodSync(path, 0o755);
};
// A checkout holding the script, a stub script/build that does nothing, the
// stubs, and the directory they log to.
beforeEach(() => {
checkout = mkdtempSync(join(tmpdir(), "quak-build-bin-"));
mkdirSync(join(checkout, "script"));
copyFileSync(
join(repoRoot, "script/build-bin"),
join(checkout, "script/build-bin"),
);
chmodSync(join(checkout, "script/build-bin"), 0o755);
writeScript(join(checkout, "script/build"), "exit 0");
mkdirSync(join(checkout, "stubs"));
for (const [command, body] of Object.entries(stubs)) {
writeScript(join(checkout, "stubs", command), body);
}
mkdirSync(join(checkout, "log"));
});
afterEach(() => {
rmSync(checkout, { recursive: true, force: true });
});
// Runs the script with nothing in its environment but the stubs' settings and a
// PATH that finds the stubs first.
const runBuildBin = (system: string, machine: string, libc = "glibc") =>
spawnSync(join(checkout, "script/build-bin"), {
cwd: checkout,
encoding: "utf-8",
env: {
PATH: `${join(checkout, "stubs")}:${process.env.PATH}`,
STUB_UNAME_S: system,
STUB_UNAME_M: machine,
STUB_LIBC: libc,
STUB_LOG_DIR: join(checkout, "log"),
},
});
const ran = (command: string): boolean =>
existsSync(join(checkout, "log", command));
const argumentsOf = (command: string): string[] =>
readFileSync(join(checkout, "log", command), "utf-8")
.trimEnd()
.split("\n");
describe("script/build-bin", () => {
// The stub curl writes a file that is not the archive, so the hash check
// must stop the script before unzip runs. A script without the check would
// run unzip, and the stub bun it unpacks would write bin/quak.
// On macOS `uname -m` says arm64 on Apple silicon, where bun's archive
// says aarch64.
it.each([
["Linux", "x86_64", "glibc", "bun-linux-x64-baseline.zip"],
["Linux", "x86_64", "musl", "bun-linux-x64-musl-baseline.zip"],
["Linux", "aarch64", "glibc", "bun-linux-aarch64.zip"],
["Linux", "aarch64", "musl", "bun-linux-aarch64-musl.zip"],
["Darwin", "x86_64", "glibc", "bun-darwin-x64-baseline.zip"],
["Darwin", "arm64", "glibc", "bun-darwin-aarch64.zip"],
])(
"on %s %s with %s, downloads %s and stops when its hash does not match",
(system, machine, libc, archive) => {
const result = runBuildBin(system, machine, libc);
expect(argumentsOf("curl")).toContain(
"https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" +
archive,
);
expect(result.status).not.toBe(0);
expect(ran("unzip")).toBe(false);
expect(ran("nix-shell")).toBe(false);
expect(existsSync(join(checkout, "bin/quak"))).toBe(false);
},
);
it.each([
["Linux", "riscv64"],
["FreeBSD", "amd64"],
])(
"stops on %s %s, which bun publishes no build for",
(system, machine) => {
const result = runBuildBin(system, machine);
expect(result.status).not.toBe(0);
expect(ran("curl")).toBe(false);
expect(ran("nix-shell")).toBe(false);
},
);
});