Repository navigation
build-ios-release #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # This workflow builds a signed iOS IPA and uploads it to App Store Connect. | |
| # | |
| # Required GitHub secrets (see docs/ios-ci-signing-guide.md for how to obtain them): | |
| # IOS_CERTIFICATE_P12_BASE64 – iOS Distribution certificate (.p12), base64-encoded | |
| # IOS_CERTIFICATE_PASSWORD – password for the .p12 certificate | |
| # IOS_PROVISIONING_PROFILE_BASE64 – App Store provisioning profile (.mobileprovision), base64-encoded | |
| # APPLE_ID – Apple ID email address | |
| # APPLE_APP_SPECIFIC_PASSWORD – app-specific password for altool upload | |
| # APPLE_TEAM_ID – Apple Developer Team ID (e.g. 38ZYC7L6N5) | |
| # SERVER_SECRET – JWT secret for the on-device electerm server (optional, random if unset) | |
| # | |
| # Optional secrets: | |
| # APP_STORE_CONNECT_API_KEY_P8 – App Store Connect API key (.p8), base64-encoded (alternative upload method) | |
| # APP_STORE_CONNECT_ISSUER_ID – App Store Connect API issuer ID | |
| # APP_STORE_CONNECT_KEY_ID – App Store Connect API key ID | |
| name: build-ios-release | |
| on: | |
| push: | |
| branches: [ release, release-ios, build-ios-release, dev ] | |
| workflow_dispatch: | |
| jobs: | |
| build-and-upload: | |
| name: Build & Upload iOS to App Store Connect | |
| runs-on: macos-latest | |
| timeout-minutes: 120 | |
| if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[no ci]') }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Select Xcode | |
| run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer | |
| - name: Accept Xcode license | |
| run: sudo xcodebuild -license accept || true | |
| - name: Ensure CocoaPods is available | |
| run: which pod >/dev/null 2>&1 || sudo gem install cocoapods | |
| - name: ci init | |
| run: npm config set legacy-peer-deps true | |
| - name: Install root dependencies | |
| run: npm i | |
| - name: Install Capacitor dependencies | |
| run: npm --prefix build/ios install | |
| - name: Build web frontend + Node.js backend bundle | |
| env: | |
| SERVER_SECRET: ${{ secrets.SERVER_SECRET }} | |
| run: npm run build:ios | |
| - name: Add iOS platform (first run only) | |
| run: cd build/ios && [ -d ios ] || npx cap add ios | |
| - name: Sync Capacitor (copies web assets + native plugins) | |
| run: cd build/ios && npx cap sync ios | |
| - name: Apply Info.plist overlay (ATS exception for localhost) | |
| run: cd build/ios && node build.mjs --overlay-only | |
| - name: Resolve Swift Package Manager dependencies | |
| run: | | |
| cd build/ios/ios/App | |
| xcodebuild -project App.xcodeproj -resolvePackageDependencies \ | |
| -configuration Release | |
| # ────────────────────────────────────────────────────────────────────── | |
| # Code signing setup | |
| # ────────────────────────────────────────────────────────────────────── | |
| - name: Create temporary keychain and import signing certificate | |
| env: | |
| IOS_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_CERTIFICATE_P12_BASE64 }} | |
| IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| KEYCHAIN="ios-build.keychain-db" | |
| KEYCHAIN_PASSWORD="$(openssl rand -hex 16)" | |
| # Delete any leftover keychain with the same name | |
| security delete-keychain "$KEYCHAIN" 2>/dev/null || true | |
| # Create a new temporary keychain and make it the default | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| security default-keychain -s "$KEYCHAIN" | |
| security set-keychain-settings -t 3600 -l "$KEYCHAIN" | |
| # Unlock it so xcodebuild / codesign can access it later | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| # Import the .p12 distribution certificate + private key | |
| if [ -z "$IOS_CERTIFICATE_P12_BASE64" ]; then | |
| echo "❌ IOS_CERTIFICATE_P12_BASE64 secret is empty." | |
| echo "Set it with:" | |
| echo " base64 -i your-cert.p12 | gh secret set IOS_CERTIFICATE_P12_BASE64 --body -" | |
| exit 1 | |
| fi | |
| echo "$IOS_CERTIFICATE_P12_BASE64" | base64 --decode > /tmp/cert.p12 2>/dev/null || { | |
| echo "❌ Failed to decode IOS_CERTIFICATE_P12_BASE64 — not valid base64." | |
| echo "The secret must contain the base64-encoded .p12 certificate CONTENTS," | |
| echo "not a file path. Re-set it with:" | |
| echo " base64 -i your-cert.p12 | gh secret set IOS_CERTIFICATE_P12_BASE64 --body -" | |
| exit 1 | |
| } | |
| if [ ! -s /tmp/cert.p12 ]; then | |
| echo "❌ Decoded .p12 file is empty." | |
| exit 1 | |
| fi | |
| echo "✅ Certificate decoded ($(wc -c < /tmp/cert.p12) bytes)" | |
| security import /tmp/cert.p12 \ | |
| -k "$KEYCHAIN" \ | |
| -P "$IOS_CERTIFICATE_PASSWORD" \ | |
| -T /usr/bin/codesign \ | |
| -T /usr/bin/xcodebuild \ | |
| -T /usr/bin/security | |
| # Allow codesign to read the key without prompting for permission | |
| # (sets the partition list so the key is accessible by the tool) | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: \ | |
| -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| # Verify the import | |
| echo "✅ Certificates in keychain:" | |
| security find-identity -v -p codesigning "$KEYCHAIN" | |
| rm -f /tmp/cert.p12 | |
| - name: Install App Store provisioning profile | |
| env: | |
| IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} | |
| run: | | |
| PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" | |
| mkdir -p "$PROFILES_DIR" | |
| echo "$IOS_PROVISIONING_PROFILE_BASE64" | base64 --decode > /tmp/profile.mobileprovision | |
| # Extract the UUID from the profile so we can name the file correctly | |
| UUID=$(security cms -D -i /tmp/profile.mobileprovision 2>/dev/null \ | |
| | plutil -extract UUID raw - 2>/dev/null) | |
| if [ -z "$UUID" ]; then | |
| echo "❌ Could not extract UUID from provisioning profile" | |
| echo "The profile may be invalid or corrupted." | |
| exit 1 | |
| fi | |
| echo "Provisioning profile UUID: $UUID" | |
| cp /tmp/profile.mobileprovision "$PROFILES_DIR/$UUID.mobileprovision" | |
| # Also copy it to a well-known path for xcodebuild -exportArchive | |
| cp /tmp/profile.mobileprovision "$PROFILES_DIR/electerm-ios.mobileprovision" | |
| echo "✅ Installed provisioning profile:" | |
| ls -la "$PROFILES_DIR/" | |
| # Print profile details for debugging | |
| echo "" | |
| echo "Profile details:" | |
| security cms -D -i "$PROFILES_DIR/$UUID.mobileprovision" 2>/dev/null \ | |
| | plutil -extract Name raw - 2>/dev/null && echo "" | |
| security cms -D -i "$PROFILES_DIR/$UUID.mobileprovision" 2>/dev/null \ | |
| | plutil -extract TeamIdentifier.0 raw - 2>/dev/null && echo "" | |
| rm -f /tmp/profile.mobileprovision | |
| - name: Update build number | |
| env: | |
| BUILD_NUMBER: ${{ github.run_number }} | |
| run: | | |
| # Update CURRENT_PROJECT_VERSION (CFBundleVersion) to the GitHub | |
| # Actions run number so each upload is unique (App Store Connect | |
| # rejects duplicate build numbers). | |
| cd build/ios/ios/App | |
| # Use sed to replace CURRENT_PROJECT_VERSION in the Release config | |
| # The project file has CURRENT_PROJECT_VERSION = 1 in the Release section | |
| sed -i '' 's/CURRENT_PROJECT_VERSION = 1;/CURRENT_PROJECT_VERSION = '"$BUILD_NUMBER"';/g' \ | |
| App.xcodeproj/project.pbxproj | |
| echo "✅ Updated build number to $BUILD_NUMBER" | |
| grep 'CURRENT_PROJECT_VERSION' App.xcodeproj/project.pbxproj | |
| # ────────────────────────────────────────────────────────────────────── | |
| # Archive (produces .xcarchive) | |
| # ────────────────────────────────────────────────────────────────────── | |
| - name: Archive iOS app (signed) | |
| env: | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| cd build/ios/ios/App | |
| ARCHIVE_PATH="$GITHUB_WORKSPACE/build/ios/output/electerm.xcarchive" | |
| mkdir -p "$(dirname "$ARCHIVE_PATH")" | |
| echo "Archiving App (Release, signed)..." | |
| xcodebuild -project App.xcodeproj \ | |
| -scheme App \ | |
| -configuration Release \ | |
| -sdk iphoneos \ | |
| -destination 'generic/platform=iOS' \ | |
| -archivePath "$ARCHIVE_PATH" \ | |
| DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ | |
| CODE_SIGN_STYLE="Manual" \ | |
| CODE_SIGN_IDENTITY="iPhone Distribution" \ | |
| CODE_SIGNING_REQUIRED=YES \ | |
| CODE_SIGNING_ALLOWED=YES \ | |
| ENABLE_USER_SCRIPT_SANDBOXING=NO \ | |
| archive \ | |
| 2>&1 | tee /tmp/xcodebuild-archive.log | |
| # Check if archive was created | |
| if [ ! -d "$ARCHIVE_PATH" ]; then | |
| echo "❌ Archive was not created at $ARCHIVE_PATH" | |
| exit 1 | |
| fi | |
| echo "✅ Archive created:" | |
| du -sh "$ARCHIVE_PATH" | |
| ls -la "$ARCHIVE_PATH/Products/Applications/" | |
| # ────────────────────────────────────────────────────────────────────── | |
| # Export IPA (from .xcarchive) | |
| # ────────────────────────────────────────────────────────────────────── | |
| - name: Generate ExportOptions.plist and export signed IPA | |
| env: | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| ARCHIVE_PATH="$GITHUB_WORKSPACE/build/ios/output/electerm.xcarchive" | |
| EXPORT_DIR="$GITHUB_WORKSPACE/build/ios/output/ipa" | |
| PLIST_PATH="$GITHUB_WORKSPACE/build/ios/ExportOptions.plist" | |
| BUNDLE_ID="org.electerm.electermios" | |
| mkdir -p "$EXPORT_DIR" | |
| # Extract the provisioning profile name from the installed profile | |
| # so ExportOptions.plist always matches, regardless of what the user | |
| # named the profile when creating it. | |
| PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" | |
| PROFILE_FILE=$(find "$PROFILES_DIR" -name "electerm-ios.mobileprovision" | head -1) | |
| if [ -z "$PROFILE_FILE" ]; then | |
| # Fallback: find any .mobileprovision that matches our bundle ID | |
| for f in "$PROFILES_DIR"/*.mobileprovision; do | |
| NAME=$(security cms -D -i "$f" 2>/dev/null | plutil -extract Name raw - 2>/dev/null) | |
| ENT_BUNDLE=$(security cms -D -i "$f" 2>/dev/null | plutil -extract Entitlements.application-identifier raw - 2>/dev/null) | |
| if echo "$ENT_BUNDLE" | grep -q "$BUNDLE_ID"; then | |
| PROFILE_FILE="$f" | |
| break | |
| fi | |
| done | |
| fi | |
| if [ -z "$PROFILE_FILE" ]; then | |
| echo "❌ Could not find a provisioning profile for $BUNDLE_ID" | |
| exit 1 | |
| fi | |
| PROFILE_NAME=$(security cms -D -i "$PROFILE_FILE" 2>/dev/null | plutil -extract Name raw - 2>/dev/null) | |
| PROFILE_UUID=$(security cms -D -i "$PROFILE_FILE" 2>/dev/null | plutil -extract UUID raw - 2>/dev/null) | |
| echo "Using provisioning profile:" | |
| echo " Name: $PROFILE_NAME" | |
| echo " UUID: $PROFILE_UUID" | |
| # Generate ExportOptions.plist dynamically | |
| cat > "$PLIST_PATH" <<PLIST | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>method</key> | |
| <string>app-store</string> | |
| <key>teamID</key> | |
| <string>${APPLE_TEAM_ID}</string> | |
| <key>uploadBitcode</key> | |
| <false/> | |
| <key>uploadSymbols</key> | |
| <true/> | |
| <key>signingStyle</key> | |
| <string>manual</string> | |
| <key>provisioningProfiles</key> | |
| <dict> | |
| <key>${BUNDLE_ID}</key> | |
| <string>${PROFILE_NAME}</string> | |
| </dict> | |
| <key>stripSwiftSymbols</key> | |
| <true/> | |
| </dict> | |
| </plist> | |
| PLIST | |
| echo "ExportOptions.plist:" | |
| cat "$PLIST_PATH" | |
| echo "" | |
| echo "Exporting IPA from archive..." | |
| xcodebuild -exportArchive \ | |
| -archivePath "$ARCHIVE_PATH" \ | |
| -exportPath "$EXPORT_DIR" \ | |
| -exportOptionsPlist "$PLIST_PATH" \ | |
| DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ | |
| 2>&1 | tee /tmp/xcodebuild-export.log | |
| # Find the IPA | |
| IPA_FILE=$(find "$EXPORT_DIR" -name "*.ipa" -type f | head -1) | |
| if [ -z "$IPA_FILE" ]; then | |
| echo "❌ No IPA file was created in $EXPORT_DIR" | |
| echo "Contents of export dir:" | |
| ls -laR "$EXPORT_DIR" | |
| exit 1 | |
| fi | |
| echo "✅ IPA created: $IPA_FILE" | |
| echo "Size: $(du -h "$IPA_FILE" | cut -f1)" | |
| echo "ipa_path=$IPA_FILE" >> "$GITHUB_ENV" | |
| # ────────────────────────────────────────────────────────────────────── | |
| # Upload to App Store Connect | |
| # ────────────────────────────────────────────────────────────────────── | |
| - name: Upload IPA to App Store Connect | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} | |
| APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
| APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} | |
| run: | | |
| IPA_FILE="${{ env.ipa_path }}" | |
| BUNDLE_ID="org.electerm.electermios" | |
| if [ -z "$IPA_FILE" ] || [ ! -f "$IPA_FILE" ]; then | |
| echo "❌ IPA file not found" | |
| exit 1 | |
| fi | |
| echo "Uploading to App Store Connect..." | |
| echo " IPA: $IPA_FILE" | |
| echo " Size: $(du -h "$IPA_FILE" | cut -f1)" | |
| echo " Bundle ID: $BUNDLE_ID" | |
| echo "" | |
| # Prefer App Store Connect API key authentication if available | |
| # (more reliable than app-specific password, and doesn't expire) | |
| if [ -n "$APP_STORE_CONNECT_API_KEY_P8" ] && \ | |
| [ -n "$APP_STORE_CONNECT_ISSUER_ID" ] && \ | |
| [ -n "$APP_STORE_CONNECT_KEY_ID" ]; then | |
| echo "Using App Store Connect API key authentication..." | |
| API_KEY_DIR="$HOME/.appstoreconnect/private_keys" | |
| mkdir -p "$API_KEY_DIR" | |
| echo "$APP_STORE_CONNECT_API_KEY_P8" | base64 --decode \ | |
| > "$API_KEY_DIR/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" | |
| xcrun altool --upload-app \ | |
| --type ios \ | |
| --file "$IPA_FILE" \ | |
| --apiKey "$APP_STORE_CONNECT_KEY_ID" \ | |
| --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" \ | |
| --verbose || { | |
| echo "" | |
| echo "❌ Upload failed (API key method)" | |
| echo "" | |
| echo "Common reasons:" | |
| echo " • App with bundle ID '$BUNDLE_ID' doesn't exist in App Store Connect" | |
| echo " • API key lacks App Manager or Admin role" | |
| echo " • Build number already exists in App Store Connect" | |
| echo "" | |
| echo "Solutions:" | |
| echo " 1. Create app in App Store Connect: https://appstoreconnect.apple.com/apps" | |
| echo " 2. Verify bundle ID matches exactly: $BUNDLE_ID" | |
| echo " 3. Check the API key has 'App Manager' or 'Admin' role" | |
| echo " 4. Increment the build number (each upload must be unique)" | |
| echo "" | |
| exit 1 | |
| } | |
| else | |
| echo "Using app-specific password authentication..." | |
| xcrun altool --upload-app \ | |
| --type ios \ | |
| --file "$IPA_FILE" \ | |
| --username "$APPLE_ID" \ | |
| --password "$APPLE_APP_SPECIFIC_PASSWORD" \ | |
| --primary-bundle-id "$BUNDLE_ID" \ | |
| --verbose || { | |
| echo "" | |
| echo "❌ Upload failed (app-specific password method)" | |
| echo "" | |
| echo "Common reasons:" | |
| echo " • App with bundle ID '$BUNDLE_ID' doesn't exist in App Store Connect" | |
| echo " • Apple ID doesn't have access to this app" | |
| echo " • App-specific password is incorrect or expired" | |
| echo " • Build number already exists in App Store Connect" | |
| echo "" | |
| echo "Solutions:" | |
| echo " 1. Create app in App Store Connect: https://appstoreconnect.apple.com/apps" | |
| echo " 2. Verify bundle ID matches exactly: $BUNDLE_ID" | |
| echo " 3. Check Apple ID has App Manager or Admin role" | |
| echo " 4. Regenerate app-specific password: https://appleid.apple.com" | |
| echo " 5. Increment the build number (each upload must be unique)" | |
| echo "" | |
| echo "Alternative: Download IPA artifact and upload via Transporter.app GUI" | |
| echo "" | |
| exit 1 | |
| } | |
| fi | |
| echo "" | |
| echo "✅ Upload to App Store Connect complete!" | |
| echo "" | |
| echo "Next steps:" | |
| echo "1. Go to https://appstoreconnect.apple.com" | |
| echo "2. Select your app" | |
| echo "3. Wait for Apple's automated processing (usually 5-30 minutes)" | |
| echo "4. Add release notes / screenshots" | |
| echo "5. Submit for review" | |
| # ────────────────────────────────────────────────────────────────────── | |
| # Cleanup keychain (always run, even on failure) | |
| # ────────────────────────────────────────────────────────────────────── | |
| - name: Cleanup temporary keychain | |
| if: ${{ always() }} | |
| run: | | |
| security delete-keychain "ios-build.keychain-db" 2>/dev/null || true | |
| echo "Keychain cleaned up" | |
| # ────────────────────────────────────────────────────────────────────── | |
| # Upload IPA as artifact (so you can download and inspect / manually upload) | |
| # ────────────────────────────────────────────────────────────────────── | |
| - name: Upload signed IPA artifact | |
| if: ${{ !cancelled() && env.ipa_path != '' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: electerm-ios-ipa | |
| path: ${{ env.ipa_path }} | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| - name: Upload xcarchive artifact | |
| if: ${{ !cancelled() }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: electerm-ios-xcarchive | |
| path: build/ios/output/electerm.xcarchive | |
| if-no-files-found: warn | |
| retention-days: 3 | |
| - name: Summary | |
| if: ${{ !cancelled() }} | |
| run: | | |
| echo "## iOS Build & Upload Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Build Information:" >> $GITHUB_STEP_SUMMARY | |
| echo "- Branch: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- Commit: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- Build number: ${{ github.run_number }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- Bundle ID: org.electerm.electermios" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Artifacts:" >> $GITHUB_STEP_SUMMARY | |
| echo "- \`electerm-ios-ipa\` — signed IPA ready for App Store" >> $GITHUB_STEP_SUMMARY | |
| echo "- \`electerm-ios-xcarchive\` — Xcode archive (for debugging symbol extraction)" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### App Store Connect:" >> $GITHUB_STEP_SUMMARY | |
| if [ -n "${{ env.ipa_path }}" ]; then | |
| echo "✅ IPA uploaded to App Store Connect" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Visit https://appstoreconnect.apple.com to complete submission." >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "⚠️ Upload may have failed — check the logs above." >> $GITHUB_STEP_SUMMARY | |
| fi |