Building a MacOS .app

Building a MacOS .app Bundle For a Go Projects

I spent the better half of last week trying to build a MacOS .app bundle for one of my Go projects. I want to eventually put this online for others to download, so it was time to work out how to go from a simple binary to a .app bundle.

I want something that is repeatable, automated, and could be done within a GitHub Action, and because this was a Go project, doing this from Xcode was out of the question. I think I've got an approach that works using just the command line, at least at the moment, and this post documents what that approach is.

First, a few assumptions. I'm going to assume that you know the general process of building a MacOS .apps for distribution outside the App Store. You don't need to know the particulars, but I won't be going into great detail on what each step does. I'm also using Go here, but I imagine most of this will work for any language producing Mach-O executables. Finally, I haven't done extensive testing on this but I've managed to get this working on both my M2 Mac Mini, and a Github Action with a macos-13 runner.

Building

Assume that we've got a project called "teapot". It has a main file "cmd/teapot/main.go" that simply prints out "I am a little teapot", and want to build an Mac app with the name "Teapot.app".

Building the executable is straightforward: it just a call to go build:

$ pwd
/Users/person/Developer/teapot
    
$ go build ./cmd/teapot
$ ./teapot
I'm a little teapoit

You can use Go's cross-compile facilities to make separate binaries for Intel and Apple Silicon. If you wish to combine them into a universal binary, thus making a single downloadable artefact, you can use the lipo command to do so:

$ GOARCH=amd64 go build -o ./teapot-amd64 ./cmd/teapot
$ GOARCH=arm64 go build -o ./teapot-arm64 ./cmd/teapot

$ lipo -create -output dist/teapot-universal teapot-amd64 teapot-arm64

Preparing The Bundle

Next thing we do is prepare our .app bundle. App bundles are effectively directories with the suffix .app and with specific files expected in specific directories. Making one for our project involves, at a minimum:

  • Creating the bundle directory "Teapot.app", with the sub-directory Contents/MacOS

  • Moving our binary into the "MacOS" directory we just created

  • Making sure the name is the same as the bundle, minus the ".app" suffix.

Here are the commands to do that:

$ mkdir -p Teapot.app/Contents/MacOS
$ mv teapot-universal Teapot.app/Contents/MacOS/Teapot

You've effectively got an app bundle now. It won't be a good looking app: there's no app icon for example. But right-clicking on it in the Finder and clicking "Open" should launch it (it probably won't do anything as there won't be a terminal). Only trouble is that it'll only work on your machine. In order to get others to run it, we'll have to sign and notarise it.

Signing

Signing the app involves getting a developers certificate from Apple. I believe you'll need to organise for a paid developer account, and provision a Application Developer ID certificate from the web admin console. The certificate will have a name like:

Developer ID Application: Your Name (7xxxxxxxxx)

with a private key with the name like Your Name Dev ID.

Normally this will be in your keychain, which the codesign tool will use. But we also want to run this on a GitHub Actions runner, so we'll have to do something about that.

Setting A Temporary Keychain

Using the following commands, what we'll do is create a temporary keychain:

$ security create-keychain -p $KEYCHAIN_PASS $KEYCHAIN_PATH
$ security set-keychain-settings -lut 900 $KEYCHAIN_PATH
$ security unlock-keychain -p $KEYCHAIN_PASS $KEYCHAIN_PATH

where:

  • KEYCHAIN_PATH: path and name of the temporary keychain on the file-system (e.g /tmp/my-keychain)

  • KEYCHAIN_PASS: password to use for the temporary keychain

We want to use it for code signing. It looks like the codesign tool only uses the keychains in the search path, but we can simply set the temporary keychain as the default so that codesign can pick it up (It looks like the codesign tool did, at one point, support selecting the keychain to look for the certificate in, but it looks like that option has been removed).

Now I must stress: please be careful before doing this step while testing this. You want to avoid setting the default keychain without recording what the keychain was. The security command has a sub-command used for managing the default keychain, so an easy way to save the default is to save it into an environment variable or file:

$ EXISTING_KEYCHAIN=`security default-keychain`

You can then set the default keychain to the temporary one you've just created:

$ security default-keychain -s $KEYCHAIN_PATH

Another approach I've seen is adding the temporary keychain to the search list, using the list-keychains sub-command. I haven't managed to get this working, although I think the reason was because the certificate I was trying to import was wrong, so it might be a viable strategy.

Importing Your Dev Certificate

We have our temporary keychain, so now we can import our temporary certificate.

If you have your certificate in your "real" keychain, you can export it by:

  1. Launching Keychain Access

  2. Selecting the "My Certificates" tab

  3. Right clicking on the certificate with the name "Developer ID Application: Your Name (7xxxxxxxxx)." Note that there should be a caret on the left that you can expand to reveal the private key.

  4. Selecting "Export (certificate name)"

  5. Saving the certificate as a ".p12" file.

  6. Set a passphrase for your exported certificate. You'll use it later.

If you're exporting from Keychain Access, make sure you export the certificate with the private key. The filename would have the extension "Personal Information Exchange (.p12)" and would be closer to 3 KB in size, vs. 1 KB with the certificate alone. I believe this'll work if you export it from within the "My Certificate" section:

Once you've got your certificate, you can import it into your temporary keychain using the import sub-command:

$ security import $CERT_P12_FILE -k $KEYCHAIN_PATH -P $CERT_PASSPHRASE -T /usr/bin/codesign

where:

  • CERT_P12_FILE is the location of the exported .p12 file

  • CERT_PASSPHARSE is the passphrase you've set when exporting the certificate.

Use the find-certificate sub-command to get the certificates currently in the keychain.

$ security find-certificate

Now, given that this is running on GitHub Actions, you'll need a way to get your exported certificate file onto the runner. I'd advise not checking it into the repository. What I've done is converted the .p12 file into base64, and saving it as a Repository Secret. Then, at this stage of the action, I read the secret, base64 decode it, and save it to a file:

$ echo -n {{ secrets.MACOS_DEVELOPER_CERTIFICATE }} | base64 -D > /tmp/cert.p12
$ security import /tmp/cert.p12 -k /tmp/keychain -P ${{ secrets.CERTIFICATE_PASSPHRASE }} -T /usr/bin/codesign

Running Codesign

You should be in a position to sign the app, using the codesign tool:

$ codesign --timestamp --options=runtime --deep \
    -s "Developer ID Application: Your Name (7xxxxxxxxx)" -v Teapot.app

There're a few options you'll want here:

  • --timestamp includes a timestamp in the signature. This is required for notarisation.

  • --options=runtime indicates that the app should use the hardened runtime. This is also required for notarisation.

  • --deep instructs codesign to recursively search from binaries from the top level (i.e. the "Teapot.app" directory) and sign each one. It's not required, but you will need to specify which binaries you want to sign explicitly if you don't use it.

  • -s indicates the "sign" mode. This takes the name of the certificate you wish to sign with (the one you imported)

  • -v is verbose mode.

If everything works, you should see a message that the signing was successful. If, however, you see a message The specified item could not be found in the keychain, check that the certificate was properly imported and that it includes the private key. You might be going crazy in thinking that it couldn't find the certificate, and you're hitting your head against the wall trying to work out how codesign couldn't find something in the keychain that you're seeing with your very eyes. But it might be that the "item" that codesign couldn't find is the private key.

Notarisation

Finally, there's notarisation.

$ /usr/bin/ditto -c -k --keepParent Teapot.app teapot.zip

There are a bunch of ways you can prepare your app for upload. What works for me is archiving the .app bundle into a zip file using the ditto tool (I think it includes some metadata that zip doesn't).

You'll need to setup an app password in Apple's Developer portal to allow commands to access Apple's services on your behalf. Once you've done this, add the password to your temporary keychain:

$ xcrun notarytool store-credentials notarization \
    --apple-id 'Your Name' \
    --password 'aaaa-bbbb-cccc-dddd' \
    --team-id '7xxxxxxxxx' \
    --keychain $KEYCHAIN_PATH

Then, it's a matter of submitting your app for notarisation:

$ xcrun notarytool submit teapot.zip --keychain-profile notarization --keychain $KEYCHAIN_PATH --wait

It may take a few minutes for notarisation to complete. You'll get a summary as to whether or not it did so successfully. Each notarisation submission has an ID, which looks like a UUID, which you can use to get more details of how it went and, if it failed, why:

$ LOG_FILE=/tmp/notarization-log-file
$ xcrun notarytool log $SUBMISSION_ID --keychain-profile notarization --keychain $KEYCHAIN_PATH $LOG_FILE
$ cat $LOG_FILE

If all goes well, you can staple the notarisation ticket to the app:

$ xcrun stapler staple Teapot.app

Then, you should be free to upload your app to the web for others to download and run.

Conclusion

So, that's it. I've glossed over much of the detail here, including things like setting up an Info.plist, enabling sandboxing, adding app icons, etc. Many of these I haven't yet worked out myself — once I have, I'll probably document it here. Some, such as making DMG files, I haven't had the need for yet.

But this should be enough to get an .app bundle out of a Go project that you can share with others. I hope you find this guide helpful.

Additional Reading

A lot of this was "built on the shoulders of giants", so to speak, by taking instructions from other blog posts or open-source code. I wouldn't have had anything working without the help from these sources:

Just note that I was unable to successfully follow their instructions completely as written — Apple tends to change the tooling sometimes — so the process here is sort of an amalgamation of everything they suggest.

Finally, there are a bunch of Github Action steps for code-signing and notarisation that might be useful out of the box. I tried a few but some were out of date, and I couldn't really get them working, but you may have more luck.

Last updated