Signing and notarizing a Wails app in GitHub Actions

DevOpsPad ships a macOS app, a command-line tool (devopspadctl) and a DMG that holds the app. For all three to open without Gatekeeper warnings, each must be signed with a Developer ID certificate and notarized by Apple, with the ticket stapled where possible. We do this in one GitHub Actions workflow on a macos-15 runner. This post walks through it, including the parts that took a few attempts.

The problem

Wails builds a normal .app bundle, but nothing about Wails signs it for distribution. A downloaded app that is not notarized is blocked by Gatekeeper on first launch, and users have to override that in System Settings. That is a bad first impression, and it trains users to click through security warnings. We wanted:

  • tag a version, get a signed and notarized DMG and CLI attached to a draft release,
  • the signing certificate never written to a persistent keychain or left on the runner,
  • a manual run that still builds without secrets, for testing.

The design

Tags must match the app version

The workflow runs on v* tags and on manual dispatch. For tags, the first step reads info.productVersion from wails.json and fails unless the tag equals v<version>. It also fails if the signing and notarization secrets are missing. A tag can never produce an unsigned release. A manual run without secrets still builds, with an ad-hoc signature and no notarization, which is clearly not a release.

The job runs in a GitHub release environment, so the secrets are scoped to it, and a concurrency group keeps two runs for the same ref from overlapping.

A throwaway keychain for the certificate

The Developer ID Application certificate arrives as a base64-encoded .p12 secret. The workflow:

  1. creates a new keychain in the runner’s temp directory with a random password (openssl rand -hex 24),
  2. imports the .p12 into it, allowing only codesign to use the key,
  3. sets the key partition list so codesign can sign without a UI prompt,
  4. adds the keychain to the search list and deletes the decoded .p12 file,
  5. finds the Developer ID Application: … identity and fails if there is none.

A final step, which runs even if earlier steps fail, deletes the keychain and the App Store Connect API key file.

Build, then sign with the hardened runtime

go run github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 build -clean -platform darwin/arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o build/cli/devopspadctl ./cmd/devopspadctl

codesign --force --options runtime --timestamp --sign "$MACOS_SIGNING_IDENTITY" build/cli/devopspadctl
codesign --force --options runtime --timestamp --sign "$MACOS_SIGNING_IDENTITY" build/bin/devopspad.app

Pinning the Wails CLI version with go run …@v2.12.0 means the runner needs no global Wails install, and a new Wails release cannot change the build without a commit. --options runtime enables the hardened runtime, which notarization requires. --timestamp adds a secure timestamp, so the signature stays valid after the certificate expires.

Notarize with an API key, then staple

Notarization uses an App Store Connect API key (issuer ID, key ID and the .p8 contents as secrets) rather than an Apple ID password:

ditto -c -k --keepParent build/bin/devopspad.app app.zip
xcrun notarytool submit app.zip --key AuthKey.p8 --key-id "$KEY_ID" --issuer "$ISSUER" --wait
xcrun stapler staple build/bin/devopspad.app

Stapling attaches the notarization ticket to the app, so Gatekeeper can verify it offline. Two details took iterations:

  • The CLI cannot be stapled. A bare Mach-O binary has nowhere to store a ticket. It is zipped and notarized anyway, and Gatekeeper finds its ticket online on first run.
  • Stapling sometimes fails for no reason of ours. The ticket lookup goes through CloudKit, and on hosted runners the connection occasionally drops. stapler staple and stapler validate are wrapped in a retry: up to five attempts, 15 seconds apart. The git history has two commits that exist only to add these retries.

Package, then sign the package too

The app is copied into a staging folder next to an /Applications symlink, and hdiutil create -format UDZO builds DevOpsPad-<version>-macos-arm64.dmg. The CLI is packed as devopspadctl-<version>-darwin-arm64.tar.gz. The DMG is then signed, notarized and stapled on its own, so opening the disk image does not trigger a warning either.

Verify before uploading

codesign --verify --deep --strict --verbose=2 build/bin/devopspad.app
spctl --assess --type execute --verbose=2 build/bin/devopspad.app
spctl --assess --type open --context context:primary-signature --verbose=2 DevOpsPad-*.dmg
xcrun stapler validate DevOpsPad-*.dmg
shasum -a 256 * > SHA256SUMS

spctl --assess asks Gatekeeper the same question a user’s Mac will ask. A SHA256SUMS file is published with the artifacts.

Draft, not publish

For tags, a second job on Ubuntu downloads the artifacts and runs gh release create … --draft --generate-notes. A person reads the notes and publishes. The app’s update check reads the latest published GitHub release and only offers a DMG whose name ends in -macos-<arch>.dmg, so a draft never reaches users by accident.

Trade-offs and limits

  • Apple silicon only. The workflow builds darwin/arm64. Intel Macs, Windows and Linux are not built or verified by this pipeline. Those users build from source.
  • A paid Apple Developer account is required for the Developer ID certificate and the API key.
  • Notarization time varies. --wait can take minutes, and the job has a 60-minute timeout.
  • Hosted runners and CloudKit are outside our control, hence the retries.

Installing the result is covered in install on macOS, and updates in update DevOpsPad. The packaged CLI is documented in devopspadctl. Downloads are on the download page.