Feat: improve local build script
This commit is contained in:
BIN
scripts/__pycache__/build.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/build.cpython-313.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/build.cpython-314.pyc
Normal file
BIN
scripts/__pycache__/build.cpython-314.pyc
Normal file
Binary file not shown.
@@ -1,473 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export MACOSX_DEPLOYMENT_TARGET="12.0"
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
PLUGIN_NAME="cagire-plugins"
|
||||
LIB_NAME="cagire_plugins" # cargo converts hyphens to underscores
|
||||
OUT="releases"
|
||||
|
||||
PLATFORMS=(
|
||||
"aarch64-apple-darwin"
|
||||
"x86_64-apple-darwin"
|
||||
"x86_64-unknown-linux-gnu"
|
||||
"aarch64-unknown-linux-gnu"
|
||||
"x86_64-pc-windows-gnu"
|
||||
)
|
||||
|
||||
PLATFORM_LABELS=(
|
||||
"macOS aarch64 (native)"
|
||||
"macOS x86_64 (native)"
|
||||
"Linux x86_64 (cross)"
|
||||
"Linux aarch64 (cross)"
|
||||
"Windows x86_64 (cross)"
|
||||
)
|
||||
|
||||
PLATFORM_ALIASES=(
|
||||
"macos-arm64"
|
||||
"macos-x86_64"
|
||||
"linux-x86_64"
|
||||
"linux-aarch64"
|
||||
"windows-x86_64"
|
||||
)
|
||||
|
||||
# --- CLI argument parsing ---
|
||||
|
||||
cli_platforms=""
|
||||
cli_targets=""
|
||||
cli_yes=false
|
||||
cli_all=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--platforms) cli_platforms="$2"; shift 2 ;;
|
||||
--targets) cli_targets="$2"; shift 2 ;;
|
||||
--yes) cli_yes=true; shift ;;
|
||||
--all) cli_all=true; shift ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --platforms <list> Comma-separated: macos-arm64,macos-x86_64,linux-x86_64,linux-aarch64,windows-x86_64"
|
||||
echo " --targets <list> Comma-separated: cli,desktop,plugins"
|
||||
echo " --all Build all platforms and targets"
|
||||
echo " --yes Skip confirmation prompt"
|
||||
echo ""
|
||||
echo "Without options, runs interactively."
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
resolve_platform_alias() {
|
||||
local alias="$1"
|
||||
for i in "${!PLATFORM_ALIASES[@]}"; do
|
||||
if [[ "${PLATFORM_ALIASES[$i]}" == "$alias" ]]; then
|
||||
echo "$i"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo "Unknown platform: $alias" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
prompt_platforms() {
|
||||
echo "Select platform (0=all, comma-separated):"
|
||||
echo " 0) All"
|
||||
for i in "${!PLATFORMS[@]}"; do
|
||||
echo " $((i+1))) ${PLATFORM_LABELS[$i]}"
|
||||
done
|
||||
read -rp "> " choice
|
||||
|
||||
if [[ "$choice" == "0" || -z "$choice" ]]; then
|
||||
selected_platforms=("${PLATFORMS[@]}")
|
||||
selected_labels=("${PLATFORM_LABELS[@]}")
|
||||
else
|
||||
IFS=',' read -ra indices <<< "$choice"
|
||||
selected_platforms=()
|
||||
selected_labels=()
|
||||
for idx in "${indices[@]}"; do
|
||||
idx="${idx// /}"
|
||||
idx=$((idx - 1))
|
||||
if (( idx < 0 || idx >= ${#PLATFORMS[@]} )); then
|
||||
echo "Invalid platform index: $((idx+1))"
|
||||
exit 1
|
||||
fi
|
||||
selected_platforms+=("${PLATFORMS[$idx]}")
|
||||
selected_labels+=("${PLATFORM_LABELS[$idx]}")
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_targets() {
|
||||
echo ""
|
||||
echo "Select targets (0=all, comma-separated):"
|
||||
echo " 0) All"
|
||||
echo " 1) cagire"
|
||||
echo " 2) cagire-desktop"
|
||||
echo " 3) cagire-plugins (CLAP/VST3)"
|
||||
read -rp "> " choice
|
||||
|
||||
build_cagire=false
|
||||
build_desktop=false
|
||||
build_plugins=false
|
||||
|
||||
if [[ "$choice" == "0" || -z "$choice" ]]; then
|
||||
build_cagire=true
|
||||
build_desktop=true
|
||||
build_plugins=true
|
||||
else
|
||||
IFS=',' read -ra targets <<< "$choice"
|
||||
for t in "${targets[@]}"; do
|
||||
t="${t// /}"
|
||||
case "$t" in
|
||||
1) build_cagire=true ;;
|
||||
2) build_desktop=true ;;
|
||||
3) build_plugins=true ;;
|
||||
*) echo "Invalid target: $t"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
confirm_summary() {
|
||||
echo ""
|
||||
echo "=== Build Summary ==="
|
||||
echo ""
|
||||
echo "Platforms:"
|
||||
for label in "${selected_labels[@]}"; do
|
||||
echo " - $label"
|
||||
done
|
||||
echo ""
|
||||
echo "Targets:"
|
||||
$build_cagire && echo " - cagire"
|
||||
$build_desktop && echo " - cagire-desktop"
|
||||
$build_plugins && echo " - cagire-plugins (CLAP/VST3)"
|
||||
echo ""
|
||||
read -rp "Proceed? [Y/n] " yn
|
||||
case "${yn,,}" in
|
||||
n|no) echo "Aborted."; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
platform_os() {
|
||||
case "$1" in
|
||||
*windows*) echo "windows" ;;
|
||||
*linux*) echo "linux" ;;
|
||||
*apple*) echo "macos" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
platform_arch() {
|
||||
case "$1" in
|
||||
aarch64*) echo "aarch64" ;;
|
||||
x86_64*) echo "x86_64" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
platform_suffix() {
|
||||
case "$1" in
|
||||
*windows*) echo ".exe" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_cross_target() {
|
||||
case "$1" in
|
||||
*linux*|*windows*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
native_target() {
|
||||
[[ "$1" == "aarch64-apple-darwin" ]]
|
||||
}
|
||||
|
||||
release_dir() {
|
||||
if native_target "$1"; then
|
||||
echo "target/release"
|
||||
else
|
||||
echo "target/$1/release"
|
||||
fi
|
||||
}
|
||||
|
||||
target_flag() {
|
||||
if native_target "$1"; then
|
||||
echo ""
|
||||
else
|
||||
echo "--target $1"
|
||||
fi
|
||||
}
|
||||
|
||||
builder_for() {
|
||||
if is_cross_target "$1"; then
|
||||
echo "cross"
|
||||
else
|
||||
echo "cargo"
|
||||
fi
|
||||
}
|
||||
|
||||
build_binary() {
|
||||
local platform="$1"
|
||||
shift
|
||||
local builder
|
||||
builder=$(builder_for "$platform")
|
||||
local tf
|
||||
tf=$(target_flag "$platform")
|
||||
# shellcheck disable=SC2086
|
||||
$builder build --release $tf "$@"
|
||||
}
|
||||
|
||||
bundle_plugins_native() {
|
||||
local platform="$1"
|
||||
local tf
|
||||
tf=$(target_flag "$platform")
|
||||
# shellcheck disable=SC2086
|
||||
cargo xtask bundle "$PLUGIN_NAME" --release $tf
|
||||
}
|
||||
|
||||
bundle_desktop_native() {
|
||||
local platform="$1"
|
||||
local tf
|
||||
tf=$(target_flag "$platform")
|
||||
# shellcheck disable=SC2086
|
||||
cargo bundle --release --features desktop --bin cagire-desktop $tf
|
||||
}
|
||||
|
||||
bundle_plugins_cross() {
|
||||
local platform="$1"
|
||||
local rd
|
||||
rd=$(release_dir "$platform")
|
||||
local os
|
||||
os=$(platform_os "$platform")
|
||||
local arch
|
||||
arch=$(platform_arch "$platform")
|
||||
|
||||
# Build the cdylib with cross
|
||||
# shellcheck disable=SC2046
|
||||
build_binary "$platform" -p "$PLUGIN_NAME"
|
||||
|
||||
# Determine source library file
|
||||
local src_lib
|
||||
case "$os" in
|
||||
linux) src_lib="$rd/lib${LIB_NAME}.so" ;;
|
||||
windows) src_lib="$rd/${LIB_NAME}.dll" ;;
|
||||
esac
|
||||
|
||||
if [[ ! -f "$src_lib" ]]; then
|
||||
echo " ERROR: Expected library not found: $src_lib"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Assemble CLAP bundle (flat file)
|
||||
local clap_out="$OUT/${PLUGIN_NAME}-${os}-${arch}.clap"
|
||||
cp "$src_lib" "$clap_out"
|
||||
echo " CLAP -> $clap_out"
|
||||
|
||||
# Assemble VST3 bundle (directory tree)
|
||||
local vst3_dir="$OUT/${PLUGIN_NAME}-${os}-${arch}.vst3"
|
||||
local vst3_contents
|
||||
case "$os" in
|
||||
linux)
|
||||
vst3_contents="$vst3_dir/Contents/${arch}-linux"
|
||||
mkdir -p "$vst3_contents"
|
||||
cp "$src_lib" "$vst3_contents/${PLUGIN_NAME}.so"
|
||||
;;
|
||||
windows)
|
||||
vst3_contents="$vst3_dir/Contents/${arch}-win"
|
||||
mkdir -p "$vst3_contents"
|
||||
cp "$src_lib" "$vst3_contents/${PLUGIN_NAME}.vst3"
|
||||
;;
|
||||
esac
|
||||
echo " VST3 -> $vst3_dir/"
|
||||
}
|
||||
|
||||
copy_artifacts() {
|
||||
local platform="$1"
|
||||
local rd
|
||||
rd=$(release_dir "$platform")
|
||||
local os
|
||||
os=$(platform_os "$platform")
|
||||
local arch
|
||||
arch=$(platform_arch "$platform")
|
||||
local suffix
|
||||
suffix=$(platform_suffix "$platform")
|
||||
|
||||
if $build_cagire; then
|
||||
local src="$rd/cagire${suffix}"
|
||||
local dst="$OUT/cagire-${os}-${arch}${suffix}"
|
||||
cp "$src" "$dst"
|
||||
echo " cagire -> $dst"
|
||||
fi
|
||||
|
||||
if $build_desktop; then
|
||||
local src="$rd/cagire-desktop${suffix}"
|
||||
local dst="$OUT/cagire-desktop-${os}-${arch}${suffix}"
|
||||
cp "$src" "$dst"
|
||||
echo " cagire-desktop -> $dst"
|
||||
|
||||
# macOS .app bundle
|
||||
if [[ "$os" == "macos" ]]; then
|
||||
local app_src="$rd/bundle/osx/Cagire.app"
|
||||
if [[ ! -d "$app_src" ]]; then
|
||||
echo " ERROR: .app bundle not found at $app_src"
|
||||
echo " Did 'cargo bundle' succeed?"
|
||||
return 1
|
||||
fi
|
||||
local app_dst="$OUT/Cagire-${arch}.app"
|
||||
rm -rf "$app_dst"
|
||||
cp -R "$app_src" "$app_dst"
|
||||
echo " Cagire.app -> $app_dst"
|
||||
scripts/make-dmg.sh "$app_dst" "$OUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# NSIS installer for Windows targets
|
||||
if [[ "$os" == "windows" ]] && command -v makensis &>/dev/null; then
|
||||
echo " Building NSIS installer..."
|
||||
local version
|
||||
version=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
local abs_root
|
||||
abs_root=$(pwd)
|
||||
makensis -DVERSION="$version" \
|
||||
-DCLI_EXE="$abs_root/$rd/cagire.exe" \
|
||||
-DDESKTOP_EXE="$abs_root/$rd/cagire-desktop.exe" \
|
||||
-DICON="$abs_root/assets/Cagire.ico" \
|
||||
-DOUTDIR="$abs_root/$OUT" \
|
||||
nsis/cagire.nsi
|
||||
echo " Installer -> $OUT/cagire-${version}-windows-x86_64-setup.exe"
|
||||
fi
|
||||
|
||||
# AppImage for Linux targets
|
||||
if [[ "$os" == "linux" ]]; then
|
||||
if $build_cagire; then
|
||||
scripts/make-appimage.sh "$rd/cagire" "$arch" "$OUT"
|
||||
fi
|
||||
if $build_desktop; then
|
||||
scripts/make-appimage.sh "$rd/cagire-desktop" "$arch" "$OUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Plugin artifacts for native targets (cross handled in bundle_plugins_cross)
|
||||
if $build_plugins && ! is_cross_target "$platform"; then
|
||||
local bundle_dir="target/bundled"
|
||||
|
||||
# CLAP
|
||||
local clap_src="$bundle_dir/${PLUGIN_NAME}.clap"
|
||||
if [[ -e "$clap_src" ]]; then
|
||||
local clap_dst="$OUT/${PLUGIN_NAME}-${os}-${arch}.clap"
|
||||
cp -r "$clap_src" "$clap_dst"
|
||||
echo " CLAP -> $clap_dst"
|
||||
fi
|
||||
|
||||
# VST3
|
||||
local vst3_src="$bundle_dir/${PLUGIN_NAME}.vst3"
|
||||
if [[ -d "$vst3_src" ]]; then
|
||||
local vst3_dst="$OUT/${PLUGIN_NAME}-${os}-${arch}.vst3"
|
||||
rm -rf "$vst3_dst"
|
||||
cp -r "$vst3_src" "$vst3_dst"
|
||||
echo " VST3 -> $vst3_dst/"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if $cli_all; then
|
||||
selected_platforms=("${PLATFORMS[@]}")
|
||||
selected_labels=("${PLATFORM_LABELS[@]}")
|
||||
build_cagire=true
|
||||
build_desktop=true
|
||||
build_plugins=true
|
||||
elif [[ -n "$cli_platforms" || -n "$cli_targets" ]]; then
|
||||
# Resolve platforms from CLI
|
||||
if [[ -n "$cli_platforms" ]]; then
|
||||
selected_platforms=()
|
||||
selected_labels=()
|
||||
IFS=',' read -ra aliases <<< "$cli_platforms"
|
||||
for alias in "${aliases[@]}"; do
|
||||
alias="${alias// /}"
|
||||
idx=$(resolve_platform_alias "$alias")
|
||||
selected_platforms+=("${PLATFORMS[$idx]}")
|
||||
selected_labels+=("${PLATFORM_LABELS[$idx]}")
|
||||
done
|
||||
else
|
||||
selected_platforms=("${PLATFORMS[@]}")
|
||||
selected_labels=("${PLATFORM_LABELS[@]}")
|
||||
fi
|
||||
|
||||
# Resolve targets from CLI
|
||||
build_cagire=false
|
||||
build_desktop=false
|
||||
build_plugins=false
|
||||
if [[ -n "$cli_targets" ]]; then
|
||||
IFS=',' read -ra tgts <<< "$cli_targets"
|
||||
for t in "${tgts[@]}"; do
|
||||
t="${t// /}"
|
||||
case "$t" in
|
||||
cli) build_cagire=true ;;
|
||||
desktop) build_desktop=true ;;
|
||||
plugins) build_plugins=true ;;
|
||||
*) echo "Unknown target: $t (expected: cli, desktop, plugins)"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
build_cagire=true
|
||||
build_desktop=true
|
||||
build_plugins=true
|
||||
fi
|
||||
else
|
||||
prompt_platforms
|
||||
prompt_targets
|
||||
fi
|
||||
|
||||
if ! $cli_yes && [[ -z "$cli_platforms" ]] && ! $cli_all; then
|
||||
confirm_summary
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
step=0
|
||||
total=${#selected_platforms[@]}
|
||||
|
||||
for platform in "${selected_platforms[@]}"; do
|
||||
step=$((step + 1))
|
||||
echo ""
|
||||
echo "=== [$step/$total] $platform ==="
|
||||
|
||||
if $build_cagire; then
|
||||
echo " -> cagire"
|
||||
build_binary "$platform"
|
||||
fi
|
||||
|
||||
if $build_desktop; then
|
||||
echo " -> cagire-desktop"
|
||||
build_binary "$platform" --features desktop --bin cagire-desktop
|
||||
if ! is_cross_target "$platform"; then
|
||||
echo " -> bundling cagire-desktop .app"
|
||||
bundle_desktop_native "$platform"
|
||||
fi
|
||||
fi
|
||||
|
||||
if $build_plugins; then
|
||||
echo " -> cagire-plugins"
|
||||
if is_cross_target "$platform"; then
|
||||
bundle_plugins_cross "$platform"
|
||||
else
|
||||
bundle_plugins_native "$platform"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " Copying artifacts..."
|
||||
copy_artifacts "$platform"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo ""
|
||||
ls -lhR "$OUT/"
|
||||
949
scripts/build.py
Executable file
949
scripts/build.py
Executable file
@@ -0,0 +1,949 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["rich>=13.0", "questionary>=2.0"]
|
||||
# ///
|
||||
"""Cagire release builder — replaces build-all.sh, make-dmg.sh, make-appimage.sh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import tomllib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.layout import Layout
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.progress_bar import ProgressBar
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
import questionary
|
||||
|
||||
console = Console()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build progress tracking (shared between threads)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_progress_lock = threading.Lock()
|
||||
_build_progress: dict[str, tuple[str, int]] = {} # alias -> (phase, step)
|
||||
_build_logs: list[tuple[str, str]] = [] # (alias, line)
|
||||
|
||||
|
||||
def _update_phase(alias: str, phase: str, step: int) -> None:
|
||||
with _progress_lock:
|
||||
_build_progress[alias] = (phase, step)
|
||||
|
||||
|
||||
class BuildLog(list):
|
||||
"""A list that also feeds lines into the shared log buffer."""
|
||||
|
||||
def __init__(self, alias: str):
|
||||
super().__init__()
|
||||
self._alias = alias
|
||||
|
||||
def append(self, line: str) -> None:
|
||||
super().append(line)
|
||||
with _progress_lock:
|
||||
_build_logs.append((self._alias, line))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PLUGIN_NAME = "cagire-plugins"
|
||||
LIB_NAME = "cagire_plugins"
|
||||
OUT = "releases"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Platform:
|
||||
triple: str
|
||||
label: str
|
||||
alias: str
|
||||
os: str
|
||||
arch: str
|
||||
cross: bool
|
||||
native: bool
|
||||
|
||||
|
||||
def _parse_triple(triple: str) -> Platform:
|
||||
"""Derive a full Platform from a Rust target triple."""
|
||||
parts = triple.split("-")
|
||||
arch = parts[0]
|
||||
if "apple" in triple:
|
||||
os_name, cross = "macos", False
|
||||
elif "linux" in triple:
|
||||
os_name, cross = "linux", True
|
||||
elif "windows" in triple:
|
||||
os_name, cross = "windows", True
|
||||
else:
|
||||
raise ValueError(f"Unknown OS in triple: {triple}")
|
||||
host_arch = platform.machine()
|
||||
host_triple_arch = "aarch64" if host_arch == "arm64" else host_arch
|
||||
native = (os_name == "macos" and arch == host_triple_arch and platform.system() == "Darwin") \
|
||||
or (os_name == "linux" and arch == host_triple_arch and platform.system() == "Linux")
|
||||
mode = "native" if native else "cross" if cross else "native"
|
||||
alias_arch = "arm64" if (os_name == "macos" and arch == "aarch64") else arch
|
||||
alias = f"{os_name}-{alias_arch}"
|
||||
label = f"{'macOS' if os_name == 'macos' else os_name.capitalize()} {arch} ({mode})"
|
||||
return Platform(triple, label, alias, os_name, arch, cross, native)
|
||||
|
||||
|
||||
def load_platforms(root: Path) -> list[Platform]:
|
||||
"""Load platform definitions from scripts/platforms.toml."""
|
||||
with open(root / "scripts" / "platforms.toml", "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return [_parse_triple(t) for t in data["triples"]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildConfig:
|
||||
cli: bool = True
|
||||
desktop: bool = True
|
||||
plugins: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformResult:
|
||||
platform: Platform
|
||||
success: bool
|
||||
elapsed: float
|
||||
artifacts: list[str] = field(default_factory=list)
|
||||
log_lines: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_version(root: Path) -> str:
|
||||
with open(root / "Cargo.toml", "rb") as f:
|
||||
cargo = tomllib.load(f)
|
||||
return cargo["workspace"]["package"]["version"]
|
||||
|
||||
|
||||
def builder_for(p: Platform) -> str:
|
||||
return "cross" if p.cross else "cargo"
|
||||
|
||||
|
||||
def release_dir(root: Path, p: Platform) -> Path:
|
||||
if p.native:
|
||||
return root / "target" / "release"
|
||||
return root / "target" / p.triple / "release"
|
||||
|
||||
|
||||
def target_flags(p: Platform) -> list[str]:
|
||||
if p.native:
|
||||
return []
|
||||
return ["--target", p.triple]
|
||||
|
||||
|
||||
def suffix_for(p: Platform) -> str:
|
||||
return ".exe" if p.os == "windows" else ""
|
||||
|
||||
|
||||
def run_cmd(
|
||||
cmd: list[str], log: list[str], env: dict[str, str] | None = None,
|
||||
input: str | None = None, cwd: Path | None = None,
|
||||
) -> None:
|
||||
log.append(f" $ {' '.join(cmd)}")
|
||||
merged_env = {**os.environ, **(env or {})}
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, env=merged_env,
|
||||
stdin=subprocess.PIPE if input else subprocess.DEVNULL,
|
||||
cwd=cwd,
|
||||
)
|
||||
if input:
|
||||
proc.stdin.write(input)
|
||||
proc.stdin.close()
|
||||
for line in proc.stdout:
|
||||
log.append(f" {line.rstrip()}")
|
||||
rc = proc.wait()
|
||||
if rc != 0:
|
||||
raise subprocess.CalledProcessError(rc, cmd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _macos_env(p: Platform) -> dict[str, str] | None:
|
||||
if p.os == "macos":
|
||||
return {"MACOSX_DEPLOYMENT_TARGET": "12.0"}
|
||||
return None
|
||||
|
||||
|
||||
def build_binary(root: Path, p: Platform, log: list[str], extra_args: list[str] | None = None) -> None:
|
||||
cmd = [builder_for(p), "build", "--release", *target_flags(p), *(extra_args or [])]
|
||||
log.append(f" Building: {' '.join(extra_args or ['default'])}")
|
||||
run_cmd(cmd, log, env=_macos_env(p))
|
||||
|
||||
|
||||
def bundle_plugins(root: Path, p: Platform, log: list[str]) -> None:
|
||||
if p.cross:
|
||||
_bundle_plugins_cross(root, p, log)
|
||||
else:
|
||||
_bundle_plugins_native(root, p, log)
|
||||
|
||||
|
||||
def _bundle_plugins_native(root: Path, p: Platform, log: list[str]) -> None:
|
||||
log.append(" Bundling plugins (native xtask)")
|
||||
cmd = ["cargo", "xtask", "bundle", PLUGIN_NAME, "--release", *target_flags(p)]
|
||||
run_cmd(cmd, log, env=_macos_env(p))
|
||||
|
||||
|
||||
def _bundle_plugins_cross(root: Path, p: Platform, log: list[str]) -> None:
|
||||
log.append(" Bundling plugins (cross)")
|
||||
build_binary(root, p, log, extra_args=["-p", PLUGIN_NAME])
|
||||
|
||||
rd = release_dir(root, p)
|
||||
if p.os == "linux":
|
||||
src_lib = rd / f"lib{LIB_NAME}.so"
|
||||
elif p.os == "windows":
|
||||
src_lib = rd / f"{LIB_NAME}.dll"
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected cross OS: {p.os}")
|
||||
|
||||
if not src_lib.exists():
|
||||
raise FileNotFoundError(f"Expected library not found: {src_lib}")
|
||||
|
||||
out = root / OUT
|
||||
|
||||
# CLAP — flat file
|
||||
clap_dst = out / f"{PLUGIN_NAME}-{p.os}-{p.arch}.clap"
|
||||
shutil.copy2(src_lib, clap_dst)
|
||||
log.append(f" CLAP -> {clap_dst}")
|
||||
|
||||
# VST3 — directory tree
|
||||
vst3_dir = out / f"{PLUGIN_NAME}-{p.os}-{p.arch}.vst3"
|
||||
if p.os == "linux":
|
||||
contents = vst3_dir / "Contents" / f"{p.arch}-linux"
|
||||
contents.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_lib, contents / f"{PLUGIN_NAME}.so")
|
||||
elif p.os == "windows":
|
||||
contents = vst3_dir / "Contents" / f"{p.arch}-win"
|
||||
contents.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_lib, contents / f"{PLUGIN_NAME}.vst3")
|
||||
log.append(f" VST3 -> {vst3_dir}/")
|
||||
|
||||
|
||||
def bundle_desktop_app(root: Path, p: Platform, log: list[str]) -> None:
|
||||
if p.cross:
|
||||
return
|
||||
log.append(" Bundling desktop .app")
|
||||
cmd = ["cargo", "bundle", "--release", "--features", "desktop", "--bin", "cagire-desktop", *target_flags(p)]
|
||||
run_cmd(cmd, log, env=_macos_env(p))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packaging: DMG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_dmg(root: Path, app_path: Path, arch: str, output_dir: Path, log: list[str]) -> str | None:
|
||||
log.append(f" Building DMG for {app_path.name}")
|
||||
|
||||
binary = app_path / "Contents" / "MacOS" / "cagire-desktop"
|
||||
result = subprocess.run(["lipo", "-info", str(binary)], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
log.append(f" ERROR: lipo failed on {binary}")
|
||||
return None
|
||||
|
||||
lipo_out = result.stdout.strip()
|
||||
if "Architectures in the fat file" in lipo_out:
|
||||
dmg_arch = "universal"
|
||||
else:
|
||||
raw_arch = lipo_out.split()[-1]
|
||||
dmg_arch = "aarch64" if raw_arch == "arm64" else raw_arch
|
||||
|
||||
staging = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
shutil.copytree(app_path, staging / "Cagire.app")
|
||||
(staging / "Applications").symlink_to("/Applications")
|
||||
readme = root / "assets" / "DMG-README.txt"
|
||||
if readme.exists():
|
||||
shutil.copy2(readme, staging / "README.txt")
|
||||
|
||||
dmg_name = f"Cagire-{dmg_arch}.dmg"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
dmg_path = output_dir / dmg_name
|
||||
|
||||
run_cmd([
|
||||
"hdiutil", "create",
|
||||
"-volname", "Cagire",
|
||||
"-srcfolder", str(staging),
|
||||
"-ov", "-format", "UDZO",
|
||||
str(dmg_path),
|
||||
], log)
|
||||
log.append(f" DMG -> {dmg_path}")
|
||||
return str(dmg_path)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packaging: AppImage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
APPRUN_SCRIPT = """\
|
||||
#!/bin/sh
|
||||
SELF="$(readlink -f "$0")"
|
||||
HERE="$(dirname "$SELF")"
|
||||
exec "$HERE/usr/bin/cagire" "$@"
|
||||
"""
|
||||
|
||||
|
||||
def _build_appdir(root: Path, binary: Path, appdir: Path) -> None:
|
||||
(appdir / "usr" / "bin").mkdir(parents=True)
|
||||
shutil.copy2(binary, appdir / "usr" / "bin" / "cagire")
|
||||
(appdir / "usr" / "bin" / "cagire").chmod(0o755)
|
||||
|
||||
icons = appdir / "usr" / "share" / "icons" / "hicolor" / "512x512" / "apps"
|
||||
icons.mkdir(parents=True)
|
||||
shutil.copy2(root / "assets" / "Cagire.png", icons / "cagire.png")
|
||||
shutil.copy2(root / "assets" / "cagire.desktop", appdir / "cagire.desktop")
|
||||
|
||||
apprun = appdir / "AppRun"
|
||||
apprun.write_text(APPRUN_SCRIPT)
|
||||
apprun.chmod(0o755)
|
||||
|
||||
(appdir / "cagire.png").symlink_to("usr/share/icons/hicolor/512x512/apps/cagire.png")
|
||||
|
||||
|
||||
def _download_if_missing(url: str, dest: Path, log: list[str]) -> None:
|
||||
if dest.exists():
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.append(f" Downloading {url}")
|
||||
run_cmd(["curl", "-fSL", url, "-o", str(dest)], log)
|
||||
|
||||
|
||||
def _make_appimage_native(root: Path, binary: Path, arch: str, output_dir: Path, log: list[str]) -> str:
|
||||
cache = root / ".cache"
|
||||
runtime = cache / f"runtime-{arch}"
|
||||
_download_if_missing(
|
||||
f"https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-{arch}",
|
||||
runtime, log,
|
||||
)
|
||||
|
||||
linuxdeploy = cache / f"linuxdeploy-{arch}.AppImage"
|
||||
_download_if_missing(
|
||||
f"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-{arch}.AppImage",
|
||||
linuxdeploy, log,
|
||||
)
|
||||
linuxdeploy.chmod(0o755)
|
||||
|
||||
appdir = Path(tempfile.mkdtemp()) / "AppDir"
|
||||
_build_appdir(root, binary, appdir)
|
||||
|
||||
app_name = binary.name
|
||||
env = {"ARCH": arch, "LDAI_RUNTIME_FILE": str(runtime)}
|
||||
run_cmd([
|
||||
str(linuxdeploy),
|
||||
"--appimage-extract-and-run",
|
||||
"--appdir", str(appdir),
|
||||
"--desktop-file", str(appdir / "cagire.desktop"),
|
||||
"--icon-file", str(appdir / "usr" / "share" / "icons" / "hicolor" / "512x512" / "apps" / "cagire.png"),
|
||||
"--output", "appimage",
|
||||
], log, env=env, cwd=root)
|
||||
|
||||
# linuxdeploy drops the AppImage in cwd
|
||||
candidates = sorted(root.glob("*.AppImage"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not candidates:
|
||||
raise FileNotFoundError("No AppImage produced by linuxdeploy")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
final = output_dir / f"{app_name}-linux-{arch}.AppImage"
|
||||
shutil.move(str(candidates[0]), final)
|
||||
log.append(f" AppImage -> {final}")
|
||||
return str(final)
|
||||
|
||||
|
||||
def _make_appimage_docker(root: Path, binary: Path, arch: str, output_dir: Path, log: list[str]) -> str:
|
||||
cache = root / ".cache"
|
||||
runtime = cache / f"runtime-{arch}"
|
||||
_download_if_missing(
|
||||
f"https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-{arch}",
|
||||
runtime, log,
|
||||
)
|
||||
|
||||
appdir = Path(tempfile.mkdtemp()) / "AppDir"
|
||||
_build_appdir(root, binary, appdir)
|
||||
|
||||
docker_platform = "linux/amd64" if arch == "x86_64" else "linux/arm64"
|
||||
image_tag = f"cagire-appimage-{arch}"
|
||||
|
||||
log.append(f" Building Docker image {image_tag} ({docker_platform})")
|
||||
dockerfile = "FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y --no-install-recommends squashfs-tools && rm -rf /var/lib/apt/lists/*\n"
|
||||
run_cmd([
|
||||
"docker", "build", "--platform", docker_platform, "-q", "-t", image_tag, "-",
|
||||
], log, input=dockerfile)
|
||||
|
||||
squashfs = cache / f"appimage-{arch}.squashfs"
|
||||
run_cmd([
|
||||
"docker", "run", "--rm", "--platform", docker_platform,
|
||||
"-v", f"{appdir}:/appdir:ro",
|
||||
"-v", f"{cache}:/cache",
|
||||
image_tag,
|
||||
"mksquashfs", "/appdir", f"/cache/appimage-{arch}.squashfs",
|
||||
"-root-owned", "-noappend", "-comp", "gzip", "-no-progress",
|
||||
], log)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
app_name = binary.name
|
||||
final = output_dir / f"{app_name}-linux-{arch}.AppImage"
|
||||
with open(final, "wb") as out_f:
|
||||
out_f.write(runtime.read_bytes())
|
||||
out_f.write(squashfs.read_bytes())
|
||||
final.chmod(0o755)
|
||||
squashfs.unlink(missing_ok=True)
|
||||
log.append(f" AppImage -> {final}")
|
||||
return str(final)
|
||||
|
||||
|
||||
def make_appimage(root: Path, binary: Path, arch: str, output_dir: Path, log: list[str]) -> str:
|
||||
log.append(f" Building AppImage for {binary.name} ({arch})")
|
||||
host_arch = platform.machine()
|
||||
if host_arch == arch and platform.system() == "Linux":
|
||||
return _make_appimage_native(root, binary, arch, output_dir, log)
|
||||
return _make_appimage_docker(root, binary, arch, output_dir, log)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packaging: NSIS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_nsis(root: Path, rd: Path, version: str, output_dir: Path, log: list[str]) -> str | None:
|
||||
if not shutil.which("makensis"):
|
||||
log.append(" makensis not found, skipping NSIS installer")
|
||||
return None
|
||||
|
||||
log.append(" Building NSIS installer")
|
||||
abs_root = str(root.resolve())
|
||||
run_cmd([
|
||||
"makensis",
|
||||
f"-DVERSION={version}",
|
||||
f"-DCLI_EXE={abs_root}/{rd.relative_to(root)}/cagire.exe",
|
||||
f"-DDESKTOP_EXE={abs_root}/{rd.relative_to(root)}/cagire-desktop.exe",
|
||||
f"-DICON={abs_root}/assets/Cagire.ico",
|
||||
f"-DOUTDIR={abs_root}/{OUT}",
|
||||
str(root / "nsis" / "cagire.nsi"),
|
||||
], log)
|
||||
|
||||
installer = f"cagire-{version}-windows-x86_64-setup.exe"
|
||||
log.append(f" Installer -> {output_dir / installer}")
|
||||
return str(output_dir / installer)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artifact copying & packaging dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def copy_artifacts(root: Path, p: Platform, config: BuildConfig, log: list[str]) -> list[str]:
|
||||
rd = release_dir(root, p)
|
||||
out = root / OUT
|
||||
sx = suffix_for(p)
|
||||
version = get_version(root)
|
||||
artifacts: list[str] = []
|
||||
|
||||
if config.cli:
|
||||
src = rd / f"cagire{sx}"
|
||||
dst = out / f"cagire-{p.os}-{p.arch}{sx}"
|
||||
shutil.copy2(src, dst)
|
||||
log.append(f" cagire -> {dst}")
|
||||
artifacts.append(str(dst))
|
||||
|
||||
if config.desktop:
|
||||
src = rd / f"cagire-desktop{sx}"
|
||||
dst = out / f"cagire-desktop-{p.os}-{p.arch}{sx}"
|
||||
shutil.copy2(src, dst)
|
||||
log.append(f" cagire-desktop -> {dst}")
|
||||
artifacts.append(str(dst))
|
||||
|
||||
if p.os == "macos":
|
||||
app_src = rd / "bundle" / "osx" / "Cagire.app"
|
||||
if not app_src.is_dir():
|
||||
raise FileNotFoundError(f".app bundle not found at {app_src}")
|
||||
app_dst = out / f"Cagire-{p.arch}.app"
|
||||
if app_dst.exists():
|
||||
shutil.rmtree(app_dst)
|
||||
shutil.copytree(app_src, app_dst)
|
||||
log.append(f" Cagire.app -> {app_dst}")
|
||||
artifacts.append(str(app_dst))
|
||||
|
||||
dmg = make_dmg(root, app_dst, p.arch, out, log)
|
||||
if dmg:
|
||||
artifacts.append(dmg)
|
||||
|
||||
if p.os == "windows":
|
||||
nsis = make_nsis(root, rd, version, out, log)
|
||||
if nsis:
|
||||
artifacts.append(nsis)
|
||||
|
||||
if p.os == "linux":
|
||||
if config.cli:
|
||||
ai = make_appimage(root, rd / "cagire", p.arch, out, log)
|
||||
artifacts.append(ai)
|
||||
if config.desktop:
|
||||
ai = make_appimage(root, rd / "cagire-desktop", p.arch, out, log)
|
||||
artifacts.append(ai)
|
||||
|
||||
if config.plugins and not p.cross:
|
||||
bundle_dir = root / "target" / "bundled"
|
||||
clap_src = bundle_dir / f"{PLUGIN_NAME}.clap"
|
||||
if clap_src.exists():
|
||||
clap_dst = out / f"{PLUGIN_NAME}-{p.os}-{p.arch}.clap"
|
||||
if clap_src.is_dir():
|
||||
if clap_dst.exists():
|
||||
shutil.rmtree(clap_dst)
|
||||
shutil.copytree(clap_src, clap_dst)
|
||||
else:
|
||||
shutil.copy2(clap_src, clap_dst)
|
||||
log.append(f" CLAP -> {clap_dst}")
|
||||
artifacts.append(str(clap_dst))
|
||||
|
||||
vst3_src = bundle_dir / f"{PLUGIN_NAME}.vst3"
|
||||
if vst3_src.is_dir():
|
||||
vst3_dst = out / f"{PLUGIN_NAME}-{p.os}-{p.arch}.vst3"
|
||||
if vst3_dst.exists():
|
||||
shutil.rmtree(vst3_dst)
|
||||
shutil.copytree(vst3_src, vst3_dst)
|
||||
log.append(f" VST3 -> {vst3_dst}/")
|
||||
artifacts.append(str(vst3_dst))
|
||||
|
||||
return artifacts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-platform orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _count_phases(p: Platform, config: BuildConfig) -> int:
|
||||
n = 0
|
||||
if config.cli:
|
||||
n += 1
|
||||
if config.desktop:
|
||||
n += 1
|
||||
if not p.cross:
|
||||
n += 1 # bundle .app
|
||||
if config.plugins:
|
||||
n += 1
|
||||
n += 1 # copy artifacts / packaging
|
||||
return n
|
||||
|
||||
|
||||
def build_platform(root: Path, p: Platform, config: BuildConfig) -> PlatformResult:
|
||||
log = BuildLog(p.alias)
|
||||
t0 = time.monotonic()
|
||||
step = 0
|
||||
try:
|
||||
if config.cli:
|
||||
_update_phase(p.alias, "compiling cli", step)
|
||||
build_binary(root, p, log)
|
||||
step += 1
|
||||
|
||||
if config.desktop:
|
||||
_update_phase(p.alias, "compiling desktop", step)
|
||||
build_binary(root, p, log, extra_args=["--features", "desktop", "--bin", "cagire-desktop"])
|
||||
step += 1
|
||||
if not p.cross:
|
||||
_update_phase(p.alias, "bundling .app", step)
|
||||
bundle_desktop_app(root, p, log)
|
||||
step += 1
|
||||
|
||||
if config.plugins:
|
||||
_update_phase(p.alias, "bundling plugins", step)
|
||||
bundle_plugins(root, p, log)
|
||||
step += 1
|
||||
|
||||
_update_phase(p.alias, "packaging", step)
|
||||
log.append(" Copying artifacts...")
|
||||
artifacts = copy_artifacts(root, p, config, log)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
return PlatformResult(p, True, elapsed, artifacts, log)
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.monotonic() - t0
|
||||
log.append(f" ERROR: {e}")
|
||||
return PlatformResult(p, False, elapsed, [], log, str(e))
|
||||
|
||||
|
||||
def _build_display(
|
||||
platforms: list[Platform],
|
||||
config: BuildConfig,
|
||||
completed: dict[str, PlatformResult],
|
||||
start_times: dict[str, float],
|
||||
log_max_lines: int,
|
||||
) -> Layout:
|
||||
layout = Layout()
|
||||
status_height = len(platforms) + 4
|
||||
layout.split_column(
|
||||
Layout(name="status", size=status_height),
|
||||
Layout(name="logs"),
|
||||
)
|
||||
|
||||
table = Table(padding=(0, 1), expand=True)
|
||||
table.add_column("Platform", style="cyan", min_width=28, no_wrap=True)
|
||||
table.add_column("Phase", min_width=20, no_wrap=True)
|
||||
table.add_column("Progress", min_width=22, no_wrap=True)
|
||||
table.add_column("Time", justify="right", min_width=6, no_wrap=True)
|
||||
|
||||
with _progress_lock:
|
||||
progress_snapshot = dict(_build_progress)
|
||||
|
||||
for p in platforms:
|
||||
alias = p.alias
|
||||
total = _count_phases(p, config)
|
||||
|
||||
if alias in completed:
|
||||
r = completed[alias]
|
||||
if r.success:
|
||||
n = len(r.artifacts)
|
||||
phase = Text(f"OK ({n} artifacts)", style="green")
|
||||
bar = ProgressBar(total=total, completed=total, width=20, complete_style="green")
|
||||
else:
|
||||
phase = Text(f"FAIL", style="red")
|
||||
bar = ProgressBar(total=total, completed=total, width=20, complete_style="red")
|
||||
elapsed = f"{r.elapsed:.0f}s"
|
||||
elif alias in progress_snapshot:
|
||||
ph, step = progress_snapshot[alias]
|
||||
phase = Text(ph, style="yellow")
|
||||
bar = ProgressBar(total=total, completed=step, width=20)
|
||||
elapsed = f"{time.monotonic() - start_times.get(alias, time.monotonic()):.0f}s"
|
||||
else:
|
||||
phase = Text("waiting", style="dim")
|
||||
bar = ProgressBar(total=total, completed=0, width=20)
|
||||
elapsed = ""
|
||||
|
||||
table.add_row(p.label, phase, bar, elapsed)
|
||||
|
||||
layout["status"].update(Panel(table, title="[bold blue]Build Progress[/]", border_style="blue"))
|
||||
|
||||
with _progress_lock:
|
||||
recent = _build_logs[-log_max_lines:]
|
||||
|
||||
if recent:
|
||||
lines: list[str] = []
|
||||
for alias, line in recent:
|
||||
short = alias.split("-")[0][:3]
|
||||
lines.append(f"[dim]{short}[/] {line.rstrip()}")
|
||||
log_text = "\n".join(lines)
|
||||
else:
|
||||
log_text = "[dim]waiting for output...[/]"
|
||||
|
||||
layout["logs"].update(Panel(log_text, title="[bold]Build Output[/]", border_style="dim"))
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def run_builds(
|
||||
root: Path, platforms: list[Platform], config: BuildConfig, version: str, verbose: bool = False,
|
||||
) -> list[PlatformResult]:
|
||||
(root / OUT).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_build_progress.clear()
|
||||
_build_logs.clear()
|
||||
for p in platforms:
|
||||
_update_phase(p.alias, "waiting", 0)
|
||||
|
||||
results: list[PlatformResult] = []
|
||||
completed: dict[str, PlatformResult] = {}
|
||||
start_times: dict[str, float] = {p.alias: time.monotonic() for p in platforms}
|
||||
|
||||
term_height = console.size.height
|
||||
log_max_lines = max(term_height - len(platforms) - 10, 5)
|
||||
|
||||
def make_display() -> Layout:
|
||||
return _build_display(platforms, config, completed, start_times, log_max_lines)
|
||||
|
||||
with Live(make_display(), console=console, refresh_per_second=4) as live:
|
||||
if len(platforms) == 1:
|
||||
p = platforms[0]
|
||||
r = build_platform(root, p, config)
|
||||
completed[p.alias] = r
|
||||
results.append(r)
|
||||
live.update(make_display())
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=len(platforms)) as pool:
|
||||
futures = {pool.submit(build_platform, root, p, config): p for p in platforms}
|
||||
pending = set(futures.keys())
|
||||
while pending:
|
||||
done = {f for f in pending if f.done()}
|
||||
for f in done:
|
||||
r = f.result()
|
||||
completed[r.platform.alias] = r
|
||||
results.append(r)
|
||||
pending.discard(f)
|
||||
live.update(make_display())
|
||||
if pending:
|
||||
time.sleep(0.25)
|
||||
|
||||
for r in results:
|
||||
_print_platform_log(r, verbose)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _print_platform_log(r: PlatformResult, verbose: bool = False) -> None:
|
||||
if r.success and not verbose:
|
||||
return
|
||||
style = "green" if r.success else "red"
|
||||
status = "OK" if r.success else "FAIL"
|
||||
console.print(Panel(
|
||||
"\n".join(r.log_lines) if r.log_lines else "[dim]no output[/]",
|
||||
title=f"{r.platform.label} [{status}] {r.elapsed:.1f}s",
|
||||
border_style=style,
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI & interactive mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def prompt_platforms(platforms: list[Platform], alias_map: dict[str, Platform]) -> list[Platform]:
|
||||
choices = [
|
||||
questionary.Choice("All platforms", value="all", checked=True),
|
||||
*[questionary.Choice(p.label, value=p.alias) for p in platforms],
|
||||
]
|
||||
selected = questionary.checkbox("Select platforms:", choices=choices).ask()
|
||||
if selected is None:
|
||||
sys.exit(0)
|
||||
if "all" in selected or not selected:
|
||||
return list(platforms)
|
||||
return [alias_map[alias] for alias in selected]
|
||||
|
||||
|
||||
def prompt_targets() -> BuildConfig:
|
||||
choices = [
|
||||
questionary.Choice("cagire (CLI)", value="cli", checked=True),
|
||||
questionary.Choice("cagire-desktop", value="desktop", checked=True),
|
||||
questionary.Choice("cagire-plugins (CLAP/VST3)", value="plugins", checked=True),
|
||||
]
|
||||
selected = questionary.checkbox("Select targets:", choices=choices).ask()
|
||||
if selected is None:
|
||||
sys.exit(0)
|
||||
if not selected:
|
||||
return BuildConfig()
|
||||
return BuildConfig(
|
||||
cli="cli" in selected,
|
||||
desktop="desktop" in selected,
|
||||
plugins="plugins" in selected,
|
||||
)
|
||||
|
||||
|
||||
def confirm_summary(platforms: list[Platform], config: BuildConfig) -> None:
|
||||
console.print("[bold]Platforms:[/]")
|
||||
for p in platforms:
|
||||
console.print(f" [cyan]{p.label}[/]")
|
||||
console.print("[bold]Targets:[/]")
|
||||
if config.cli:
|
||||
console.print(" cagire")
|
||||
if config.desktop:
|
||||
console.print(" cagire-desktop")
|
||||
if config.plugins:
|
||||
console.print(" cagire-plugins")
|
||||
console.print()
|
||||
|
||||
if not questionary.confirm("Proceed?", default=True).ask():
|
||||
console.print("[dim]Aborted.[/]")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def print_results(results: list[PlatformResult], wall_time: float) -> None:
|
||||
table = Table(title="Results", title_style="bold")
|
||||
table.add_column("Platform", style="cyan", min_width=26)
|
||||
table.add_column("Status", justify="center")
|
||||
table.add_column("Time", justify="right")
|
||||
table.add_column("Artifacts")
|
||||
|
||||
succeeded = 0
|
||||
for r in results:
|
||||
if r.success:
|
||||
status = "[green]OK[/]"
|
||||
names = ", ".join(Path(a).name for a in r.artifacts)
|
||||
detail = names or "no artifacts"
|
||||
succeeded += 1
|
||||
else:
|
||||
status = "[red]FAIL[/]"
|
||||
detail = f"[red]{r.error or 'unknown error'}[/]"
|
||||
table.add_row(r.platform.label, status, f"{r.elapsed:.1f}s", detail)
|
||||
|
||||
console.print(table)
|
||||
|
||||
total = len(results)
|
||||
color = "green" if succeeded == total else "red"
|
||||
console.print(f"\n[{color}]{succeeded}/{total}[/] succeeded in [bold]{wall_time:.1f}s[/] (wall clock)")
|
||||
|
||||
|
||||
def resolve_cli_platforms(raw: str, alias_map: dict[str, Platform]) -> list[Platform]:
|
||||
platforms = []
|
||||
for alias in raw.split(","):
|
||||
alias = alias.strip()
|
||||
if alias not in alias_map:
|
||||
console.print(f"[red]Unknown platform:[/] {alias}")
|
||||
console.print(f"Valid: {', '.join(alias_map.keys())}")
|
||||
sys.exit(1)
|
||||
platforms.append(alias_map[alias])
|
||||
return platforms
|
||||
|
||||
|
||||
def resolve_cli_targets(raw: str) -> BuildConfig:
|
||||
cfg = BuildConfig(cli=False, desktop=False, plugins=False)
|
||||
for t in raw.split(","):
|
||||
t = t.strip()
|
||||
if t == "cli":
|
||||
cfg.cli = True
|
||||
elif t == "desktop":
|
||||
cfg.desktop = True
|
||||
elif t == "plugins":
|
||||
cfg.plugins = True
|
||||
else:
|
||||
console.print(f"[red]Unknown target:[/] {t} (expected: cli, desktop, plugins)")
|
||||
sys.exit(1)
|
||||
return cfg
|
||||
|
||||
|
||||
def check_git_clean(root: Path) -> tuple[str, bool]:
|
||||
"""Return (short SHA, is_clean)."""
|
||||
sha = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"], text=True, cwd=root,
|
||||
).strip()
|
||||
status = subprocess.check_output(
|
||||
["git", "status", "--porcelain"], text=True, cwd=root,
|
||||
).strip()
|
||||
return sha, len(status) == 0
|
||||
|
||||
|
||||
def check_prerequisites(platforms: list[Platform], config: BuildConfig) -> None:
|
||||
"""Verify required tools are available, fail fast if not."""
|
||||
need_cross = any(p.cross for p in platforms)
|
||||
need_docker = any(p.cross and p.os == "linux" for p in platforms)
|
||||
need_bundle = config.desktop and any(not p.cross and p.os == "macos" for p in platforms)
|
||||
need_nsis = any(p.os == "windows" for p in platforms)
|
||||
|
||||
checks: list[tuple[str, bool]] = [("cargo", True)]
|
||||
if need_cross:
|
||||
checks.append(("cross", True))
|
||||
if need_docker:
|
||||
checks.append(("docker", True))
|
||||
if need_bundle:
|
||||
checks.append(("cargo-bundle", True))
|
||||
if need_nsis:
|
||||
checks.append(("makensis", False))
|
||||
|
||||
console.print("[bold]Prerequisites:[/]")
|
||||
missing_critical: list[str] = []
|
||||
for tool, critical in checks:
|
||||
found = shutil.which(tool) is not None
|
||||
if not found and critical:
|
||||
missing_critical.append(tool)
|
||||
if found:
|
||||
status = "[green]found[/]"
|
||||
elif critical:
|
||||
status = "[red]MISSING[/]"
|
||||
else:
|
||||
status = "[yellow]missing (optional)[/]"
|
||||
console.print(f" {tool}: {status}")
|
||||
|
||||
if missing_critical:
|
||||
console.print(f"\n[red]Missing critical tools: {', '.join(missing_critical)}[/]")
|
||||
sys.exit(1)
|
||||
console.print()
|
||||
|
||||
|
||||
def write_checksums(results: list[PlatformResult], out_dir: Path) -> Path:
|
||||
"""Write SHA256 checksums for all artifacts."""
|
||||
lines: list[str] = []
|
||||
for r in results:
|
||||
if not r.success:
|
||||
continue
|
||||
for artifact_path in r.artifacts:
|
||||
p = Path(artifact_path)
|
||||
if p.is_dir():
|
||||
continue
|
||||
h = hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
lines.append(f"SHA256 ({p.name}) = {h}")
|
||||
lines.sort()
|
||||
checksum_file = out_dir / "checksums.sha256"
|
||||
checksum_file.write_text("\n".join(lines) + "\n")
|
||||
return checksum_file
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Cagire release builder")
|
||||
parser.add_argument("--platforms", help="Comma-separated: macos-arm64,macos-x86_64,linux-x86_64,linux-aarch64,windows-x86_64")
|
||||
parser.add_argument("--targets", help="Comma-separated: cli,desktop,plugins")
|
||||
parser.add_argument("--all", action="store_true", help="Build all platforms and targets")
|
||||
parser.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Show build logs for all platforms (not just failures)")
|
||||
parser.add_argument("--force", action="store_true", help="Allow building from a dirty git tree")
|
||||
parser.add_argument("--no-checksums", action="store_true", help="Skip SHA256 checksum generation")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip())
|
||||
|
||||
all_platforms = load_platforms(root)
|
||||
alias_map = {p.alias: p for p in all_platforms}
|
||||
|
||||
version = get_version(root)
|
||||
sha, clean = check_git_clean(root)
|
||||
dirty_tag = "" if clean else ", dirty"
|
||||
console.print(Panel(f"Cagire [bold]{version}[/] ({sha}{dirty_tag}) — release builder", style="blue"))
|
||||
|
||||
if not clean and not args.force:
|
||||
console.print("[red]Working tree is dirty. Commit your changes or use --force.[/]")
|
||||
sys.exit(1)
|
||||
|
||||
if args.all:
|
||||
platforms = list(all_platforms)
|
||||
config = BuildConfig()
|
||||
elif args.platforms or args.targets:
|
||||
platforms = resolve_cli_platforms(args.platforms, alias_map) if args.platforms else list(all_platforms)
|
||||
config = resolve_cli_targets(args.targets) if args.targets else BuildConfig()
|
||||
else:
|
||||
platforms = prompt_platforms(all_platforms, alias_map)
|
||||
config = prompt_targets()
|
||||
|
||||
if not args.yes and not args.all and not (args.platforms or args.targets):
|
||||
confirm_summary(platforms, config)
|
||||
|
||||
check_prerequisites(platforms, config)
|
||||
|
||||
t0 = time.monotonic()
|
||||
results = run_builds(root, platforms, config, version, verbose=args.verbose)
|
||||
wall_time = time.monotonic() - t0
|
||||
|
||||
print_results(results, wall_time)
|
||||
|
||||
if not args.no_checksums and any(r.success for r in results):
|
||||
checksum_file = write_checksums(results, root / OUT)
|
||||
console.print(f"[green]Checksums written to {checksum_file}[/]")
|
||||
|
||||
if any(not r.success for r in results):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: scripts/make-appimage.sh <binary-path> <arch> <output-dir>
|
||||
# Produces an AppImage from a Linux binary.
|
||||
# On native Linux with matching arch: uses linuxdeploy.
|
||||
# Otherwise (cross-compilation): builds AppImage via mksquashfs in Docker.
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "Usage: $0 <binary-path> <arch> <output-dir>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BINARY="$1"
|
||||
ARCH="$2"
|
||||
OUTDIR="$3"
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
CACHE_DIR="$REPO_ROOT/.cache"
|
||||
APP_NAME="$(basename "$BINARY")"
|
||||
|
||||
RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-${ARCH}"
|
||||
RUNTIME="$CACHE_DIR/runtime-${ARCH}"
|
||||
|
||||
build_appdir() {
|
||||
local appdir="$1"
|
||||
mkdir -p "$appdir/usr/bin"
|
||||
cp "$BINARY" "$appdir/usr/bin/cagire"
|
||||
chmod +x "$appdir/usr/bin/cagire"
|
||||
|
||||
mkdir -p "$appdir/usr/share/icons/hicolor/512x512/apps"
|
||||
cp "$REPO_ROOT/assets/Cagire.png" "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png"
|
||||
|
||||
cp "$REPO_ROOT/assets/cagire.desktop" "$appdir/cagire.desktop"
|
||||
|
||||
# AppRun entry point
|
||||
cat > "$appdir/AppRun" <<'APPRUN'
|
||||
#!/bin/sh
|
||||
SELF="$(readlink -f "$0")"
|
||||
HERE="$(dirname "$SELF")"
|
||||
exec "$HERE/usr/bin/cagire" "$@"
|
||||
APPRUN
|
||||
chmod +x "$appdir/AppRun"
|
||||
|
||||
# Symlink icon at root for AppImage spec
|
||||
ln -sf usr/share/icons/hicolor/512x512/apps/cagire.png "$appdir/cagire.png"
|
||||
ln -sf cagire.desktop "$appdir/.DirIcon" 2>/dev/null || true
|
||||
}
|
||||
|
||||
download_runtime() {
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if [[ ! -f "$RUNTIME" ]]; then
|
||||
echo " Downloading AppImage runtime for $ARCH..."
|
||||
curl -fSL "$RUNTIME_URL" -o "$RUNTIME"
|
||||
fi
|
||||
}
|
||||
|
||||
run_native() {
|
||||
local linuxdeploy_url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
|
||||
local linuxdeploy="$CACHE_DIR/linuxdeploy-${ARCH}.AppImage"
|
||||
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if [[ ! -f "$linuxdeploy" ]]; then
|
||||
echo " Downloading linuxdeploy for $ARCH..."
|
||||
curl -fSL "$linuxdeploy_url" -o "$linuxdeploy"
|
||||
chmod +x "$linuxdeploy"
|
||||
fi
|
||||
|
||||
local appdir
|
||||
appdir="$(mktemp -d)/AppDir"
|
||||
build_appdir "$appdir"
|
||||
|
||||
export ARCH
|
||||
export LDAI_RUNTIME_FILE="$RUNTIME"
|
||||
"$linuxdeploy" \
|
||||
--appimage-extract-and-run \
|
||||
--appdir "$appdir" \
|
||||
--desktop-file "$appdir/cagire.desktop" \
|
||||
--icon-file "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png" \
|
||||
--output appimage
|
||||
|
||||
local appimage
|
||||
appimage=$(ls -1t ./*.AppImage 2>/dev/null | head -1 || true)
|
||||
if [[ -z "$appimage" ]]; then
|
||||
echo " ERROR: No AppImage produced"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$OUTDIR"
|
||||
mv "$appimage" "$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
|
||||
echo " AppImage -> $OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
|
||||
}
|
||||
|
||||
run_docker() {
|
||||
local platform
|
||||
case "$ARCH" in
|
||||
x86_64) platform="linux/amd64" ;;
|
||||
aarch64) platform="linux/arm64" ;;
|
||||
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
local appdir
|
||||
appdir="$(mktemp -d)/AppDir"
|
||||
build_appdir "$appdir"
|
||||
|
||||
local image_tag="cagire-appimage-${ARCH}"
|
||||
|
||||
echo " Building Docker image $image_tag ($platform)..."
|
||||
docker build --platform "$platform" -q -t "$image_tag" - <<'DOCKERFILE'
|
||||
FROM ubuntu:22.04
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
squashfs-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
DOCKERFILE
|
||||
|
||||
echo " Creating squashfs via Docker ($image_tag)..."
|
||||
docker run --rm --platform "$platform" \
|
||||
-v "$appdir:/appdir:ro" \
|
||||
-v "$CACHE_DIR:/cache" \
|
||||
"$image_tag" \
|
||||
mksquashfs /appdir /cache/appimage-${ARCH}.squashfs \
|
||||
-root-owned -noappend -comp gzip -no-progress
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
local final="$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
|
||||
cat "$RUNTIME" "$CACHE_DIR/appimage-${ARCH}.squashfs" > "$final"
|
||||
chmod +x "$final"
|
||||
rm -f "$CACHE_DIR/appimage-${ARCH}.squashfs"
|
||||
echo " AppImage -> $final"
|
||||
}
|
||||
|
||||
HOST_ARCH="$(uname -m)"
|
||||
|
||||
download_runtime
|
||||
|
||||
echo " Building AppImage for ${APP_NAME} ($ARCH)..."
|
||||
|
||||
if [[ "$HOST_ARCH" == "$ARCH" ]] && [[ "$(uname -s)" == "Linux" ]]; then
|
||||
run_native
|
||||
else
|
||||
run_docker
|
||||
fi
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: scripts/make-dmg.sh <app-path> <output-dir>
|
||||
# Produces a .dmg from a macOS .app bundle using only hdiutil.
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 <app-path> <output-dir>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_PATH="$1"
|
||||
OUTDIR="$2"
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
if [[ ! -d "$APP_PATH" ]]; then
|
||||
echo "ERROR: $APP_PATH is not a directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LIPO_OUTPUT=$(lipo -info "$APP_PATH/Contents/MacOS/cagire-desktop" 2>/dev/null)
|
||||
|
||||
if [[ -z "$LIPO_OUTPUT" ]]; then
|
||||
echo "ERROR: could not determine architecture from $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if echo "$LIPO_OUTPUT" | grep -q "Architectures in the fat file"; then
|
||||
ARCH="universal"
|
||||
else
|
||||
ARCH=$(echo "$LIPO_OUTPUT" | awk '{print $NF}')
|
||||
case "$ARCH" in
|
||||
arm64) ARCH="aarch64" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
STAGING="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGING"' EXIT
|
||||
|
||||
cp -R "$APP_PATH" "$STAGING/Cagire.app"
|
||||
ln -s /Applications "$STAGING/Applications"
|
||||
cp "$REPO_ROOT/assets/DMG-README.txt" "$STAGING/README.txt"
|
||||
|
||||
DMG_NAME="Cagire-${ARCH}.dmg"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
hdiutil create -volname "Cagire" \
|
||||
-srcfolder "$STAGING" \
|
||||
-ov -format UDZO \
|
||||
"$OUTDIR/$DMG_NAME"
|
||||
|
||||
echo " DMG -> $OUTDIR/$DMG_NAME"
|
||||
9
scripts/platforms.toml
Normal file
9
scripts/platforms.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
# Cagire build targets — each triple defines a compilation platform.
|
||||
# Everything else (os, arch, cross, alias, label) is derived by build.py.
|
||||
triples = [
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-gnu",
|
||||
]
|
||||
Reference in New Issue
Block a user