[object Object]

← back to Exo

ci: migrate build-app to github hosted runners

0a7fe5d9439f4be37f9212df5bf31aa1f55b2a8a · 2025-12-22 14:47:07 +0000 · Jake Hillion

Files touched

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 →