macOSFastlaneCertificatesApp Store ConnectGitHub

macOS CI/CD with Fastlane (part 2): setup, certificates and credentials

In part 1 I laid out the map: sign, notarize, staple, distribute. Before touching Fastlane, the credentials the pipeline orchestrates need to be ready: the certificate that signs, the API Key that authenticates against Apple for notarization, and the token that publishes to GitHub Releases. This post pulls the four pieces together and gets them ready for the lanes to consume.

Everything below lives only on my Mac. None of it is uploaded to the repo. The only thing that goes into git is a .env.example with placeholders, so anyone cloning knows what they need.

Prerequisites

  • Active Apple Developer account. It costs USD $99 a year and without it there’s no Developer ID.
  • Recent Xcode (I use Xcode 26). This isn’t Fastlane’s requirement; it’s because xcodebuild and notarytool live inside Xcode.
  • Ruby 3.x and Bundler to run Fastlane. rbenv or system Ruby, either works.

In the pokedex-macos repo the first two are already visible in project.yml (deployment target macOS 14, Swift 6) and in the Gemfile (gem "fastlane", "~> 2.230").

The certificate — Developer ID Application vs Mac App Distribution

This is where most people get stuck, so it deserves detail.

Apple issues several types of certificates for macOS developers. The two that get confused are:

  • Mac App Distribution — signs apps that will be submitted to the Mac App Store. That’s its only job. If you try to notarize a .app signed with this certificate, Apple rejects it.
  • Developer ID Application — signs apps distributed outside the Mac App Store. This is the one we need.

They’re generated from the same place at developer.apple.com → Certificates, but they’re different certificates, with different roles, and they don’t substitute for each other. There’s also a third one, Developer ID Installer, which signs .pkg packages when you distribute with an installer instead of a DMG. In this series the flow is a DMG with the .app inside, so we skip it.

To generate the Developer ID Application:

  1. In the Apple Developer portal → Certificates → + button.
  2. Under the Software section, pick Developer ID Application.
  3. Upload a Certificate Signing Request (CSR) generated in Keychain Access. Apple documents it in Create a certificate signing request.
  4. Download the .cer Apple issues, double-click to import it into Keychain.
  5. In Keychain Access, export the pair (certificate + private key) as a .p12 with a password. Store it outside the repo. That .p12 is your backup.

Once it’s in the Keychain, Xcode and Fastlane can sign. In the repo’s project.yml signing is set to automatic:

settings:
  base:
    CODE_SIGN_STYLE: Automatic

With automatic style, Xcode looks in the Keychain for the certificate matching the export_method: "developer-id" that Fastlane passes, and signs on its own.

App Store Connect API Key for notarization

Notarization requires authenticating against Apple. There are three options, worst to best:

  1. Apple ID + password. No longer recommended.
  2. Apple ID + app-specific password. Works but expires and has to be renewed.
  3. App Store Connect API Key. A .p8 file that doesn’t expire unless you revoke it, and it’s what the repo’s Fastfile uses.

Generate it at App Store Connect → Users and Access → Integrations → Keys:

  1. Click + to create a new one.
  2. A descriptive name (e.g. “notarize-local”).
  3. Role: Developer is enough for notarization. Admin isn’t necessary.
  4. Download the .p8. Apple only lets you download it once —if you lose it you have to revoke and regenerate. Keep it somewhere outside the repo. I use ~/Documents/asc-keys/.

Alongside the file, App Store Connect shows two identifiers:

  • Key ID — 10 characters, visible in the key list.
  • Issuer ID — a UUID that’s the same for your whole account.

These three —.p8, Key ID and Issuer ID— are what notarytool consumes. Fastlane wraps them. In the repo’s Fastfile the relevant line is:

notarize(
  package:      app_path,
  bundle_id:    BUNDLE_ID,
  api_key_path: ENV["APP_STORE_CONNECT_API_KEY_PATH"],
  print_log:    true,
  verbose:      true
)

Fastlane reads the Key ID and Issuer ID from inside the .p8 itself if the file has Apple’s default name, AuthKey_<KEY_ID>.p8. No hardcoded IDs.

GitHub Personal Access Token

The last step of the pipeline uploads the DMG to GitHub Releases. That needs a token with write access to the repo.

Two options:

  • Classic PAT with repo scope. Simple, works across all your repos.
  • Fine-grained PAT scoped to the pokedex-macos repo with Contents: Read and write. More secure, and the one I use.

Generate at github.com/settings/tokens. Copy it right when you generate it —GitHub doesn’t show it a second time— and save it for the .env.

How it all sits in the .env

Fastlane automatically loads fastlane/.env if it exists. That file is in .gitignore and never gets committed. The .env.example template does get committed, with placeholders:

APPLE_ID=you@example.com
APPLE_TEAM_ID=ABCDE12345
APP_STORE_CONNECT_API_KEY_PATH=/Users/your-user/Documents/asc-keys/AuthKey_XXXXXXXXXX.p8
GITHUB_TOKEN=ghp_your_token_here

Four variables. APPLE_ID is read by the Appfile for logging, APPLE_TEAM_ID identifies which team the certificate belongs to, APP_STORE_CONNECT_API_KEY_PATH is used by the notarization step, and GITHUB_TOKEN is used by set_github_release.

The 10-character Team ID lives at developer.apple.com/account under Membership.

The .gitignore that protects it all

Before committing anything, this is the .gitignore block that prevents accidents:

# Secrets — NEVER commit
fastlane/.env
fastlane/.env.default
fastlane/AuthKey_*.p8
*.p12
*.mobileprovision

.p12 is the exported certificate with private key, .p8 is the ASC API Key, .mobileprovision doesn’t apply to pure Developer ID but stays there in case explicit provisioning is ever needed.

Quick check

With the four variables set and bundle install done, a quick check:

bundle exec fastlane lanes

Should list the two available lanes:

------ mac ------
mac qa      Build QA signed with Developer ID (unnotarized). ...
mac release Official release: build → notarize → staple → GitHub Release

If that works, the Fastfile parses, Ruby loads, and the environment variables are in scope. Nothing real has been fired yet —that arrives in parts 4 and 5.

What’s next

In part 3 we open the notarization black box: what codesign actually signs, what the hardened runtime is, what entitlements Developer ID requires, how notarytool works with the API Key we just generated, and why stapling isn’t optional.