ci: migrate build-app to github hosted runners

This commit is contained in:
Jake Hillion
2025-12-22 14:47:07 +00:00
parent 51a5191ff3
commit 0a7fe5d943
2 changed files with 175 additions and 69 deletions

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
build-macos-app: build-macos-app:
runs-on: [self-hosted, XCode262_Beta] runs-on: "macos-26"
env: env:
SPARKLE_VERSION: 2.8.1 SPARKLE_VERSION: 2.8.1
SPARKLE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }} SPARKLE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }}
@@ -21,6 +21,10 @@ jobs:
EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }} EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
steps: steps:
# ============================================================
# Checkout and tag validation
# ============================================================
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -29,7 +33,6 @@ jobs:
- name: Derive release version from tag - name: Derive release version from tag
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
# Detect alpha tags
if [[ "$VERSION" == *-alpha* ]]; then if [[ "$VERSION" == *-alpha* ]]; then
echo "IS_ALPHA=true" >> $GITHUB_ENV echo "IS_ALPHA=true" >> $GITHUB_ENV
else else
@@ -40,7 +43,7 @@ jobs:
- name: Ensure tag commit is on main - name: Ensure tag commit is on main
run: | run: |
git fetch origin main git fetch origin main
# Allow alpha tags on any branch, but require production tags to be on main # Alpha tags can be on any branch, production tags must be on main
if [[ "$IS_ALPHA" == "true" ]]; then if [[ "$IS_ALPHA" == "true" ]]; then
echo "Alpha tag detected, skipping main branch check" echo "Alpha tag detected, skipping main branch check"
elif ! git merge-base --is-ancestor origin/main HEAD; then elif ! git merge-base --is-ancestor origin/main HEAD; then
@@ -48,27 +51,20 @@ jobs:
exit 1 exit 1
fi fi
- name: Add Homebrew to PATH # ============================================================
run: | # Install dependencies
if [ -f /opt/homebrew/bin/brew ]; then # ============================================================
echo "/opt/homebrew/bin" >> $GITHUB_PATH
elif [ -f /usr/local/bin/brew ]; then
echo "/usr/local/bin" >> $GITHUB_PATH
fi
- name: Check Metal toolchain is installed - name: Select Xcode 26.2
run: | run: |
sudo xcode-select -s /Applications/Xcode_26.2.app
if ! xcrun -f metal >/dev/null 2>&1; then if ! xcrun -f metal >/dev/null 2>&1; then
echo "Metal toolchain is not installed. Run 'xcodebuild -downloadComponent MetalToolchain' on the runner host." echo "Metal toolchain is not installed."
exit 1 exit 1
fi fi
echo "Metal toolchain is installed."
- name: Install Just - name: Install Homebrew packages
run: brew install just run: brew install just awscli macmon
- name: Install AWS CLI
run: brew install awscli
- name: Install UV - name: Install UV
uses: astral-sh/setup-uv@v6 uses: astral-sh/setup-uv@v6
@@ -76,17 +72,25 @@ jobs:
enable-cache: true enable-cache: true
cache-dependency-glob: uv.lock cache-dependency-glob: uv.lock
- name: Setup Python (UV) - name: Setup Python
run: | run: |
uv python install uv python install
uv sync --locked uv sync --locked
- name: Install macmon - name: Build dashboard
run: brew install macmon
- name: Build PyInstaller bundle
run: | run: |
uv run pyinstaller packaging/pyinstaller/exo.spec cd dashboard
npm ci
npm run build
- name: Install Sparkle CLI
run: |
CLI_URL="${SPARKLE_CLI_URL:-https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz}"
echo "Downloading Sparkle CLI from: $CLI_URL"
mkdir -p /tmp/sparkle
curl --fail --location --output /tmp/sparkle.tar.xz "$CLI_URL"
tar -xJf /tmp/sparkle.tar.xz -C /tmp/sparkle --strip-components=1
echo "SPARKLE_BIN=/tmp/sparkle/bin" >> $GITHUB_ENV
- name: Prepare code-signing keychain - name: Prepare code-signing keychain
env: env:
@@ -96,9 +100,6 @@ jobs:
run: | run: |
KEYCHAIN_PATH="$HOME/Library/Keychains/build.keychain-db" KEYCHAIN_PATH="$HOME/Library/Keychains/build.keychain-db"
# Remove stale keychain from previous failed runs
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true
# Create fresh keychain # Create fresh keychain
security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH" security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
@@ -132,6 +133,13 @@ jobs:
# Export keychain path for other steps # Export keychain path for other steps
echo "BUILD_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV echo "BUILD_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV
# ============================================================
# Build the bundle
# ============================================================
- name: Build PyInstaller bundle
run: uv run pyinstaller packaging/pyinstaller/exo.spec
- name: Build Swift app - name: Build Swift app
env: env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
@@ -162,7 +170,7 @@ jobs:
mkdir -p output/EXO.app/Contents/Resources mkdir -p output/EXO.app/Contents/Resources
cp -R dist/exo output/EXO.app/Contents/Resources/exo cp -R dist/exo output/EXO.app/Contents/Resources/exo
- name: Codesign PyInstaller runtime payload - name: Codesign PyInstaller runtime
env: env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: | run: |
@@ -226,40 +234,39 @@ jobs:
- name: Generate Sparkle appcast - name: Generate Sparkle appcast
env: env:
SPARKLE_VERSION: ${{ env.SPARKLE_VERSION }}
SPARKLE_DOWNLOAD_PREFIX: ${{ env.SPARKLE_DOWNLOAD_PREFIX }} SPARKLE_DOWNLOAD_PREFIX: ${{ env.SPARKLE_DOWNLOAD_PREFIX }}
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }} SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
SPARKLE_CLI_URL: ${{ secrets.SPARKLE_CLI_URL }}
IS_ALPHA: ${{ env.IS_ALPHA }} IS_ALPHA: ${{ env.IS_ALPHA }}
run: | run: |
set -euo pipefail set -euo pipefail
cd output cd output
DOWNLOAD_PREFIX="${SPARKLE_DOWNLOAD_PREFIX:-https://assets.exolabs.net}" DOWNLOAD_PREFIX="${SPARKLE_DOWNLOAD_PREFIX:-https://assets.exolabs.net}"
mkdir -p sparkle
CLI_URL="${SPARKLE_CLI_URL:-}"
if [[ -z "$CLI_URL" ]]; then
CLI_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
fi
echo "Downloading Sparkle CLI from: $CLI_URL"
curl --fail --location --output sparkle.tar.xz "$CLI_URL"
tar -xJf sparkle.tar.xz -C sparkle --strip-components=1
echo "$SPARKLE_ED25519_PRIVATE" > sparkle_ed25519.key echo "$SPARKLE_ED25519_PRIVATE" > sparkle_ed25519.key
chmod 600 sparkle_ed25519.key chmod 600 sparkle_ed25519.key
# Add --channel alpha flag for alpha builds
CHANNEL_FLAG="" CHANNEL_FLAG=""
if [[ "$IS_ALPHA" == "true" ]]; then if [[ "$IS_ALPHA" == "true" ]]; then
CHANNEL_FLAG="--channel alpha" CHANNEL_FLAG="--channel alpha"
echo "Generating appcast for alpha channel" echo "Generating appcast for alpha channel"
fi fi
./sparkle/bin/generate_appcast \ $SPARKLE_BIN/generate_appcast \
--ed-key-file sparkle_ed25519.key \ --ed-key-file sparkle_ed25519.key \
--download-url-prefix "$DOWNLOAD_PREFIX" \ --download-url-prefix "$DOWNLOAD_PREFIX" \
$CHANNEL_FLAG \ $CHANNEL_FLAG \
. .
- name: Upload Sparkle assets to S3 # ============================================================
# Upload artifacts
# ============================================================
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: EXO-dmg-${{ env.RELEASE_VERSION }}
path: output/EXO-${{ env.RELEASE_VERSION }}.dmg
- name: Upload to S3
if: env.SPARKLE_S3_BUCKET != '' if: env.SPARKLE_S3_BUCKET != ''
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
@@ -281,22 +288,3 @@ jobs:
aws s3 cp "$DMG_NAME" "s3://${SPARKLE_S3_BUCKET}/${PREFIX}EXO-latest.dmg" aws s3 cp "$DMG_NAME" "s3://${SPARKLE_S3_BUCKET}/${PREFIX}EXO-latest.dmg"
fi fi
aws s3 cp appcast.xml "s3://${SPARKLE_S3_BUCKET}/${PREFIX}appcast.xml" --content-type application/xml --cache-control no-cache aws s3 cp appcast.xml "s3://${SPARKLE_S3_BUCKET}/${PREFIX}appcast.xml" --content-type application/xml --cache-control no-cache
- name: Cleanup keychain
if: always()
run: |
KEYCHAIN_PATH="$HOME/Library/Keychains/build.keychain-db"
security default-keychain -s login.keychain || true
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true
- name: Upload app bundle
uses: actions/upload-artifact@v4
with:
name: EXO-app-${{ env.RELEASE_VERSION }}
path: output/EXO.app
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: EXO-dmg-${{ env.RELEASE_VERSION }}
path: output/EXO-${{ env.RELEASE_VERSION }}.dmg

View File

@@ -0,0 +1,118 @@
# -*- mode: python ; coding: utf-8 -*-
import importlib.util
import shutil
from pathlib import Path
from PyInstaller.utils.hooks import collect_submodules
PROJECT_ROOT = Path.cwd()
SOURCE_ROOT = PROJECT_ROOT / "src"
ENTRYPOINT = SOURCE_ROOT / "exo" / "__main__.py"
DASHBOARD_DIR = PROJECT_ROOT / "dashboard" / "build"
EXO_SHARED_MODELS_DIR = SOURCE_ROOT / "exo" / "shared" / "models"
if not ENTRYPOINT.is_file():
raise SystemExit(f"Unable to locate Exo entrypoint: {ENTRYPOINT}")
if not DASHBOARD_DIR.is_dir():
raise SystemExit(f"Dashboard assets are missing: {DASHBOARD_DIR}")
if not EXO_SHARED_MODELS_DIR.is_dir():
raise SystemExit(f"Shared model assets are missing: {EXO_SHARED_MODELS_DIR}")
block_cipher = None
def _module_directory(module_name: str) -> Path:
spec = importlib.util.find_spec(module_name)
if spec is None:
raise SystemExit(f"Module '{module_name}' is not available in the current environment.")
if spec.submodule_search_locations:
return Path(next(iter(spec.submodule_search_locations))).resolve()
if spec.origin:
return Path(spec.origin).resolve().parent
raise SystemExit(f"Unable to determine installation directory for '{module_name}'.")
MLX_PACKAGE_DIR = _module_directory("mlx")
MLX_LIB_DIR = MLX_PACKAGE_DIR / "lib"
if not MLX_LIB_DIR.is_dir():
raise SystemExit(f"mlx Metal libraries are missing: {MLX_LIB_DIR}")
def _safe_collect(package_name: str) -> list[str]:
try:
return collect_submodules(package_name)
except ImportError:
return []
HIDDEN_IMPORTS = sorted(
set(
collect_submodules("mlx")
+ _safe_collect("mlx_lm")
+ _safe_collect("transformers")
)
)
DATAS: list[tuple[str, str]] = [
(str(DASHBOARD_DIR), "dashboard"),
(str(MLX_LIB_DIR), "mlx/lib"),
(str(EXO_SHARED_MODELS_DIR), "exo/shared/models"),
]
MACMON_PATH = shutil.which("macmon")
if MACMON_PATH is None:
raise SystemExit(
"macmon binary not found in PATH. "
"Install it via: brew install macmon"
)
BINARIES: list[tuple[str, str]] = [
(MACMON_PATH, "."),
]
a = Analysis(
[str(ENTRYPOINT)],
pathex=[str(SOURCE_ROOT)],
binaries=BINARIES,
datas=DATAS,
hiddenimports=HIDDEN_IMPORTS,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="exo",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="exo",
)