DEV Community

Christos Karaiskos
Christos Karaiskos

Posted on • Originally published at Medium

Archive and export iOS app with GitHub Actions

This post describes the process and learnings of creating a workflow to automate archiving and exporting of an iOS app. The complete workflow file used is included at the end of the article.

GitHub Actions

GitHub Actions help us automate our software development workflows in the same place we store code and collaborate on pull requests and issues. Workflows are stored in the form of YAML files, under the .github/workflows folder of our repo. They can be created and edited in a repository’s GitHub page, under the Actions tab.

A simple “Build and Test iOS app” workflow

Let’s take a quick look at how a simple workflow file with a single job looks like. Below is a workflow file for building an iOS project and running tests on a single simulator instance:

    name: Build and Test app
    on: 
      push:
        branches: [master]
    jobs:
      build:
        runs-on: [macos-latest]
        env:
          XC_VERSION: ${{ '11.4' }}
          XC_WORKSPACE: ${{ 'MyApp.xcworkspace' }}
          XC_SCHEME: ${{ 'MyApp' }}
        steps:
        - name: Select latest Xcode
          run: "sudo xcode-select -s /Applications/Xcode_$XC_VERSION.app"
        - uses: actions/checkout@v2
        - name: Run Unit and UI Tests
          run: /usr/bin/xcodebuild test -workspace "$XC_WORKSPACE" -scheme "$XC_SCHEME" -destination 'platform=iOS Simulator,name=iPhone 11'

The workflow is named “Build and Test app” and this is how it will appear on GitHub:

How a workflow appears in our GitHub repo

It is triggered on every commit we push to the master branch. It contains a single job, which runs on the latest available macOS instance provided by GitHub. The job consists of three steps: Selecting Xcode version, checking out our repository’s source code, building and testing the app. Three environment variables are defined, which will be available throughout job execution and across all steps. The steps within the single job run sequentially, and their progress and final outcome can be observed through the Actions tab:

Workflow progress

For detailed information on workflow file syntax and step-by-step setup, see the official GitHub docs. For a list of software pre-installed on the macOS runner and installation location of all available Xcode versions see here.

How does exporting a signed .ipa differ?

The creation of an .ipa is a bit more elaborate than building and testing our app due to the requirement for code signing using distribution certificate and provisioning profiles. The fact that GitHub runs our workflow on a fresh instance of the virtual machine each time means that we cannot rely on the ease of Xcode’s automatic provisioning, since we will not be logging into Xcode with our Apple account on each build. Thus, we will rely on manual code signing (Note that this does not mean abandoning our project’s automatic code signing, more on this in the next section).

Manual Code Signing

Let’s assume we are building an iOS app named ‘Test’ which also includes a watch app (3 targets in total) and that so far we have relied on automatic Xcode certificates and provisioning. Let’s set up manual code signing.

First step is to log in to the Apple developer portal and manually setup certificates and provisioning profiles for our app. Note that explicit app IDs will be required for the main app as well as any extensions (e.g. WatchKit extension) if they don’t already exist.

Explicit app ids for Test appExplicit app ids for Test app

We will need to create both development and distribution provisioning profiles; development to archive the app, distribution to export to .ipa — exactly as Xcode does when using automatic code signing. I like to keep everything regarding automation separate, so I use dedicated development and distribution certificates for automation, to be as flexible as possible and allow revocation at any time if needed. I use distinct names for the Common Name during the certificate request to easily distinguish these certs from others. Using these certificates, the respective development and distribution provisioning profiles are then created (one per target — 6 total in this case):

Manual distribution provisioning profiles in developer portalManual distribution provisioning profiles in developer portal

We download all new certificates and make sure they are added to our default keychain. We also download all new provisioning profiles. Let’s say we have all downloaded provisioning profiles inside a “Test provision” folder:

Downloaded provisioning profilesDownloaded provisioning profiles

To be found by Xcode, provisioning profiles need to be stored in ~/Library/MobileDevice/Provisioning Profiles/ and must be named using their UUID, otherwise they are immediately removed from the filesystem if moved there. To install through command line we can use the following install-provision.sh script (needs to be placed and run inside same folder as .mobileprovision files):

    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 script simply looks for the UUID field value in each mobileprovision plist file, renames the file using that UUID and moves it to the desired directory. After invoking the script, the ~/Library/MobileDevice/Provisioning Profiles/ would look like (assuming it was empty before, for simplicity):

Now we can go back to our project in Xcode. To keep the project’s existing automatic code signing untouched (since it makes local development easy), we simply add a new project configuration Automation by duplicating the Release configuration:

Then, in our target’s build settings, we modify the Automation configuration by setting CODE_SIGN_STYLE to manual and PROVISIONING_PROFILE_SPECIFIER to the downloaded profile. If profiles were installed correctly, Xcode will show which profiles are eligible in a dialog. We must select the development ones we created before. This must be done per target, as provisioning profiles differ per bundle id.

Before moving to next steps and command line, we verify that the app can now be archived successfully through Xcode using the new configuration Automation instead of Release.

We also check that it can be exported. We go to Organizer, locate the previously created archive, click Distribute app -> App Store Connect -> Export. We select the distribution certificates and provisioning profiles we created before, like so:

The result will be:

Archiving and exporting through command line

The same archiving can be performed through command line using:

    /usr/bin/xcodebuild archive -workspace "$XC_WORKSPACE" -scheme "$XC_SCHEME" -configuration "$XC_CONFIGURATION" -archivePath "$XC_ARCHIVE_PATH" "OTHER_CODE_SIGN_FLAGS=--keychain '$KEYCHAIN'"

Some parameters might seem redundant (e.g. specifying keychain) but they will make our life easier later when building on the remote machine. Values for the environment variables for our local test project could be:

    XC_SCHEME='Test'
    XC_CONFIGURATION='Automation'
    XC_WORKSPACE='Test.xcworkspace'
    XC_ARCHIVE_PATH='./Test.xcarchive'
    KEYCHAIN='login.keychain'

After creating the archive, the final step is to export it. This requires the distribution certificate and provisioning profiles per target. These are passed to xcodebuild using a property list, i.e. ExportOptions.plist. An easy way to obtain this plist file is by exporting once through Xcode (see previous section). We copy the ExportOptions.plist to a preferred location, we will need it in our repo as well later. The contents of the file look like this:

    <?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](http://www.apple.com/DTDs/PropertyList-1.0.dtd)">
    <plist version="1.0">
    <dict>
     <key>destination</key>
     <string>export</string>
     <key>method</key>
     <string>app-store</string>
     <key>provisioningProfiles</key>
     <dict>
      <key>com.ckaraiskos.test</key>
      <string>Test GitHub Actions Dist</string>
      <key>com.ckaraiskos.test.watchkitapp</key>
      <string>Test GitHub Actions Watch App Dist</string>
      <key>com.ckaraiskos.test.watchkitapp.watchkitextension</key>
      <string>Test GitHub Actions Watch Extension Dist</string>
     </dict>
     <key>signingCertificate</key>
     <string>Apple Distribution</string>
     <key>signingStyle</key>
     <string>manual</string>
     <key>stripSwiftSymbols</key>
     <true/>
     <key>teamID</key>
     <string>Your-Team-ID</string>
     <key>uploadBitcode</key>
     <true/>
     <key>uploadSymbols</key>
     <true/>
    </dict>
    </plist>

Now we are ready to go, using the xcodebuild tool again:

/usr/bin/xcodebuild -exportArchive -archivePath "$XC_ARCHIVE_PATH" -exportOptionsPlist "$XC_EXPORT_OPTIONS_FILE_PATH" -exportPath "$XC_EXPORT_PATH"

The archive path is the one we indicated in the archive command previously. XC_EXPORT_OPTIONS_FILE_PATH points to the ExportOptions.plist path, while XC_EXPORT_PATH is the folder where the .ipa will be placed. E.g.,

    XC_ARCHIVE_PATH='./Test.xcarchive'
    XC_EXPORT_PATH='./artifacts/'
    XC_EXPORT_OPTIONS_FILE_PATH='./ExportOptions.plist'

Certificates and signing on a remote machine

All is well so far, but our commands run on our own machine. The problem is that a GitHub macOS runner does not have our certificates or provisioning profiles installed. We will need to make these available.

Encrypting certificates and provisioning profiles

Before uploading our keys to our repo, we encrypt them on our local machine with a strong symmetric password that will not be committed to the repo. This is also what fastlane match does; the reasoning about security concerns of uploading encrypted certs can be found here. For simplicity, we will store the encrypted files in the same repo as our code.

First, we open the Keychain Access application, click on the keychain which has our certs, go to Certificates, select the two certificate-private key pairs and export all as .p12 (making sure a total of 4 items is exported) using a strong password. We save as certs.p12. Next, we cd to the folder where we have saved our provisioning profiles and create a tar.gz archive:

    tar cvfz provisioning.tar.gz *.mobileprovision

We then use gpg locally to encrypt the files separately using a strong symmetric password for each.

    gpg -c certs.p12
    gpg -c provisioning.tar.gz

The output will be certs.p12.gpg and provisioning.tar.gz.gpg. All 3 passwords (1 for certs export, 2 for encryption) should be added as GitHub Secrets to our repo and not be visible anywhere else. They will be used for decryption later.

Save the encryption and export passwords as secretsSave the encryption and export passwords as secrets

We will refer to them in the workflow file later as:

CERTS_ENCRYPTION_PWD: ${{ secrets.CERTS_ENCRYPTION_PWD }}
PROVISION_ENCRYPTION_PWD: ${{ secrets.PROVISION_ENCRYPTION_PWD }}
CERTS_EXPORT_PWD: ${{ secrets.CERTS_EXPORT_PWD }}

Uploading everything to GitHub

We will need to add the following files to our repo:

  • The encrypted certs.p12.gpg file

  • The encrypted provisioning.tar.gz.gpg file

  • The ExportOptions.plist

The runner will also need to know all the passwords used to decrypt the files and install the certificates to keychain, which it will use directly through GitHub Secrets (we DON’T commit these).

Workflow steps

Our final GitHub workflow will follow the following structure:

  • Select which Xcode to use

  • Checkout source code

  • Create and configure a temporary keychain

  • Decrypt certificates and provisioning profiles

  • Import certificates to the temporary keychain

  • Install provisioning profiles where Xcode can find them

  • Archive the app using Automation configuration

  • Export the app and upload artifacts

We have been through most steps already on our local machine, let’s describe the rest. All environment variables used in the following sections (e.g. $KEYCHAIN) are customizable — take a look at the complete workflow file in the final section to see example values.

Create and configure a temporary keychain

We create a new keychain with empty password, add it to the list of keychains, make it the default one and unlock it. We make sure that it does not auto-lock and that it does not prompt user through UI alerts.

    security create-keychain -p "" "$KEYCHAIN"
    security list-keychains -s "$KEYCHAIN"
    security default-keychain -s "$KEYCHAIN"
    security unlock-keychain -p "" "$KEYCHAIN"
    security set-keychain-settings

Decrypt certificates and provisioning profiles

We decrypt provisioning.tar.gz.gpg and certs.p12.gpg using the same password we encrypted them with (retrieved through GitHub Secrets). Again, we must be careful not to cause a prompt to appear:

    gpg -d -o "$DECRYPTED_CERTS_FILE_PATH" --pinentry-mode=loopback --passphrase "$CERTS_ENCRYPTION_PWD" "$ENCRYPTED_CERTS_FILE_PATH" 
    gpg -d -o "$DECRYPTED_PROVISION_FILE_PATH" --pinentry-mode=loopback --passphrase "$PROVISION_ENCRYPTION_PWD" "$ENCRYPTED_PROVISION_FILE_PATH" 

Import certificates to keychain

Next we use security to import the certificates and private keys to our newly created keychain, providing the password we used on our local machine to export the .p12 as argument (again, through GitHub Secrets):

    security import "$DECRYPTED_CERTS_FILE_PATH" -k "$KEYCHAIN" -P "$CERTS_EXPORT_PWD" -A        
    security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN"

Install provisioning profiles where Xcode can find them

We unpack the provisioning profile tarball and put the provisioning profiles where Xcode can find them. Remember that the machine has no user logged in, in this case we also need to create the provisioning directory itself:

    tar xzvf $DECRYPTED_PROVISION_FILE_PATH
    mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"

Then we rename and copy all provisioning profiles to that location, exactly as we did locally:

    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

Complete workflow file

The complete workflow file can be seen below. For demonstration, there is no extraction of steps to scripts, everything is listed in the same file. Most sections of this file should look familiar by now. There is just one additional step at the end, which simply makes everything under the ./artifacts folder available for download. Notice also that the workflow is not triggered on every push to master, but only whenever we push a tag of a specific format (e.g. v1.0).

name: Archive and export
on:
  push:
    tags: 
      - v*
jobs:
  build:
    runs-on: [macos-latest]
    env:
      XC_VERSION: ${{ '11.4' }}
      XC_SCHEME: ${{ 'Test' }}
      XC_CONFIGURATION: ${{ 'Automation' }}
      XC_WORKSPACE: ${{ 'Test.xcworkspace' }}
      XC_ARCHIVE_PATH: ${{ './Test.xcarchive' }}
      XC_EXPORT_PATH: ${{ './artifacts/' }}
      XC_EXPORT_OPTIONS_FILE_PATH: ${{ './ExportOptions.plist' }}
      ENCRYPTED_CERTS_FILE_PATH: ${{ './certs.p12.gpg' }}
      DECRYPTED_CERTS_FILE_PATH: ${{ './certs.p12' }}
      ENCRYPTED_PROVISION_FILE_PATH: ${{ './provisioning.tar.gz.gpg' }}
      DECRYPTED_PROVISION_FILE_PATH: ${{ './provisioning.tar.gz' }}
      CERTS_ENCRYPTION_PWD: ${{ secrets.CERTS_ENCRYPTION_PWD }}
      PROVISION_ENCRYPTION_PWD: ${{ secrets.PROVISION_ENCRYPTION_PWD }}
      CERTS_EXPORT_PWD: ${{ secrets.CERTS_EXPORT_PWD }}
      KEYCHAIN: ${{ 'test.keychain' }}
    steps:
    - name: Select latest Xcode
      run: "sudo xcode-select -s /Applications/Xcode_$XC_VERSION.app"
    - uses: actions/checkout@v2
    - name: Configure Keychain
      run: |
        security create-keychain -p "" "$KEYCHAIN"
        security list-keychains -s "$KEYCHAIN"
        security default-keychain -s "$KEYCHAIN"
        security unlock-keychain -p "" "$KEYCHAIN"
        security set-keychain-settings
        security list-keychains
    - name: Configure Code Signing
      run: |
        gpg -d -o "$DECRYPTED_CERTS_FILE_PATH" --pinentry-mode=loopback --passphrase "$CERTS_ENCRYPTION_PWD" "$ENCRYPTED_CERTS_FILE_PATH"
        gpg -d -o "$DECRYPTED_PROVISION_FILE_PATH" --pinentry-mode=loopback --passphrase "$PROVISION_ENCRYPTION_PWD" "$ENCRYPTED_PROVISION_FILE_PATH"
        security import "$DECRYPTED_CERTS_FILE_PATH" -k "$KEYCHAIN" -P "$CERTS_EXPORT_PWD" -A
        security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN"
        tar xzvf $DECRYPTED_PROVISION_FILE_PATH
        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
    - name: Archive
      run: |
        mkdir artifacts
        /usr/bin/xcodebuild archive -workspace "$XC_WORKSPACE" -scheme "$XC_SCHEME" -configuration "$XC_CONFIGURATION" -archivePath "$XC_ARCHIVE_PATH" "OTHER_CODE_SIGN_FLAGS=--keychain '$KEYCHAIN'"
    - name: Export for App Store
      run: |
        /usr/bin/xcodebuild -exportArchive -archivePath "$XC_ARCHIVE_PATH" -exportOptionsPlist "$XC_EXPORT_OPTIONS_FILE_PATH" -exportPath "$XC_EXPORT_PATH"
    - name: Upload artifacts
      uses: actions/upload-artifact@v1.0.0
      with:
       name: Artifacts 
       path: ./artifacts

Final Remarks

Testing GitHub Actions has been very fun and I can see how useful it will be in many contexts. I saw many benefits:

  • Integration. Everything contained in a single repo; code, secrets, workflows are now easy to configure and change together. I did not have to think about git or install software at all, everything had already been taken care of. Machines are up-to-date and latest Xcode is available very quickly.

  • Community. Official workflows are starting to emerge and will likely minimize the need to create custom workflows from scratch, especially considering that the product is still in early stage.

Regarding iOS specifically, I would likely not choose GitHub Actions for the following iOS-specific reasons:

  • Unnecessary hassle with manual provisioning. If instead we used in-house CI with a controlled machine, that would allow us to login to Xcode and use automatic provisioning, never having to worry about committing private keys or updating provisioning profiles when a new device is added.

  • Cost. GitHub Actions uses a pay-as-you-go model, and machines running macOS are expensive (1 minute of running on macOS is equal in price to 10 min running on Linux). It feels that serious use (i.e., running unit tests per commit or PR open, UI tests and archiving) would quickly eat through even the paid quota. Furthermore, if we also wanted to include xcarchives in the artifacts folder, it would take few hundred megabytes and there is also quota in that aspect. See billing.

Top comments (0)