Skip to content

Release to TestFlight & Google Play #81

Release to TestFlight & Google Play

Release to TestFlight & Google Play #81

Workflow file for this run

name: Release to TestFlight & Google Play
on:
workflow_dispatch:
inputs:
version_override:
description: 'Override app version (leave empty to use APP_VERSION repo variable)'
required: false
build_number_offset:
description: 'Offset added to run number (increase if you need to jump ahead, default: repo var BUILD_NUMBER_OFFSET or 0)'
required: false
schedule:
- cron: '0 6 * * *' # Daily at 06:00 UTC
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
MAUI_PROJECT_PATH: 'PolyPilot/PolyPilot.csproj'
APP_VERSION: ${{ inputs.version_override || vars.APP_VERSION || '1.0.0' }}
jobs:
# ─────────────────────────────────────────────
# Check if there are new commits (skips on schedule if nothing changed)
# ─────────────────────────────────────────────
check-changes:
name: Check for new commits
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.check.outputs.should_release }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check for commits in last 24 hours
id: check
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual trigger — always release"
echo "should_release=true" >> $GITHUB_OUTPUT
exit 0
fi
COUNT=$(git log --oneline --since="24 hours ago" | wc -l | tr -d ' ')
echo "Commits in last 24 hours: $COUNT"
if [ "$COUNT" -gt 0 ]; then
echo "should_release=true" >> $GITHUB_OUTPUT
git log --oneline --since="24 hours ago"
else
echo "should_release=false" >> $GITHUB_OUTPUT
echo "No new commits — skipping release"
fi
# ─────────────────────────────────────────────
# Android → Google Play (Open Testing)
# ─────────────────────────────────────────────
build-android:
name: Build Android AAB
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.should_release == 'true'
steps:
- name: Compute build number
id: build
run: |
OFFSET=${{ inputs.build_number_offset || vars.BUILD_NUMBER_OFFSET || '0' }}
BUILD=$(( ${{ github.run_number }} + OFFSET ))
echo "number=$BUILD" >> $GITHUB_OUTPUT
echo "Build number: ${{ github.run_number }} + $OFFSET = $BUILD"
- name: Checkout
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
dotnet-quality: 'preview'
- name: Install MAUI workload
run: dotnet workload install maui-android
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'microsoft'
java-version: '17'
- name: Decode Android keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ${{ github.workspace }}/keystore.jks
- name: Publish Android AAB
run: |
dotnet publish ${{ env.MAUI_PROJECT_PATH }} \
-f net10.0-android \
-c Release \
-p:ApplicationId=nl.versluis.polypilot \
-p:AndroidPackageFormat=aab \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore='${{ github.workspace }}/keystore.jks' \
-p:AndroidSigningKeyAlias='${{ secrets.ANDROID_KEY_ALIAS }}' \
-p:AndroidSigningKeyPass='${{ secrets.ANDROID_KEY_PASSWORD }}' \
-p:AndroidSigningStorePass='${{ secrets.ANDROID_KEYSTORE_PASSWORD }}' \
-p:ApplicationDisplayVersion='${{ env.APP_VERSION }}' \
-p:ApplicationVersion='${{ steps.build.outputs.number }}'
- name: Upload AAB artifact
uses: actions/upload-artifact@v7
with:
name: android-aab
path: PolyPilot/bin/Release/net10.0-android/publish/*-Signed.aab
retention-days: 30
deploy-android:
name: Deploy to Google Play
runs-on: ubuntu-latest
needs: build-android
environment: android-release
steps:
- name: Download AAB artifact
uses: actions/download-artifact@v8
with:
name: android-aab
path: ./android-artifacts/
- name: Upload to Google Play (Internal Testing)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: 'nl.versluis.polypilot'
releaseFiles: ./android-artifacts/*-Signed.aab
track: beta
status: completed
# ─────────────────────────────────────────────
# Mac Catalyst → TestFlight
# ─────────────────────────────────────────────
build-maccatalyst:
name: Build Mac Catalyst PKG
runs-on: macos-26
needs: check-changes
if: needs.check-changes.outputs.should_release == 'true'
steps:
- name: Compute build number
id: build
run: |
OFFSET=${{ inputs.build_number_offset || vars.BUILD_NUMBER_OFFSET || '0' }}
BUILD=$(( ${{ github.run_number }} + OFFSET ))
echo "number=$BUILD" >> $GITHUB_OUTPUT
echo "Build number: ${{ github.run_number }} + $OFFSET = $BUILD"
- name: Checkout
uses: actions/checkout@v6
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.2'
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
dotnet-quality: 'preview'
- name: Install MAUI workload
run: |
dotnet workload update
dotnet workload install maui
- name: Import Apple Distribution certificate
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
p12-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- name: Import Mac Installer certificate
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: ${{ secrets.MACCATALYST_INSTALLER_CERT_BASE64 }}
p12-password: ${{ secrets.MACCATALYST_INSTALLER_CERT_PASSWORD }}
keychain: installer_temp
create-keychain: true
- name: Fix keychain search list
run: |
# Each import-codesign-certs overwrites the search list with only its own keychain.
# Restore all keychains so dotnet build can find both the Distribution and Installer certs.
security list-keychains -d user -s signing_temp.keychain installer_temp.keychain login.keychain
echo "=== Keychain search list ==="
security list-keychains -d user
echo "=== Signing identities ==="
security find-identity -v -p codesigning
echo "=== All identities (including installer) ==="
security find-identity -v
- name: Install provisioning profile
env:
PROVISIONING_PROFILE_BASE64: ${{ secrets.MACCATALYST_PROVISIONING_PROFILE_BASE64 }}
run: |
PP_PATH="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PP_PATH"
# Decode profile using openssl (handles secrets without trailing newline)
printenv PROVISIONING_PROFILE_BASE64 | openssl base64 -d -A > "$RUNNER_TEMP/maccatalyst.provisionprofile"
# Validate the profile was decoded correctly
if [ ! -s "$RUNNER_TEMP/maccatalyst.provisionprofile" ]; then
echo "::error::Failed to decode MACCATALYST_PROVISIONING_PROFILE_BASE64"
exit 1
fi
# Extract UUID and install with UUID-based filename (required by MAUI SDK)
PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print UUID" /dev/stdin <<< $(security cms -D -i "$RUNNER_TEMP/maccatalyst.provisionprofile"))
cp "$RUNNER_TEMP/maccatalyst.provisionprofile" "$PP_PATH/$PROFILE_UUID.provisionprofile"
echo "Installed Mac Catalyst profile with UUID: $PROFILE_UUID"
- name: Publish Mac Catalyst App
run: |
dotnet publish ${{ env.MAUI_PROJECT_PATH }} \
-f net10.0-maccatalyst \
-c Release \
-r maccatalyst-arm64 \
-p:ApplicationId=nl.versluis.polypilot \
-p:CodesignKey="${{ secrets.IOS_CODESIGN_KEY }}" \
-p:CodesignProvision="${{ secrets.MACCATALYST_PROVISIONING_PROFILE_NAME }}" \
-p:CodesignEntitlements=Platforms/MacCatalyst/Entitlements.AppStore.plist \
-p:CreatePackage=false \
-p:EnableCodeSigning=true \
-p:ApplicationDisplayVersion='${{ env.APP_VERSION }}' \
-p:ApplicationVersion='${{ steps.build.outputs.number }}'
- name: Fix Mac Catalyst app icon
run: |
APP_PATH=$(find PolyPilot/bin/Release/net10.0-maccatalyst -name "PolyPilot.app" -type d | head -1)
PLIST="$APP_PATH/Contents/Info.plist"
# MAUI's resizetizer generates an incomplete icns (4 sizes, up to 128@2x).
# Mac App Store requires 512x512@2x. Replace with the complete pre-built icns.
cp PolyPilot/Resources/AppIcon/appicon.icns "$APP_PATH/Contents/Resources/appicon.icns"
echo "Replaced icns with complete pre-built version"
# Ensure CFBundleIconName is set
if ! /usr/libexec/PlistBuddy -c "Print :CFBundleIconName" "$PLIST" 2>/dev/null; then
/usr/libexec/PlistBuddy -c "Add :CFBundleIconName string appicon" "$PLIST"
echo "Added CFBundleIconName to Info.plist"
else
echo "CFBundleIconName already set: $(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$PLIST")"
fi
- name: Embed provisioning profile
run: |
APP_PATH=$(find PolyPilot/bin/Release/net10.0-maccatalyst -name "PolyPilot.app" -type d | head -1)
# CreatePackage=false doesn't embed the profile. TestFlight requires it.
cp "$RUNNER_TEMP/maccatalyst.provisionprofile" "$APP_PATH/Contents/embedded.provisionprofile"
echo "Embedded provisioning profile in app bundle"
- name: Inject application-identifier into entitlements
run: |
# Extract application-identifier and team-identifier from provisioning profile
# and add them to the entitlements file. codesign doesn't do this automatically
# (Xcode does). Required for TestFlight eligibility.
PROFILE_PLIST=$(security cms -D -i "$RUNNER_TEMP/maccatalyst.provisionprofile")
APP_ID=$(echo "$PROFILE_PLIST" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
TEAM_ID=$(echo "$PROFILE_PLIST" | plutil -extract Entitlements.com\\.apple\\.developer\\.team-identifier raw -o - -)
echo "Application identifier: $APP_ID"
echo "Team identifier: $TEAM_ID"
ENTITLEMENTS="PolyPilot/Platforms/MacCatalyst/Entitlements.AppStore.plist"
/usr/libexec/PlistBuddy -c "Add :com.apple.application-identifier string $APP_ID" "$ENTITLEMENTS" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Set :com.apple.application-identifier $APP_ID" "$ENTITLEMENTS"
/usr/libexec/PlistBuddy -c "Add :com.apple.developer.team-identifier string $TEAM_ID" "$ENTITLEMENTS" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Set :com.apple.developer.team-identifier $TEAM_ID" "$ENTITLEMENTS"
echo "Updated entitlements:"
cat "$ENTITLEMENTS"
- name: Re-sign app bundle (inside-out)
env:
CODESIGN_KEY: ${{ secrets.IOS_CODESIGN_KEY }}
run: |
APP_PATH=$(find PolyPilot/bin/Release/net10.0-maccatalyst -name "PolyPilot.app" -type d | head -1)
echo "App path: $APP_PATH"
# Sign the copilot CLI binary with minimal helper entitlements (sandbox + inherit)
COPILOT_BIN="$APP_PATH/Contents/MonoBundle/copilot"
if [ -f "$COPILOT_BIN" ]; then
echo "Signing bundled copilot binary..."
codesign --force --sign "$CODESIGN_KEY" \
--entitlements PolyPilot/Platforms/MacCatalyst/Entitlements.Helper.plist \
--options runtime --timestamp \
"$COPILOT_BIN"
else
echo "Warning: copilot binary not found at $COPILOT_BIN"
fi
# Re-sign all dylibs (inside-out)
find "$APP_PATH" -type f \( -name "*.dylib" -o -name "*.so" \) | while read f; do
echo " Signing: $f"
codesign --force --options runtime --timestamp \
--sign "$CODESIGN_KEY" "$f"
done
# Re-sign frameworks
find "$APP_PATH" -type d -name "*.framework" | while read f; do
echo " Signing: $f"
codesign --force --options runtime --timestamp \
--sign "$CODESIGN_KEY" "$f"
done
# Re-sign the top-level app bundle with full entitlements
codesign --force --sign "$CODESIGN_KEY" \
--entitlements PolyPilot/Platforms/MacCatalyst/Entitlements.AppStore.plist \
--options runtime --timestamp \
"$APP_PATH"
echo "=== Verify signature ==="
codesign --verify --deep --strict "$APP_PATH" 2>&1
echo "=== App entitlements ==="
codesign -d --entitlements - "$APP_PATH" 2>/dev/null | head -30
echo "=== Verify copilot binary ==="
codesign -dvv "$COPILOT_BIN" 2>&1 | head -10
- name: Create and sign PKG
env:
PKG_SIGN_KEY: ${{ secrets.MACCATALYST_INSTALLER_SIGNING_KEY }}
run: |
APP_PATH=$(find PolyPilot/bin/Release/net10.0-maccatalyst -name "PolyPilot.app" -type d | head -1)
PKG_DIR="PolyPilot/bin/Release/net10.0-maccatalyst"
# Create component package
productbuild --component "$APP_PATH" /Applications \
"$PKG_DIR/PolyPilot-unsigned.pkg"
# Sign the package with Mac Installer certificate
productsign --sign "$PKG_SIGN_KEY" \
"$PKG_DIR/PolyPilot-unsigned.pkg" \
"$PKG_DIR/PolyPilot-${{ env.APP_VERSION }}.pkg"
rm "$PKG_DIR/PolyPilot-unsigned.pkg"
echo "Created signed PKG"
- name: Find PKG file
id: find_pkg
run: |
PKG_PATH=$(find PolyPilot/bin/Release/net10.0-maccatalyst -name "*.pkg" | head -1)
echo "PKG_PATH=$PKG_PATH" >> $GITHUB_OUTPUT
echo "Found PKG at: $PKG_PATH"
- name: Upload PKG artifact
uses: actions/upload-artifact@v7
with:
name: maccatalyst-pkg
path: ${{ steps.find_pkg.outputs.PKG_PATH }}
retention-days: 30
deploy-maccatalyst:
name: Deploy Mac Catalyst to TestFlight
runs-on: macos-26
needs: build-maccatalyst
steps:
- name: Compute build number
id: build
run: |
OFFSET=${{ inputs.build_number_offset || vars.BUILD_NUMBER_OFFSET || '0' }}
BUILD=$(( ${{ github.run_number }} + OFFSET ))
echo "number=$BUILD" >> $GITHUB_OUTPUT
- name: Download PKG artifact
uses: actions/download-artifact@v8
with:
name: maccatalyst-pkg
path: ./maccatalyst-artifacts/
- name: Find PKG file
id: find_pkg
run: |
PKG_PATH=$(find ./maccatalyst-artifacts -name "*.pkg" | head -1)
echo "PKG_PATH=$PKG_PATH" >> $GITHUB_OUTPUT
echo "Found PKG at: $PKG_PATH"
- name: Upload to TestFlight
env:
APPSTORE_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
APPSTORE_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
APPSTORE_PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
PKG_PATH: ${{ steps.find_pkg.outputs.PKG_PATH }}
APP_VERSION: ${{ env.APP_VERSION }}
BUILD_NUMBER: ${{ steps.build.outputs.number }}
run: |
mkdir -p private_keys
echo "$APPSTORE_PRIVATE_KEY" > "private_keys/AuthKey_${APPSTORE_KEY_ID}.p8"
xcrun altool --upload-package "$PKG_PATH" \
-t macos \
--apple-id "6759370598" \
--bundle-id "nl.versluis.polypilot" \
--bundle-version "$BUILD_NUMBER" \
--bundle-short-version-string "$APP_VERSION" \
--apiKey "$APPSTORE_KEY_ID" \
--apiIssuer "$APPSTORE_ISSUER_ID"
rm -rf private_keys
- name: Distribute to external testers (MAUI Team)
continue-on-error: true
env:
APPSTORE_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
APPSTORE_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
APPSTORE_PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
APP_BUNDLE_ID: 'nl.versluis.polypilot'
BUILD_NUMBER: ${{ steps.build.outputs.number }}
APP_VERSION: ${{ env.APP_VERSION }}
BETA_GROUP_NAME: 'MAUI Team'
run: |
set -euo pipefail
gem install fastlane --no-document
python3 -c "
import json, os
key = {
'key_id': os.environ['APPSTORE_KEY_ID'],
'issuer_id': os.environ['APPSTORE_ISSUER_ID'],
'key': os.environ['APPSTORE_PRIVATE_KEY']
}
with open('api_key.json', 'w') as f:
json.dump(key, f)
"
echo "Waiting for build processing before distributing..."
fastlane pilot distribute \
--api_key_path api_key.json \
--app_identifier "$APP_BUNDLE_ID" \
--app_platform "osx" \
--build_number "$BUILD_NUMBER" \
--app_version "$APP_VERSION" \
--groups "$BETA_GROUP_NAME" \
--distribute_only
rm -f api_key.json
# ─────────────────────────────────────────────
# iOS → TestFlight
# ─────────────────────────────────────────────
build-ios:
name: Build iOS IPA
runs-on: macos-26
needs: check-changes
if: needs.check-changes.outputs.should_release == 'true'
steps:
- name: Compute build number
id: build
run: |
OFFSET=${{ inputs.build_number_offset || vars.BUILD_NUMBER_OFFSET || '0' }}
BUILD=$(( ${{ github.run_number }} + OFFSET ))
echo "number=$BUILD" >> $GITHUB_OUTPUT
echo "Build number: ${{ github.run_number }} + $OFFSET = $BUILD"
- name: Checkout
uses: actions/checkout@v6
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.2'
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
dotnet-quality: 'preview'
- name: Install MAUI workload
run: |
dotnet workload update
dotnet workload install maui
- name: Import signing certificate
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
p12-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
- name: Install provisioning profile
run: |
PP_PATH="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PP_PATH"
echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 -d > "$PP_PATH/profile.mobileprovision"
- name: Publish iOS IPA
run: |
dotnet publish ${{ env.MAUI_PROJECT_PATH }} \
-f net10.0-ios \
-c Release \
-p:RuntimeIdentifier=ios-arm64 \
-p:ApplicationId=nl.versluis.polypilot \
-p:CodesignKey="${{ secrets.IOS_CODESIGN_KEY }}" \
-p:CodesignProvision="${{ secrets.IOS_PROVISIONING_PROFILE_NAME }}" \
-p:ArchiveOnBuild=true \
-p:ApplicationDisplayVersion='${{ env.APP_VERSION }}' \
-p:ApplicationVersion='${{ steps.build.outputs.number }}'
- name: Find IPA file
id: find_ipa
run: |
IPA_PATH=$(find PolyPilot/bin/Release/net10.0-ios -name "*.ipa" | head -1)
echo "IPA_PATH=$IPA_PATH" >> $GITHUB_OUTPUT
echo "Found IPA at: $IPA_PATH"
- name: Upload IPA artifact
uses: actions/upload-artifact@v7
with:
name: ios-ipa
path: ${{ steps.find_ipa.outputs.IPA_PATH }}
retention-days: 30
deploy-ios:
name: Deploy to TestFlight
runs-on: macos-26
needs: build-ios
environment: ios-release
steps:
- name: Compute build number
id: build
run: |
OFFSET=${{ inputs.build_number_offset || vars.BUILD_NUMBER_OFFSET || '0' }}
BUILD=$(( ${{ github.run_number }} + OFFSET ))
echo "number=$BUILD" >> $GITHUB_OUTPUT
- name: Download IPA artifact
uses: actions/download-artifact@v8
with:
name: ios-ipa
path: ./ios-artifacts/
- name: Find IPA file
id: find_ipa
run: |
IPA_PATH=$(find ./ios-artifacts -name "*.ipa" | head -1)
echo "IPA_PATH=$IPA_PATH" >> $GITHUB_OUTPUT
echo "Found IPA at: $IPA_PATH"
- name: Upload to TestFlight
uses: apple-actions/upload-testflight-build@v3
with:
app-path: ${{ steps.find_ipa.outputs.IPA_PATH }}
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
- name: Distribute to external testers (MAUI Team)
continue-on-error: true
env:
APPSTORE_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
APPSTORE_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
APPSTORE_PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
APP_BUNDLE_ID: 'nl.versluis.polypilot'
BUILD_NUMBER: ${{ steps.build.outputs.number }}
APP_VERSION: ${{ env.APP_VERSION }}
BETA_GROUP_NAME: 'MAUI Team'
run: |
set -euo pipefail
# Install fastlane
gem install fastlane --no-document
# Write API key JSON for fastlane (from_json_file requires 'key', not 'key_filepath')
python3 -c "
import json, os
key = {
'key_id': os.environ['APPSTORE_KEY_ID'],
'issuer_id': os.environ['APPSTORE_ISSUER_ID'],
'key': os.environ['APPSTORE_PRIVATE_KEY']
}
with open('api_key.json', 'w') as f:
json.dump(key, f)
"
echo "Waiting for build processing before distributing..."
fastlane pilot distribute \
--api_key_path api_key.json \
--app_identifier "$APP_BUNDLE_ID" \
--build_number "$BUILD_NUMBER" \
--app_version "$APP_VERSION" \
--groups "$BETA_GROUP_NAME" \
--distribute_only
rm -f api_key.json