Skip to content

Commit cf8ffb8

Browse files
authored
feat(build): add musl binaries for Alpine Linux support (#762)
## Summary Closes #754 - Add `sentry-linux-x64-musl` and `sentry-linux-arm64-musl` native binaries using Bun's built-in musl cross-compilation targets - Add runtime musl detection so the install script and self-upgrade download the correct binary variant - Add Alpine Docker smoke test in CI to verify musl binaries work ## Changes **Build system** (`script/build.ts`): Extend `BuildTarget` with `libc?: "musl"`, add 2 musl entries to `ALL_TARGETS`, update `getPackageName`/`getBunTarget`/`parseTarget` to handle musl suffix. **Runtime detection** (`src/lib/binary.ts`): New `isMusl()` function checks for `/lib/ld-musl-<arch>.so.1` (fast stat) with `ldd --version` fallback. `getPlatformBinaryName()` appends `-musl` on musl systems — auto-propagates to upgrade and download URLs. **Install script** (`install`): `detect_musl` function with same two heuristics. `libc_suffix` applied to both nightly (GHCR) and stable (GitHub Releases) download paths. `libc` tag added to error telemetry. **CI** (`.github/workflows/ci.yml`): `linux-x64-musl` and `linux-arm64-musl` added to full build matrix (cross-compiled on ubuntu-latest). Alpine Docker smoke test for x64-musl. **No changes needed** to `.craft.yml` (regex already matches), Homebrew formula (correctly excludes musl), or downstream consumers of `getPlatformBinaryName()`.
1 parent 26b033d commit cf8ffb8

6 files changed

Lines changed: 210 additions & 22 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,13 @@ jobs:
7373
else
7474
# main, release/**, workflow_call: full cross-platform matrix
7575
echo '{"include":[
76-
{"target":"darwin-arm64", "os":"macos-latest", "can-test":true},
77-
{"target":"linux-x64", "os":"ubuntu-latest", "can-test":true},
78-
{"target":"windows-x64", "os":"windows-latest","can-test":true},
79-
{"target":"darwin-x64", "os":"macos-latest", "can-test":false},
80-
{"target":"linux-arm64", "os":"ubuntu-latest", "can-test":false}
76+
{"target":"darwin-arm64", "os":"macos-latest", "can-test":true},
77+
{"target":"linux-x64", "os":"ubuntu-latest", "can-test":true},
78+
{"target":"linux-x64-musl", "os":"ubuntu-latest", "can-test":false},
79+
{"target":"windows-x64", "os":"windows-latest","can-test":true},
80+
{"target":"darwin-x64", "os":"macos-latest", "can-test":false},
81+
{"target":"linux-arm64", "os":"ubuntu-latest", "can-test":false},
82+
{"target":"linux-arm64-musl", "os":"ubuntu-latest", "can-test":false}
8183
]}'
8284
fi
8385
echo 'MATRIX_EOF'
@@ -265,6 +267,11 @@ jobs:
265267
else
266268
./dist-bin/sentry-${{ matrix.target }} --help
267269
fi
270+
- name: Smoke test (musl/Alpine)
271+
if: matrix.target == 'linux-x64-musl'
272+
run: |
273+
docker run --rm -v "$PWD/dist-bin:/dist-bin:ro" alpine:latest \
274+
/dist-bin/sentry-linux-x64-musl --help
268275
- name: Upload binary artifact
269276
uses: actions/upload-artifact@v7
270277
with:

‎install‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ report_error() {
5151
envelope=$(printf '%s\n%s\n%s' \
5252
"{\"event_id\":\"${event_id}\",\"dsn\":\"https://${SENTRY_DSN_KEY}@o1.ingest.us.sentry.io/${SENTRY_PROJECT_ID}\"}" \
5353
'{"type":"event"}' \
54-
"{\"event_id\":\"${event_id}\",\"timestamp\":\"${timestamp}\",\"platform\":\"other\",\"level\":\"error\",\"logger\":\"install\",\"server_name\":\"install-script\",\"message\":{\"formatted\":\"${json_msg}\"},\"tags\":{\"os\":\"${os:-unknown}\",\"arch\":\"${arch:-unknown}\",\"channel\":\"${json_channel}\",\"step\":\"${json_step}\",\"install.version\":\"${json_version}\"},\"contexts\":{\"runtime\":{\"name\":\"bash\",\"version\":\"${BASH_VERSION:-unknown}\"}}}")
54+
"{\"event_id\":\"${event_id}\",\"timestamp\":\"${timestamp}\",\"platform\":\"other\",\"level\":\"error\",\"logger\":\"install\",\"server_name\":\"install-script\",\"message\":{\"formatted\":\"${json_msg}\"},\"tags\":{\"os\":\"${os:-unknown}\",\"arch\":\"${arch:-unknown}\",\"libc\":\"${libc_variant:-glibc}\",\"channel\":\"${json_channel}\",\"step\":\"${json_step}\",\"install.version\":\"${json_version}\"},\"contexts\":{\"runtime\":{\"name\":\"bash\",\"version\":\"${BASH_VERSION:-unknown}\"}}}")
5555

5656
curl -sf --max-time 2 \
5757
-H "Content-Type: application/x-sentry-envelope" \
@@ -143,6 +143,33 @@ case "$arch" in
143143
*) die "Unsupported architecture: $arch" "detect-arch" ;;
144144
esac
145145

146+
# Detect C library variant (musl vs glibc) on Linux.
147+
# musl-based systems (Alpine, Void, etc.) need a different binary.
148+
libc_suffix=""
149+
if [[ "$os" == "linux" ]]; then
150+
detect_musl() {
151+
# Heuristic 1: Check for musl dynamic linker
152+
local musl_arch
153+
case "$arch" in
154+
x64) musl_arch="x86_64" ;;
155+
arm64) musl_arch="aarch64" ;;
156+
esac
157+
[ -f "/lib/ld-musl-${musl_arch}.so.1" ] && return 0
158+
159+
# Heuristic 2: ldd --version outputs "musl libc" on musl systems
160+
if command -v ldd >/dev/null 2>&1; then
161+
ldd --version 2>&1 | grep -qi musl && return 0
162+
fi
163+
164+
return 1
165+
}
166+
167+
if detect_musl; then
168+
libc_suffix="-musl"
169+
libc_variant="musl"
170+
fi
171+
fi
172+
146173
# Validate supported combinations
147174
suffix=""
148175
if [[ "$os" == "windows" ]]; then
@@ -199,7 +226,7 @@ if [[ "$requested_version" == "nightly" ]]; then
199226
# Each OCI layer has "digest" before "org.opencontainers.image.title".
200227
# Track the last-seen digest and print it when the target filename matches.
201228
# This avoids sed newline replacement which differs between GNU and BSD.
202-
gz_filename="sentry-${os}-${arch}${suffix}.gz"
229+
gz_filename="sentry-${os}-${arch}${libc_suffix}${suffix}.gz"
203230
digest=$(echo "$MANIFEST" \
204231
| awk -F'"' -v target="$gz_filename" '{
205232
for(i=1;i<=NF;i++){
@@ -238,7 +265,7 @@ else
238265

239266
# Strip leading 'v' if present (releases use version without 'v' prefix)
240267
version="${version#v}"
241-
filename="sentry-${os}-${arch}${suffix}"
268+
filename="sentry-${os}-${arch}${libc_suffix}${suffix}"
242269
url="https://github.com/getsentry/cli/releases/download/${version}/${filename}"
243270

244271
echo -e "${MUTED}Downloading sentry v${version}...${NC}"

‎script/build.ts‎

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525
* sentry-darwin-arm64
2626
* sentry-darwin-x64
2727
* sentry-linux-arm64
28+
* sentry-linux-arm64-musl
2829
* sentry-linux-x64
30+
* sentry-linux-x64-musl
2931
* sentry-windows-x64.exe
3032
* bin.js.map (sourcemap, uploaded to Sentry then deleted)
3133
*/
3234

33-
import { mkdirSync, renameSync } from "node:fs";
35+
import { existsSync, mkdirSync, renameSync } from "node:fs";
3436
import { promisify } from "node:util";
3537
import { gzip } from "node:zlib";
3638
import { processBinary } from "binpunch";
@@ -51,25 +53,43 @@ const SENTRY_CLIENT_ID = process.env.SENTRY_CLIENT_ID ?? "";
5153
type BuildTarget = {
5254
os: "darwin" | "linux" | "win32";
5355
arch: "arm64" | "x64";
56+
/** C library variant. Only relevant for Linux targets (musl for Alpine, etc.) */
57+
libc?: "musl";
5458
};
5559

5660
const ALL_TARGETS: BuildTarget[] = [
5761
{ os: "darwin", arch: "arm64" },
5862
{ os: "darwin", arch: "x64" },
5963
{ os: "linux", arch: "arm64" },
64+
{ os: "linux", arch: "arm64", libc: "musl" },
6065
{ os: "linux", arch: "x64" },
66+
{ os: "linux", arch: "x64", libc: "musl" },
6167
{ os: "win32", arch: "x64" },
6268
];
6369

6470
/** Get package name for a target (uses "windows" instead of "win32") */
6571
function getPackageName(target: BuildTarget): string {
6672
const platformName = target.os === "win32" ? "windows" : target.os;
67-
return `sentry-${platformName}-${target.arch}`;
73+
const libcSuffix = target.libc ? `-${target.libc}` : "";
74+
return `sentry-${platformName}-${target.arch}${libcSuffix}`;
75+
}
76+
77+
/**
78+
* Detect musl libc on the current system (for `--single` builds).
79+
* Checks for the musl dynamic linker at the well-known path.
80+
*/
81+
function detectMusl(): boolean {
82+
if (process.platform !== "linux") {
83+
return false;
84+
}
85+
const muslArch = process.arch === "x64" ? "x86_64" : "aarch64";
86+
return existsSync(`/lib/ld-musl-${muslArch}.so.1`);
6887
}
6988

7089
/** Get Bun compile target string */
7190
function getBunTarget(target: BuildTarget): string {
72-
return `bun-${target.os}-${target.arch}`;
91+
const libcSuffix = target.libc ? `-${target.libc}` : "";
92+
return `bun-${target.os}-${target.arch}${libcSuffix}`;
7393
}
7494

7595
/** Path to the pre-bundled JS used by Step 2 (compile). */
@@ -278,7 +298,9 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
278298
| "bun-darwin-arm64"
279299
| "bun-darwin-x64"
280300
| "bun-linux-x64"
301+
| "bun-linux-x64-musl"
281302
| "bun-linux-arm64"
303+
| "bun-linux-arm64-musl"
282304
| "bun-windows-x64",
283305
outfile,
284306
},
@@ -331,16 +353,18 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
331353
return true;
332354
}
333355

334-
/** Parse target string (e.g., "darwin-x64" or "linux-arm64") into BuildTarget */
356+
/** Parse target string (e.g., "darwin-x64", "linux-arm64", "linux-x64-musl") into BuildTarget */
335357
function parseTarget(targetStr: string): BuildTarget | null {
336358
// Handle "windows" alias for "win32"
337359
const normalized = targetStr.replace("windows-", "win32-");
338-
const [os, arch] = normalized.split("-") as [
339-
BuildTarget["os"],
340-
BuildTarget["arch"],
341-
];
342-
343-
const target = ALL_TARGETS.find((t) => t.os === os && t.arch === arch);
360+
const parts = normalized.split("-");
361+
const os = parts[0] as BuildTarget["os"];
362+
const arch = parts[1] as BuildTarget["arch"];
363+
const libc = parts[2] === "musl" ? ("musl" as const) : undefined;
364+
365+
const target = ALL_TARGETS.find(
366+
(t) => t.os === os && t.arch === arch && t.libc === libc
367+
);
344368
return target ?? null;
345369
}
346370

@@ -372,15 +396,19 @@ async function build(): Promise<void> {
372396
if (!target) {
373397
console.error(`Invalid target: ${targetArg}`);
374398
console.error(
375-
`Valid targets: ${ALL_TARGETS.map((t) => `${t.os === "win32" ? "windows" : t.os}-${t.arch}`).join(", ")}`
399+
`Valid targets: ${ALL_TARGETS.map((t) => `${t.os === "win32" ? "windows" : t.os}-${t.arch}${t.libc ? `-${t.libc}` : ""}`).join(", ")}`
376400
);
377401
process.exit(1);
378402
}
379403
targets = [target];
380404
console.log(`\nBuilding for target: ${getPackageName(target)}`);
381405
} else if (singleBuild) {
406+
const musl = detectMusl();
382407
const currentTarget = ALL_TARGETS.find(
383-
(t) => t.os === process.platform && t.arch === process.arch
408+
(t) =>
409+
t.os === process.platform &&
410+
t.arch === process.arch &&
411+
(musl ? t.libc === "musl" : !t.libc)
384412
);
385413
if (!currentTarget) {
386414
console.error(

‎src/lib/binary.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,58 @@ import { stringifyUnknown, UpgradeError } from "./errors.js";
2020
/** Known directories where the curl installer may place the binary */
2121
export const KNOWN_CURL_DIRS = [".local/bin", "bin", ".sentry/bin"];
2222

23+
/**
24+
* Detect whether the current process is running on a musl-based Linux system
25+
* (e.g., Alpine Linux, Void Linux musl variant).
26+
*
27+
* Uses two heuristics in order of reliability:
28+
* 1. Check for `/lib/ld-musl-<arch>.so.1` — the musl dynamic linker is always
29+
* at this path on musl systems. Fast stat check, no subprocess.
30+
* 2. Parse `ldd --version` output — musl's ldd writes "musl libc" to stderr,
31+
* while glibc outputs "GNU C Library" to stdout.
32+
*
33+
* The result is cached after first call since libc cannot change at runtime.
34+
*/
35+
let cachedIsMusl: boolean | undefined;
36+
37+
export function isMusl(): boolean {
38+
if (process.platform !== "linux") {
39+
return false;
40+
}
41+
if (cachedIsMusl !== undefined) {
42+
return cachedIsMusl;
43+
}
44+
45+
// Heuristic 1: Check for musl dynamic linker
46+
const muslArch = process.arch === "x64" ? "x86_64" : "aarch64";
47+
if (existsSync(`/lib/ld-musl-${muslArch}.so.1`)) {
48+
cachedIsMusl = true;
49+
return true;
50+
}
51+
52+
// Heuristic 2: ldd --version output (musl ldd writes "musl libc" to stderr)
53+
try {
54+
const result = Bun.spawnSync(["ldd", "--version"], {
55+
stdout: "pipe",
56+
stderr: "pipe",
57+
});
58+
const output =
59+
Buffer.from(result.stdout).toString() +
60+
Buffer.from(result.stderr).toString();
61+
cachedIsMusl = output.toLowerCase().includes("musl");
62+
return cachedIsMusl;
63+
} catch {
64+
// ldd not found or failed — assume glibc (the common case)
65+
cachedIsMusl = false;
66+
return false;
67+
}
68+
}
69+
2370
/**
2471
* Build the platform-specific binary base name.
2572
*
2673
* Matches the naming convention used by GitHub Releases and GHCR:
27-
* `sentry-<os>-<arch>[.exe]` (e.g., `sentry-linux-x64`, `sentry-darwin-arm64`).
74+
* `sentry-<os>-<arch>[-musl][.exe]` (e.g., `sentry-linux-x64`, `sentry-linux-arm64-musl`).
2875
*/
2976
export function getPlatformBinaryName(): string {
3077
let os: string;
@@ -36,8 +83,9 @@ export function getPlatformBinaryName(): string {
3683
os = "linux";
3784
}
3885
const arch = process.arch === "arm64" ? "arm64" : "x64";
86+
const libcSuffix = isMusl() ? "-musl" : "";
3987
const suffix = process.platform === "win32" ? ".exe" : "";
40-
return `sentry-${os}-${arch}${suffix}`;
88+
return `sentry-${os}-${arch}${libcSuffix}${suffix}`;
4189
}
4290

4391
/**

‎src/lib/telemetry.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { chmodSync, statSync } from "node:fs";
1313
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
1414
import * as Sentry from "@sentry/node-core/light";
15+
import { isMusl } from "./binary.js";
1516
import {
1617
CLI_VERSION,
1718
getCliEnvironment,
@@ -433,6 +434,19 @@ export function getSentryTracePropagationTargets(): (string | RegExp)[] {
433434
*
434435
* @internal Exported for testing
435436
*/
437+
438+
/**
439+
* Set the cli.libc Sentry tag on Linux (musl for Alpine, glibc for most distros).
440+
* No-op on non-Linux — the concept doesn't apply to macOS/Windows.
441+
* Extracted from initSentry to stay under the cognitive complexity limit.
442+
*/
443+
function setLibcTag(): void {
444+
if (process.platform !== "linux") {
445+
return;
446+
}
447+
Sentry.setTag("cli.libc", isMusl() ? "musl" : "glibc");
448+
}
449+
436450
export function initSentry(
437451
enabled: boolean,
438452
options?: { libraryMode?: boolean }
@@ -600,6 +614,9 @@ export function initSentry(
600614
// Tag whether running in an interactive terminal or agent/CI environment
601615
Sentry.setTag("is_tty", !!process.stdout.isTTY);
602616

617+
// Tag the C library variant on Linux (musl vs glibc).
618+
setLibcTag();
619+
603620
// Tag which AI agent (if any) is driving the CLI.
604621
// Env var detection is sync (instant). If no env var matches, fire off
605622
// async process tree detection in the background — it sets the tag

‎test/lib/binary.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import {
2222
getBinaryDownloadUrl,
2323
getBinaryFilename,
2424
getBinaryPaths,
25+
getPlatformBinaryName,
2526
installBinary,
2627
isDowngrade,
28+
isMusl,
2729
releaseLock,
2830
replaceBinarySync,
2931
} from "../../src/lib/binary.js";
@@ -523,3 +525,62 @@ describe("isDowngrade", () => {
523525
);
524526
});
525527
});
528+
529+
describe("isMusl", () => {
530+
test("returns false on non-Linux platforms", () => {
531+
if (process.platform !== "linux") {
532+
expect(isMusl()).toBe(false);
533+
}
534+
});
535+
536+
test("returns a boolean on Linux", () => {
537+
if (process.platform === "linux") {
538+
expect(typeof isMusl()).toBe("boolean");
539+
}
540+
});
541+
542+
test("result is cached (calling twice returns same value)", () => {
543+
const first = isMusl();
544+
const second = isMusl();
545+
expect(first).toBe(second);
546+
});
547+
});
548+
549+
describe("getPlatformBinaryName", () => {
550+
test("starts with sentry- prefix", () => {
551+
expect(getPlatformBinaryName()).toStartWith("sentry-");
552+
});
553+
554+
test("includes correct architecture", () => {
555+
const name = getPlatformBinaryName();
556+
const expectedArch = process.arch === "arm64" ? "arm64" : "x64";
557+
expect(name).toContain(expectedArch);
558+
});
559+
560+
test("includes correct OS", () => {
561+
const name = getPlatformBinaryName();
562+
if (process.platform === "darwin") {
563+
expect(name).toContain("darwin");
564+
} else if (process.platform === "win32") {
565+
expect(name).toContain("windows");
566+
expect(name).toEndWith(".exe");
567+
} else {
568+
expect(name).toContain("linux");
569+
}
570+
});
571+
572+
test("on CI (glibc), does not contain -musl suffix", () => {
573+
// CI runners use glibc-based Ubuntu. This test verifies the
574+
// musl detection correctly identifies glibc systems.
575+
if (process.platform === "linux" && !isMusl()) {
576+
expect(getPlatformBinaryName()).not.toContain("-musl");
577+
}
578+
});
579+
580+
test("includes -musl suffix on musl systems", () => {
581+
// Only runs on musl-based systems (e.g., Alpine CI containers)
582+
if (process.platform === "linux" && isMusl()) {
583+
expect(getPlatformBinaryName()).toContain("-musl");
584+
}
585+
});
586+
});

0 commit comments

Comments
 (0)