macOSFastlaneVersioningSemVerXcode

macOS CI/CD with Fastlane (part 4): auto-versioning and QA vs Release lanes

With certificates in the Keychain and notarization explained, time to dig into what Fastlane actually does. In this part I break down the repo’s Fastfile piece by piece: how versions get handled, what separates the qa lane from the release lane, and how the skip_publish flag lets you rehearse the full pipeline without publishing yet.

Two versions macOS distinguishes

Every Apple app carries two distinct numbers:

  • MARKETING_VERSION (CFBundleShortVersionString) — the “marketing” version, visible in About, the App Store, the website. Follows SemVer: 1.0.1, 2.0.0, etc.
  • CURRENT_PROJECT_VERSION (CFBundleVersion) — the build number, an integer that increases monotonically with every distributable compilation. 1, 2, 3, 123

macOS uses them differently. When the user sees “Pokédex 1.0.1”, that’s marketing. The build number matters when you publish two different binaries of the same marketing version (a hotfix, say): macOS picks the one with the higher build. And on the App Store the rule is even stricter —every upload to ASC requires a new build number.

The repo’s project.yml base values:

settings:
  base:
    MARKETING_VERSION: "1.0.0"
    CURRENT_PROJECT_VERSION: "1"

Those are the initial defaults. From there, Fastlane moves them.

How Fastlane touches them

Two actions do the work:

increment_version_number(xcodeproj: PROJECT, version_number: version)
bump = increment_build_number(xcodeproj: PROJECT)

increment_version_number sets the marketing version to the value you pass. That’s why the release lane takes it as a parameter (options[:version]): marketing is your decision, not the pipeline’s.

increment_build_number increments the build number by one. It doesn’t take a value —it reads the current one from the .xcodeproj and bumps it. Returns the new number, stored in bump so it can be logged or used in the commit.

After running, Fastlane writes the new values directly into project.pbxproj. That means the bump lands as a git change —hence the release lane running commit_version_bump later to leave it committed.

The qa lane — fast, signed, unnotarized

The repo’s qa lane, in full:

lane :qa do
  ensure_git_status_clean

  bump = increment_build_number(xcodeproj: PROJECT)
  UI.message "New build number: #{bump}"

  build_mac_app(
    project:          PROJECT,
    scheme:           SCHEME,
    configuration:    "Release",
    output_directory: BUILD_DIR,
    output_name:      "#{APP_NAME}-QA",
    export_method:    "developer-id",
    clean:            true,
    skip_package_pkg: true
  )

  UI.success "✅ QA build ready at #{BUILD_DIR}/#{APP_NAME}-QA.app"
  UI.important "This build is SIGNED but NOT notarized. Internal use only."
end

Four things worth noting:

ensure_git_status_clean — aborts the lane if there are uncommitted changes. Fastlane is about to touch the .xcodeproj (the build number), and if you mix that change with others you were editing, the bump commit gets contaminated. Abort at the start, not halfway.

Only bumps the build number, not marketing — because QA doesn’t change the public version. You’re doing internal builds of the same version.

export_method: "developer-id" — tells build_mac_app to sign with Developer ID Application (not with an App Store distribution cert). This anchors the signing we discussed in part 3.

No notarize — deliberate. Notarizing takes 30 seconds to 5 minutes. For internal builds that will only run on Macs where the certificate is already in the Keychain, it’s not worth the wait. The build comes out signed, and for you as the developer with the cert installed, it opens without problems. If another dev on your team wants to test it, the first time they’ll have to right-click → Open. That’s acceptable friction for internal builds.

The release lane — the full pipeline

The release lane is longer because it does everything. Here it is with annotations:

lane :release do |options|
  version = options[:version] || UI.user_error!(
    "Missing version:. Example: fastlane release version:1.0.1"
  )
  skip_publish = options[:skip_publish] || false

  ensure_git_status_clean
  ensure_git_branch(branch: "main")

Starts stricter: on top of a clean git tree, it demands you be on main. An official release doesn’t ship from a feature branch. Discipline is enforced by the Fastfile, not by memory.

  # 1. Marketing version from parameter. Build number auto-increments.
  increment_version_number(xcodeproj: PROJECT, version_number: version)
  bump = increment_build_number(xcodeproj: PROJECT)

Marketing = what you said in the command. Build = auto-incremented. This order matters: if something crashes after this, your project.pbxproj is modified locally but the commit hasn’t happened yet, so git checkout project.pbxproj gets you back to clean.

  # 2. Build signed with Developer ID Application.
  build_mac_app(
    project:          PROJECT,
    scheme:           SCHEME,
    configuration:    "Release",
    output_directory: BUILD_DIR,
    output_name:      APP_NAME,
    export_method:    "developer-id",
    clean:            true,
    skip_package_pkg: true
  )

  app_path = "#{BUILD_DIR}/#{APP_NAME}.app"

Identical to the qa lane except for output_name. Here the binary is named “Pokédex.app”, not “Pokédex-QA.app”. That’s the only effective difference in the build phase.

  # 3. Notarization with notarytool (clean API Key, no passwords).
  notarize(
    package:      app_path,
    bundle_id:    BUNDLE_ID,
    api_key_path: ENV["APP_STORE_CONNECT_API_KEY_PATH"],
    print_log:    true,
    verbose:      true
  )

The step the qa lane doesn’t have. Part 3 covered it in detail. Fastlane’s notarize also runs stapler staple automatically when Apple approves.

Then the DMG, the spctl verification, and then the decision point:

  if skip_publish
    UI.important "skip_publish=true → stopping before tag and GitHub Release."
    UI.success "DMG ready at #{dmg_path}. Publish manually when you want to."
    next
  end

skip_publish — the “rehearsal” mode

This flag is the one I use most during development. When you enable it:

bundle exec fastlane release version:1.0.1 skip_publish:true

The whole pipeline runs —build, sign, notarize, staple, DMG, spctl— but the lane ends before the bump commit, the tag, and the GitHub Release. When done, you have a DMG ready, notarized, signed, at build/Pokédex-1.0.1.dmg. You can:

  • Install it on a clean Mac and confirm it opens without friction.
  • Share it with testers before deciding to publish.
  • Review the notarize output for warnings worth addressing.

If anything goes wrong in the pipeline, the .xcodeproj is modified locally (version and build bumps) but there’s no commit, no tag, no Release. git checkout PokedexMac.xcodeproj gets you back to clean to retry.

When you’re truly ready, run without the flag:

bundle exec fastlane release version:1.0.1

And now the publishing part runs.

The publishing part

After the skip_publish if, the lane finishes with:

  commit_version_bump(
    xcodeproj: PROJECT,
    message:   "chore(release): v#{version} (build #{bump})"
  )
  add_git_tag(tag: "v#{version}")
  push_to_git_remote(tags: true)

  changelog = read_changelog_section(version: version)

  set_github_release(
    repository_name: GITHUB_REPO,
    api_bearer:      ENV["GITHUB_TOKEN"],
    name:            "v#{version}",
    tag_name:        "v#{version}",
    description:     changelog,
    commitish:       "main",
    upload_assets:   [dmg_path]
  )

Those are steps 6 and 7. I break them down in part 5, the last one: how the DMG is built with hdiutil, how read_changelog_section extracts the [Unreleased] section of CHANGELOG.md, and what exactly set_github_release does.

A pattern behind the two lanes

If you look at the general structure, there’s a deliberate symmetry:

  • qa is a subset of release.
  • Both start with ensure_git_status_clean.
  • Both run build_mac_app with export_method: "developer-id".
  • The main difference is: release notarizes, packages, publishes.

That makes the qa lane the fastest way to catch pipeline issues before spending time on notarization. If the app has a signing or entitlements problem it will fail in qa too, but without the 3-minute round-trip to Apple.