Managing iOS signing certificates and provisioning profiles in GitHub Actions

Every year, like clockwork, my iOS signing certificates and provisioning profiles expire. And every year, I have to go through the process of regenerating them, encrypting them, and updating the GitHub Actions workflow. Since it is easy to forget the exact commands and steps between renewals, I decided to write them down here.

This guide covers how to securely store your certificates in a repository, set up a temporary keychain on a macOS runner, and sign your builds manually in CI.

The Encryption Pipeline

Instead of keeping unencrypted files in the repository or storing raw certificates in GitHub Secrets, we can check GPG-encrypted files directly into Git.

To set this up, you need a few things from the Apple Developer Portal:

  • An Apple Distribution certificate (exported from Keychain Access on your Mac as a .p12 file).
  • The App Store Provisioning Profiles for your main app and any extensions (like widgets).

1. Encrypt the Certificate

Run this command in your terminal to encrypt the .p12 file:

gpg --symmetric --cipher-algo AES256 --output release/Certificates.p12.gpg path/to/your/certificate.p12

Choose a strong passphrase. This passphrase will be stored as a secret in your repository (for example, IOS_CERT_PASSWORD).

Note: If your passphrase starts with a hyphen (-), do not use the --passphrase flag on the command line because GPG will try to parse it as an option. Instead, omit that flag and GPG will prompt you to enter the passphrase interactively.

2. Archive and Encrypt the Provisioning Profiles

Xcode looks up provisioning profiles using their UUIDs rather than their filenames. To handle this in CI, we pack all .mobileprovision files into a single archive first:

# Navigate to the folder containing your profiles
cd /path/to/profiles

# Archive them (make sure they are in the root of the archive)
tar -czf provisions.tar.gz *.mobileprovision

# Encrypt the archive
gpg --symmetric --cipher-algo AES256 --output /path/to/project/release/provisioning.tar.gz.gpg provisions.tar.gz

Use another passphrase here, which you will save as IOS_PROVISIONING_DECRYPT_PWD in your GitHub secrets.

3. Create the ExportOptions.plist

You also need an ExportOptions.plist to configure how xcodebuild exports the signed .ipa. Here is a basic template:

<?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>compileBitcode</key>
	<false/>
	<key>destination</key>
	<string>export</string>
	<key>method</key>
	<string>app-store</string>
	<key>provisioningProfiles</key>
	<dict>
		<key>your.app.bundle.id</key>
		<string>Your App Profile Name</string>
		<key>your.app.bundle.id.Widget</key>
		<string>Your Widget Profile Name</string>
	</dict>
	<key>signingStyle</key>
	<string>manual</string>
	<key>stripSwiftSymbols</key>
	<true/>
	<key>teamID</key>
	<string>YOUR_TEAM_ID</string>
	<key>thinning</key>
	<string>&lt;none&gt;</string>
	<key>uploadSymbols</key>
	<true/>
</dict>
</plist>

Make sure the keys in the provisioningProfiles dictionary map the exact bundle identifiers to the profile names as registered on the developer portal.

Once configured, base64 encode this file and save it as a GitHub secret called EXPORT_OPTIONS_PLIST:

base64 -i ExportOptions.plist | pbcopy

Configuring the Workflow

Now we configure the GitHub Actions workflow. The main challenge in macOS runners is setting up the signing environment without prompting interactive UI dialogs.

Here are the key steps in the workflow:

1. Configure a Temporary Keychain

Instead of using the default keychain, we create a temporary one for the build. This avoids permission prompts and lets us clean up easily:

- name: Configure Keychain
  run: |
    security create-keychain -p "" "build.keychain"
    security list-keychains -s "build.keychain"
    security default-keychain -s "build.keychain"
    security unlock-keychain -p "" "build.keychain"
    security set-keychain-settings

2. Decrypt and Import Secrets

Next, we decrypt our certificate and provisioning profiles using the GPG passphrases, import the certificate into the temporary keychain, and copy the profiles to the location Xcode expects:

- name: Configure code sign
  env:
    CERTS_PWD: ${{ secrets.IOS_CERT_PASSWORD }}
    PROVISION_ENCRYPTION_PWD: ${{ secrets.IOS_PROVISIONING_DECRYPT_PWD }}
  run: |
    # Decrypt files
    gpg --batch --yes --quiet --decrypt --passphrase="$CERTS_PWD" --output release/certs.p12 release/Certificates.p12.gpg
    gpg --batch --yes --quiet --decrypt --passphrase="$PROVISION_ENCRYPTION_PWD" --output release/provisions.tar.gz release/provisioning.tar.gz.gpg          
    
    # Import certificate
    security import 'release/certs.p12' -k "build.keychain" -P "$CERTS_PWD" -A
    security set-key-partition-list -S apple-tool:,apple: -s -k "" "build.keychain"
    
    # Extract and install profiles by UUID
    tar xzf 'release/provisions.tar.gz'
    mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
    for PROVISION in `ls ./*.mobileprovision`
    do
      UUID=`/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i ./$PROVISION)`
      cp "./$PROVISION" "$HOME/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision"
    done

The security set-key-partition-list command is important because it allows the codesign utility to access the private key in the keychain without prompting for a password dialog.

3. Build and Export

Now we can run the build and export command using xcodebuild:

- name: Build Archive
  run: |          
    xcodebuild -project ./iosApp/iosApp.xcodeproj \
    -scheme iosApp \
    -archivePath $RUNNER_TEMP/app.xcarchive \
    -sdk iphoneos \
    -configuration Release \
    -destination generic/platform=iOS \
    clean archive;

- name: Export ipa
  env:
    EXPORT_OPTIONS_PLIST: ${{ secrets.EXPORT_OPTIONS_PLIST }}
  run: |
    EXPORT_OPTS_PATH=$RUNNER_TEMP/ExportOptions.plist
    echo -n "$EXPORT_OPTIONS_PLIST" | base64 --decode -o $EXPORT_OPTS_PATH
    xcodebuild -exportArchive -archivePath $RUNNER_TEMP/app.xcarchive -exportOptionsPlist $EXPORT_OPTS_PATH -exportPath $RUNNER_TEMP/build

4. Clean Up

Always delete the temporary keychain and profiles when the build finishes, even if it fails:

- name: Clean up secrets
  if: ${{ always() }}
  run: |
    security delete-keychain "build.keychain"
    rm -rf ~/Library/MobileDevice/Provisioning\ Profiles/
    rm -rf release/

Xcode Project Configuration

To make manual signing work on the CI runner, we need to instruct Xcode to use the correct provisioning profiles for release builds. In the Release build configuration of your Xcode project, make sure these settings are set:

CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
DEVELOPMENT_TEAM = YOUR_TEAM_ID;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = YOUR_TEAM_ID;
PROVISIONING_PROFILE_SPECIFIER = "Your App Dev Profile Name";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Your App Dist Profile Name";

By adding the [sdk=iphoneos*] conditional override, Xcode will use the distribution certificates and profiles for device builds (which run in CI), while development simulator builds can fall back to automatic signing and development profiles.

Summary

Setting up iOS code signing in CI requires reproducing manually what Xcode handles automatically on local machines. Once the GPG encryption and temporary keychain setup are configured, the pipeline runs smoothly. When the profiles or certificates eventually expire, you just need to regenerate them, re-run the local encryption commands, and update the repository.

Thanks for reading, until next time ✌🏾