diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index e6cd0f20af43..64d68d98f3d0 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -10,6 +10,12 @@ Options: --target Build a single target, e.g. `aarch64-macos` or `x86_64-macos`. --output Destination path for the built helper. + +Environment: + CMUX_GHOSTTY_HELPER_CACHE_DIR + Override the local helper cache directory. + CMUX_DISABLE_GHOSTTY_HELPER_CACHE=1 + Disable cache reads and writes for this invocation. EOF } @@ -24,6 +30,14 @@ ZIG_REQUIRED="${ZIG_REQUIRED:-$(ghostty_minimum_zig_version "$REPO_ROOT")}" OUTPUT_PATH="" TARGET_TRIPLE="" UNIVERSAL="false" +if [[ -n "${CMUX_GHOSTTY_HELPER_CACHE_DIR:-}" ]]; then + CACHE_ROOT="$CMUX_GHOSTTY_HELPER_CACHE_DIR" +elif [[ -n "${HOME:-}" ]]; then + CACHE_ROOT="$HOME/Library/Caches/cmux/ghostty-cli-helper" +else + CACHE_ROOT="" +fi +CACHE_SCHEMA="ghostty-cli-helper-cache-v1" zig_binary_arch() { local zig_path="$1" @@ -37,6 +51,78 @@ target_arch_for_triple() { esac } +ghostty_cache_is_safe() { + [[ -n "$CACHE_ROOT" ]] || return 1 + [[ "${CMUX_DISABLE_GHOSTTY_HELPER_CACHE:-0}" != "1" ]] || return 1 + [[ -d "$GHOSTTY_DIR/.git" || -f "$GHOSTTY_DIR/.git" ]] || return 1 + # A dirty or caller-owned Ghostty tree must never be substituted by a + # revision-only cache entry. Ignored Zig build output is intentionally fine. + git -C "$GHOSTTY_DIR" diff --quiet HEAD -- . || return 1 + [[ -z "$(git -C "$GHOSTTY_DIR" ls-files --others --exclude-standard)" ]] || return 1 + return 0 +} + +ghostty_cache_metadata() { + local zig_bin="$1" + local target="$2" + local effective_target="$3" + local zig_version zig_fingerprint ghostty_sha script_sha sdk_version host_version host_arch + zig_version="$($zig_bin version 2>/dev/null || true)" + zig_fingerprint="$(shasum -a 256 "$zig_bin" 2>/dev/null | awk '{print $1}')" + ghostty_sha="$(git -C "$GHOSTTY_DIR" rev-parse HEAD)" + script_sha="$(shasum -a 256 "$SCRIPT_DIR/build-ghostty-cli-helper.sh" | awk '{print $1}')" + sdk_version="$(xcrun --sdk macosx --show-sdk-version 2>/dev/null || echo unknown)" + host_version="$(sw_vers -productVersion 2>/dev/null || echo unknown)" + host_arch="$(uname -m)" + cat </dev/null || true)" + [[ "$cached_metadata" == "$metadata" ]] || return 1 + cached_sha="$(sed -n 's/^binary_sha256=//p' "$cache_manifest" 2>/dev/null || true)" + expected_sha="$(shasum -a 256 "$cache_bin" 2>/dev/null | awk '{print $1}')" + [[ -n "$cached_sha" && "$cached_sha" == "$expected_sha" ]] || return 1 + mkdir -p "$prefix/bin" + install -m 755 "$cache_bin" "$prefix/bin/ghostty" + echo "Reusing cached Ghostty CLI helper" + return 0 +} + +ghostty_cache_publish() { + local cache_dir="$1" + local metadata="$2" + local binary="$3" + local cache_bin="$cache_dir/ghostty" + local cache_manifest="$cache_dir/manifest" + local tmp_binary="$cache_dir/.ghostty.tmp.$$" + local tmp_manifest="$cache_dir/.manifest.tmp.$$" + local binary_sha + mkdir -p "$cache_dir" + install -m 755 "$binary" "$tmp_binary" + mv -f "$tmp_binary" "$cache_bin" + binary_sha="$(shasum -a 256 "$cache_bin" | awk '{print $1}')" + printf '%s\nbinary_sha256=%s' "$metadata" "$binary_sha" > "$tmp_manifest" + mv -f "$tmp_manifest" "$cache_manifest" +} + # Real host arch, accounting for Rosetta where `uname -m` reports x86_64 on # Apple Silicon. Used so the default single-arch stub targets the true host. detected_host_arch() { @@ -253,6 +339,21 @@ build_helper() { effective_target="" fi + local cache_enabled=0 + local cache_dir="" + local cache_metadata="" + if ghostty_cache_is_safe; then + cache_enabled=1 + cache_metadata="$(ghostty_cache_metadata "$zig_bin" "$target" "$effective_target")" + local cache_key + cache_key="$(printf '%s' "$cache_metadata" | shasum -a 256 | awk '{print $1}')" + cache_dir="$CACHE_ROOT/$cache_key" + if ghostty_cache_install_if_valid \ + "$cache_dir/ghostty" "$cache_dir/manifest" "$cache_metadata" "$prefix"; then + return 0 + fi + fi + local args=( "$zig_bin" build @@ -278,6 +379,16 @@ build_helper() { # leaves build-runner binaries unlinked against libSystem on a cold cache. env -u SDKROOT "${args[@]}" ) + + [[ -x "$prefix/bin/ghostty" ]] || { + echo "error: Zig did not produce a Ghostty CLI helper at $prefix/bin/ghostty" >&2 + return 1 + } + if [[ "$cache_enabled" -eq 1 ]]; then + if ! ghostty_cache_publish "$cache_dir" "$cache_metadata" "$prefix/bin/ghostty"; then + echo "warning: unable to publish Ghostty CLI helper cache; continuing with built helper" >&2 + fi + fi } TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-ghostty-helper.XXXXXX")" diff --git a/tests/test_ghostty_cli_helper_cache.sh b/tests/test_ghostty_cli_helper_cache.sh new file mode 100755 index 000000000000..a88c1a20cc3d --- /dev/null +++ b/tests/test_ghostty_cli_helper_cache.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-ghostty-helper-cache-test.XXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +FAKE_ZIG="$TMP_DIR/zig" +cat > "$FAKE_ZIG" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "version" ]]; then + echo "0.16.0" + exit 0 +fi +if [[ "${1:-}" == "build" ]]; then + prefix="" + previous="" + for arg in "$@"; do + if [[ "$previous" == "--prefix" ]]; then + prefix="$arg" + break + fi + previous="$arg" + done + [[ -n "$prefix" ]] || { echo "missing --prefix" >&2; exit 1; } + mkdir -p "$prefix/bin" + printf '#!/usr/bin/env bash\necho fake ghostty helper\n' > "$prefix/bin/ghostty" + chmod +x "$prefix/bin/ghostty" + exit 0 +fi +echo "unsupported fake zig invocation" >&2 +exit 1 +EOF +chmod +x "$FAKE_ZIG" + +CACHE_DIR="$TMP_DIR/cache" +FIRST="$TMP_DIR/first" +SECOND="$TMP_DIR/second" +THIRD="$TMP_DIR/third" +FOURTH="$TMP_DIR/fourth" +FIFTH="$TMP_DIR/fifth" +SIXTH="$TMP_DIR/sixth" + +CMUX_ZIG="$FAKE_ZIG" \ +CMUX_GHOSTTY_HELPER_CACHE_DIR="$CACHE_DIR" \ + "$ROOT_DIR/scripts/build-ghostty-cli-helper.sh" \ + --target aarch64-macos --output "$FIRST" >"$TMP_DIR/first.log" +CMUX_ZIG="$FAKE_ZIG" \ +CMUX_GHOSTTY_HELPER_CACHE_DIR="$CACHE_DIR" \ + "$ROOT_DIR/scripts/build-ghostty-cli-helper.sh" \ + --target aarch64-macos --output "$SECOND" >"$TMP_DIR/second.log" + +grep -q 'Building Ghostty CLI helper' "$TMP_DIR/first.log" +grep -q 'Reusing cached Ghostty CLI helper' "$TMP_DIR/second.log" +cmp -s "$FIRST" "$SECOND" + +cached_helper="$(find "$CACHE_DIR" -type f -name ghostty -print -quit)" +[[ -n "$cached_helper" ]] +printf 'tampered\n' >> "$cached_helper" +CMUX_ZIG="$FAKE_ZIG" \ +CMUX_GHOSTTY_HELPER_CACHE_DIR="$CACHE_DIR" \ + "$ROOT_DIR/scripts/build-ghostty-cli-helper.sh" \ + --target aarch64-macos --output "$FOURTH" >"$TMP_DIR/fourth.log" +grep -q 'Building Ghostty CLI helper' "$TMP_DIR/fourth.log" +cmp -s "$FIRST" "$FOURTH" + +CMUX_ZIG="$FAKE_ZIG" \ +CMUX_GHOSTTY_HELPER_CACHE_DIR="$CACHE_DIR" \ +CMUX_DISABLE_GHOSTTY_HELPER_CACHE=1 \ + "$ROOT_DIR/scripts/build-ghostty-cli-helper.sh" \ + --target aarch64-macos --output "$THIRD" >"$TMP_DIR/third.log" +grep -q 'Building Ghostty CLI helper' "$TMP_DIR/third.log" +cmp -s "$FIRST" "$THIRD" + +env -u HOME -u CMUX_GHOSTTY_HELPER_CACHE_DIR \ + CMUX_ZIG="$FAKE_ZIG" \ + "$ROOT_DIR/scripts/build-ghostty-cli-helper.sh" \ + --target aarch64-macos --output "$FIFTH" >"$TMP_DIR/fifth.log" +env -u HOME -u CMUX_GHOSTTY_HELPER_CACHE_DIR \ + CMUX_ZIG="$FAKE_ZIG" \ + "$ROOT_DIR/scripts/build-ghostty-cli-helper.sh" \ + --target aarch64-macos --output "$SIXTH" >"$TMP_DIR/sixth.log" +grep -q 'Building Ghostty CLI helper' "$TMP_DIR/fifth.log" +grep -q 'Building Ghostty CLI helper' "$TMP_DIR/sixth.log" +cmp -s "$FIFTH" "$SIXTH" + +echo "PASS: Ghostty CLI helper cache reuses matching builds, rejects tampering, and disables safely" diff --git a/tests/test_ghostty_zig_version_sync.sh b/tests/test_ghostty_zig_version_sync.sh index e368e6b71aa4..c63bef53e217 100755 --- a/tests/test_ghostty_zig_version_sync.sh +++ b/tests/test_ghostty_zig_version_sync.sh @@ -69,6 +69,8 @@ python3 \ --require-setup-zig \ "$ROOT_DIR/.github/workflows" +"$ROOT_DIR/tests/test_ghostty_cli_helper_cache.sh" + if ! grep -Fq 'source "$SCRIPT_DIR/ghostty-zig-version.sh"' "$ROOT_DIR/scripts/setup.sh" || ! grep -Fq 'ghostty_minimum_zig_version "$PROJECT_DIR"' "$ROOT_DIR/scripts/setup.sh" || ! grep -Fq 'ZIG_ACTUAL="$(zig version)"' "$ROOT_DIR/scripts/setup.sh" ||