Preserve glibc 2.36 runtime compatibility (#365)

- Pin GNU runtime packs to glibc 2.36 Zig targets and reject incompatible imports.

- Provision verified Zig builds and exercise published artifacts on Debian Bookworm.

Fixes #349

Co-authored-by: Cruel <383999+Cruel@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-20 22:36:10 -05:00
committed by GitHub
co-authored by Cruel
parent f2c81630ef
commit f5ed014cc7
14 changed files with 254 additions and 19 deletions
+10 -2
View File
@@ -114,7 +114,7 @@ jobs:
llvm_artifacts_linux_x64:
name: test (Linux x64 LLVM artifacts, no clang)
runs-on: ubuntu-24.04
timeout-minutes: 25
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -135,10 +135,10 @@ jobs:
echo "$RUNNER_TEMP/llvm-22.1.8/bin" >> "$GITHUB_PATH"
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @scriptc/llvm-linux-x64-gnu build:native
- run: CC=clang AR=llvm-ar pnpm --filter @scriptc/runtime-linux-x64-gnu build:native
- uses: vercel-labs/setup-zig@v1
with:
version: 0.16.0
- run: CC=zig AR=zig pnpm --filter @scriptc/runtime-linux-x64-gnu build:native
- name: Build and verify Linux x64 musl release packages
run: |
pnpm --filter @scriptc/llvm-linux-x64-musl build:native
@@ -173,6 +173,14 @@ jobs:
run: |
node packages/cli/dist/main.js build tests/corpus/001-hello.ts -o "$RUNNER_TEMP/hello"
test "$("$RUNNER_TEMP/hello")" = 'hello world'
- name: Debian 12 glibc 2.36 runtime-pack smoke
run: |
docker run --rm --volume "$GITHUB_WORKSPACE:/work:ro" --workdir /work node:24-bookworm-slim sh -ec '
apt-get update
apt-get install --yes --no-install-recommends clang
SCRIPTC_NO_CACHE=1 SCRIPTC_LEGACY_C_PIPELINE=0 node packages/cli/dist/main.js build tests/corpus/001-hello.ts -o /tmp/hello
test "$(/tmp/hello)" = "hello world"
'
- name: WASI helper object/runtime-pack smoke
run: pnpm test packages/cli/test/wasi-runtime-pack.test.ts
+2
View File
@@ -70,11 +70,13 @@ jobs:
helper: llvm-linux-x64-gnu
runtime: runtime-linux-x64-gnu
llvm_asset: LLVM-22.1.8-Linux-X64
use_zig: true
- platform: linux-arm64
runner: ubuntu-24.04-arm
helper: llvm-linux-arm64-gnu
runtime: runtime-linux-arm64-gnu
llvm_asset: LLVM-22.1.8-Linux-ARM64
use_zig: true
- platform: windows-x64
runner: windows-2022
helper: llvm-win32-x64-msvc
+1 -6
View File
@@ -13,12 +13,7 @@ pnpm install && pnpm -r build # build the workspace
pnpm test:sandbox # full gate: ~4m custom image, ~9m cold managed fallback
```
The ordinary workspace build does not rebuild packaged native artifacts.
When changing native assembly/object emission or runtime-pack selection,
install CMake, Ninja, and the pinned LLVM 22 development package, then run the
matching `@scriptc/llvm-<platform>` and `@scriptc/runtime-<platform>`
`build:native` scripts explicitly. The macOS full test suite also needs its
generated artifacts.
The ordinary workspace build does not rebuild packaged native artifacts. When changing native assembly/object emission or runtime-pack selection, install CMake, Ninja, the pinned LLVM 22 development package, and Zig 0.16.0, then run the matching `@scriptc/llvm-<platform>` and `@scriptc/runtime-<platform>` `build:native` scripts explicitly. The macOS full test suite also needs its generated artifacts.
Use focused local tests while iterating, then use `pnpm test:sandbox` whenever a
full validation gate is required. It loads Sandbox configuration from the
+5
View File
@@ -3,6 +3,7 @@ FROM ubuntu:24.04
ARG NODE_VERSION
ARG PNPM_VERSION=11.1.3
ARG ZIG_VERSION=0.16.0
ENV DEBIAN_FRONTEND=noninteractive
@@ -45,6 +46,10 @@ RUN curl --fail --silent --show-error --location \
&& tar -xJf /tmp/node.tar.xz --directory /usr/local --strip-components=1 \
&& rm /tmp/node.tar.xz /tmp/SHASUMS256.txt
COPY scripts/install-zig.sh /tmp/install-zig.sh
RUN sh /tmp/install-zig.sh "$ZIG_VERSION" \
&& rm /tmp/install-zig.sh
RUN npm install --global "pnpm@${PNPM_VERSION}"
WORKDIR /workspace
@@ -1,5 +1,12 @@
import { dirname } from "node:path"; import { fileURLToPath } from "node:url";
if (process.platform !== "linux" || process.arch !== "arm64") { process.stdout.write("@scriptc/runtime-linux-arm64-gnu: skipped on this host\n"); process.exit(0); }
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { glibcRuntimeToolchain } from "../../runtime-pack-common/scripts/glibc-toolchain.mjs";
if (process.platform !== "linux" || process.arch !== "arm64") {
process.stdout.write("@scriptc/runtime-linux-arm64-gnu: skipped on this host\n");
process.exit(0);
}
process.env.SCRIPTC_RUNTIME_PACK_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ platform: "linux", runtimeDefines: ["_GNU_SOURCE"], threadArgs: ["-pthread"], target: { name: "linux-arm64-gnu", llvm_triple: "aarch64-unknown-linux-gnu", architecture: "arm64", object_format: "elf", minimum_os: "glibc 2.36" }, targetArgs: ["-target", "aarch64-unknown-linux-gnu"], compileFlags: ["-ffunction-sections", "-fdata-sections"], systemLibraries: [{ name: "m", predicate: true }] });
const { minimumOs, ...toolchain } = glibcRuntimeToolchain("arm64");
process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ platform: "linux", runtimeDefines: ["_GNU_SOURCE"], threadArgs: ["-pthread"], target: { name: "linux-arm64-gnu", llvm_triple: "aarch64-unknown-linux-gnu", architecture: "arm64", object_format: "elf", minimum_os: minimumOs }, ...toolchain, compileFlags: ["-ffunction-sections", "-fdata-sections"], systemLibraries: [{ name: "m", predicate: true }] });
await import("../../runtime-pack-common/scripts/build.mjs");
@@ -1,5 +1,12 @@
import { dirname } from "node:path"; import { fileURLToPath } from "node:url";
if (process.platform !== "linux" || process.arch !== "x64") { process.stdout.write("@scriptc/runtime-linux-x64-gnu: skipped on this host\n"); process.exit(0); }
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { glibcRuntimeToolchain } from "../../runtime-pack-common/scripts/glibc-toolchain.mjs";
if (process.platform !== "linux" || process.arch !== "x64") {
process.stdout.write("@scriptc/runtime-linux-x64-gnu: skipped on this host\n");
process.exit(0);
}
process.env.SCRIPTC_RUNTIME_PACK_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ platform: "linux", runtimeDefines: ["_GNU_SOURCE"], threadArgs: ["-pthread"], target: { name: "linux-x64-gnu", llvm_triple: "x86_64-unknown-linux-gnu", architecture: "x64", object_format: "elf", minimum_os: "glibc 2.36" }, targetArgs: ["-target", "x86_64-unknown-linux-gnu"], compileFlags: ["-ffunction-sections", "-fdata-sections"], systemLibraries: [{ name: "m", predicate: true }] });
const { minimumOs, ...toolchain } = glibcRuntimeToolchain("x64");
process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({ platform: "linux", runtimeDefines: ["_GNU_SOURCE"], threadArgs: ["-pthread"], target: { name: "linux-x64-gnu", llvm_triple: "x86_64-unknown-linux-gnu", architecture: "x64", object_format: "elf", minimum_os: minimumOs }, ...toolchain, compileFlags: ["-ffunction-sections", "-fdata-sections"], systemLibraries: [{ name: "m", predicate: true }] });
await import("../../runtime-pack-common/scripts/build.mjs");
@@ -0,0 +1,32 @@
import { readFile, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";
export async function assertArtifactsExcludeStrings(root, forbiddenStrings) {
if (!Array.isArray(forbiddenStrings)) {
throw new Error("runtime-pack forbidden artifact strings must be an array");
}
if (forbiddenStrings.length === 0) return;
if (forbiddenStrings.some((value) => typeof value !== "string" || value.length === 0)) {
throw new Error("runtime-pack forbidden artifact strings must be non-empty strings");
}
const forbidden = forbiddenStrings.map((value) => ({ value, bytes: Buffer.from(value) }));
const visit = async (directory) => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
await visit(path);
continue;
}
if (!entry.isFile()) continue;
const bytes = await readFile(path);
const match = forbidden.find((candidate) => bytes.includes(candidate.bytes));
if (match !== undefined) {
const artifact = relative(root, path).split(sep).join("/");
throw new Error(
`runtime-pack artifact ${artifact} contains forbidden symbol family ${match.value}`,
);
}
}
};
await visit(root);
}
@@ -3,10 +3,11 @@ import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { availableParallelism, tmpdir } from "node:os";
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { basename, dirname, join, relative, sep } from "node:path";
import { dirname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { createRuntimePackMatrix } from "../runtime-pack-matrix.mjs";
import { assertArtifactsExcludeStrings } from "./artifact-policy.mjs";
import { createDeterministicArchive } from "./archive.mjs";
import { installRuntimePack, withBuildLock } from "./build-state.mjs";
@@ -35,7 +36,7 @@ async function build() {
const archiver = process.env.AR ?? config.archiver ?? "ar";
const archiverArgs = config.archiverArgs ?? [];
const commonFlags = [
...config.targetArgs, "-std=c11", ...(config.threadArgs ?? []), "-fno-math-errno", "-fno-strict-aliasing",
...config.targetArgs, ...(config.compilerFlags ?? []), "-std=c11", ...(config.threadArgs ?? []), "-fno-math-errno", "-fno-strict-aliasing",
...matrix.executable_section_elimination.compile_flags, "-Wno-deprecated-declarations", "-I", runtimeSrc,
...(config.runtimeDefines ?? []).map((define) => `-D${define}`),
];
@@ -55,6 +56,7 @@ async function build() {
for (let i = 0; i < items.length; i += width) await Promise.all(items.slice(i, i + width).map(task));
};
const archive = async (id, sources, sourceRoot, flags) => {
process.stdout.write(`building ${packageManifest.name} ${id} archive\n`);
const root = join(stagedOutputRoot, "vendor", id);
const objectRoot = join(root, "objects");
await parallel(sources, async (source) => compile(join(sourceRoot, source), join(objectRoot, source.replace(/\.c$/, ".o")), flags));
@@ -80,6 +82,7 @@ async function build() {
}
const flavors = {};
for (const [flavor, flavorSpec] of Object.entries(matrix.flavors)) {
process.stdout.write(`building ${packageManifest.name} ${flavor} runtime\n`);
const units = [];
for (const unit of matrix.runtime_units) {
const variants = [];
@@ -103,6 +106,7 @@ async function build() {
// selected, so vendor sources inherit the target's runtime defines too.
const vendorTarget = [
...config.targetArgs,
...(config.compilerFlags ?? []),
...(config.runtimeDefines ?? []).map((define) => `-D${define}`),
];
const requestedArchives = new Set(matrix.archives.map((entry) => entry.id));
@@ -112,6 +116,7 @@ async function build() {
...(requestedArchives.has("zlib") ? [await archive("zlib", zlibSources, zlib, [...vendorTarget, "-std=c11", "-Os", "-I", zlib])] : []),
...(requestedArchives.has("mbedtls") ? [await archive("mbedtls", mbedtlsSources, join(mbedtls, "library"), [...vendorTarget, "-std=c11", "-Os", "-I", join(mbedtls, "include"), "-I", join(mbedtls, "library")])] : []),
];
await assertArtifactsExcludeStrings(stagedOutputRoot, config.forbiddenArtifactStrings ?? []);
const archiveSpecs = new Map(matrix.archives.map((entry) => [entry.id, entry]));
const licensed = [[join(runtimeRoot, "LICENSE"), "artifacts/licenses/scriptc-runtime.txt", "Apache-2.0"], [join(quickjs, "LICENSE"), "artifacts/licenses/quickjs-ng.txt", "MIT"], [join(vendorRoot, "ryu", "LICENSE-Boost"), "artifacts/licenses/ryu.txt", "BSL-1.0"], [join(zlib, "LICENSE"), "artifacts/licenses/zlib.txt", "Zlib"], [join(mbedtls, "LICENSE"), "artifacts/licenses/mbedtls.txt", "Apache-2.0"]];
await Promise.all(licensed.map(async ([source, destination]) => { const output = join(buildRoot, destination); await mkdir(dirname(output), { recursive: true }); await copyFile(source, output); }));
@@ -0,0 +1,23 @@
const GLIBC_RUNTIME_FLOOR = "2.36";
const GNU_ARCHITECTURES = {
x64: "x86_64",
arm64: "aarch64",
};
export function glibcRuntimeToolchain(architecture) {
const targetArchitecture = GNU_ARCHITECTURES[architecture];
if (targetArchitecture === undefined) {
throw new Error(`unsupported GNU runtime architecture: ${architecture}`);
}
return {
minimumOs: `glibc ${GLIBC_RUNTIME_FLOOR}`,
compiler: "zig",
compilerArgs: ["cc"],
archiver: "zig",
archiverArgs: ["ar"],
targetArgs: ["-target", `${targetArchitecture}-linux-gnu.${GLIBC_RUNTIME_FLOOR}`],
compilerFlags: ["-fno-sanitize=undefined"],
forbiddenArtifactStrings: ["__isoc23_", "__ubsan_"],
};
}
@@ -0,0 +1,31 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { assertArtifactsExcludeStrings } from "../scripts/artifact-policy.mjs";
test("accepts artifacts whose imports stay within the declared libc floor", async () => {
const root = await mkdtemp(join(tmpdir(), "scriptc-artifact-policy-"));
try {
await writeFile(join(root, "runtime.o"), Buffer.from("strtol\0__isoc99_sscanf\0"));
await expect(assertArtifactsExcludeStrings(root, ["__isoc23_", "__ubsan_"])).resolves.toBeUndefined();
} finally {
await rm(root, { recursive: true, force: true });
}
});
test.each([
["__isoc23_sscanf", "__isoc23_"],
["__ubsan_handle_add_overflow", "__ubsan_"],
])("rejects the %s import in nested runtime artifacts", async (symbol, family) => {
const root = await mkdtemp(join(tmpdir(), "scriptc-artifact-policy-"));
try {
await mkdir(join(root, "release", "runtime"), { recursive: true });
await writeFile(join(root, "release", "runtime", "scr_lib.o"), Buffer.from(`${symbol}\0`));
await expect(assertArtifactsExcludeStrings(root, ["__isoc23_", "__ubsan_"])).rejects.toThrow(
`runtime-pack artifact release/runtime/scr_lib.o contains forbidden symbol family ${family}`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,24 @@
import { expect, test } from "vitest";
import { glibcRuntimeToolchain } from "../scripts/glibc-toolchain.mjs";
test.each([
["x64", "x86_64-linux-gnu.2.36"],
["arm64", "aarch64-linux-gnu.2.36"],
])("pins the %s GNU runtime to its glibc floor", (architecture, target) => {
expect(glibcRuntimeToolchain(architecture)).toEqual({
minimumOs: "glibc 2.36",
compiler: "zig",
compilerArgs: ["cc"],
archiver: "zig",
archiverArgs: ["ar"],
targetArgs: ["-target", target],
compilerFlags: ["-fno-sanitize=undefined"],
forbiddenArtifactStrings: ["__isoc23_", "__ubsan_"],
});
});
test("rejects unknown GNU runtime architectures", () => {
expect(() => glibcRuntimeToolchain("riscv64")).toThrow(
"unsupported GNU runtime architecture: riscv64",
);
});
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
set -eu
version=${1:?usage: install-zig.sh <version>}
case "$version" in
*[!0-9A-Za-z.+-]*) echo "invalid Zig version: $version" >&2; exit 1 ;;
esac
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) target=x86_64-linux ;;
Linux-aarch64 | Linux-arm64) target=aarch64-linux ;;
*) echo "unsupported Zig host: $(uname -s)/$(uname -m)" >&2; exit 1 ;;
esac
if command -v zig >/dev/null 2>&1 && [ "$(zig version)" = "$version" ]; then
exit 0
fi
temporary=$(mktemp -d)
trap 'rm -rf "$temporary"' EXIT HUP INT TERM
curl --fail --silent --show-error --location \
https://ziglang.org/download/index.json \
--output "$temporary/index.json"
index_size=$(wc -c <"$temporary/index.json" | tr -d ' ')
if [ "$index_size" -gt 2097152 ]; then
echo "Zig download index exceeds 2 MiB" >&2
exit 1
fi
node --input-type=module - "$version" "$target" "$temporary/index.json" >"$temporary/metadata" <<'NODE'
import { readFile } from "node:fs/promises";
const [version, target, indexPath] = process.argv.slice(2);
const release = JSON.parse(await readFile(indexPath, "utf8"))[version];
const artifact = release?.[target];
if (!artifact?.tarball || !artifact?.shasum || !artifact?.size) {
throw new Error(`Zig ${version} does not publish ${target}`);
}
const url = new URL(artifact.tarball);
if (url.protocol !== "https:" || !/^[0-9a-f]{64}$/.test(artifact.shasum)) {
throw new Error(`Zig ${version} returned invalid ${target} metadata`);
}
process.stdout.write(`${url.href}\n${artifact.shasum}\n${artifact.size}\n`);
NODE
url=$(sed -n '1p' "$temporary/metadata")
sha256=$(sed -n '2p' "$temporary/metadata")
expected_size=$(sed -n '3p' "$temporary/metadata")
archive="$temporary/zig.tar.xz"
curl --fail --silent --show-error --location "$url" --output "$archive"
printf '%s %s\n' "$sha256" "$archive" | sha256sum --check --strict -
actual_size=$(wc -c <"$archive" | tr -d ' ')
if [ "$actual_size" != "$expected_size" ]; then
echo "Zig archive size mismatch: expected $expected_size, got $actual_size" >&2
exit 1
fi
as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
destination="/opt/zig-$version"
as_root rm -rf "$destination"
as_root mkdir -p "$destination"
as_root tar -xJf "$archive" --directory "$destination" --strip-components=1
as_root ln -sf "$destination/zig" /usr/local/bin/zig
test "$(zig version)" = "$version"
+5 -2
View File
@@ -3,8 +3,9 @@ set -eu
node_version=$(tr -d '\r\n' < .node-version)
pnpm_version=$(sed -n 's/^ARG PNPM_VERSION=//p' Dockerfile.sandbox)
if [ -z "$node_version" ] || [ -z "$pnpm_version" ]; then
echo "could not read the pinned Node or pnpm version" >&2
zig_version=$(sed -n 's/^ARG ZIG_VERSION=//p' Dockerfile.sandbox)
if [ -z "$node_version" ] || [ -z "$pnpm_version" ] || [ -z "$zig_version" ]; then
echo "could not read the pinned Node, pnpm, or Zig version" >&2
exit 1
fi
@@ -50,6 +51,7 @@ grep " ${node_archive}$" /tmp/node-SHASUMS256.txt \
| sed "s# ${node_archive}# /tmp/${node_archive}#" \
| sha256sum --check --strict -
sudo tar -xJf "/tmp/${node_archive}" --directory /usr/local --strip-components=1
sh scripts/install-zig.sh "$zig_version"
sudo npm install --global "pnpm@${pnpm_version}"
rm -f "/tmp/${node_archive}" /tmp/node-SHASUMS256.txt /tmp/llvm-snapshot.gpg.key /tmp/llvm-snapshot.gpg
@@ -57,4 +59,5 @@ sudo rm -rf /var/lib/apt/lists/*
node --version
pnpm --version
zig version
clang --version | sed -n '1p'
+22 -1
View File
@@ -725,7 +725,28 @@ try {
// Workspace builds deliberately do not rebuild packaged native artifacts.
// Every remote lane needs the Linux helper and runtime from this worktree.
await execIn(worker, "pnpm", ["--filter", "@scriptc/llvm-linux-x64-gnu", "build:native"], {}, "LLVM helper", 5 * 60_000);
await execIn(worker, "pnpm", ["--filter", "@scriptc/runtime-linux-x64-gnu", "build:native"], { CC: "clang-22", AR: "llvm-ar-22" }, "runtime pack", 5 * 60_000);
await execIn(
worker,
"pnpm",
["--filter", "@scriptc/runtime-linux-x64-gnu", "build:native"],
{ CC: "zig", AR: "zig" },
"runtime pack",
5 * 60_000,
"/workspace",
3 * 60_000,
);
// Zig is a build-only dependency in this lane. Cross-target suites own
// the conditional Zig tests; exposing it here would silently expand the
// native-cache shard while that shard deliberately disables stable
// toolchain caching.
await execIn(
worker,
"sudo",
["rm", "-f", "/usr/local/bin/zig"],
{},
"runtime toolchain cleanup",
60_000,
);
}, imageConfig.custom ? workers.length : 8);
await allWorkers("Testing", async (worker) => {