← back to Exo
ci: migrate build-app to github hosted runners
0a7fe5d9439f4be37f9212df5bf31aa1f55b2a8a · 2025-12-22 14:47:07 +0000 · Jake Hillion
Files touched
M .github/workflows/build-app.ymlA packaging/pyinstaller/exo.spec
Diff
commit 0a7fe5d9439f4be37f9212df5bf31aa1f55b2a8a
Author: Jake Hillion <jake@hillion.co.uk>
Date: Mon Dec 22 14:47:07 2025 +0000
ci: migrate build-app to github hosted runners
---
.github/workflows/build-app.yml | 124 ++++++++++++++++++----------------------
packaging/pyinstaller/exo.spec | 118 ++++++++++++++++++++++++++++++++++++++
2 files changed, 174 insertions(+), 68 deletions(-)
diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
index 1a2caf8f..fa6e3934 100644
--- a/.github/workflows/build-app.yml
+++ b/.github/workflows/build-app.yml
@@ -7,7 +7,7 @@ on:
jobs:
build-macos-app:
- runs-on: [self-hosted, XCode262_Beta]
+ runs-on: "macos-26"
env:
SPARKLE_VERSION: 2.8.1
SPARKLE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }}
@@ -21,6 +21,10 @@ jobs:
EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
steps:
+ # ============================================================
+ # Checkout and tag validation
+ # ============================================================
+
- name: Checkout
uses: actions/checkout@v4
with:
@@ -29,7 +33,6 @@ jobs:
- name: Derive release version from tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
- # Detect alpha tags
if [[ "$VERSION" == *-alpha* ]]; then
echo "IS_ALPHA=true" >> $GITHUB_ENV
else
@@ -40,7 +43,7 @@ jobs:
- name: Ensure tag commit is on main
run: |
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
echo "Alpha tag detected, skipping main branch check"
elif ! git merge-base --is-ancestor origin/main HEAD; then
@@ -48,27 +51,20 @@ jobs:
exit 1
fi
- - name: Add Homebrew to PATH
- run: |
- 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
+ # ============================================================
+ # Install dependencies
+ # ============================================================
- - name: Check Metal toolchain is installed
+ - name: Select Xcode 26.2
run: |
+ sudo xcode-select -s /Applications/Xcode_26.2.app
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
fi
- echo "Metal toolchain is installed."
-
- - name: Install Just
- run: brew install just
- - name: Install AWS CLI
- run: brew install awscli
+ - name: Install Homebrew packages
+ run: brew install just awscli macmon
- name: Install UV
uses: astral-sh/setup-uv@v6
@@ -76,17 +72,25 @@ jobs:
enable-cache: true
cache-dependency-glob: uv.lock
- - name: Setup Python (UV)
+ - name: Setup Python
run: |
uv python install
uv sync --locked
- - name: Install macmon
- run: brew install macmon
+ - name: Build dashboard
+ run: |
+ cd dashboard
+ npm ci
+ npm run build
- - name: Build PyInstaller bundle
+ - name: Install Sparkle CLI
run: |
- uv run pyinstaller packaging/pyinstaller/exo.spec
+ 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
env:
@@ -95,43 +99,47 @@ jobs:
PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }}
run: |
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
security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
-
+
# Disable auto-lock (no timeout, no lock-on-sleep)
security set-keychain-settings "$KEYCHAIN_PATH"
-
+
# Add to search list while preserving existing keychains
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
-
+
# Set as default and unlock
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
-
+
# Import certificate with full access for codesign
echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
rm /tmp/cert.p12
-
+
# Allow codesign to access the key without prompting
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
-
+
# Verify keychain is unlocked and identity is available
echo "Verifying signing identity..."
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
-
+
# Setup provisioning profile
mkdir -p "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
echo "$PROVISIONING_PROFILE" | base64 --decode > "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/EXO.provisionprofile"
-
+
# Export keychain path for other steps
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
env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
@@ -162,7 +170,7 @@ jobs:
mkdir -p output/EXO.app/Contents/Resources
cp -R dist/exo output/EXO.app/Contents/Resources/exo
- - name: Codesign PyInstaller runtime payload
+ - name: Codesign PyInstaller runtime
env:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
@@ -226,40 +234,39 @@ jobs:
- name: Generate Sparkle appcast
env:
- SPARKLE_VERSION: ${{ env.SPARKLE_VERSION }}
SPARKLE_DOWNLOAD_PREFIX: ${{ env.SPARKLE_DOWNLOAD_PREFIX }}
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
- SPARKLE_CLI_URL: ${{ secrets.SPARKLE_CLI_URL }}
IS_ALPHA: ${{ env.IS_ALPHA }}
run: |
set -euo pipefail
cd output
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
chmod 600 sparkle_ed25519.key
- # Add --channel alpha flag for alpha builds
CHANNEL_FLAG=""
if [[ "$IS_ALPHA" == "true" ]]; then
CHANNEL_FLAG="--channel alpha"
echo "Generating appcast for alpha channel"
fi
- ./sparkle/bin/generate_appcast \
+ $SPARKLE_BIN/generate_appcast \
--ed-key-file sparkle_ed25519.key \
--download-url-prefix "$DOWNLOAD_PREFIX" \
$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 != ''
env:
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"
fi
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
diff --git a/packaging/pyinstaller/exo.spec b/packaging/pyinstaller/exo.spec
new file mode 100644
index 00000000..e562093d
--- /dev/null
+++ b/packaging/pyinstaller/exo.spec
@@ -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",
+)
+
← 51a5191f format readme (#978)
·
back to Exo
·
build-app: add branch trigger from named branch e8855959 →