Stanislav Khromov

Looking for building Android bundles with Capacitor? Check out this blog post instead!

In this post we will set up a GitHub Actions workflow for an iOS Capacitor app that will produce an IPA file, ready for upload to Apple via the Transporter app.

At a high level, we will:

  • Set up certificate, identifiers and profiles
  • Configure out GitHub Actions Secrets to use the above
  • Create a GitHub Actions workflow
  • Submit the app to the app store via the Transporter app

Let’s get started! 🤩

Setting up Certificates, Identifiers and Profiles

Compared to Android, building an iOS app will require a bit of work.

First we need to make sure that we have at least one distribution certificate. It’s easiest to do this via XCode by going to Settings > Accounts > Manage certificates > + > Apple distribution

Now that we have a certificate, we need to add a profile. We can go to to the Apple Developer site for profiles and add a new App Store profile.

After this, you’re going to select the App Id, this should be the one specific to your app, not a wildcard one.

Then you select the associated certificate. Since you generated a distribution certificate from XCode in an earlier step, it should show up here.

Name your profile, and then download it, you should get a .mobileprovision file.

Switching your app to manual signing

In order to be able to sign your builds in CI, you need to disable “Automatically manage signing” and set the provisioning profile to be the one you created in the earlier step. After changing this setting in XCode, you will get a diff in project.pbxproj that will need to be committed to GitHub source control.

Setting up the workflow

The first thing you need is to follow the official GitHub guide called Installing an Apple certificate on macOS runners for Xcode development. You don’t need to start creating the YAML file yet, but you do need to perform all the steps to have the variables BUILD_CERTIFICATE_BASE64, P12_PASSWORD, BUILD_PROVISION_PROFILE_BASE64 and KEYCHAIN_PASSWORD set in your GitHub repos secrets under Settings > Secrets and variables > Actions.

When it asks you for the BUILD_CERTIFICATE.p12 file to convert to base64, you need to go back into the certificates page on XCode and export your distribution certificate to get the .p12 file:

You will also need to set a password for this file and specify it as P12_PASSWORD.

When you’re asked for the PROVISIONING_PROFILE.mobileprovision, it’s the provisioning profile that you generated as part of the previous steps we did.

Provided you set all the secrets correctly, under Settings for your repo > Secrets and variables > Actions, it should look something like this:

Now it’s time for our workflow. This workflow is for a fairly typical Capacitor app that is built with npm run build, but I encourage you to go through the file and adjust things such as XCode version and Node.js version to match your project. Save this file under .github/workflows/build-ios.yml

name: Build iOS

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    runs-on: macos-13
    name: Build iOS app
    steps:
      - name: Checkout source
        uses: actions/checkout@v3

      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          # create variables
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # import certificate and provisioning profile from secrets
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

          # create temporary keychain
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          # import certificate to keychain
          security import $CERTIFICATE_PATH -P "$P12_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

      - name: Set up XCode
        uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: 14.3.1

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 19.x

      - name: Install app dependencies
        run: npm install

      - name: Build project app
        run: npm run build

      - name: Capacitor update
        run: npx cap update

      - name: Capacitor copy
        run: npx cap copy

      - name: Build project
        run: xcodebuild -workspace './ios/App/App.xcworkspace' -scheme App -destination generic/platform=iOS -archivePath App.xcarchive archive

      - name: 🍻 Assemble IPA
        run: xcodebuild archive -archivePath App.xcarchive -exportArchive -exportOptionsPlist ./archive.plist -exportPath output -allowProvisioningUpdates

      - name: Upload release bundle
        uses: actions/upload-artifact@v3
        with:
          name: app-ios
          path: output/
          retention-days: 60

      # For use on self-hosted runners, see 
      # https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development
      #- name: Clean up keychain and provisioning profile
      #  if: ${{ always() }}
      #  run: |
      #    security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
      #    rm ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision

There is one more file we have to add called archive.plist, add this file to the root of your project:

<?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>provisioningProfiles</key>
<dict>
    <key>place.appreciation.jar</key>
    <string>Deploy GitHub</string>
</dict>
</dict>
</plist>

The thing you have to change in this file is to replace place.appreciation.jar with your app id, and replace Deploy GitHub with the name of your provisioning profile. I’m not sure why this manual step is required, but if you omit this and use the archive.plist file from the first step of the XCode build, it does not have the correct provisioningProfiles set so you will get the following error:

error: exportArchive: "App.app" requires a provisioning profile with the Associated Domains and Push Notifications features.
        
 Error Domain=IDEProvisioningErrorDomain Code=9 ""App.app" requires a provisioning profile with the Associated Domains and Push Notifications features." UserInfo={IDEDistributionIssueSeverity=3, NSLocalizedDescription="App.app" requires a provisioning profile with the Associated Domains and Push Notifications features., NSLocalizedRecoverySuggestion=Add a profile to the "provisioningProfiles" dictionary in your Export Options property list.}
        
** EXPORT FAILED **

Delivering the file in via the Transporter app

After your build finishes, you can click on the build under your project on GitHub > Actions and download the resulting artifact.

After you extract this zip file, you will have an App.ipa file. Drag this file to the Transporter app and press “Deliver” to send it to Apple.

If you get an error that your build version has already been used, keep in mind you will have to update these manually in XCode via the General settings for your app:

Conclusion

Building your app using only GitHub Actions is possible, but it’s definitely not a straightforward process. There are tools such as Fastlane and of course XCode Cloud that simplifies this process, however my goal was a solution that was not dependent on third party libraries or services.

Good luck on your CI journey!

Photo by James Yarema on Unsplash

Full-stack impostor syndrome sufferer & Software Engineer at Schibsted Media Group

View Comments

  • SteveSteve

    Author Reply

    In the “Assemble IPA” step, is the command correct? Does it actually need another `archive`?


    • Hi Steve! I’m not sure! Feel free to try to remove it and report back – if it works I’d be happy to update the post!


  • FlávioFlávio

    Author Reply

    You can upload the IPA file from the GitHub Action.
    You need to create an app-specific password: https://support.apple.com/en-vn/102654

    – name: Upload IPA to AppStore
    env:
    APPLEID_EMAIL: ${{ secrets.APPLEID_EMAIL }}
    APPLEID_PASSWORD: ${{ secrets.APPLEID_PASSWORD }}
    run: xcrun altool -allowProvisioningUpdates –upload-app –type ios –file ./output/App.ipa –username $APPLEID_EMAIL –password $APPLEID_PASSWORD


Next Post