diff --git a/.github/workflows/pub-release.yml b/.github/workflows/pub-release.yml index afa4b54f5..3798d6253 100644 --- a/.github/workflows/pub-release.yml +++ b/.github/workflows/pub-release.yml @@ -202,7 +202,7 @@ jobs: include: # Keep GNU Linux release artifacts on Ubuntu 22.04 to preserve # a broadly compatible GLIBC baseline for user distributions. - - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2404] + - os: ubuntu-22.04 target: x86_64-unknown-linux-gnu artifact: zeroclaw archive_ext: tar.gz @@ -217,7 +217,7 @@ jobs: linker_env: "" linker: "" use_cross: true - - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2404] + - os: ubuntu-22.04 target: aarch64-unknown-linux-gnu artifact: zeroclaw archive_ext: tar.gz @@ -232,7 +232,7 @@ jobs: linker_env: "" linker: "" use_cross: true - - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2404] + - os: ubuntu-22.04 target: armv7-unknown-linux-gnueabihf artifact: zeroclaw archive_ext: tar.gz diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index e311a8ca7..92982405f 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -132,29 +132,33 @@ get_available_disk_mb() { fi } -detect_release_target() { +detect_release_targets() { local os arch os="$(uname -s)" arch="$(uname -m)" case "$os:$arch" in Linux:x86_64) - echo "x86_64-unknown-linux-gnu" + printf '%s\n' \ + "x86_64-unknown-linux-musl" \ + "x86_64-unknown-linux-gnu" ;; Linux:aarch64|Linux:arm64) - echo "aarch64-unknown-linux-gnu" + printf '%s\n' \ + "aarch64-unknown-linux-musl" \ + "aarch64-unknown-linux-gnu" ;; Linux:armv7l|Linux:armv6l) - echo "armv7-unknown-linux-gnueabihf" + printf '%s\n' "armv7-unknown-linux-gnueabihf" ;; Darwin:x86_64) - echo "x86_64-apple-darwin" + printf '%s\n' "x86_64-apple-darwin" ;; Darwin:arm64|Darwin:aarch64) - echo "aarch64-apple-darwin" + printf '%s\n' "aarch64-apple-darwin" ;; FreeBSD:amd64|FreeBSD:x86_64) - echo "x86_64-unknown-freebsd" + printf '%s\n' "x86_64-unknown-freebsd" ;; *) return 1 @@ -264,6 +268,7 @@ detect_config_channel_features() { install_prebuilt_binary() { local target archive_url temp_dir archive_path extracted_bin install_dir + local -a candidate_targets=() if ! have_cmd curl; then warn "curl is required for pre-built binary installation." @@ -274,19 +279,25 @@ install_prebuilt_binary() { return 1 fi - target="$(detect_release_target || true)" - if [[ -z "$target" ]]; then + mapfile -t candidate_targets < <(detect_release_targets || true) + if [[ "${#candidate_targets[@]}" -eq 0 ]]; then warn "No pre-built binary target mapping for $(uname -s)/$(uname -m)." return 1 fi - archive_url="https://github.com/zeroclaw-labs/zeroclaw/releases/latest/download/zeroclaw-${target}.tar.gz" temp_dir="$(mktemp -d -t zeroclaw-prebuilt-XXXXXX)" - archive_path="$temp_dir/zeroclaw-${target}.tar.gz" - - info "Attempting pre-built binary install for target: $target" - if ! curl -fsSL "$archive_url" -o "$archive_path"; then - warn "Could not download release asset: $archive_url" + for target in "${candidate_targets[@]}"; do + archive_url="https://github.com/zeroclaw-labs/zeroclaw/releases/latest/download/zeroclaw-${target}.tar.gz" + archive_path="$temp_dir/zeroclaw-${target}.tar.gz" + info "Attempting pre-built binary install for target: $target" + if curl -fsSL "$archive_url" -o "$archive_path"; then + break + fi + rm -f "$archive_path" + archive_path="" + done + if [[ -z "${archive_path:-}" || ! -f "$archive_path" ]]; then + warn "Could not download a compatible release asset." rm -rf "$temp_dir" return 1 fi diff --git a/scripts/ci/tests/test_release_installers.py b/scripts/ci/tests/test_release_installers.py new file mode 100644 index 000000000..05c14275f --- /dev/null +++ b/scripts/ci/tests/test_release_installers.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Behavioral checks for release installer target selection helpers.""" + +from __future__ import annotations + +import subprocess +import textwrap +import unittest +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +INSTALL_RELEASE = ROOT / "scripts" / "install-release.sh" +BOOTSTRAP = ROOT / "scripts" / "bootstrap.sh" +PUB_RELEASE = ROOT / ".github" / "workflows" / "pub-release.yml" + + +def run_cmd(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + text=True, + capture_output=True, + check=False, + ) + + +def extract_function(function_name: str, script_path: Path) -> str: + lines = script_path.read_text(encoding="utf-8").splitlines() + start = None + for index, line in enumerate(lines): + if line == f"{function_name}() {{": + start = index + break + if start is None: + raise AssertionError(f"could not find function {function_name} in {script_path}") + + body: list[str] = [] + for line in lines[start:]: + body.append(line) + if line == "}": + break + return "\n".join(body) + "\n" + + +def run_shell_function(script_path: Path, function_name: str, os_name: str, arch: str) -> list[str]: + function_source = extract_function(function_name, script_path) + shell = textwrap.dedent( + f""" + set -euo pipefail + {function_source} + uname() {{ + if [[ "${{1:-}}" == "-m" ]]; then + printf '%s\\n' "{arch}" + else + printf '%s\\n' "{os_name}" + fi + }} + {function_name} + """ + ) + proc = run_cmd(["bash", "-lc", shell]) + if proc.returncode != 0: + raise AssertionError(proc.stderr or proc.stdout) + return [line for line in proc.stdout.splitlines() if line] + + +def workflow_target_os(target: str) -> str: + workflow = PUB_RELEASE.read_text(encoding="utf-8") + pattern = re.compile( + rf"^\s+- os: (?P.+)\n\s+target: {re.escape(target)}$", + re.MULTILINE, + ) + match = pattern.search(workflow) + if match is None: + raise AssertionError(f"could not find workflow target block for {target}") + return match.group("os").strip() + + +class ReleaseInstallerTargetSelectionTest(unittest.TestCase): + def test_install_release_prefers_musl_for_linux_x86_64(self) -> None: + self.assertEqual( + run_shell_function(INSTALL_RELEASE, "linux_triples", "Linux", "x86_64"), + ["x86_64-unknown-linux-musl", "x86_64-unknown-linux-gnu"], + ) + + def test_install_release_prefers_musl_for_linux_aarch64(self) -> None: + self.assertEqual( + run_shell_function(INSTALL_RELEASE, "linux_triples", "Linux", "aarch64"), + ["aarch64-unknown-linux-musl", "aarch64-unknown-linux-gnu"], + ) + + def test_bootstrap_prefers_musl_for_linux_x86_64(self) -> None: + self.assertEqual( + run_shell_function(BOOTSTRAP, "detect_release_targets", "Linux", "x86_64"), + ["x86_64-unknown-linux-musl", "x86_64-unknown-linux-gnu"], + ) + + def test_bootstrap_preserves_non_linux_target_mapping(self) -> None: + self.assertEqual( + run_shell_function(BOOTSTRAP, "detect_release_targets", "Darwin", "arm64"), + ["aarch64-apple-darwin"], + ) + + def test_pub_release_keeps_gnu_linux_targets_on_ubuntu_22_04(self) -> None: + self.assertEqual(workflow_target_os("x86_64-unknown-linux-gnu"), "ubuntu-22.04") + self.assertEqual(workflow_target_os("aarch64-unknown-linux-gnu"), "ubuntu-22.04") + self.assertEqual(workflow_target_os("armv7-unknown-linux-gnueabihf"), "ubuntu-22.04") + + def test_pub_release_keeps_musl_linux_targets_on_self_hosted_runner(self) -> None: + expected = "[self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2404]" + self.assertEqual(workflow_target_os("x86_64-unknown-linux-musl"), expected) + self.assertEqual(workflow_target_os("aarch64-unknown-linux-musl"), expected) + + def test_scripts_remain_shell_parseable(self) -> None: + proc = run_cmd(["bash", "-n", str(INSTALL_RELEASE), str(BOOTSTRAP)]) + self.assertEqual(proc.returncode, 0, msg=proc.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/install-release.sh b/scripts/install-release.sh index d9d22452b..271b7863f 100755 --- a/scripts/install-release.sh +++ b/scripts/install-release.sh @@ -23,13 +23,23 @@ run_privileged() { fi } -linux_triple() { +linux_triples() { local arch arch="$(uname -m)" case "$arch" in - x86_64|amd64) echo "x86_64-unknown-linux-gnu" ;; - aarch64|arm64) echo "aarch64-unknown-linux-gnu" ;; - armv7l|armv7) echo "armv7-unknown-linux-gnueabihf" ;; + x86_64|amd64) + printf '%s\n' \ + "x86_64-unknown-linux-musl" \ + "x86_64-unknown-linux-gnu" + ;; + aarch64|arm64) + printf '%s\n' \ + "aarch64-unknown-linux-musl" \ + "aarch64-unknown-linux-gnu" + ;; + armv7l|armv7) + printf '%s\n' "armv7-unknown-linux-gnueabihf" + ;; *) echo "error: unsupported Linux architecture: $arch" >&2 echo "supported: x86_64, aarch64, armv7" >&2 @@ -90,9 +100,8 @@ need_cmd tar need_cmd mktemp need_cmd install -TRIPLE="$(linux_triple)" -ASSET="zeroclaw-${TRIPLE}.tar.gz" -DOWNLOAD_URL="${RELEASE_BASE}/${ASSET}" +TRIPLE="" +ASSET="" TMP_DIR="$(mktemp -d)" cleanup() { @@ -106,8 +115,27 @@ if ! curl -fsSL "$API_URL" >/dev/null; then exit 1 fi -echo "==> Downloading ${ASSET}" -curl -fL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET" +download_asset() { + local triple asset download_url + while IFS= read -r triple; do + [ -n "$triple" ] || continue + asset="zeroclaw-${triple}.tar.gz" + download_url="${RELEASE_BASE}/${asset}" + echo "==> Attempting ${asset}" + if curl -fsSL "$download_url" -o "$TMP_DIR/$asset"; then + TRIPLE="$triple" + ASSET="$asset" + return 0 + fi + done < <(linux_triples) + + return 1 +} + +if ! download_asset; then + echo "error: unable to download a compatible Linux release artifact" >&2 + exit 1 +fi echo "==> Extracting release archive" tar -xzf "$TMP_DIR/$ASSET" -C "$TMP_DIR"