Indie Mac App DevOps With GitHub Actions
Are you thinking about writing an app for macOS? Would you like to distribute it outside the Mac App Store using Homebrew? In this article, I describe how to use GitHub and GitHub Actions to set up continuous integration, delivery, and deployment for a Mac app signed with an Apple Developer ID. Best of all, if the app is open source, everything I describe is completely free to use! (If your app isn’t open source but is hosted in a private repository on GitHub, you can still do this for free, subject to limits.)
Why Is This Hard?
It used to be that you could just compile your code, package it in an archive or installer, put it on a site for download, and expect people to download it — not anymore. Thanks to the spread of malware, Apple has added restrictions to the operating system to prevent untrusted code from running. These restrictions complicate building and packaging. Furthermore, users who’ve used Homebrew expect to be able to install and keep up-to-date software from the command-line. This expectation complicates distribution, as well.
Since 2012, the Mac operating system has included Gatekeeper, a feature that requires apps to have been signed with Apple-provided cryptographic certificates in order to run. The most recent version of macOS has even begun to require apps that are distributed outside of the Mac App Store to have been notarized by Apple in order to run, a process which requires you to upload your app to Apple prior to release and then wait for a response.
There are many reasons to use the Mac App Store for distribution — for most apps, it’s probably the right place. But if you want to use APIs that aren’t allowed in the Mac App Store, or you don’t want to have to deal with the review process, then it can make sense to opt-out, and find a different distribution channel. I’m going to show you how.
Our Ideal DevOps Workflow
Our ideal DevOps workflow is to have our code automatically tested on every commit and automatically built and published for distribution on every release tag. There should be no manual steps between us pushing changes and users having access to them.
In this case, when we say “published for distribution,” we mean that the new version of the app can be installed using Homebrew. To accomplish this we will host our own Homebrew tap on GitHub, and host our built app on GitHub as a release asset.
The Six Manual Steps
In the context of software deployment, work that tends to be “manual, repetitive, automatable, tactical, devoid of enduring value, and that scales linearly” is known as toil. There are six manual steps we must perform to release our app for distribution after committing changes, tagging them, and pushing them to GitHub — and they are all toil:
- Build the app for release.
- Sign the app using an Apple Developer ID certificate.
- Package the signed app into a disk image.
- Notarize the disk image.
- Upload the notarized disk image to GitHub as a release asset.
- Update the Homebrew tap with the new app version and SHA256 values.
Say Goodbye to Toil
What do we do with toil? We automate it away! The end result will be that whenever we push a tag to GitHub, the app will be automatically built, signed, packaged, notarized, uploaded as a GitHub release asset, and our Homebrew tap will be automatically updated to point to the new version. The whole process will take just a few minutes.
The automation is a mix of continuous integration (CI) and continuous deployment (CD), implemented using a GitHub Actions workflow.
The first piece of the automation we create is continuous integration: building and testing our app after each commit. To accomplish this we run
xcodebuild test in the GitHub Actions environment on a macOS host.
Note: Even if your app doesn’t include tests you can still build your app on every commit and get value out of continuous integration. In that case, use the
build action instead of the
test action with
xcodebuild in the last step of the workflow.
By putting the following GitHub Actions workflow in our project workspace at
.github/workflows/master_test.yml we enable continuous integration:
This workflow runs every time we push new commits to the
master branch. It includes a single job that runs on a macOS host. The job includes three steps.
First, the ref of the commit that triggered the workflow will be the ref checked out.
Then, the default scheme of the project is determined and set as an environment variable. (This approach may seem like overkill at this point — we could just hard-code our project’s default scheme name, after all — but I’m introducing this pattern now because we will use it again in the next workflow, and it allows this workflow to be re-used without modifications with other projects.)
Finally, the project is built and tested using
xcodebuild test, and the output is piped through
xcpretty, a handy open-source tool that comes pre-installed on the GitHub Actions runners. We override the
CODE_SIGN_IDENTITY build setting with the value “
—” (this value is called “Sign to Run Locally” in Xcode) to enable ad-hoc code signing. This allows us to build and test the app on a single machine without needing to configure any code-signing secrets for this workflow.
For this continuous integration workflow to work, a few things must be true. The above workflow file must be committed to the
master branch of the project workspace and pushed to GitHub. The project itself must be configured with a shared scheme that contains build and test stages, and that scheme must come first in the list of schemes. Finally, the tests must be able to run when invoked via
xcodebuild test — verify this for yourself by running the tests locally from the command-line before deploying the workflow for the first time.
The second piece of the automation we create is the continuous deployment: publishing our app after each release tag. We use the convention that any tag that begins with the letter “
v” is a release tag — e.g.
xcodebuild install in the GitHub Actions environment on a macOS host to build and sign the project, and then we package our app up into a disk image, notarize it, upload it as a release asset, and finally update our Homebrew tap with the lastest version information.
By putting the following GitHub Actions workflow in our project workspace at
.github/workflows/master_deploy.yml we enable continuous deployment:
There’s a lot more going with this workflow than the previous one, but the two start off similarly. This workflow runs every time we push new tags to the repository that start with the letter “
v”. It includes a single job that runs on a macOS host. The job includes eleven steps.
As before, the first step checks out the project. This time, the tag that triggered the workflow is the tag that’s checked out.
Then, the default scheme of the project is determined and set as an environment variable, as before.
Next, additional build settings from the project are read using
xcodebuild -showBuildSettings and set as environment variables for use by subsequent steps. This avoids the need to hard-code project details in the workflow and makes it easier to re-use the workflow with other projects.
The next step is to set up a macOS keychain on the action runner host that contains our app’s signing credentials because Xcode expects to find the signing identity in the default keychain. The step uses the
SIGNING_CERTIFICATE_PASSWORD secrets and the
security tool to import the signing certificate. (See below for information about how to set up the secrets.) This is the most complicated and least documented step of this process. The answers to this StackOverflow question provide some context.
Now we test the app. We never want to release a version of the app that fails tests, so we make sure the tests pass before we move on. If your project does not include tests, omit this step.
Once the app has been tested the next step rebuilds it for installation and then install the app into a distribution root (
Upon successful completion of the install step, the app is packaged into a disk image using
The next step in the workflow notarizes the disk image using the
notarize-cli tool. This is a tool I wrote to simplify notarization of Mac apps in the context of continuous deployment. It wraps two tools provided with Xcode —
stapler. It uses those tools to upload the disk image to Apple, wait for success, and then staple the notarization to the disk image. This step uses the
NOTARIZE_PASSWORD secrets to authenticate to Apple. (See below for information about how to set up the secrets.)
The app is ready to be deployed. The next step is to deliver the notarized disk image to GitHub as a release asset. We use the
softprops/action-gh-release action from the GitHub marketplace to perform this action for us. This step uses the
GITHUB_TOKEN secret which is automatically set up for us by GitHub Actions. This token is scoped to allow us to make modifications only to the repository that hosts the running workflow.
At this point, the app has been delivered. Someone poking around on GitHub could find the released app and download it, but who has time for that? To finish deployment we still need to update a Homebrew tap. Popular apps can be hosted in the homebrew-cask repository, but if your app doesn’t have an audience yet it won’t be accepted for inclusion in the official tap — you’ll need to host your own. Fortunately, hosting your own tap is easy and Homebrew supports installing from 3rd-party taps seamlessly. (See below for information about how to set up a tap and cask for use with this workflow.)
The next step in the workflow checks out the tap. This step uses the
CASK_REPO_TOKEN secrets. (See below for information about how to set up the secrets.) The cask repo token is saved in the checked-out workspace and is used to push changes back to the tap repository in the next step.
The final step is to update the cask with the app’s new version number and the hash value of the release asset. The version number is determined by the release tag that triggered the workflow, and the hash is calculated from the notarized disk image. The commit author is set to the user that triggered the workflow, and the changes are committed and pushed back to the tap repository.
For this workflow to work, a few things must be true. The above workflow file must be committed to the
master branch of the project workspace and pushed to GitHub. The project itself must be configured with a default shared scheme that contains build and test stages. The project must be configured with the name of the code signing identity to use when building for release, and that name must match the signing credentials that are stored in the GitHub secret. Finally, there must already exist a Cask in a Homebrew tap repository for the final step to update.
There are a seemingly infinite number of ways to configure an Xcode project. I hope these workflows work with yours. I’ve verified that these workflows work with a newly-created macOS app project configured with UI tests, with the exception that the default
testLaunchPerformance test fails when run from the command-line — delete that test and you should be good to go.
To configure a project for Developer ID signing, first add you Apple ID account in the “Accounts” preferences of Xcode. After adding the account, select your team from the Team table and click the “Manage Certificates…” button. Look for a “Developer ID Application” certificate. If you don’t see one, create one by clicking the “+” button and selecting “Developer ID Application” from the menu.
Note: If you create the certificate with Xcode then it should also automatically be imported into your local keychain. If for some reason you need to manually download your Developer ID certificate and private key and import them into your keychain you can do so from this page. Double-click the downloaded “.cer” file to import it using the “Keychain Access” app.
Next, make the following changes to the “Signing & Capabilities” settings for the app target in the Xcode project:
- Disable “Automatically manage signing.”
- Set “Team” to the team associated with your Apple ID.
- Set “Provisioning Profile” to “None”].
- Set “Signing Certificate” to “Developer ID Application.”
It should now be possible to use Xcode to sign your app with your Developer ID certificate. Verify that everything is configured correctly by selecting “Archive” from the “Product” menu to build the application for release. Then select “Organizer” from the “Window” menu and find the newly-created archive in the Archives table. Click “Distribute App” and then select “Developer ID” and click “Next.” Select “Export” and click “Next.” Leave the “Distribution certificate” and profile settings at their default values and click “Next.” Enter your credentials if prompted and finally click “Export” to save the signed app.
That was a lot of tedious clicking and waiting — no wonder we decided to automate this!
We’ve created a GitHub Actions workflow to perform continuous integration and continuous delivery, and our project is configured for Developer ID code signing. The next thing to do is set the secrets that our workflow needs to run successfully.
To add a secret to your GitHub repository, navigate to the repository “Settings” tab. Then select “Secrets” from the sidebar. Click “New secret”, then enter the name of the secret and the value. Finally, click “Add secret.”
This secret should be set to a password that will be used to encrypt the p12 file that you create while generating the
SIGNING_CERTIFICATE_P12_DATA secret, below.
Here’s my favorite one-liner to generate a random password using the command-line:
< /dev/urandom LC_CTYPE=C tr -dc a-zA-Z0-9 | head -c 32; echo
This secret should be set to the base64-encoded contents of a p12 file containing the signing certificate and private key.
To set this secret we first need to export the signing certificate and private key from the keychain. To do this, open the “Keychain Access” app, make sure your default “login” keychain is selected, and then select the “My Certificates” category. Select your Developer ID certificate from the list and choose “Export Items…” from the “File” menu. Leave the file format set to “Personal Information Exchange (.p12)” and click “Save.” Enter the value of the
SIGNING_CERTIFICATE_PASSWORD secret when prompted for an encryption password. Then enter your local account credentials when prompted by Keychain Access.
Next, open a terminal window and find the p12 file on disk. Run the following command to base64-encode the file and copy it to your clipboard:
cat Certificates.p12 | base64 | pbcopy
Paste the copied text as the value of the secret.
NOTARIZE_USERNAME: This secret should be set to the Apple ID of your developer account.
NOTARIZE_PASSWORD: This secret should be set to an application-specific password for your Apple ID account. Follow these instructions to create an application-specific password.
CASK_REPO: This secret should be set to the fully-qualified name of the GitHub repository that hosts the cask for this app, e.g.
CASK_REPO_TOKEN: This secret should be set to a GitHub personal access token (PAT) that is scoped to have write access to the repository specified by
CASK_REPO. To generate a PAT, navigate to your user “Settings” on GitHub, select “Developer Settings” from the sidebar, then select “Personal access tokens” from the sidebar, then click “Generate new token.” Give the token a name, select the “repo” scope, and click “Generate token.” Copy the displayed token and add it as a secret to your repository.
A Homebrew tap is just a GitHub repository that follows certain conventions. Read the documentation for more information.
I suggest naming your tap repository
homebrew-tap. This will allow you to install your app using a command like the following:
brew install <username>/tap/<app-name>
Your fully-qualified repository name would be
<username>/homebrew-tap, and that’s the value you should set the
CASK_REPO secret to.
Let’s suppose your project’s repository is named “chuck-wagon,” your Xcode project file is named “chuck-wagon.xcodeproj”, the default scheme is named “Chuck Wagon,” and the scheme produces a product named “Chuck Wagon.app”. In this case, the workflow will publish a disk image named “Chuck_Wagon.dmg” and the cask file is expected to be named “chuck-wagon.rb”.
You will need to create the cask file in your tap repository. Read the documentation for more information.
Here is what the cask for “Chuck Wagon” might look like:
To make this work for your app you would need to replace “<username>”, “chuck-wagon”, “Chuck Wagon”, “Chuck Wagon.app”, and “Chuck_Wagon.dmg” with the values appropriate for your GitHub username and for your app.
After you create a file in your
CASK_REPO repository at the path
master_deploy workflow will be able to update the version and sha256 values.
It wasn’t easy, but we did it. Congratulations!
I hope this article has inspired you to automate toil out of your life. Happy coding!