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