(TL;DR: By using fastlane match and Github Action we can compile and publish a code-signed iOS / macOS app without ever using a mac in person)

(Part two of my series on code-signing / distributing apps, check Part 1 on Windows)


Introduction

Code-signing on Apple platform has been a requirement for quite some times now, so devs are used to it if you are publishing on that platform. It is, I think, much easier to setup than with Windows but that is due in part with fastlane reducing much of the headaches.

Before you jump in, you need to enroll in the Apple Developer Program which is about $99 per year.


Create Github Repository

Create a Github Repository for your project. Feel free to use the sample repository as a template since it already contains all the workflows files.


Installing fastlane

We need to install fastlane locally on our machine, first makes sure you have ruby installed (personally I prefer via WSL2).

Also you need to makes sure Bundler is installed:

gem install bundler

Now create a file called Gemfile in the root:

source "https://rubygems.org"
gem "fastlane"
gem 'fastlane-plugin-github_action', git: "https://github.com/starburst997/fastlane-plugin-github_action"

Notice that we’re using my fork of joshdholtz/fastlane-plugin-github_action, this is because the published gem is out of date and also because there was an issue preventing us to re-use the same repository for fastlane match again for other projects.

Now run:

bundle install


Create app in App Store Connect

New App
1
  
New App Form
2
  
Create Bundle ID
3
  1. Register your app in App Store Connect, click on the (+) button and select New App. You might create a single app for both platforms.

  2. You’ll be asked to pick a Bundle ID which can be created in the Apple Developer website under the Certificates, Identifiers & Profiles section.

  3. Remember it’s value and pick the Explicit option.

Save secrets

Save these 2 secrets in the github repository for your project (both equals if you registered only one app):

  • IOS_BUNDLE_ID: The iOS bundle ID
  • MAC_BUNDLE_ID: The macOS bundle ID


Generate App Store Connect API Key

New API Key
1
  
New API Key form
2

App Store Connect API Key is the recomended way to sign in using fastlane.

However if you want to also sign macOS app for individual distribution on your website, you will need to use the standard username / password procedure, this is because it requires the Account Holder permission to do so which is not (yet) possible with API Key. We’ll discuss that in the next steps.

  1. Go to your Users and Access page on App Store Connect. Go to the Integrations tab and makes sure to select the section App Store Connect API on the left side panel and the Team Keys tab.

    Takes note of the Issuer ID value.

  2. Create a new Key by clicking the (+) button, give it a name and select the Admin access.

    Takes note of the Key ID and download the generated P8 file (open the file in a text editor, we will save the entire value inside a secret).

(Sadly, Individual Keys won’t work since they are not fully compatible yet with match / sigh but that might change in the future)

Save secrets

Save these 3 secrets in the github repository for your project:

  • APPSTORE_ISSUER_ID: The Issuer ID from above
  • APPSTORE_KEY_ID: The Key ID from your generated API Key
  • APPSTORE_P8: The text content of the downloaded P8 file


Generate Session token

This step is necessary if you need the developer_id certificate to sign “.app” for individual distribution. Technically, if we use that route, we could skip the API Key step as well but these can still be useful, so I recommend doing both steps.

Since Apple requires 2FA, saving the username / password in secrets is not enough, thankfully we can generate a Session Token (valid only for a short time) that we really only need once to save the certificate in the match repository (and in the future when that certificate expires).

Takes note of both your Apple ID username and password.

Generate a Session Token using fastlane spaceauth:

fastlane spaceauth -u YOUR_USERNAME

Copy (right-click in most terminal) the generated value (without the export FASTLANE_SESSION= prefix).

Save secrets

Save these 3 secrets in the github repository for your project:

  • FASTLANE_USER: Your Apple ID username
  • FASTLANE_PASSWORD: Your Apple ID password
  • FASTLANE_SESSION: The value of fastlane spaceauth


Create the fastlane match repository

The fastlane match command can save all of your Apple certificates / profiles inside a private git repository for convenience and use in a CI environment.

Create a new empty PRIVATE github repository (any name you want).

Save secrets

Save these 2 secrets in the github repository for your project (not the newly created one for match):

  • MATCH_REPOSITORY: Your newly created private repo (ex: starburst997/fastlane-match)
  • MATCH_PASSWORD: A unique password of your choice (save it in your password manager for re-use in other projects)


Generate a Personal Access Token (PAT)

Generate New Token (Classic)
1
  
Use repo score
2

We also need to generate a Personal Access Token for Github.

  1. Visit your settings page and click on Generate new token and select Generate new token (classic).

  2. We need all the repo scope enabled. Set no expiration. Click Generate token and save the value.

Save secrets

Save this secret in the github repository for your project:

  • GH_PAT: The value of your newly generated token


Additionals secrets

Also add these 3 secrets to your github repository:


Secrets reviews

Makes sure you have all of these 14 secrets in your github repository:

  • IOS_BUNDLE_ID
  • MAC_BUNDLE_ID
  • APPSTORE_ISSUER_ID
  • APPSTORE_KEY_ID
  • APPSTORE_P8
  • FASTLANE_USER
  • FASTLANE_PASSWORD
  • FASTLANE_SESSION
  • MATCH_REPOSITORY
  • MATCH_PASSWORD
  • GH_PAT
  • APPLE_DEVELOPER_EMAIL
  • APPLE_CONNECT_EMAIL
  • APPLE_TEAM_ID

(Save those variables inside a Password Manager for re-use in future projects, except for the bundle ids, they won’t change)


Create Fastfiles

By using import_from_git we can reference external Fastfile files, but feel free to copy the original instead to fit your pipeline better.

fastlane/Appfile

for_platform :ios do
  apple_dev_portal_id(ENV['APPLE_DEVELOPER_EMAIL'])
  itunes_connect_id(ENV['APPLE_CONNECT_EMAIL'])
  team_id(ENV['APPLE_TEAM_ID'])
  itc_team_id(ENV['APPLE_TEAM_ID'])
  app_identifier(ENV['IOS_BUNDLE_ID'])
end

for_platform :mac do
  apple_dev_portal_id(ENV['APPLE_DEVELOPER_EMAIL'])
  itunes_connect_id(ENV['APPLE_CONNECT_EMAIL'])
  team_id(ENV['APPLE_TEAM_ID'])
  itc_team_id(ENV['APPLE_TEAM_ID'])
  app_identifier(ENV['MAC_BUNDLE_ID'])
end

fastlane/Fastfile (iOS / macOS)

import_from_git(
  url: "https://github.com/starburst997/apple-code-sign.git",
  branch: "v1",
  path: "fastlane/iOS/Fastfile"
)

import_from_git(
  url: "https://github.com/starburst997/apple-code-sign.git",
  branch: "v1",
  path: "fastlane/macOS/Fastfile"
)


Create workflows

By using workflow_call we can simplify the workflow file by referencing an external one, but feel free to copy the original instead to fit your pipeline better.

Save those inside the .github/workflows directory of your repository.

apple_setup.yml (original)

name: Apple Setup

on: workflow_dispatch

jobs:
  setup:
    uses: starburst997/apple-code-sign/.github/workflows/apple_setup.yml@v1
    secrets: inherit
    with:
      generate_macos: true
      generate_ios: true
      generate_appstore: true
      generate_developer_id: true
      use_session: true

ios.yml (original)

name: Build iOS

on: 
  workflow_dispatch:

jobs:
  build:
    uses: starburst997/apple-code-sign/.github/workflows/ios.yml@v1
    secrets: inherit
    with:
      project_path: iOS
      xcodeproj_path: Test.xcodeproj
      plist_path: Test/Info.plist
      project_target: Test
      version: "2025.1"
      artifact: false

macos.yml (original)

name: Build MacOS

on: 
  workflow_dispatch:

jobs:
  build:
    uses: starburst997/apple-code-sign/.github/workflows/macos.yml@v1
    secrets: inherit
    with:
      project_path: macOS
      xcodeproj_path: Test.xcodeproj
      plist_path: Test/Info.plist
      project_target: Test
      version: "2025.1"
      artifact: true
      generate_appstore: true
      generate_developer_id: true
      generate_dmg: true

Notice that we need to specify the project’s path, target, plist and version.

If you want to upload the artifact to use in your workflow (ex; upload to S3 afterward), set artifact: true. The artifact name for iOS is build-ios and for macOS is build-macos.


Initialize fastlane match

Go into the Actions tab of your project’s github repository and run the action Apple Setup (disable use_session if you want to use API Key).

Notice that your match repository will now be populated with the certificates and profiles for your app, it will also save the deploy key as a secret inside your repo.

In the future (in a year), you might want to run again to renew any expired certificates. The Developer ID Application will need to be renewed in ~5 year.


Build and Distribute App

Now you can manually run the action Build iOS and Build Mac to build your app and have it uploaded to Testflight. If you’re satisfied with your builds, you can then use those to publish on the AppStore inside App Store Connect.

The workflow will also automatically increment the build number and save it as a variable in the repository.

I’ve also included a release workflow as an example.

The macOS builds also includes an optional .dmg and .app.zip ready to be hosted, both are notarized and stapled.


Conclusion

You can now build and distribute an iOS or macOS app without owning a mac. Of course, you’ll want one to properly develop and test your apps. However, in the age of cross-platform development, if your app runs well on one platform, it should works well on Apple’s devices too and being able to build those on Github Action lower the barrier cost a lot.