Blog Post

Automating Mac Catalyst Distribution with fastlane

Illustration: 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.

Author
Matej Bukovinski CTO

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.

Share post
Free trial Ready to get started?
Free trial