I built a GitHub workflow to bundle, sign and publish an iOS app. There are already a couple guides on how to do this, but none of them worked out of the box for me (an iOS/mac noob). With lots of googling plus trial and error, I pieced together the missing and broken parts. Since I wouldn't wish that on anyone, here is some information on how to set up the workflow.
The project that the workflow was build for might be a bit unconventional. It's a mobile app using the Rust game engine Bevy. The workflow expects a specific project structure with the Xcode project living in a subdirectory. You can change the name of the subdirectory and the Xcode project in the env
section. The whole workflow is part of this post, but if you want to see it in use, you can look at bevy_game_template.
The following goes into a yaml
file in your GitHub workflows directory (for example .github/workflows/release-ios-testflight.yaml
).
name: release-ios-testflight
on:
workflow_dispatch:
inputs:
version:
description: 'Version - e.g. v1.2.3'
required: true
type: string
env:
# used for uploading the app to a GitHub release
APP_NAME: bevy_game
XCODE_PROJECT: mobile
MOBILE_DIRECTORY: mobile
permissions:
contents: write
jobs:
build-for-iOS:
runs-on: macos-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Add iOS targets
run: rustup target add aarch64-apple-ios
- name: Install the Apple certificate and provisioning profile
id: profile
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISION_PROFILE: ${{ secrets.IOS_PROVISION_PROFILE }}
IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
run: |
# create variables
CERTIFICATE_PATH=${{ runner.temp }}/build_certificate.p12
PP_PATH=${{ runner.temp }}/profile.mobileprovision
KEYCHAIN_PATH=${{ runner.temp }}/app-signing.keychain-db
# import certificate and provisioning profile from secrets
echo -n "$IOS_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$IOS_PROVISION_PROFILE" | base64 --decode -o $PP_PATH
uuid=`grep UUID -A1 -a $PP_PATH | grep -io "[-A-F0-9]\{36\}"`
echo "uuid=$uuid" >> $GITHUB_OUTPUT
# create temporary keychain
security create-keychain -p "$IOS_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$IOS_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $CERTIFICATE_PATH -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# apply provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$uuid.mobileprovision
- name: Build app for iOS
run: |
cd ${{ env.MOBILE_DIRECTORY }}
xcodebuild PROVISIONING_PROFILE=${{ steps.profile.outputs.uuid }} -scheme ${{ env.XCODE_PROJECT }} clean archive -archivePath "Actions" -configuration Release -arch arm64
- name: export ipa
env:
EXPORT_PLIST: ${{ secrets.IOS_EXPORT_PRODUCTION }}
run: |
EXPORT_PLIST_PATH=${{ runner.temp }}/ExportOptions.plist
echo -n "$EXPORT_PLIST" | base64 --decode --output $EXPORT_PLIST_PATH
xcodebuild PROVISIONING_PROFILE=${{ steps.profile.outputs.uuid }} -exportArchive -archivePath ${{ env.MOBILE_DIRECTORY }}/Actions.xcarchive -exportOptionsPlist $EXPORT_PLIST_PATH -exportPath ${{ runner.temp }}/export
- name: decode API key
env:
API_KEY_BASE64: ${{ secrets.IOS_APPSTORE_API_PRIVATE_KEY }}
run: |
mkdir -p ~/private_keys
echo -n "$API_KEY_BASE64" | base64 --decode --output ~/private_keys/AuthKey_${{ secrets.IOS_APPSTORE_API_KEY_ID }}.p8
- name: Upload to testflight
run: |
xcrun altool --validate-app -f ${{ runner.temp }}/export/${{ env.XCODE_PROJECT }}.ipa -t ios --apiKey ${{ secrets.IOS_APPSTORE_API_KEY_ID }} --apiIssuer ${{ secrets.IOS_APPSTORE_ISSUER_ID }}
xcrun altool --upload-app -f ${{ runner.temp }}/export/${{ env.XCODE_PROJECT }}.ipa -t ios --apiKey ${{ secrets.IOS_APPSTORE_API_KEY_ID }} --apiIssuer ${{ secrets.IOS_APPSTORE_ISSUER_ID }}
- name: Upload release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ runner.temp }}/export/${{ env.XCODE_PROJECT }}.ipa
asset_name: ${{ env.APP_NAME }}_${{ inputs.version }}_ios.ipa
release_name: ${{ inputs.version }}
tag: ${{ inputs.version }}
overwrite: true
Configure the env
section according to your project.
The workflow requires multiple secrets to be configured in GitHub. Some of those need an active membership in the Apple developer program, which costs 99$ per year. We'll get back to getting and configuring the secrets later. First, let's go through the workflow steps:
~/Library/MobileDevice/Provisioning\ Profiles/
. I use its uuid as file name, because I had some issues where Xcode couldn't find the correct profile, but I am not sure if this is actually needed.xcodebuild
to archive your project.ExportOptions.plist
to export an ipa
from the archive.~/private_keys
and needs to contain the key id in its name for altool
to find it.ipa
.ipa
to a GitHub release.Before starting the workflow for the first time, your app should be configured in App Store Connect. The builds will automatically show up in TestFlight after a short processing period.
Simple strings like passwords can directly go into a GitHub secret. Files will be encoded first using base64 (e.g. openssl base64 -in ~/ExportOptions.plist
).
To configure a secret go to your repository settings in GitHub. Navigate to Security - Secrets and variables, select Actions then click "New repository secret".
You will need:
Apple Distribution
to be able to publish the builds in the store.p12
from xbuild and set the password while doing so.p12
file needs to be base64 encoded before adding it as a GitHub secret.mobileprovision
file and proceed with base64 encoding and configuring the secret in GitHub.ExportOptions.plist
with the following:
<?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>destination</key>
<string>export</string>
<key>provisioningProfiles</key>
<dict>
<key>{application identifier e.g. me.nikl.bevygame}</key>
<string>{name of distribution profile}</string>
</dict>
<key>manageAppVersionAndBuildNumber</key>
<true/>
<key>method</key>
<string>app-store</string>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>{your team id}</string>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
.p8
) and encode the key using base64 before adding it as a secretNow that all required secrets are configured, head over to the "Actions" tab in your repository. Find the workflow in the list on the left, select it and click "Run workflow" in the top right.
The version input of the workflow is only used as GitHub release to upload the artifact. The actual app version is defined in your Info.plist
and needs to be new for the App Store to accept your build.
You cannot easily install the signed ipa on any device without going through the App Store or TestFlight. For macOS apps, there is a different certificate type called "Developer ID Installer" to do so, but there doesn't seem to be something similar for iOS apps.
Workflow runs are free for public repositories on GitHub. If your project is private, it will use build minutes from your allowance (2000 minutes per month on a free account). Know that every minute with a macOS runner is billed as 10 minutes.
In case you are also interested in Android development with Bevy, there is a separate post about a workflow for Android builds.
Thank you for reading! If you have any feedback, questions, or comments, you can find me at @nikl_me@mastodon.online or on the Bevy Discord server (@nikl).