The past few days I was attempting to auto deploy a react native app to App Center using Github actions. This process is the exact same for a fully native app, since we are running the normal xcode / gradle commands. I'll walk you through all the annoying things Apple put me through, so you don't have to. If you only want to build for Android, you can skip to that and be done in a few minutes... iOS setup is much more involved

Contents

iOS

There's not much out there when you search for help with iOS builds, and the only ones you find tell you to use fastlane for cert management. I knew there had to be a way though, even with my naive knowledge of the profiles and certificate logic that iOS app builds have, I know I only needed to download two files in order to build an app on my local machine, so it couldn't be too hard, right?

Before I start, let me quickly rant about Apple. I was able to get it mostly working, then ran into the code signing process prompting for a password and hanging indefinitely inside the build container.... The fix for this is a command that makes no sense, and someone had to reverse engineer Apple's tooling just to find it. It's been almost 4 years and Apple has yet to improve on any of these command line utilities. It's honestly a miracle we are able to make this work at all.

Getting Started

Instead of stepping through each little piece of the config, I'm going to share the final piece of the main config, then explain the parts around it. If you are not using react native, you will need to remove the ios folder prefix from my commands. Place this inside .github/workflows/ios-build.yaml

name: Deploy iOS to App Center

on:
  push:
    branches:
      - master

jobs:
  build:
    runs-on: macOS-latest
    steps:
      - uses: actions/checkout@v1
      
      - name: Install gpg
        run: brew install gnupg
        
      - name: Switch XCode Version
        run: sudo xcode-select -s /Applications/Xcode_11.2.app

      - name: Cache NPM dependencies
        uses: actions/cache@v1
        with:
          path: node_modules
          key: ${{ runner.OS }}-npm-cache-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.OS }}-npm-cache-
      - name: Install yarn dependencies
        run: |
          echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
          yarn --frozen-lockfile
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      - name: Cache Pods dependencies
        uses: actions/cache@v1
        with:
          path: ios/Pods
          key: ${{ runner.OS }}-pods-cache-${{ hashFiles('**/ios/Podfile.lock') }}
          restore-keys: |
            ${{ runner.OS }}-pods-cache-
      - name: Install pod dependencies
        run: |
          cd ios
          pod install
        shell: bash
      - name: Setup provisioning profile
        run: ./.github/secrets/decrypt_secrets.sh
        env:
          IOS_PROFILE_KEY: ${{ secrets.IOS_PROFILE_KEY }}
      - name: Build app
        run: |
          cd ios && xcodebuild archive \
            -workspace appname.xcworkspace \
            -scheme appname \
            -sdk iphoneos13.2 \
            -configuration Release \
            -archivePath $PWD/build/appname.xcarchive \
            IPHONEOS_DEPLOYMENT_TARGET=9.0
            PROVISIONING_PROFILE="UUID HERE" \
            CODE_SIGN_IDENTITY="iPhone Distribution: Some Company (T343ff)"
      - name: Export app
        run: |
          cd ios && xcodebuild \
            -exportArchive \
            -archivePath $PWD/build/appname.xcarchive \
            -exportOptionsPlist $PWD/ci.plist \
            -exportPath $PWD/build
      - name: Deploy to App Center
        run: |
          npm install appcenter-cli@2.3.3
          npx appcenter distribute release --token "${{secrets.APP_CENTER_TOKEN}}" --app "apporg/appname" --group "group-name" --file "ios/build/appname.ipa" --release-notes "$(git log -1 --pretty=format:%s)" --debug

This tutorial is assuming that you have a signing certificate and provisioning profile for the app on you machine already. Also, I'm doing an ad-hoc build.

Install GPG

I followed the official guide from github on how to create secrets that are too large to be stored as plain text. Unfortunately, they fail to mention that gpg isn't installed on the Mac OS images, but it is on all the others. There's an open issue about getting this added to the Mac OS image itself. This would be great because it takes about 2.5 minutes just to install gpg. We will be using it to encrypt our provisioning profile and signing certificate.

Add Cert / Profile to the repo

Here's what I did first. Open up your app in xcode, go to Build Settings under your app target, and find Signing

Hover over the release profile, and to the right of it you should see Profile Name (uuid)

Copy this uuid. We need it to get the profile and it's also needed in a later step.

Open up a terminal window inside of your app's directory. Make a folder called .github inside it create workflows and secrets

Next, run this:

cat ~/Library/MobileDevice/Provisioning\ Profiles/UUID.mobileprovision >> profile.mobileprovision

I know, so weird that I didn't use cp. I was thinking the same thing. Anyways, be sure to paste the UUID from xcode so that you get the right one.

Next, we need to encrypt it, this way other people can't access the profile. But first, we need to grab the signing certificate as well.

Inside xcode, go to preferences -> accounts -> click the account you're using -> manage certificates -> right click on the cert you are using, and click export.

Don't give it a password. Put it inside the app folder as well, it should be named Certificates.p12

Encrypt Certificate and Profile

Encrypting them is easy, make sure you have gpg installed. On Mac, you can run brew install gpg, linux machines should have it already installed. From a terminal window inside your app:

gpg --symmetric --cipher-algo AES256 Certificates.p12
gpg --symmetric --cipher-algo AES256 profile.mobileprovision

For both commands, I used a randomly generated password. I recommend using the same password for both files.

Immediately after you finish running the command for both files, open up the settings page for your repo on github, go to secrets, and create a new one called IOS_PROFILE_KEY and the value is the password you just used for gpg. Next, delete Certificates.p12 and profile.mobileprovision. We don't want to accidentally commit these since they are unencrypted.  

Lastly, move Certificates.p12.gpg and profile.mobileprovision.gpg to the  .github/secrets folder.

Decrypt and Install Certs on the server

We're almost there with this gpg stuff, I promise. Create .github/secrets/decrypt_secrets.sh and put the following inside it:

#!/bin/sh

gpg --quiet --batch --yes --decrypt --passphrase="$IOS_PROFILE_KEY" --output ./.github/secrets/profile.mobileprovision ./.github/secrets/profile.mobileprovision.gpg
gpg --quiet --batch --yes --decrypt --passphrase="$IOS_PROFILE_KEY" --output ./.github/secrets/Certificates.p12 ./.github/secrets/Certificates.p12.gpg

mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles

cp ./.github/secrets/profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/86ce4d81-fd7e-46b2-ae6a-3092b4af6cd7.mobileprovision


security create-keychain -p "" build.keychain
security import ./.github/secrets/Certificates.p12 -t agg -k ~/Library/Keychains/build.keychain -P "" -A

security list-keychains -s ~/Library/Keychains/build.keychain
security default-keychain -s ~/Library/Keychains/build.keychain
security unlock-keychain -p "" ~/Library/Keychains/build.keychain

security set-key-partition-list -S apple-tool:,apple: -s -k "" ~/Library/Keychains/build.keychain

First off, we decrypt both files using the secret key we just added to github. Next, we copy the mobile provision into the same location that it existed on our local machine. Funny enough.... I never saw this anywhere because I could only find people using fastlane. It was just a lucky guess that this is the correct procedure.

After that is the weird stuff... We create a new keychain on the build server. This might not be necessary, but through my trials and errors I ended up doing it. The problem I initially ran into was xcode building correctly, but hanging forever because the codesign tool would prompt asking for a password, clearly we can't go into the UI and hit the enter key (the password is blank).

The final line, set-key-partition-list, is the most important part that fixes the issue I described above. Even though nobody seems to really understand the meaning of this command. You can read more about it on this SO post I found. The commenter seemed to give more info than Apple does about this whole process, so I followed them fully, by including the new keychain part. Do as you wish, but the config I have works :)

Make sure you run chmod +x .github/secrets/decrypt_secrets.sh before you commit any of this.

Back to the actions

With most of the setup out of the way, let me continue explaining the actions file I posted above. Please have it open while reading along below.

Selecting xcode version

After installing gpg, we can select an xcode version. This may be really outdated by the time you're reading it. Github has a nifty docs page that is about a mile long. Here's the section on xcode. This way you can adjust the config if you need to use a different version of xcode or the iOS sdk.

npm / yarn

Next step is caching the npm dependencies then running yarn install. You can remove it if you're doing a fully native app. If you are using React Native, this will cache the node_modules folder until the yarn.lock changes. If you use npm, simply change yarn.lock to package.json

You'll notice my yarn install step is setting an NPM_TOKEN value. My app uses private npm packages so this piece of code is needed for it to download them.

Pods

Very similar to npm / yarn step, this will cache all the pods until the lock file changes. Very self explanatory.

Build app

Okay, our last couple things to configure...

cd ios && xcodebuild archive \
            -workspace appname.xcworkspace \
            -scheme appname \
            -sdk iphoneos13.2 \
            -configuration Release \
            -archivePath $PWD/build/appname.xcarchive \
            IPHONEOS_DEPLOYMENT_TARGET=9.0
            PROVISIONING_PROFILE="UUID" \
            CODE_SIGN_IDENTITY="iPhone Distribution: Company Name (RT483843)"

Replace appname with the actual app name you see in xcode. Replace UUID with the mobile provision uuid that we copied over in the gpg encryption step. There may be an easier way, but here's how I got the CODE_SIGN_IDENTITY value... Inside xcode, with the project open, go to General and click the little i icon next to the Signing (Release) section. It should be spelled out under the Certificates included section. I hope this helps. If you still can't find it, the next step will have you do a build inside xcode, and the outputted plist file should have it inside.

Exporting

cd ios && xcodebuild \
-exportArchive \
-archivePath $PWD/build/appname.xcarchive \
-exportOptionsPlist $PWD/ci.plist \
-exportPath $PWD/build

Replace appname with the app's name one more time, this command actually spits out the .ipa file. To get the exportOptionsPlist, inside xcode, do a product -> archive of your app. Choose distribute, choose ad-hoc. And then export it to your desktop. Now copy the file inside it, ExportOptions.plist to appname/ios/ci.plist. It's almost all set for you, but we have to add the following BEFORE ApplicationProperties

 <key>method</key>
    <string>ad-hoc</string>
		<key>compileBitcode</key>
    <false/>
    <key>provisioningProfiles</key>
    <dict>
        <key>com.cnn.marvel</key>
        <string>UUID of provisiong profile</string>
    </dict>

Now it should be good to go and successfully build...

Deploy it!

npm install appcenter-cli@2.3.3
npx appcenter distribute release --token "${{secrets.APP_CENTER_TOKEN}}" --app "org/appname" --group "groupname" --file "ios/build/cnnrnv2.ipa" --release-notes "$(git log -1 --pretty=format:%s)" --debug

The ipa is here: ios/build/cnnrnv2.ipa

Which means it's up to you what to do with it. The Mac OS environment doesn't support docker actions on github, most actions won't work. If there's not an existing action for the platform you're using, you will have to make a node module and deploy it to npm like appcenter does.

You'll see that I am deploying to app center. They offer a node cli, so I install it, and then run the distribute command with the path to the ipa file. Take note of this part: --release-notes "$(git log -1 --pretty=format:%s)" this attaches the commit to the release notes. Might be useful to pass this git log command for any service you may be using.

Android

This is much quicker than iOS. Here's the config. Place it in .github/workflows/android-deploy.yaml

name: Deploy Android to App Center

on:
  push:
    branches:
      - master
jobs:
  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: set up JDK 1.8
        uses: actions/setup-java@v1
        with:
          java-version: 1.8
      - uses: actions/setup-node@v1
        with:
          node-version: 12
          registry-url: https://registry.npmjs.org/
      - name: Cache NPM dependencies
        uses: actions/cache@v1
        with:
          path: node_modules
          key: ${{ runner.OS }}-npm-cache-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.OS }}-npm-cache-
      - name: Install yarn dependencies
        run: |
          echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
          yarn --frozen-lockfile
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      - name: Build for Android
        run: cd android && ./gradlew assembleRelease
      - name: Deploy to App Center
        uses: zackify/AppCenter-Github-Action@1.0.0
        with:
          appName: org/appname
          token: ${{secrets.APP_CENTER_TOKEN}}
          group: group name
          file: android/app/build/outputs/apk/release/app-release.apk

Thats.... seriously it. So much easier than iOS. Not much setup is required here, but I'll go over the file.

Setup

The first three actions are setup. Checking out the code, adding in node and java. If you are not using react native you may remove the node action and yarn install step.

Caching npm dependencies

As explained in the iOS tutorial, this will hash the yarn.lock file (change to package-lock.json if using npm) and restore the node_modules each time it is ran, if the lock hasn't changed.

Install yarn dependencies

This step adds my NPM_TOKEN secret to let yarn access private npm packages, otherwise its a basic yarn install command.

Build for Android

Builds and spits out the apk for us! Standard gradle.

Deploy

Please reference the deploy section for iOS. The only difference is the nicely built action that we are able to use, because linux supports all docker based actions.

Conclusion

I hope I didn't miss anything. I tried my best to reference issues and articles I found along the way. If you get stuck during any of this, or have other solutions, please reach out on twitter @zachcodes so I can update the article. I really wish Apple would invest in making their command line tooling better. I don't mind having to encrypt the cert and profile, but let me pass them in to the build command as arguments... Also, why do we need to create this extra plist file just to export? Give me an ad-hoc build command and remove the duplication of setting the profile uuid in every command... Oh well...

At least we have a way to make it work! Overall I'm happy with the outcome, just shocked at how Apple doesn't bother making any of this an easy process. Github has definitely taken away much of the pain.