Automating Mac Catalyst Distribution with fastlane
At PSPDFKit, we consider ourselves early adopters of Mac Catalyst. We added support for it to our SDK in 2019, just shortly after the technology was first made available, and we also used it to bring our PDF Viewer app to the Mac around the same time.
One of the compromises we had to make to get our products out this early was to accept that we’d have to manually build and distribute PDF Viewer for Mac instead of leveraging our CI. fastlane, our automation tool of choice for those tasks, simply didn’t support Mac Catalyst applications at that time. It took quite a bit of effort, a bunch of fastlane updates, and some help from the fastlane team (thanks Josh) to finally put together a configuration that works reliably for a shared iOS and Mac Catalyst application. In this post, I’ll share some details about our setup, which you can use as inspiration for your own Mac Catalyst projects.
App Builds
Below, you can see the key parts of our PDF Viewer Fastfile
related to building and app distribution for both iOS and Mac Catalyst. We’ll go over the interesting bits section by section:
default_platform :ios require 'dotenv' Dotenv.overload('.env.local') api_key = app_store_connect_api_key( key_id: ENV['APP_STORE_CONNECT_KEY_ID'], issuer_id: ENV['APP_STORE_CONNECT_ISSUER_ID'], key_content: ENV['APP_STORE_CONNECT_API_KEY_B64'], is_key_content_base64: true, in_house: false ) desc 'Synchronizes certificates / profiles using via the App Store Connect API. Optionally creates new ones.' private_lane :match_configuration do readonly = UI.confirm( "Read only? ('y' doesn't create new certificates/profiles... 'n' creates/updates if needed)" ) match(api_key: api_key, readonly: readonly, verbose: true) end require_relative('../../fastlane/actions/update_project_from_match.rb') desc 'Updates project signing settings for manual code signing.' private_lane :update_for_manual_siging do update_project_from_match( project: 'Viewer.xcodeproj', configuration: 'Release', code_sign_style: 'Manual', code_sign_identity: 'Apple Distribution' ) end platform :ios do desc 'Synchronizes certificates / profiles and optionally creates new ones.' lane :sync_signing do match_configuration end desc 'Synchronizes distribution certificates / profiles and updates project settings.' lane :prepare_manual_signing do match update_for_manual_siging end desc 'Builds the application.' lane :compile_app do unlock_keychain(path: 'login', password: ENV['CI_USER_PASSWORD']) build_ios_app end desc 'Builds and uploads a new build to App Store Connect for TestFlight testing.' lane :build_and_upload_app do prepare_manual_signing compile_app pilot(skip_waiting_for_build_processing: false) upload_symbols_to_crashlytics end end platform :mac do desc 'Synchronizes certificates / profiles and optionally creates new ones.' lane :sync_signing do match_configuration end desc 'Synchronizes distribution certificates / profiles and updates project settings.' lane :prepare_manual_signing do match update_for_manual_siging end desc 'Build app' lane :compile_app do unlock_keychain(path: 'login', password: ENV['CI_USER_PASSWORD']) build_mac_app( destination: 'platform=macOS,arch=x86_64,variant=Mac Catalyst', installer_cert_name: '3rd Party Mac Developer Installer: PSPDFKit GmbH (XXXXXXXXXX)' ) end desc 'Builds and uploads a new build to App Store Connect.' lane :build_and_upload_app do prepare_manual_signing compile_app deliver upload_symbols_to_crashlytics end end
The file has three main sections. At the top we have some common helpers, which are then referenced in two platform-specific sections — one for iOS and one for the Mac. The first line defines iOS as the default platform, which means it’ll be used when we omit the platform specifier. If you look closely, you’ll see that the iOS and macOS sections are in fact very similar. Both define the same helpers and really only differ in some configuration options and the choice of final distribution method.
For the sake of brevity, the Fastfile
above omits some less interesting helpers, as well as metadata upload, the latter of which we’ll cover separately in a subsequent section.
API Keys and Environment
The first few lines of our Fastfile
deal with API credentials for App Store Connect access. We recently switched our fastlane configuration from the legacy Apple ID-based system to the official App Store Connect API. By doing so, we avoided issues with 2FA authentication and increased overall reliability of our setup. Fortunately, all the App Store functionality we need can be accessed via the API without issues.
If you want to do the same, I recommend this blog post from Alastair Hendricks to get you started.
API credentials are stored securely on our CI agents and included as environment variables. To allow use of fastlane on local development machines, we import an .env.local
file, which is ignored by Git and can contain secrets like the API keys.
Signing
We use match to manage certificates and provisioning profiles for our production builds. For a time, we tried to instead leverage automatic signing, but it turned out to be more trouble than it’s worth. We want to be in charge of certificate updates and only update them explicitly, so we defined sync_signing
lanes for both iOS and Mac, which in turn use the match_configuration
helper, which needs to be run on a development machine.
You might be confused about the configuration looking the same for iOS and Mac. The conditional configuration here is in our Matchfile
, which is another separate configuration file fastlane can use. By selecting either the iOS or Mac sync_signing
lane, we implicitly pick the corresponding configuration from the Matchfile
:
readonly true type 'appstore' git_url '[email protected]:PSPDFKit/certificates.git' keychain_password ENV['CI_USER_PASSWORD'] for_platform :ios do platform 'ios' app_identifier %w[ com.pspdfkit.viewer com.pspdfkit.viewer.stickers com.pspdfkit.viewer.PDF-Actions ] end for_platform :mac do platform 'catalyst' app_identifier 'com.pspdfkit.viewer' additional_cert_types %w[mac_installer_distribution] end
As you can see, we use the same bundle identifier for the iOS and Mac versions of PDF Viewer. This wasn’t always the case. As early adopters of Mac Catalyst, we initially had to use maccatalyst
-prefixed identifiers on the Mac, which complicated code signing and made sharing in-app purchases difficult. Even though chaining the bundle ID essentially means shipping a brand-new application, we determined that resolving both of those issues was worth the effort. The iOS version also contains a sticker pack and action extension, which is why it lists multiple bundle IDs.
The update_project_from_match
action invoked from prepare_manual_signing
is a custom action specific to our setup. Our projects are configured to use automatic code signing for all build configurations to ease local development and testing. The action modifies the Xcode project to use manual code signing instead. We have to do this instead of setting xcargs
, because they get applied to all targets and cause some resources that don’t need signing to be signed. Simply setting up the Release configuration to always use manual code signing should be the better option for most projects.
Build and Upload
The main entry points for our setup used on CI are the two build_and_upload_app
lanes, which upload the build products generated by the compile_app
lanes.
To build our apps, we use build_ios_app
and build_mac_app
, which are platform-specific aliases for gym, fastlane’s building and packaging helper. The helpers define some platform-specific configuration options. To get things working, we also had to explicitly set some parameters for the Mac version. Everything else is defined in our Gymfile
and will typically be the same for an application that’s distributed to iOS and Mac Catalyst:
scheme 'Viewer' configuration 'Release' clean true export_method 'app-store' buildlog_path 'fastlane/logs' output_directory './'
The distribution step is similar as well, with one key difference: On iOS, we want to make our build directly available for internal TestFlight testing, which is why we use pilot
. On the Mac, TestFlight isn’t available, so we just use deliver
to upload the binary.
Metadata
The same approach to application binary distribution can also be extended to other resources supported by fastlane, such as metadata:
platform :ios do desc 'Updates the text metadata while preserving existing screenshots from App Store Connect.' lane :upload_text do deliver( skip_metadata: false, skip_screenshots: true, skip_binary_upload: true ) end end platform :mac do desc 'Updates the text metadata while preserving existing screenshots from App Store Connect' lane :upload_text do deliver( skip_metadata: false, skip_screenshots: true, skip_binary_upload: true ) end end
Additional options can then be set in a separate Deliverfile
. The key is to set different directories for the iOS app and the Mac app and to follow the usual directory structure for deliver
:
submit_for_review false skip_metadata true skip_screenshots true skip_binary_upload false for_platform :ios do platform 'ios' metadata_path './fastlane/metadata' screenshots_path './fastlane/screenshots' end for_platform :mac do platform 'osx' metadata_path './fastlane/metadata-mac' screenshots_path './fastlane/screenshots-mac' run_precheck_before_submit false end
Use
Invoking fastlane
will display a handy selection UI that lists iOS and Mac OS lanes. To execute one of the platform-specific lanes, we just pass the platform-prefixed lane to the command — e.g. fastlane ios build_and_upload_app
or fastlane mac build_and_upload_app
. If we omit the platform, we use iOS, due to our default_platform
configuration option. The build_and_upload_app
app commands are also exactly what our CI uses.
Conclusion
As you can see, automating Mac Catalyst distribution — something that was tricky to get working in the past — has become pretty straightforward with recent fastlane updates. All it takes are some platform-specific lanes and a few well-placed configuration options. It’s exciting to see how the tooling around Mac Catalyst is evolving as Mac Catalyst is becoming more of a mainstream option for Mac development. In that sense, I hope this article makes it even easier for you to make the decision to give Mac Catalyst a try.
Matej is a software engineering leader from Slovenia. He began his career freelancing and contributing to open source software. Later, he joined Nutrient, where he played a key role in creating its initial products and teams, eventually taking over as the company’s Chief Technology Officer. Outside of work, Matej enjoys playing tennis, skiing, and traveling.