check / check (push) Successful in 3m10s
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
240 lines
9.0 KiB
TypeScript
240 lines
9.0 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`, `nix-shell` and `shasum`, so no test
|
|
// downloads anything or runs a real bun.
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
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`,
|
|
// `nix-shell` and `shasum` 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"`,
|
|
// The script checks the hash with `shasum` where there is no `sha256sum`.
|
|
// The stub fails, as `shasum` does when the hash does not match.
|
|
shasum: `
|
|
printf '%s\\n' "$@" > "$STUB_LOG_DIR/shasum"
|
|
exit 1`,
|
|
};
|
|
|
|
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. Unless the caller passes one, the PATH finds the stubs first and then
|
|
// the host's commands.
|
|
const runBuildBin = (
|
|
system: string,
|
|
machine: string,
|
|
{
|
|
libc = "glibc",
|
|
path = `${join(checkout, "stubs")}:${process.env.PATH}`,
|
|
} = {},
|
|
) =>
|
|
spawnSync(join(checkout, "script/build-bin"), {
|
|
cwd: checkout,
|
|
encoding: "utf-8",
|
|
env: {
|
|
PATH: path,
|
|
STUB_UNAME_S: system,
|
|
STUB_UNAME_M: machine,
|
|
STUB_LIBC: libc,
|
|
STUB_LOG_DIR: join(checkout, "log"),
|
|
},
|
|
});
|
|
|
|
// The real commands that the script and the stubs run.
|
|
const hostCommands = [
|
|
"basename",
|
|
"chmod",
|
|
"dirname",
|
|
"grep",
|
|
"mkdir",
|
|
"mktemp",
|
|
"rm",
|
|
];
|
|
|
|
// A Mac has no `ldd`, and not every Mac has `sha256sum`. This makes a PATH that
|
|
// finds neither: the stubs other than `ldd`, then links to hostCommands.
|
|
const makeMacPath = (): string => {
|
|
rmSync(join(checkout, "stubs", "ldd"));
|
|
mkdirSync(join(checkout, "host-commands"));
|
|
for (const command of hostCommands) {
|
|
const target = execFileSync("sh", ["-c", `command -v ${command}`], {
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
symlinkSync(target, join(checkout, "host-commands", command));
|
|
}
|
|
return `${join(checkout, "stubs")}:${join(checkout, "host-commands")}`;
|
|
};
|
|
|
|
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.
|
|
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"],
|
|
])(
|
|
"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);
|
|
},
|
|
);
|
|
|
|
// On the PATH from makeMacPath the script finds no `sha256sum` and checks
|
|
// the hash with the stub `shasum`. That stub fails, so the script must stop
|
|
// before unzip runs.
|
|
// On Apple silicon `uname -m` says arm64, where bun's archive says aarch64.
|
|
it.each([
|
|
["Darwin", "x86_64", "bun-darwin-x64-baseline.zip"],
|
|
["Darwin", "arm64", "bun-darwin-aarch64.zip"],
|
|
])(
|
|
"on %s %s, downloads %s and stops when shasum rejects its hash",
|
|
(system, machine, archive) => {
|
|
const result = runBuildBin(system, machine, {
|
|
path: makeMacPath(),
|
|
});
|
|
expect(argumentsOf("curl")).toContain(
|
|
"https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" +
|
|
archive,
|
|
);
|
|
expect(argumentsOf("shasum")).toEqual(["-a", "256", "-c", "-"]);
|
|
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);
|
|
},
|
|
);
|
|
});
|