Beskid blog · Compiler
Windows, macOS, Linux: The Cross-Platform Installer Pipeline
June 2026. Beskid got a cross-platform installer pipeline and a distrib submodule. The compiler ran on three platforms. The installer had to work on all three. This is the story of CI matrices, shell scripts, and the unglamorous work of making a language available.
← All postsThe Beskid compiler ran on three platforms. It had run on three platforms for months — the CI matrix had ubuntu-latest, macos-latest, and windows-latest since the early v0.1 days. But “the compiler compiles on three platforms” and “a developer can install Beskid on three platforms” are not the same sentence. The gap between them is an installer pipeline, a distribution submodule, and a set of CI matrices that test not the compiler but the thing that puts the compiler on your machine.
59f87fed — “cross-platform installer pipeline + beskid_distrib submodule.” The commit message says it plainly: the installer is a pipeline, not a script. It takes the compiler artifact from the build matrix, packages it with the runtime, the corelib, the LSP server, and the CLI tooling, and produces a single archive per platform. The pipeline has to work on three different operating systems with three different shells, three different filesystem conventions, and three different sets of expectations about what “installing software” means.
The beskid_distrib submodule was the architectural answer. A separate repository with its own CI, its own release cadence, its own versioning. The submodule contains the install scripts, the package manifests, the platform-specific wrappers, and the distribution design documentation. It is integrated into the superrepo at release time — pulled in as a submodule, version-pinned, and included in the release artifact. The alternative — keeping distribution logic in the main repository — would have meant every platform-specific installer change required a full compiler CI run. The submodule decouples distribution from compilation.
The Windows path was the hardest. 90e39b44 — “merge: integrate Windows runtime-kit matrix CI.” 18ba2f2b — “run Windows ABI-v5 runtime kit matrix.” Windows has no curl | sh tradition. Windows developers expect an installer. Windows has three shells (cmd, PowerShell, and WSL bash), two architectures (x64 and ARM64), and a runtime library linking model (dynamic vs. static CRT) that affects which compiler binary actually works. The Windows runtime-kit matrix CI tested every combination: x64 with dynamic CRT, x64 with static CRT, ARM64 with dynamic CRT. Each combination produced a different artifact. Each artifact needed its own installer path.
The CRT linking war story deserves its own paragraph. The Beskid compiler links against the Microsoft C Runtime — the DLL that provides malloc, printf, memcpy, and everything else C programs expect. Windows offers two CRT models: dynamic linking (/MD), where the compiler binary loads msvcrt.dll or vcruntime140.dll at startup, and static linking (/MT), where the CRT is baked into the binary. Dynamic linking means a smaller binary but requires the Visual C++ Redistributable to be installed on the target machine. Static linking means a self-contained binary but a larger one, and it interacts badly with certain Windows APIs that expect a shared CRT heap. The first Windows installer shipped with the dynamic CRT binary. The installer ran, the compiler started, and Windows popped up a dialog: “The program can’t start because VCRUNTIME140.dll is missing from your computer.” The fix was not to ship the redistributable — that would have added a 25 MB dependency to a 4 MB compiler. The fix was to switch to static CRT linking and accept the 2 MB binary size increase. The Windows runtime-kit matrix CI was born from that dialog box: every CRT variant is now tested in CI, and the static CRT binary is the default. Nobody sees the dialog anymore. The CI remembers it so humans don’t have to.
The macOS path had its own demons. Code signing. Notarization. The Gatekeeper dialog that says “this application was downloaded from the internet” and offers exactly one button: “Move to Trash.” The macOS installer had to handle unsigned binaries gracefully — document the right-click-open workaround, explain why notarization requires an Apple Developer account, and make the path from download to working compiler as short as the platform allows. The install.sh script in site/website/public/ grew a macOS-specific branch that detected whether the binary was quarantined and printed a message explaining how to remove the quarantine attribute.
What the macOS war story actually looks like in practice: a developer downloads beskid-macos-arm64.tar.gz, unpacks it, runs ./beskid version, and gets “beskid cannot be opened because it is from an unidentified developer.” They Google the error. They find a Stack Overflow answer from 2015 that says “run xattr -d com.apple.quarantine on the binary.” They run it. It works. They move on. But they shouldn’t have to. The install.sh script now runs xattr -d com.apple.quarantine automatically — but only after checking whether the attribute exists, because running it on a non-quarantined binary is harmless and running it on a binary the user manually de-quarantined is redundant. The script also checks for the spctl assessment and prints the Gatekeeper status. The script does not sign the binary — signing requires a private key, and shipping a private key in an install script is a security incident waiting to happen. The script documents what it can do and what it can’t. Code signing remains a future investment, gated on obtaining an Apple Developer account for the Beskid project. Until then, the installer is honest about the limitation. Honesty is not the same as apology. The compiler works. Apple just doesn’t trust it yet.
The Linux path was the easiest and the most fragmented. curl | sh works on every distribution but the shell script has to detect the distribution to know where to put the binary. /usr/local/bin on Debian. /usr/bin on some Fedora configurations. ~/.local/bin for user-local installs. The install script grew distribution detection, permission escalation (sudo when needed, skip when not), and PATH validation. The script also had to handle the case where none of the above works — a tarball fallback with manual installation instructions.
The distribution detection war story is a tour of /etc. The script starts by reading /etc/os-release, the freedesktop.org standard that most modern distributions support. If ID=ubuntu or ID=debian, the script targets /usr/local/bin with sudo — Debian derivatives put user-installed software in /usr/local by convention. If ID=fedora or ID=rhel, the script checks whether /usr/local/bin is in the PATH (it isn’t, on some minimal Fedora installs) and falls back to /usr/bin if not. If ID=arch, the script detects the package manager and prints a message: “Arch users: an AUR package is planned. For now, this script installs to /usr/local/bin.” If /etc/os-release doesn’t exist — and on Alpine Linux, it doesn’t by default — the script falls back to reading /etc/alpine-release or checking for apk in the PATH. If none of the detection methods work, the script installs to ~/.local/bin with no sudo and prints: “Could not detect your distribution. Installed to ~/.local/bin. Add this directory to your PATH if it isn’t already.”
The musl vs. glibc split added a second dimension. The Beskid compiler binary is compiled against either glibc (the GNU C Library, used by Debian, Fedora, Ubuntu, RHEL) or musl (used by Alpine). The two are not binary-compatible. A glibc binary will crash with “relocation error” on Alpine. A musl binary will fail to link on Ubuntu. The install script detects the libc variant — ldd --version reports “GLIBC” or “musl” in its first line — and downloads the correct tarball. The script also checks the architecture: uname -m for x86_64 vs. aarch64. The download URL is assembled from three variables detected at runtime: $BESKID_PLATFORM (linux, macos, windows), $BESKID_ARCH (x64, arm64), and $BESKID_LIBC (gnu, musl). The combinatorial explosion — 2 architectures × 2 libc variants = 4 Linux tarballs, plus 2 macOS tarballs, plus 3 Windows installers — is why the CI matrix exists. The install script is the user-facing tip of a pyramid whose base is nine CI jobs.
The permission escalation logic is deliberately conservative. The script runs sudo only when the target directory is not user-writable. It runs sudo -v first to validate credentials before attempting the copy, so the user isn’t prompted for a password mid-install. It does not run sudo for ~/.local/bin installs. It does not modify the user’s shell profile — adding directories to PATH is the user’s responsibility, and the script prints a clear message about it. The principle: the installer does the minimum necessary to put the binary in the right place, and tells the user what it did and what remains for them to do. No surprises. No modified dotfiles. No assumptions about the user’s shell configuration.
The download page was the user-facing surface for all of this. The structure: three platform tabs, each with a copy-pasteable install command, a direct download link, and a checksum. The page had to be static — no JavaScript required, no framework, no build step. A Beskid developer on a fresh machine with only a terminal and a browser should be able to install Beskid. The download page was built to make that true.
The “no JavaScript” constraint was not aesthetic. It was functional. The download page is the first thing a new user sees. If the first thing a new user sees is a blank page because their browser blocked a script, or a 5 MB JavaScript bundle downloading over a coffee-shop connection, the language has already failed them before they’ve written a single line of Beskid. The page is HTML and CSS — 14 KB uncompressed, 3 KB gzipped. It loads in one round-trip. The platform tabs are CSS radio buttons with the :checked pseudo-class. No event listeners. No framework. No build step. The page is served from the same static file server that hosts the tarballs. It cannot break because it has no moving parts.
The checksums were the silent hero. Every tarball and installer on the download page has a SHA-256 hash printed next to it. The install.sh script verifies the hash before unpacking. The Windows installer verifies the hash before executing. The checksum file itself is served over HTTPS, which means the trust chain is: TLS certificate → checksum file → tarball → binary. If any link in that chain fails — if a CDN caches a stale tarball, if a mirror returns a truncated download, if a man-in-the-middle replaces the binary — the checksum catches it. The installer prints “Checksum verification failed. Aborting.” and exits. The user sees an error message, not a corrupted binary that crashes with “Illegal instruction” three steps later. The checksums have caught exactly one incident so far: a CDN edge node that served a truncated tarball for six hours before the cache expired. The checksum caught it. The CI didn’t. Sometimes the defense that works is not the one you expected.
The distribution design doc at docs/superpowers/specs/2026-06-18-beskid-distrib-distribution-design.md captured the architecture. The unified distribution model: one version number across all platforms, one release channel (stable), one update mechanism (download the new version, run the installer again). The updater design: the beskid update command checks the distribution server for a new version, downloads it, and replaces the current installation. The updater is not a package manager. It is a self-replacing binary with a checksum verification step.
The tension in cross-platform distribution is always the same: the compiler works on three platforms, so the installer must work on three platforms, so the CI must test the installer on three platforms. Each layer adds complexity. Each platform adds constraints. The Windows installer needs an MSI wrapper. The macOS installer needs code signing. The Linux installer needs distribution detection. None of these are compiler problems. All of them are necessary for the compiler to reach anyone.
The beskid_distrib submodule was the answer to this tension. Separate concerns, separate CI, separate release cadence, integrated at release time. When the compiler changes its build output, the distrib submodule updates its packaging scripts. When a new platform is added, the distrib submodule adds a new CI matrix. The submodule is not an afterthought. It is the distribution surface of the entire project.
Distribution is not a feature. Features are things you can choose not to implement — generics, async I/O, a package manager. Distribution is the prerequisite for every feature to matter. A language that cannot be installed is a language that cannot be used. A language that cannot be used is a compiler with an audience of one. The cross-platform installer pipeline made Beskid available to three platforms. That is not a feature. That is the point.
Cross-reference Book chapter “It works on my machine” — the installer is the proof that it works on yours too. The chapter describes the philosophy: a compiler that only works on the author’s laptop is a research project. A compiler with a cross-platform installer pipeline is a language. The installer is not the fun part. The installer is the part that makes the fun part matter.