Apple Tree

How To: Awesome CI / CD for macOS apps

In the past week, I was working on building a small utility macOS application. It was pretty fun – Swift is a really nice language. One of the challenges was setting up a CI / CD pipeline: I wanted to create an app everyone could download and use after each commit, automatically. It requires a lot of digging and googling, so I wanted to share what I did – in case you’ll encounter a similar problem…

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 simple Fastfile (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, the fix 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 our Fastfile:
  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:
An example automatic release create by Fastlane
An example automatic release
The last thing we need to do is pass 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: a dmg 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

Worked fine in DEV, OPS problem now | ngeor.com
Every code we write can have bugs. It might even crash in certain situations. When this happens to a server, we can view the logs or inspect the container to debug it. When it happens to a user on their machine, debugging is a bit more tricky. It is even harder for a compiled code – you need to translate the compiled code to the original Swift code so you could see where the error is thrown and try to fix it. This process is called “symbolication” and the translation is possible using a translation file called “symbols” that the compiler produces as part of the compilation process (see this Stack Overflow answer for more details).
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
For me, this is good enough 🙂 Something very important is missing here – tests! I didn’t wrote tests yet (shame!) but running them with Fastlane is easy. That’s it for today – let me know if you find it helpful – or if you enoucnter any issues. I’ll be happy to try and help you out!

Leave a Reply

Your email address will not be published. Required fields are marked *