Build, sign, and notarize a Tauri app on GitHub Actions for distribution
When you distribute a macOS app built with Tauri, if it is unsigned or only ad-hoc signed, Gatekeeper blocks it on the machine of the user who downloaded it with "cannot be opened because the developer cannot be verified". On macOS 15 (Sequoia), even the right-click → Open bypass has been removed, and the user has to allow it manually from System Settings. To get rid of this, you need Developer ID signing + Apple notarization.
This post describes the steps to build a Tauri app for both macOS and Windows on GitHub Actions, apply Developer ID signing and notarization to the macOS version, and publish it to a GitHub Release, including the details of real operation such as pinning to a commit SHA and automatic version numbering. The build does not start automatically on push. It is triggered manually with the pnpm release command.
Prerequisites
- An Apple Developer Program membership ($99/year) is required. Notarization only passes with a Developer ID certificate. A free Apple ID does not work.
- A "Developer ID Application" certificate and its private key must be in your local Keychain. Create it in Xcode or on the Apple Developer site.
- The repository is on GitHub. For a public repository, the GitHub Actions macOS / Windows runners are free and unlimited (for private repositories, macOS consumes minutes at 10 times the rate and Windows at 2 times).
- pnpm is assumed as the package manager (you can adapt this to npm / yarn).
Apple Silicon cannot run unsigned binaries, so the option of "not signing" does not really exist. You need at least ad-hoc signing, and if you distribute the app, the right answer is to go all the way to Developer ID + notarization. A self-signed certificate is the same as ad-hoc to Gatekeeper, so it is meaningless for distribution.
The overall workflow
- Trigger:
workflow_dispatchonly. No automatic build on push. - Build: a matrix builds macOS (universal dmg) and Windows (NSIS exe) in parallel and uploads them with
tauri-apps/tauri-actionto a draft Release forv<version>. When the builds for all platforms succeed, the followingpublishjob publishes the draft. - Signing: only the macOS job imports the Developer ID certificate into a temporary keychain and passes the environment variables for signing and notarization to tauri-action.
- Versioning: the local
pnpm releasescript bumps the version, commits and pushes it, starts the workflow, and watches it until it completes.
1. Decide the signing policy in tauri.conf.json
Write the macOS signing identity in the bundle section of src-tauri/tauri.conf.json. Specify ad-hoc ("-") for local builds.
{
"bundle": {
"active": true,
"targets": "all",
"macOS": {
"signingIdentity": "-"
}
}
}
In CI, the environment variable APPLE_SIGNING_IDENTITY overrides this config value. If you read the tauri-cli source (crates/tauri-cli/src/interface/rust.rs), the signing identity is resolved as follows.
let signing_identity = match std::env::var_os("APPLE_SIGNING_IDENTITY") {
Some(signing_identity) => Some(/* use the env value */),
None => config.macos.signing_identity, // fall back to config
};
In other words, if the env var exists, it is used. So if you leave "-" in the config, you get both: local builds, which do not pass the environment variable, are ad-hoc signed (they run right away), and CI, which passes the environment variable, is Developer ID signed. If you delete signingIdentity from the config, local builds are no longer signed by tauri, so leave it in.
2. The release workflow release.yml
Create .github/workflows/release.yml. A matrix builds mac / win in parallel, uploads to a draft Release, and then the publish job publishes it.
name: Release
on:
workflow_dispatch:
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
rust-targets: aarch64-apple-darwin,x86_64-apple-darwin
args: --target universal-apple-darwin --bundles dmg
- platform: windows-latest
rust-targets: ''
args: --bundles nsis
runs-on: ${{ matrix.platform }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-targets }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- run: pnpm install --frozen-lockfile
# (the signing step is added in the next section)
- name: Build and upload to GitHub Release (draft)
uses: tauri-apps/tauri-action@<COMMIT_SHA> # v0 pinned to a SHA (see below)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v__VERSION__
releaseName: MyApp v__VERSION__
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
publish:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
gh release edit "v${VERSION}" --draft=false --latest
The important part is releaseDraft: true and the separate publish job. The two matrix jobs add their artifacts to the same v<version> Release, but with releaseDraft: false, an incomplete Release with only the platform that finished first is published on the spot (the mac universal build takes nearly twice as long as the win NSIS build). If you create it as a draft and have the publish job (which waits for all matrix legs to succeed with needs: build) publish it with gh release edit --draft=false after all jobs succeed, then when one side fails the Release stays a draft and is not published.
gh release edit <tag> can resolve a draft Release by tag name even when the tag does not exist yet (the gh CLI's draft fallback). It works as is with the Actions GITHUB_TOKEN.
3. The Developer ID signing and notarization step
Only in the macOS job, add a step before the build step that imports the certificate into a temporary keychain. tauri-action does not import the certificate itself, so you have to prepare it yourself.
- name: Import Apple Developer certificate (macOS)
if: matrix.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PASSWORD=$(openssl rand -base64 24)
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import "$RUNNER_TEMP/certificate.p12" -k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
rm "$RUNNER_TEMP/certificate.p12"
The keychain password is only used inside the disposable runner, so generate it with openssl rand and do not make it a secret. If you make the created keychain the default with default-keychain -s, codesign can find the identity in the later build step (a separate step).
Pass the environment variables for signing and notarization to the build step.
- name: Build and upload to GitHub Release (draft)
uses: tauri-apps/tauri-action@<COMMIT_SHA>
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_SIGNING_IDENTITY: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_TEAM_ID || '' }}
with:
tagName: v__VERSION__
releaseName: MyApp v__VERSION__
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
When all four of APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID are set, tauri-action signs the app, notarizes it automatically, and staples the notarization ticket to the app. APPLE_ID is the account's email address, APPLE_PASSWORD is not the normal password but an app-specific password (described below), and APPLE_TEAM_ID is the 10-character Team ID.
The environment variable values are limited to the macOS job with ${{ matrix.platform == 'macos-latest' && secrets.X || '' }} so that the Apple credentials are not passed to the action in the Windows job. The Windows Tauri bundler does not run the macOS signing process, so functionally it is harmless, but keep the exposure of secrets as narrow as possible.
Windows is often left unsigned as a trade-off. Code signing certificates (OV/EV) cost money and are troublesome to integrate with CI, so for internal or small-scale distribution, one option is to ship it unsigned (the SmartScreen warning can be bypassed with "More info → Run anyway").
Pin tauri-action to a commit SHA
Writing tauri-apps/tauri-action@<COMMIT_SHA> in the workflow above is intentional. Do not use a mutable tag such as @v0. This action receives the certificate containing Apple's private key and the credentials, so if the tag is replaced, the secrets are stolen. GitHub's Secure Use guidelines also recommend pinning third-party actions to a full commit SHA. You can get the commit SHA of the version you want to use as follows.
gh api repos/tauri-apps/tauri-action/git/refs/tags/v0 --jq '.object.sha'
# for an annotated tag, dereference further:
gh api repos/tauri-apps/tauri-action/git/tags/<the SHA above> --jq '.object.sha'
Pin the SHA you got in the form uses: tauri-apps/tauri-action@<SHA> # v0, and keep the original version in the comment.
4. Register the GitHub Secrets
Register six secrets. The certificate and the password are manual work for a human.
Export the certificate as .p12: In Keychain Access, select the "Developer ID Application" certificate, check that the private key is attached under it, and create a .p12 with right-click → Export (set a password for the export).
Issue an app-specific password: Sign in at https://account.apple.com/ and generate one under "Sign-In and Security" → "App-Specific Passwords". It can only be issued for an Apple ID with two-factor authentication enabled. Notarization does not pass with the normal Apple ID password.
Register the secrets
base64 -i cert.p12 | gh secret set APPLE_CERTIFICATE
gh secret set APPLE_CERTIFICATE_PASSWORD # the .p12 password
gh secret set APPLE_SIGNING_IDENTITY --body 'Developer ID Application: Your Name (XXXXXXXXXX)'
gh secret set APPLE_TEAM_ID --body 'XXXXXXXXXX'
gh secret set APPLE_ID --body 'you@example.com'
gh secret set APPLE_PASSWORD # app-specific password (xxxx-xxxx-xxxx-xxxx)
Make the APPLE_SIGNING_IDENTITY string match the string inside the double quotes in the output of security find-identity -v -p codesigning. The Apple ID you use must be a member of that Team and must have agreed to the developer agreement. If it has not, signing passes but notarization is rejected.
After you finish registering the .p12, delete the local file (rm cert.p12). Do not leave a plaintext copy of the private key.
Don't bundle the secrets into one blob — automate the deployment instead
Registering six secrets one by one becomes more work when you apply the same signing to several apps. The idea of "putting everything in env file format and pushing it into a single secret (for example APPLE_BUILD_ENV)" comes up, but you should not do this.
GitHub automatically masks (***) the values of registered secrets when they are written to logs. With six separate secrets, the certificate password and the base64 of the .p12 are each masked independently. But if you combine them into one blob, GitHub only masks the string of that blob itself, and the individual values that you source and expand inside the workflow are not masked. With any one of an env dump by a third-party action, set -x, or ACTIONS_STEP_DEBUG, the password of the signing key is left in the logs in plaintext. Giving up this masking for signing credentials is not worth it. In addition, it is fragile: if you forget to quote the spaces in the signing identity or the base64 padding in the env file, source breaks.
What you really want to reduce is "the manual work of entering six values for every repository", so automate that part. If you put one env file locally and write a script that pushes six masked secrets from it into a repository, the GitHub side still has six separate secrets (masking kept) and the manual work is one command per repository.
The local ~/.config/apple-signing.env (put it outside the repository and gitignore it).
APPLE_CERTIFICATE_P12=/Users/you/secrets/developer-id.p12
APPLE_CERTIFICATE_PASSWORD=…
APPLE_SIGNING_IDENTITY=Developer ID Application: Your Name (XXXXXXXXXX)
APPLE_ID=you@example.com
APPLE_PASSWORD=xxxx-xxxx-xxxx-xxxx
APPLE_TEAM_ID=XXXXXXXXXX
The deployment script push-apple-secrets.sh.
#!/usr/bin/env bash
set -euo pipefail
REPO="$1" # e.g. yourname/yourapp
ENV_FILE="${2:-$HOME/.config/apple-signing.env}"
set -a; source "$ENV_FILE"; set +a
base64 -i "$APPLE_CERTIFICATE_P12" | gh secret set APPLE_CERTIFICATE --repo "$REPO"
for k in APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
printf '%s' "${!k}" | gh secret set "$k" --repo "$REPO"
done
echo "done: $REPO"
Usage.
./push-apple-secrets.sh yourname/app-a
./push-apple-secrets.sh yourname/app-b
With this, rolling out the same signing to multiple apps takes one command per repository. On GitHub there are still six correctly masked secrets, and the workflow needs no changes. What should be reduced is not "the number of secrets" but "the registration work".
5. The release script that auto-increments the version
If you re-run the workflow with the same version as one already published, tauri-action fails with a draft state mismatch. In other words, the version has to be incremented every time. If you forget to bump it by hand, it always fails here, so leave the numbering to a script.
Create scripts/release.sh and add "release": "bash scripts/release.sh" to the scripts in package.json. pnpm publish is a built-in pnpm command that publishes to the npm registry and cannot be overridden by scripts, so the name is release.
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
BUMP="${1:-patch}"
case "${BUMP}" in
patch | minor | major) ;;
*) echo "Usage: pnpm release [patch|minor|major]" >&2; exit 1 ;;
esac
# Verify gh first. If gh is unusable after the push, the bump commit lands on
# main without triggering the workflow, leaving an unpublished version stranded.
command -v gh >/dev/null 2>&1 || { echo "gh not found" >&2; exit 1; }
gh auth status >/dev/null 2>&1 || { echo "gh not authenticated" >&2; exit 1; }
# Only bump from a clean main.
[ "$(git branch --show-current)" = "main" ] || { echo "not on main" >&2; exit 1; }
[ -z "$(git status --porcelain)" ] || { echo "tree not clean" >&2; exit 1; }
git fetch origin main
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || { echo "HEAD != origin/main" >&2; exit 1; }
# Accept only a strict X.Y.Z and bump it.
CURRENT=$(node -p "require('./src-tauri/tauri.conf.json').version")
VERSION=$(node -e '
const cur = process.argv[1], bump = process.argv[2];
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(cur)) {
console.error("not X.Y.Z: " + cur); process.exit(1);
}
const [a,b,c] = cur.split(".").map(Number);
const n = bump==="major"?[a+1,0,0]:bump==="minor"?[a,b+1,0]:[a,b,c+1];
process.stdout.write(n.join("."));
' "${CURRENT}" "${BUMP}")
# Update version in both files, writing only after both replacements succeed.
node -e '
const fs = require("fs"), version = process.argv[1];
const files = ["src-tauri/tauri.conf.json", "package.json"];
const edits = files.map((file) => {
const text = fs.readFileSync(file, "utf8");
const old = JSON.parse(text).version;
const esc = old.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const out = text.replace(new RegExp("(\"version\"\\s*:\\s*\")" + esc + "(\")"), "$1" + version + "$2");
if (out === text) throw new Error("version not replaced in " + file);
return { file, out };
});
for (const e of edits) fs.writeFileSync(e.file, e.out);
' "${VERSION}"
git add src-tauri/tauri.conf.json package.json
git commit -m "chore: release v${VERSION}"
git push origin HEAD:main
# workflow_dispatch returns no run ID, so grab the run whose ID differs from the latest pre-trigger one.
PREV=$(gh run list --workflow=release.yml --branch main --limit 1 --json databaseId --jq '.[0].databaseId // ""')
gh workflow run release.yml --ref main
RUN_ID=""
for _ in $(seq 1 15); do
sleep 2
RUN_ID=$(gh run list --workflow=release.yml --branch main --limit 1 --json databaseId --jq '.[0].databaseId // ""')
[ -n "$RUN_ID" ] && [ "$RUN_ID" != "$PREV" ] && break || RUN_ID=""
done
[ -n "$RUN_ID" ] || { echo "run not found" >&2; exit 1; }
gh run watch "$RUN_ID" --exit-status
The version is validated with a regular expression because a naive check such as [maj,min,pat].some(Number.isNaN) lets 1.2 (→ 1.2.NaN) and 1.2.3.4 (→ 1.2.4) through, since Number.isNaN(undefined) is false. The version replacement in the two files is written only after both are confirmed to succeed, to avoid a state where only one is updated. The gh check is placed before the commit and push because if gh cannot be used after the push is done, a version that is never published is left on main.
6. Run the release and verify it
pnpm release # patch: 0.1.0 -> 0.1.1
pnpm release minor # 0.1.0 -> 0.2.0
pnpm release major # 0.1.0 -> 1.0.0
The script bumps the version, commits and pushes it, starts the workflow, and watches it until it completes. When it completes, download the published dmg and check on a real machine that signing and notarization are really in effect.
hdiutil attach -nobrowse -quiet MyApp_0.1.1_universal.dmg
APP=/Volumes/MyApp/MyApp.app
codesign -dv --verbose=2 "$APP" # Authority=Developer ID Application: ... / flags=...runtime
spctl -a -vvv "$APP" # accepted / source=Notarized Developer ID
xcrun stapler validate "$APP" # The validate action worked!
lipo -archs "$APP/Contents/MacOS/MyApp" # x86_64 arm64
hdiutil detach -quiet /Volumes/MyApp
If spctl returns source=Notarized Developer ID and stapler validate succeeds, the notarization ticket is attached to the app, and no Gatekeeper warning appears at all when a user downloads and opens it. runtime in the flags of codesign means that the hardened runtime is enabled, which is a mandatory requirement for notarization.
Related posts
We look forward to discussing your development needs.