Build, sign, and notarize a Tauri app on GitHub Actions for distribution

When you distribute a macOS app built with Tauri, leaving it unsigned or ad-hoc signed means Gatekeeper blocks it on the downloader's machine with "cannot be opened because the developer cannot be verified." On macOS 15 (Sequoia), even the right-click → Open bypass is gone, so users have to allow it manually from System Settings. To get rid of this, you need a Developer ID signature plus Apple notarization.
This article walks through building a Tauri app for both macOS and Windows on GitHub Actions, signing and notarizing the macOS build, and publishing it to a GitHub Release — including the practical details like pinning the action to a commit SHA and auto-incrementing the version. The build is not triggered automatically on push; it is triggered manually with a pnpm release command.
Prerequisites
- Apple Developer Program membership ($99/year). Notarization only works with a Developer ID certificate; a free Apple ID won't do.
- A "Developer ID Application" certificate and its private key in your local Keychain. Create it in Xcode or on the Apple Developer site.
- The repository is on GitHub. On a public repo, GitHub Actions macOS / Windows runners are free and unmetered (private repos bill macOS at 10× and Windows at 2× the minute rate).
- pnpm is assumed as the package manager (adapt for npm / yarn).
Apple Silicon cannot run an unsigned binary, so "no signature" isn't really an option — you need at least ad-hoc signing, and for distribution you want Developer ID + notarization.
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 to a draft
v<version>Release viatauri-apps/tauri-action. Once every platform succeeds, a followingpublishjob un-drafts it. - Signing: only the macOS job imports the Developer ID certificate into a throwaway keychain and passes signing/notarization env vars to tauri-action.
- Versioning: a local
pnpm releasescript bumps the version, commits and pushes it, then triggers the workflow and watches it to completion.
1. Decide the signing policy in tauri.conf.json
In src-tauri/tauri.conf.json, set the macOS signing identity. Use ad-hoc ("-") for local builds.
{
"bundle": {
"active": true,
"targets": "all",
"macOS": {
"signingIdentity": "-"
}
}
}
The key point: in CI, the APPLE_SIGNING_IDENTITY environment variable overrides this config value. Reading the tauri-cli source (crates/tauri-cli/src/interface/rust.rs), the signing identity is resolved like this:
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
};
So the env var wins when present. Keeping "-" in the config therefore gives you both: local builds without the env var get ad-hoc signing (which just runs), while CI with the env var gets Developer ID signing. If you delete signingIdentity from the config, local builds end up unsigned by tauri, so keep it.
2. The release workflow release.yml
Create .github/workflows/release.yml. A matrix builds mac / win in parallel, uploads to a draft Release, and a publish job un-drafts 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 releaseDraft: true plus the separate publish job is the crucial part. The two matrix jobs append their artifacts to the same v<version> Release, but with releaseDraft: false whichever platform finishes first would immediately publish an incomplete Release (the macOS universal build takes almost twice as long as the Windows NSIS build). By creating a draft and letting the publish job (gated by needs: build, which waits for every matrix leg to succeed) run gh release edit --draft=false, a failure on one platform leaves the Release as a draft and never publishes it.
gh release edit <tag> can resolve a draft Release by its not-yet-existing tag name (gh CLI's draft fallback), and it works with the Actions GITHUB_TOKEN as-is.
3. The Developer ID signing and notarization step
Only on the macOS job, add a step that imports the certificate into a throwaway keychain, before the build step. tauri-action does not import the certificate itself, so you set it up 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 throwaway runner, so generate it with openssl rand instead of storing it as a secret. Making the keychain the default with default-keychain -s lets codesign find the identity in the later build step (a separate step).
Pass the signing/notarization env vars 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, and APPLE_TEAM_ID are present, tauri-action signs the app, notarizes it, and staples the notarization ticket automatically. APPLE_ID is the account email, APPLE_PASSWORD is not the regular password but an app-specific password (below), and APPLE_TEAM_ID is the 10-character Team ID.
The env values are scoped to the macOS job with ${{ matrix.platform == 'macos-latest' && secrets.X || '' }} so the Windows job's action never receives the Apple credentials. The Windows Tauri bundler doesn't run macOS signing so it's functionally harmless, but keep the secret exposure surface as small as possible.
Pin tauri-action to a commit SHA
The tauri-apps/tauri-action@<COMMIT_SHA> above is deliberate — don't use a mutable tag like @v0. This action receives the certificate (with its private key) and credentials, so if the tag were repointed to malicious code it could exfiltrate the secrets. GitHub's Secure Use guidance also recommends pinning third-party actions to a full commit SHA. You can get the SHA for the version you want with:
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 it as uses: tauri-apps/tauri-action@<SHA> # v0, keeping the original version in a comment.
4. Register the GitHub Secrets
Register six secrets. Exporting the certificate and password is a manual step.
Export the certificate as .p12: In Keychain Access, select the "Developer ID Application" certificate, confirm its private key is nested under it, and right-click → Export to create a .p12 (set an export password).
Issue an app-specific password: Sign in at https://account.apple.com/, go to "Sign-In and Security" → "App-Specific Passwords", and generate one. It can only be issued for an Apple ID with two-factor authentication enabled. The regular Apple ID password will not pass notarization.
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 APPLE_SIGNING_IDENTITY 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 have accepted the developer agreements — otherwise signing succeeds but notarization is rejected.
After registering the .p12, delete the local file (rm cert.p12). Don't leave a plaintext copy of the private key around.
Don't bundle the secrets into one blob — automate the deployment instead
Registering six secrets individually gets tedious once you apply the same signing setup to several apps. The tempting idea is to jam all of them into an env-file format and stuff it into a single secret (say APPLE_BUILD_ENV). Don't.
GitHub automatically masks (***) the values of registered secrets in logs. With six separate secrets, the certificate password and the .p12 base64 are each masked independently. But if you bundle them into one blob, GitHub only masks the blob string itself — the individual values you source and expand inside the workflow are no longer masked. A single env dump from a third-party action, a set -x, or ACTIONS_STEP_DEBUG will then leave your signing key's password in the logs in plaintext. Throwing away that masking for signing credentials isn't worth it. On top of that, if you forget to quote the spaces in the signing identity or the base64 padding in the env file, source breaks.
What you actually want to reduce is "typing six secrets per repository," so automate that. Keep a single env file locally and use a script that pushes six masked secrets into a repository from it. GitHub still holds six individual (masked) secrets, and the manual work becomes one command per repo.
Local ~/.config/apple-signing.env (keep it outside the repos 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
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
Now rolling the same signing setup out to multiple apps is one command per repo, GitHub keeps six properly masked secrets, and the workflow needs no change. The thing to cut down is the registration work, not the number of secrets.
5. The release script that auto-increments the version
Re-running the workflow with an already-published version fails because tauri-action hits a draft-state mismatch. In other words, the version must be incremented every time. Forgetting to bump it by hand always trips here, so fold the versioning into a script.
Create scripts/release.sh and add "release": "bash scripts/release.sh" to your package.json scripts. pnpm publish is a built-in pnpm command (publishing to the npm registry) that a script can't shadow, so name it 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
A few practical details are baked in. The version is validated with a regex because a naive check like [maj,min,pat].some(Number.isNaN) lets 1.2 (→ 1.2.NaN) and 1.2.3.4 (→ 1.2.4) slip through, since Number.isNaN(undefined) is false. The version replacement across the two files writes only after both succeed, avoiding a half-updated state. The gh check sits before the commit and push so that an unusable gh after the push doesn't strand an unpublished version 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, triggers the workflow, and watches it to completion. Once done, download the published dmg and confirm on a real machine that signing and notarization actually took 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 stapled to the app and users see no Gatekeeper warning when they download and open it. The runtime flag in the codesign output means the hardened runtime is enabled, which is a hard requirement for notarization.
Design decisions and pitfalls
- Keep
signingIdentity: "-"— don't delete it. CI overrides it viaAPPLE_SIGNING_IDENTITY, so keeping it gives "Developer ID in CI, ad-hoc locally." Deleting it leaves local builds unsigned. - Developer ID signing without notarization is a half measure. Signing alone still triggers a warning on downloaded apps, and on macOS 15 the right-click → Open bypass is gone, requiring approval from System Settings. If you sign, notarize. A self-signed certificate is no better than ad-hoc to Gatekeeper, so it's pointless for distribution.
- Without splitting draft → publish, an incomplete Release goes public. Matrix builds finish at different times, so always create a draft and publish only after every leg succeeds.
- Bump the version every time. Re-running with the same version fails on tauri-action's draft-state mismatch. Automate the versioning to structurally eliminate forgotten bumps.
- You can't use
pnpm publish. It collides with the built-in pnpm command, so name the scriptrelease. - Pin any action that receives secrets to a commit SHA. A mutable tag risks secret exfiltration via tag repointing.
- Don't merge the six secrets into one env blob. It breaks GitHub's per-secret log masking, so the signing key password can leak in plaintext. Cut the effort by automating registration, not by combining secrets (Section 4).
- Windows is often left unsigned. Code-signing certificates (OV/EV) cost money and are awkward to wire into CI, so for internal or small-scale distribution it can be reasonable to ship unsigned (the SmartScreen warning is cleared with "More info → Run anyway").
We look forward to discussing your development needs.