diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-18 16:45:07 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-18 16:45:07 -0500 |
| commit | 895062022679fe73e28147fafd7e8402357147b5 (patch) | |
| tree | 4701e57135e84c8459edc3777c8f130a8df48ff4 | |
| parent | 70bfbd098e504616e539a8194a830c748e505882 (diff) | |
| download | archsetup-895062022679fe73e28147fafd7e8402357147b5.tar.gz archsetup-895062022679fe73e28147fafd7e8402357147b5.zip | |
feat: pin zig 0.15.2 under /opt for the Emacs ghostel terminal
Arch's rolling repo ships zig 0.16+, but ghostel's native-module compile fallback needs exactly 0.15.2: ghostel pins ghostty 1.3.2-dev, whose build does requireZig(0.15.2), and 0.16's build-API changes break the dependency build scripts. So a plain pacman -S zig produces a zig that can't build ghostel.
install_zig_pin downloads zig-x86_64-linux-0.15.2.tar.xz from ziglang.org, verifies the sha256, extracts to /opt/zig-0.15.2, and symlinks /usr/local/bin/zig ahead of /usr/bin on PATH, where pacman -Syu can't bump it. I split the verify-and-install core (zig_install_from_tarball) out so it stays network-free and unit-testable: it refuses on a sha256 mismatch, a missing tarball, or a tree with no zig binary, and short-circuits when a correct install already exists.
ghostel's default path downloads a prebuilt module and needs no zig, so this only matters for the offline compile fallback. The pin needs a one-line bump (ZIG_VERSION + ZIG_SHA256) whenever ghostel moves to a newer ghostty.
Tests live in tests/zig-pin/: 7 cases covering extract+symlink, idempotency, sha256-mismatch refusal, missing tarball, and no-binary cleanup, run against the real function extracted from the script.
| -rwxr-xr-x | archsetup | 99 | ||||
| -rw-r--r-- | tests/zig-pin/test_zig_pin.py | 203 |
2 files changed, 302 insertions, 0 deletions
@@ -2000,6 +2000,104 @@ gaming() { sudo -u "$username" systemctl --user enable gamemoded.service >> "$logfile" 2>&1 || error_warn "$action" "$?" } +### Zig Toolchain Pin +# Arch's rolling repo ships zig 0.16+, but the Emacs ghostel terminal's native +# module compile fallback needs exactly 0.15.2: ghostel pins ghostty 1.3.2-dev, +# whose build does requireZig(0.15.2), and 0.16's build-API changes break the +# dependency build scripts. So we do NOT `pacman -S zig` -- we pin the upstream +# 0.15.2 toolchain under /opt and symlink it into /usr/local/bin (ahead of +# /usr/bin on PATH), where `pacman -Syu` can't bump it out from under the build. +# +# The ghostel default install path downloads a prebuilt .so and needs no zig at +# all; this pin only matters for the offline / no-release compile fallback. +ZIG_VERSION=0.15.2 +ZIG_SHA256=02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 + +# zig_install_from_tarball <tarball> <sha256> <version> <opt_root> <bindir> +# Verify <tarball>'s sha256 against <sha256>, extract it into +# <opt_root>/zig-<version>/ (stripping the upstream wrapper dir so zig lands at +# the root), and point <bindir>/zig at the extracted binary. Refuses -- without +# extracting anything -- on a sha256 mismatch, a missing tarball, or a tree +# carrying no zig binary; a tampered or truncated download must never reach +# /opt. Idempotent: a correct existing install short-circuits. Self-contained +# (no network, prints its own refusal reasons) so it can be unit-tested. +zig_install_from_tarball() { + local tarball="$1" want_sha="$2" version="$3" opt_root="$4" bindir="$5" + local dest="$opt_root/zig-$version" + local link="$bindir/zig" + + # Already extracted and linked at this version -> nothing to do. + if [ -x "$dest/zig" ] && [ "$(readlink -f "$link" 2>/dev/null)" = "$dest/zig" ]; then + return 0 + fi + + if [ ! -f "$tarball" ]; then + echo "zig pin: tarball '$tarball' not found" >&2 + return 1 + fi + + local got_sha + got_sha="$(sha256sum "$tarball" | cut -d' ' -f1)" + if [ "$got_sha" != "$want_sha" ]; then + echo "zig pin: sha256 mismatch (want $want_sha, got $got_sha) -- refusing to install" >&2 + return 1 + fi + + mkdir -p "$opt_root" "$bindir" + rm -rf "$dest" + mkdir -p "$dest" + # The upstream tarball unpacks to a single top-level dir + # zig-x86_64-linux-<version>/; --strip-components=1 drops its contents + # straight into $dest so $dest/zig is the binary. + if ! tar -xf "$tarball" -C "$dest" --strip-components=1; then + echo "zig pin: extraction failed" >&2 + rm -rf "$dest" + return 1 + fi + if [ ! -x "$dest/zig" ]; then + echo "zig pin: extracted tree has no zig binary at $dest/zig" >&2 + rm -rf "$dest" + return 1 + fi + ln -sfn "$dest/zig" "$link" +} + +# install_zig_pin -- download zig $ZIG_VERSION from ziglang.org, verify it, and +# pin it under /opt via zig_install_from_tarball. The opt root and bindir are +# env-overridable (ZIG_OPT_ROOT / ZIG_BINDIR) so the verify-and-install core +# stays testable; defaults are the real /opt and /usr/local/bin. +install_zig_pin() { + local opt_root="${ZIG_OPT_ROOT:-/opt}" + local bindir="${ZIG_BINDIR:-/usr/local/bin}" + local dest="$opt_root/zig-$ZIG_VERSION" + local link="$bindir/zig" + + # Idempotent short-circuit so a resume / re-run does no network I/O. + if [ -x "$dest/zig" ] && [ "$(readlink -f "$link" 2>/dev/null)" = "$dest/zig" ]; then + display "task" "zig $ZIG_VERSION already pinned at $dest" + return 0 + fi + + action="pinning zig $ZIG_VERSION upstream toolchain (ghostel build dep)" && display "task" "$action" + + local url="https://ziglang.org/download/$ZIG_VERSION/zig-x86_64-linux-$ZIG_VERSION.tar.xz" + local tmp tarball + tmp="$(mktemp -d)" || { error_warn "$action (mktemp)" "$?"; return 1; } + tarball="$tmp/zig.tar.xz" + + if ! curl -fsSL "$url" -o "$tarball" >> "$logfile" 2>&1; then + error_warn "$action (download)" "$?" + rm -rf "$tmp" + return 1 + fi + if ! zig_install_from_tarball "$tarball" "$ZIG_SHA256" "$ZIG_VERSION" "$opt_root" "$bindir" >> "$logfile" 2>&1; then + error_warn "$action (verify/install)" "$?" + rm -rf "$tmp" + return 1 + fi + rm -rf "$tmp" +} + ### Developer Workstation developer_workstation() { @@ -2091,6 +2189,7 @@ developer_workstation() { pacman_install imagemagick # image previews for dired/dirvish pacman_install libgccjit # native compilation for Emacs pacman_install libvterm # vterm terminal emulator + install_zig_pin # zig 0.15.2 pin for ghostel's compile fallback (NOT pacman zig) pacman_install mediainfo # generating media info in dired/dirvish pacman_install 7zip # archive info for dirvish pacman_install mpv # video viewer diff --git a/tests/zig-pin/test_zig_pin.py b/tests/zig-pin/test_zig_pin.py new file mode 100644 index 0000000..f6d87e5 --- /dev/null +++ b/tests/zig-pin/test_zig_pin.py @@ -0,0 +1,203 @@ +"""Tests for the zig_install_from_tarball helper in the archsetup installer. + +zig_install_from_tarball is the verify-and-install core of the zig 0.15.2 pin: +given a downloaded tarball it checks the sha256, extracts the tree to +<opt_root>/zig-<version>/ (stripping the upstream wrapper dir), and symlinks +<bindir>/zig at the extracted binary. It refuses — extracting nothing — on a +sha256 mismatch, a missing tarball, or a tree with no zig binary, and it +short-circuits when a correct install already exists. The network download is +the thin outer install_zig_pin's job, not this function's, so this is unit +testable. + +These tests exercise the REAL function body, extracted from the `archsetup` +script at run time (not a copy), against real temp dirs and real tarballs the +test builds. The helper is self-contained (prints its own refusal reasons to +stderr, calls no installer logger), so no stub is needed. + +Run from repo root: + python3 -m unittest tests.zig-pin.test_zig_pin +""" + +import hashlib +import os +import shutil +import subprocess +import tarfile +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") +VERSION = "0.15.2" + + +def sha256_of(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +class ZigPinHarness(unittest.TestCase): + """Source zig_install_from_tarball out of the real archsetup script.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="zig-pin-test-") + # Wrapper that extracts just zig_install_from_tarball from the real + # installer and invokes it. Sourcing the sed-extracted function means + # we test the production code path, not a reimplementation. + self.wrapper = os.path.join(self.tmp, "run.sh") + with open(self.wrapper, "w") as f: + f.write( + "#!/bin/bash\n" + 'ARCHSETUP="$1"; shift\n' + "source <(sed -n " + "'/^zig_install_from_tarball() {/,/^}/p' \"$ARCHSETUP\")\n" + 'zig_install_from_tarball "$@"\n' + ) + os.chmod(self.wrapper, 0o755) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def make_tarball(self, name="tarball.tar.xz", with_zig=True): + """Build an upstream-shaped tarball: a single top-level dir + zig-x86_64-linux-<version>/ optionally containing an executable `zig`. + Returns (tarball_path, sha256_hex).""" + builddir = os.path.join(self.tmp, "build") + topdir = "zig-x86_64-linux-%s" % VERSION + treedir = os.path.join(builddir, topdir) + os.makedirs(treedir, exist_ok=True) + # a sibling file so we can confirm the whole tree lands, not just zig + with open(os.path.join(treedir, "LICENSE"), "w") as f: + f.write("MIT") + if with_zig: + zigbin = os.path.join(treedir, "zig") + with open(zigbin, "w") as f: + f.write("#!/bin/sh\necho 0.15.2\n") + os.chmod(zigbin, 0o755) + tarball = os.path.join(self.tmp, name) + with tarfile.open(tarball, "w:xz") as tar: + tar.add(treedir, arcname=topdir) + shutil.rmtree(builddir) + return tarball, sha256_of(tarball) + + def run_install(self, tarball, want_sha, opt_root, bindir, version=VERSION): + return subprocess.run( + ["bash", self.wrapper, ARCHSETUP, + tarball, want_sha, version, opt_root, bindir], + capture_output=True, text=True, timeout=30, + ) + + def dirs(self): + opt_root = os.path.join(self.tmp, "opt") + bindir = os.path.join(self.tmp, "bin") + return opt_root, bindir + + +# ----------------------------------------------------------------------------- +# Normal cases +# ----------------------------------------------------------------------------- + +class TestZigPinNormal(ZigPinHarness): + + def test_valid_tarball_extracts_and_symlinks(self): + tarball, sha = self.make_tarball() + opt_root, bindir = self.dirs() + result = self.run_install(tarball, sha, opt_root, bindir) + self.assertEqual(result.returncode, 0, msg=result.stderr) + dest = os.path.join(opt_root, "zig-%s" % VERSION) + self.assertTrue(os.access(os.path.join(dest, "zig"), os.X_OK), + "zig binary should be extracted and executable") + self.assertTrue(os.path.isfile(os.path.join(dest, "LICENSE")), + "whole tree should land, not just the binary") + link = os.path.join(bindir, "zig") + self.assertTrue(os.path.islink(link), "bindir/zig should be a symlink") + self.assertEqual(os.path.realpath(link), os.path.join(dest, "zig")) + + def test_idempotent_second_run_skips_without_tarball(self): + tarball, sha = self.make_tarball() + opt_root, bindir = self.dirs() + self.assertEqual(self.run_install(tarball, sha, opt_root, bindir).returncode, 0) + # Second run: install already correct, so it must short-circuit before + # touching the tarball — pass a nonexistent path to prove no re-extract. + again = self.run_install(os.path.join(self.tmp, "gone.tar.xz"), + sha, opt_root, bindir) + self.assertEqual(again.returncode, 0, msg=again.stderr) + + +# ----------------------------------------------------------------------------- +# Boundary cases +# ----------------------------------------------------------------------------- + +class TestZigPinBoundary(ZigPinHarness): + + def test_stale_dest_without_link_is_reinstalled(self): + # A prior half-install left a dest dir but no/wrong symlink. A fresh + # valid run should replace it and create the link. + opt_root, bindir = self.dirs() + dest = os.path.join(opt_root, "zig-%s" % VERSION) + os.makedirs(dest) + with open(os.path.join(dest, "stale.txt"), "w") as f: + f.write("junk") + tarball, sha = self.make_tarball() + result = self.run_install(tarball, sha, opt_root, bindir) + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertFalse(os.path.exists(os.path.join(dest, "stale.txt")), + "stale contents should be cleared on reinstall") + self.assertTrue(os.path.islink(os.path.join(bindir, "zig"))) + + def test_existing_link_repointed_to_new_version(self): + # bindir/zig already points somewhere else; install should repoint it. + opt_root, bindir = self.dirs() + os.makedirs(bindir) + os.symlink("/usr/bin/zig", os.path.join(bindir, "zig")) + tarball, sha = self.make_tarball() + result = self.run_install(tarball, sha, opt_root, bindir) + self.assertEqual(result.returncode, 0, msg=result.stderr) + dest = os.path.join(opt_root, "zig-%s" % VERSION) + self.assertEqual(os.path.realpath(os.path.join(bindir, "zig")), + os.path.join(dest, "zig")) + + +# ----------------------------------------------------------------------------- +# Error cases +# ----------------------------------------------------------------------------- + +class TestZigPinErrors(ZigPinHarness): + + def test_sha256_mismatch_refuses_and_installs_nothing(self): + tarball, _ = self.make_tarball() + opt_root, bindir = self.dirs() + bad = "0" * 64 + result = self.run_install(tarball, bad, opt_root, bindir) + self.assertNotEqual(result.returncode, 0, + "a sha256 mismatch must fail") + self.assertFalse(os.path.exists(os.path.join(opt_root, "zig-%s" % VERSION)), + "no tree may be extracted on a sha mismatch") + self.assertFalse(os.path.exists(os.path.join(bindir, "zig")), + "no symlink may be created on a sha mismatch") + + def test_missing_tarball_refuses(self): + opt_root, bindir = self.dirs() + tarball, sha = self.make_tarball() + os.remove(tarball) + result = self.run_install(tarball, sha, opt_root, bindir) + self.assertNotEqual(result.returncode, 0) + + def test_tarball_without_zig_binary_refuses_and_cleans_up(self): + tarball, sha = self.make_tarball(with_zig=False) + opt_root, bindir = self.dirs() + result = self.run_install(tarball, sha, opt_root, bindir) + self.assertNotEqual(result.returncode, 0, + "a tree with no zig binary must fail") + self.assertFalse(os.path.exists(os.path.join(opt_root, "zig-%s" % VERSION)), + "the bad extracted tree should be cleaned up") + self.assertFalse(os.path.exists(os.path.join(bindir, "zig")), + "no symlink should be left behind") + + +if __name__ == "__main__": + unittest.main() |
