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 PyInstaller bundle + - name: Build dashboard 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 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", +) +