Releasing macOS applications
The first challenge was – what do I even release? When building macOS apps, you have a couple of export options (pretty similar to iOS). You could upload it to the app store (after signing the app) or you could just generate a.app
file and share it with everyone. I’ve decided to go with the second option, just because it was simpler and didn’t require creating a developer account (which costs money…).
Xcode has a cli tool,
xcodebuild
which can be used in CI to build, test, and export the app in the desired format. While this works, it’s a bit tricky to configure. So, to skip a lot of pain, it’s a lot easier to use Fastlane. Fastlane is a tool for building CI/CD pipelines (or “Lanes”) for iOS, Android, and macOS applications.
Building with Fastlane
Fastlane can build macOS applications for us, with very few lines (see the docs for more details). All that is needed is simpleFastfile
(in the fastlane
folder) with the following content:
lane :release do
gym(scheme: "<your schema>")
end
And you’re done! Now if you’ll run fastlane release
it will build and export an .app
file for you. Easy, right? Adding a CI is also simple with Github Actions. Create a new file
macos-build.yml
under .github/wrokflows
folder with the following content:
name: Fastlane Workflow
on:
push:
branches: [ main ]
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Release MacOS app with Fastlane
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/[email protected]
with:
fetch-depth: 0
- uses: ruby/[email protected]
with:
ruby-version: '2.7.2'
- uses: maierj/[email protected]
with:
lane: 'release'
Commit and push the changes – and now you have a CI / CD pipeline: Each time a code is pushed to the main branch, Github action will build and export the app for you. While this is nice, it is not enough. Let’s add some more good stuff to make our life easier.
Automatic versioning using Semantic Release
Semantic Release is a tool that analyzes the commits to decide how to increase the version. It is using well-known conventions – for example, thefix
prefix indicates a bug fix, so it will increase the patch part of the version. The feat
prefix indicates a feature so it will increase the minor part of the version. See the doc for more details.
Fastlane has a lot of plugins, and it has one for semantic releases. This plugin introduces 2 new actions – one for analyzing the release and getting the new version, and one for generating release notes. Let’s update our Fastfile
to use them:
lane :release do
isReleasable = analyze_commits(match: '*')
tag = "v#{lane_context[SharedValues::RELEASE_NEXT_VERSION]}"
gym(scheme: "workstation-statusbar")
end
The match: '*'
is used to select tags – the tool will look for the latest tag matching, and used it as the base version. Then, based on commits since this tag, it will calculate the next version and put it in the shared context – lane_context[SharedValues::RELEASE_NEXT_VERSION].
The isReleaseable
is the results – not all commits justify a release. A special kind of commit, starting with chore indicating changes to the build system, will not change the version or trigger a release.So we have a version and release note, let’s do something with them!
Creating a release
I am using Github, so the easiest way to release the software is by creating a new Release in the repository. Fastlane has built-in action for creating new releases, and using it is really simple. Let’s add the following to ourFastfile
:
if isReleasable
add_git_tag(
tag: tag
)
push_to_git_remote
notes = conventional_changelog(format: 'markdown',
title: '<doesn't matter>',
display_title: false,
commit_url: 'https://github.com/<the org>/<the repository>/commit')
set_github_release(
repository_name: "<the org>/<the repository>",
api_bearer: ENV["GITHUB_TOKEN"],
name: "Release #{lane_context[SharedValues::RELEASE_NEXT_VERSION]}",
tag_name: tag,
description: notes,
commitish: "main",
upload_assets: ["<scheme>.app", "<scheme>.app.dSYM.zip"]
)
else
print("Not release candiate, do nothing")
end
Ok, this is a lot 🙂 Let’s break it down, line by line:The first 3 lines call the built-in function
add_git_tag
to create a new tag using the tag
variable – the one with the next version to release.After creating the tag, we need to push it to the remote git server (Github) using
push_to_git_remote
. We can use another function to generate the release notes –
conventional_changelog
. This function comes from the semantic versioning plugin. It gets some parameters like the format (We want markdown so it will look pretty in Github) and a way to create a link to the original commit. I’ve chosen to hide the title because Github adds one automatically.After completing all the preparations, we can actually create the release using
set_github_release
. You can see that I’m passing in the notes
and tags
we created using the semantic release tool. The last thing is the assets – the files that the users could download from the release. The result is really beautiful:

ENV["GITHUB_TOKEN"]
to Fastlane so it could authenticate to Github. Github actions automatically create a token that we can use for this, so we just need to modify how we call Fastlane and use this token:
- uses: maierj/[email protected]
with:
lane: 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Showing the version to our users
Now that we have a version, it will be nice to show it somewhere in the application – for example, on the about screen. It will allow users to know which version they are using – either to report a bug or to check if they are using the latest version.If you google how to set the version and use it, a lot of docs will point you to
agvtool
and CFBundleShortVersionString
. Fastlane even has a built-in action for that. It seems nice, but apparently, if you’re using XCode 11 or later this will not work. Apple changed how they do versioning and it is no longer stored in Info.plist
.To set the version we’ll use another plugin that can set the version in the project properly. Using it is very simple:
increment_version_number_in_xcodeproj(
version_number: lane_context[SharedValues::RELEASE_NEXT_VERSION] # Automatically increment minor version number
)
This will set the value from semantic release in the code, and we could use CFBundleShortVersionString
it to show the version to our users:
if let text = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
//do something with the versio
}
Are we done? Almost!Creating an “Installer”
If you ever installed an app on your Mac, you’re familiar with how the “Installer” looks: admg
file, that when clicking on it will help you copy the app to the Application
folder. I wanted to create something similar to package my app. There are a lot of tools out there for creating a .dmg
from .app
file, including a Fastlane plugin. While they work, they are not the same – the Fastlane plugin, for example, creates dmg
without the “Installer” experience. There is another NodeJS tool that can create lovely
.dmg
called create-dmg. Using it from Fastlane is really easy:
sh "npx create-dmg ../workstation-statusbar.app", log: true, error_callback: ->(result) {
error_occurred = false
}
I had to add the error_callback
because the tool will show errors if you will not provide signing keys to sign the dmg – but will create it. This tells Fastlane to ignore the non-zero exit code and continue with the rest of the lane.Now that we have a beautiful
.dmg
“installer”, let’s upload it to the release by changing the release action to this:
set_github_release(
repository_name: "snyk/workstation-statusbar",
api_bearer: ENV["GITHUB_TOKEN"],
name: "Release #{lane_context[SharedValues::RELEASE_NEXT_VERSION]}",
tag_name: tag,
description: notes,
commitish: "main",
upload_assets: ["fastlane/<scheme> #{lane_context[SharedValues::RELEASE_NEXT_VERSION]}.dmg", "workstation-statusbar.app.dSYM.zip"]
)
We are almost done, we have one final and very important stage: Debug Symbols!
Uploading Debug Symbols

If you wonder what is the magical
dSYM
file that we upload to the release with the app – these are the symbols created by the compiler. Having them stored with the release allows us to quickly find them and use them when we want to symbolicate a crash report (see this guide to learn how).
While this is working and allows us to easily inspect crashes, it is a painful process. There are modern tools for crash monitoring and analysis like Firebase Crashlytics or Sentry. As part of the CI process we will upload the symbol files, and then the tool can use it to symbolicate the crash for us and just tell us when it happend.
I’ve choosed to go with Sentry, which (of course!) has Fastlane plugin for uploading the symbols files, and is really simple to use:
ENV["XCODE_VERSION_ACTUAL"] = "6"
ENV["CURRENT_PROJECT_VERSION"] = "5"
ENV["MARKETING_VERSION"] = lane_context[SharedValues::RELEASE_NEXT_VERSION]
ENV["PRODUCT_BUNDLE_IDENTIFIER"] = "<scheme>"
ENV["PRODUCT_NAME"] = "<scheme>"
sentry_upload_dsym(
auth_token: ENV["SENTRY_TOKEN"],
org_slug: '<org name in Sentry>',
project_slug: '<project name in Sentry>',
dsym_path: "<scheme>.app.dSYM.zip"
)
You’re probably wonder what are those magical environment variable. If you recall, XCode changed where the version information is stored (see the version part of this articale for more details). Sentry cli by default try to use the old method – by looking for non-existing Info.plist
file. The CLI can also use the new method – if it “thinks” it is running inside XCode (which I’ve leraned by reading the code). This is a known issues, and there is another official workaround for getting the Info.plist
back. I’ve prefered to go with the native XCode solution instead.As you’ll see – we need another variable to authenticate to Sentry. Follow the documentation to create a token with the correct permissions and put it in Github Secrets in the repository. Now update the workflow to look like the following:
steps:
- name: Checkout
uses: actions/[email protected]
with:
fetch-depth: 0
- uses: actions/[email protected]
with:
node-version: '14'
- uses: ruby/[email protected]
with:
ruby-version: '2.7.2'
- name: Install Sentry CLI
run: curl -sL https://sentry.io/get-cli/ | bash
- uses: maierj/[email protected]
with:
lane: 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}
As you can see, we added the SENTRY_TOKEN
and reading it from the secrets. Another addition is the installation of Sentry CLI and setting Node – which is required for the previous step – the DMG creation.The last step is to actually use Sentry SDK – follow the docs for details on how to do so. After doing that, every error or crash will be sent to Sentry, and using the symbols Sentry could symbolicate the crash and show us where it happened. NICE!
Wrapping Up
Wow, that was a LONG journey. Let’s recap what our pipeline does:- Calculating the version using semantic release
- Compiling and creating a
.app
files and symbols - Creating a beutiful
.dmg
“installer” - Uploading symbols to Sentry
- Creating a release with automatic, lovely, release notes