← back to Exo
ci: add build-app workflow
1bae8ebbf6b4fc423e42350df11063dbb053da3f · 2025-12-18 20:18:41 +0000 · Jake Hillion
Files touched
A .github/workflows/build-app.yml
Diff
commit 1bae8ebbf6b4fc423e42350df11063dbb053da3f
Author: Jake Hillion <jake@hillion.co.uk>
Date: Thu Dec 18 20:18:41 2025 +0000
ci: add build-app workflow
---
.github/workflows/build-app.yml | 299 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 299 insertions(+)
diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
new file mode 100644
index 00000000..acc1400c
--- /dev/null
+++ b/.github/workflows/build-app.yml
@@ -0,0 +1,299 @@
+name: Build EXO macOS DMG
+
+on:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ build-macos-app:
+ runs-on: [self-hosted, XCode262_Beta]
+ env:
+ SPARKLE_VERSION: 2.8.1
+ SPARKLE_DOWNLOAD_PREFIX: ${{ secrets.SPARKLE_DOWNLOAD_PREFIX }}
+ SPARKLE_FEED_URL: ${{ secrets.SPARKLE_FEED_URL }}
+ SPARKLE_ED25519_PUBLIC: ${{ secrets.SPARKLE_ED25519_PUBLIC }}
+ SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
+ SPARKLE_S3_BUCKET: ${{ secrets.SPARKLE_S3_BUCKET }}
+ SPARKLE_S3_PREFIX: ${{ secrets.SPARKLE_S3_PREFIX }}
+ AWS_REGION: ${{ secrets.AWS_REGION }}
+ EXO_BUILD_NUMBER: ${{ github.run_number }}
+ EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - 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
+ echo "IS_ALPHA=false" >> $GITHUB_ENV
+ fi
+ echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV
+
+ - 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
+ if [[ "$IS_ALPHA" == "true" ]]; then
+ echo "Alpha tag detected, skipping main branch check"
+ elif ! git merge-base --is-ancestor origin/main HEAD; then
+ echo "Production tag must point to a commit on main"
+ 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
+
+ - name: Check Metal toolchain is installed
+ run: |
+ if ! xcrun -f metal >/dev/null 2>&1; then
+ echo "Metal toolchain is not installed. Run 'xcodebuild -downloadComponent MetalToolchain' on the runner host."
+ 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 UV
+ uses: astral-sh/setup-uv@v6
+ with:
+ enable-cache: true
+ cache-dependency-glob: uv.lock
+
+ - name: Setup Python (UV)
+ run: |
+ uv python install
+ uv sync --locked
+
+ - name: Install macmon
+ run: brew install macmon
+
+ - name: Build PyInstaller bundle
+ run: |
+ uv run pyinstaller packaging/pyinstaller/exo.spec
+
+ - name: Prepare code-signing keychain
+ env:
+ MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ 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
+
+ - name: Build Swift app
+ env:
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ SPARKLE_FEED_URL: ${{ secrets.SPARKLE_FEED_URL }}
+ SPARKLE_ED25519_PUBLIC: ${{ secrets.SPARKLE_ED25519_PUBLIC }}
+ run: |
+ cd app/EXO
+ security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
+ SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
+ xcodebuild clean build \
+ -scheme EXO \
+ -configuration Release \
+ -derivedDataPath build \
+ MARKETING_VERSION="$RELEASE_VERSION" \
+ CURRENT_PROJECT_VERSION="$EXO_BUILD_NUMBER" \
+ EXO_BUILD_TAG="$RELEASE_VERSION" \
+ EXO_BUILD_COMMIT="$GITHUB_SHA" \
+ SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \
+ SPARKLE_ED25519_PUBLIC="$SPARKLE_ED25519_PUBLIC" \
+ CODE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES
+ mkdir -p ../../output
+ cp -R build/Build/Products/Release/EXO.app ../../output/EXO.app
+
+ - name: Inject PyInstaller runtime
+ run: |
+ rm -rf output/EXO.app/Contents/Resources/exo
+ mkdir -p output/EXO.app/Contents/Resources
+ cp -R dist/exo output/EXO.app/Contents/Resources/exo
+
+ - name: Codesign PyInstaller runtime payload
+ env:
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ run: |
+ cd output
+ security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
+ SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
+ RUNTIME_DIR="EXO.app/Contents/Resources/exo"
+ find "$RUNTIME_DIR" -type f \( -perm -111 -o -name "*.dylib" -o -name "*.so" \) -print0 |
+ while IFS= read -r -d '' file; do
+ /usr/bin/codesign --force --timestamp --options runtime \
+ --sign "$SIGNING_IDENTITY" "$file"
+ done
+
+ - name: Sign, notarize, and create DMG
+ env:
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
+ APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
+ APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }}
+ run: |
+ cd output
+ security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
+ SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
+ /usr/bin/codesign --deep --force --timestamp --options runtime \
+ --sign "$SIGNING_IDENTITY" EXO.app
+ mkdir -p dmg-root
+ cp -R EXO.app dmg-root/
+ ln -s /Applications dmg-root/Applications
+ DMG_NAME="EXO-${RELEASE_VERSION}.dmg"
+ hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME"
+ /usr/bin/codesign --force --timestamp --options runtime \
+ --sign "$SIGNING_IDENTITY" "$DMG_NAME"
+ if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then
+ SUBMISSION_OUTPUT=$(xcrun notarytool submit "$DMG_NAME" \
+ --apple-id "$APPLE_NOTARIZATION_USERNAME" \
+ --password "$APPLE_NOTARIZATION_PASSWORD" \
+ --team-id "$APPLE_NOTARIZATION_TEAM" \
+ --wait --timeout 15m 2>&1)
+ echo "$SUBMISSION_OUTPUT"
+
+ SUBMISSION_ID=$(echo "$SUBMISSION_OUTPUT" | awk 'tolower($1)=="id:" && $2 ~ /^[0-9a-fA-F-]+$/ {print $2; exit}')
+ STATUS=$(echo "$SUBMISSION_OUTPUT" | awk 'tolower($1)=="status:" {print $2; exit}')
+
+ if [[ -n "$SUBMISSION_ID" ]]; then
+ xcrun notarytool log "$SUBMISSION_ID" \
+ --apple-id "$APPLE_NOTARIZATION_USERNAME" \
+ --password "$APPLE_NOTARIZATION_PASSWORD" \
+ --team-id "$APPLE_NOTARIZATION_TEAM" > notarization-log.txt || true
+ echo "===== Notarization Log ====="
+ cat notarization-log.txt
+ echo "============================"
+ fi
+
+ if [[ "$STATUS" != "Accepted" ]]; then
+ echo "Notarization failed with status: ${STATUS:-Unknown}"
+ exit 1
+ fi
+
+ xcrun stapler staple "$DMG_NAME"
+ fi
+
+ - 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 \
+ --ed-key-file sparkle_ed25519.key \
+ --download-url-prefix "$DOWNLOAD_PREFIX" \
+ $CHANNEL_FLAG \
+ .
+
+ - name: Upload Sparkle assets to S3
+ if: env.SPARKLE_S3_BUCKET != ''
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ AWS_REGION: ${{ env.AWS_REGION }}
+ SPARKLE_S3_BUCKET: ${{ env.SPARKLE_S3_BUCKET }}
+ SPARKLE_S3_PREFIX: ${{ env.SPARKLE_S3_PREFIX }}
+ run: |
+ set -euo pipefail
+ cd output
+ PREFIX="${SPARKLE_S3_PREFIX:-}"
+ if [[ -n "$PREFIX" && "${PREFIX: -1}" != "/" ]]; then
+ PREFIX="${PREFIX}/"
+ fi
+ DMG_NAME="EXO-${RELEASE_VERSION}.dmg"
+ aws s3 cp "$DMG_NAME" "s3://${SPARKLE_S3_BUCKET}/${PREFIX}${DMG_NAME}"
+ aws s3 cp "$DMG_NAME" "s3://${SPARKLE_S3_BUCKET}/${PREFIX}EXO-latest.dmg"
+ 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
← abaeb032 Update README.md. (#956)
·
back to Exo
·
bugfix: Handle MacMon errors gracefully c582bdd6 →