In Part 1, we set up code signing for macOS (Developer ID + notarization) and Windows (Azure Key Vault). We configured tauri.conf.json, created entitlements, set up relic, and generated updater signing keys.
Now we wire it all together. In this part, we'll build:
- A GitHub Actions workflow that builds, signs, and publishes your app for macOS and Windows
- A release automation script that bumps versions, generates changelogs, and triggers the pipeline with a single command
- The full end-to-end flow from "I want to release v1.0.0" to ".dmg and .exe on GitHub Releases"
Table of Contents
- GitHub Actions Workflow
- Repository Settings
- Release Automation Script
- The Full Release Flow
- Verifying Your Release
- Troubleshooting
- Conclusion
GitHub Actions Workflow
Trigger: Tag-Based Releases
The cleanest approach is triggering builds when you push a version tag. No manual workflow dispatching, no branch-based heuristics -- push a v* tag and the pipeline runs.
on:
push:
tags:
- "v*"
This means v0.1.0, v1.0.0-beta.1, or any tag starting with v will trigger the workflow.
Build Matrix
We need three build targets:
| Platform | Target | Output |
|---|---|---|
macos-latest |
aarch64-apple-darwin |
.dmg for Apple Silicon Macs |
macos-latest |
x86_64-apple-darwin |
.dmg for Intel Macs |
windows-latest |
x86_64-pc-windows-msvc |
.exe NSIS installer |
Why not a universal macOS binary? Universal binaries (
universal-apple-darwin) combine both architectures into one.dmg, but they double the file size. Separate builds keep downloads smaller, and GitHub Releases can host both.
We use fail-fast: false so one platform failing doesn't cancel the others. If the macOS build fails, you still get the Windows artifact (and vice versa).
Full Workflow File
Create .github/workflows/release.yml:
name: Release
on:
push:
tags:
- "v*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
release:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
args: "--target aarch64-apple-darwin"
rust_target: aarch64-apple-darwin
- platform: macos-latest
args: "--target x86_64-apple-darwin"
rust_target: x86_64-apple-darwin
- platform: windows-latest
args: "--target x86_64-pc-windows-msvc"
rust_target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "npm"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: npm ci
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH=$RUNNER_TEMP/certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 24)
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH"
echo "Available signing identities:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build and release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS signing + notarization
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
# Tauri updater signing
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Windows signing (Azure Key Vault via relic)
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
with:
tagName: v__VERSION__
releaseName: "YourApp v__VERSION__"
releaseBody: "See [CHANGELOG](https://github.com/YOUR_USERNAME/YOUR_REPO/blob/main/CHANGELOG.md) for details."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
Step-by-Step Breakdown
Let's walk through what each step does and why it's there.
Concurrency
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
If you push a tag, realize there's a typo, delete it, and push a corrected one -- the in-progress build is cancelled. This prevents wasting runner minutes.
Permissions
permissions:
contents: write
Required for tauri-action to create a GitHub Release and upload artifacts. Without this, you'll get a "Resource not accessible by integration" error.
Checkout
- name: Checkout repository
uses: actions/checkout@v4
Standard checkout. Nothing special here.
Node.js + npm
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "npm"
Uses the latest LTS version and caches node_modules based on package-lock.json. The frontend build runs via beforeBuildCommand in your Tauri config.
Rust Toolchain
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
Installs the stable Rust toolchain with the specific cross-compilation target. For macOS runners, this might be aarch64-apple-darwin (ARM) or x86_64-apple-darwin (Intel).
Rust Cache
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
Caches the target/ directory between runs. Without this, Rust compilation takes 5-15 minutes per build. With caching, subsequent builds are much faster.
Frontend Dependencies
- name: Install frontend dependencies
run: npm ci
npm ci (not npm install) does a clean install from the lockfile. This ensures reproducible builds.
Apple Certificate Import (macOS only)
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH=$RUNNER_TEMP/certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 24)
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH"
echo "Available signing identities:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
This is the most involved step. Here's what each line does:
-
Decode the certificate -- the base64-encoded
.p12from your secret is written to a temp file - Create a temporary keychain -- GitHub Actions runners have a default keychain, but importing into a fresh one avoids conflicts. The password is randomly generated for this build
-
Set it as the default -- so
codesignuses it automatically -
Configure keychain settings --
-lut 21600sets a 6-hour lock timeout (enough for the longest build) - Unlock the keychain -- so signing doesn't prompt for a password
-
Import the certificate --
-Aallows all apps to access it,-t cert -f pkcs12specifies the format -
Set partition list -- this is the critical step that allows
codesignto access the imported key without a GUI prompt. Without this, signing fails silently. - Register the keychain -- adds it to the user's keychain search list
- List identities -- a debugging aid that prints available signing identities in the build log
Why not let
tauri-actionhandle the certificate import? You can --tauri-actiondoes support certificate import via its own inputs. But doing it explicitly gives you more control and better error messages when something goes wrong.
Build and Release
- name: Build and release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
with:
tagName: v__VERSION__
releaseName: "YourApp v__VERSION__"
releaseBody: "See [CHANGELOG](https://github.com/YOUR_USERNAME/YOUR_REPO/blob/main/CHANGELOG.md) for details."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
tauri-action does the heavy lifting:
- Runs
npm run tauri buildwith the specified--targetargument - Tauri compiles the Rust backend, bundles the frontend, and creates the installer
- During bundling, Tauri calls
codesign(macOS) or yoursignCommand(Windows) to sign the binaries - For macOS, Tauri submits the signed app to Apple for notarization and waits for the result
- If
createUpdaterArtifactsis enabled, it generates.sigfiles andlatest.json - Creates (or updates) a draft GitHub Release for the tag
- Uploads all artifacts to the release
Key with parameters:
| Parameter | Purpose |
|---|---|
tagName |
v__VERSION__ -- the __VERSION__ placeholder is replaced with the version from tauri.conf.json
|
releaseName |
Display name for the GitHub Release |
releaseBody |
Release description (we link to the changelog) |
releaseDraft |
true creates a draft release -- you review it before publishing |
prerelease |
Whether to mark it as a prerelease |
args |
Passed directly to tauri build (e.g., --target aarch64-apple-darwin) |
Since all three matrix jobs reference the same tag, they all upload to the same draft release. The first job to run creates the release, and subsequent jobs add their artifacts.
Repository Settings
Workflow Permissions
By default, GITHUB_TOKEN has read-only permissions. You need to enable write access:
- Go to your repository on GitHub
- Settings > Actions > General
- Scroll to Workflow permissions
- Select Read and write permissions
- Save
Adding Secrets
Go to Settings > Secrets and variables > Actions and add each secret:
macOS:
APPLE_CERTIFICATEAPPLE_CERTIFICATE_PASSWORDAPPLE_SIGNING_IDENTITYAPPLE_TEAM_IDAPPLE_IDAPPLE_PASSWORD
Windows:
AZURE_CLIENT_IDAZURE_TENANT_IDAZURE_CLIENT_SECRET
Updater:
TAURI_SIGNING_PRIVATE_KEYTAURI_SIGNING_PRIVATE_KEY_PASSWORD
GITHUB_TOKENis automatically provided by GitHub Actions -- you don't need to create it manually.
Release Automation Script
Now that CI is configured, you need a convenient way to trigger it. We'll build a shell script that:
- Bumps the version in all three files (
package.json,tauri.conf.json,Cargo.toml) - Regenerates
Cargo.lock - Auto-generates a changelog from conventional commits
- Creates a git commit and tag
- Tells you to push
What It Does
The release flow is:
./scripts/release.sh patch
|
v
Bumps 0.1.0 -> 0.1.1 in package.json, tauri.conf.json, Cargo.toml
|
v
Regenerates Cargo.lock
|
v
Parses git log for conventional commits since last tag
|
v
Generates changelog entry and inserts into CHANGELOG.md
|
v
Creates commit: "chore: release v0.1.1"
|
v
Creates tag: v0.1.1
|
v
You run: git push origin main --tags
|
v
GitHub Actions triggers on the v* tag
|
v
Draft release appears with .dmg and .exe
The Script
Create scripts/release.sh:
#!/bin/bash
# Release Script
# Usage: ./scripts/release.sh [major|minor|patch|x.y.z]
#
# Automates the full release process:
# 1. Bumps version in package.json, tauri.conf.json, Cargo.toml
# 2. Regenerates Cargo.lock
# 3. Generates changelog from conventional commits since last tag
# 4. Creates a release commit and git tag
#
# The CI release workflow (.github/workflows/release.yml) triggers on tag push
# and builds cross-platform binaries automatically.
set -eo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
DIM='\033[2m'
NC='\033[0m'
step() { printf '\n%b==>%b %s\n' "$BLUE" "$NC" "$1"; }
ok() { printf ' %bok%b %s\n' "$GREEN" "$NC" "$1"; }
fail() { printf ' %berror%b %s\n' "$RED" "$NC" "$1"; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
# ---------------------------------------------------------------------------
# Check required tools
# ---------------------------------------------------------------------------
for cmd in node cargo git; do
command -v "$cmd" >/dev/null 2>&1 || fail "Required tool not found: $cmd"
done
# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------
if [ -z "${1:-}" ]; then
echo "Usage: ./scripts/release.sh [major|minor|patch|x.y.z]"
echo ""
echo "Examples:"
echo " ./scripts/release.sh patch # 0.1.8 -> 0.1.9"
echo " ./scripts/release.sh minor # 0.1.8 -> 0.2.0"
echo " ./scripts/release.sh major # 0.1.8 -> 1.0.0"
echo " ./scripts/release.sh 0.2.0 # explicit version"
exit 1
fi
# ---------------------------------------------------------------------------
# Read current version from package.json
# ---------------------------------------------------------------------------
CURRENT_VERSION=$(node -p "require('./package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
case "$1" in
major) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
minor) NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" ;;
patch) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
*)
if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
fail "Invalid version format. Use x.y.z (e.g., 1.2.3)"
fi
NEW_VERSION="$1"
;;
esac
printf '%bRelease%b\n' "$BLUE" "$NC"
printf ' current : %b%s%b\n' "$DIM" "$CURRENT_VERSION" "$NC"
printf ' next : %b%s%b\n' "$GREEN" "$NEW_VERSION" "$NC"
echo ""
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
step "Running pre-flight checks"
if [[ -n $(git status --porcelain) ]]; then
fail "Uncommitted changes detected. Commit or stash them first."
fi
ok "Working tree clean"
CURRENT_BRANCH=$(git branch --show-current)
if [[ "$CURRENT_BRANCH" != "main" ]]; then
printf ' %bwarning%b You are on branch '\''%s'\'', not '\''main'\''.\n' "$YELLOW" "$NC" "$CURRENT_BRANCH"
read -p " Continue anyway? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
else
ok "On branch main"
fi
if git tag | grep -q "^v${NEW_VERSION}$"; then
fail "Tag v${NEW_VERSION} already exists."
fi
ok "Tag v${NEW_VERSION} is available"
# ---------------------------------------------------------------------------
# Confirm
# ---------------------------------------------------------------------------
read -p "Proceed with release v${NEW_VERSION}? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
# ---------------------------------------------------------------------------
# Bump versions
# ---------------------------------------------------------------------------
step "Updating version numbers"
node -e "
const fs = require('fs');
const version = '${NEW_VERSION}';
// package.json
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
pkg.version = version;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
// src-tauri/tauri.conf.json
const conf = JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json', 'utf-8'));
conf.version = version;
fs.writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(conf, null, 2) + '\n');
// src-tauri/Cargo.toml
const cargo = fs.readFileSync('src-tauri/Cargo.toml', 'utf-8');
fs.writeFileSync('src-tauri/Cargo.toml',
cargo.replace(/^(version\s*=\s*)\"[^\"]*\"/m, \`\\\$1\"\${version}\"\`)
);
"
ok "package.json"
ok "src-tauri/tauri.conf.json"
ok "src-tauri/Cargo.toml"
# ---------------------------------------------------------------------------
# Regenerate Cargo.lock
# ---------------------------------------------------------------------------
step "Regenerating Cargo.lock"
(cd src-tauri && cargo generate-lockfile --quiet)
ok "Cargo.lock"
# ---------------------------------------------------------------------------
# Generate changelog entry and insert into CHANGELOG.md
# ---------------------------------------------------------------------------
step "Generating changelog"
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
RANGE="${LAST_TAG}..HEAD"
else
RANGE="HEAD"
fi
TODAY=$(date +%Y-%m-%d)
CHANGELOG_ENTRY=$(node -e "
const { execSync } = require('child_process');
const fs = require('fs');
const range = '${RANGE}';
const version = '${NEW_VERSION}';
const today = '${TODAY}';
// Parse conventional commits
const log = execSync(
'git log ' + range + ' --pretty=format:\"%s\" --reverse',
{ encoding: 'utf-8' }
);
const lines = log.split('\n').filter(Boolean);
const added = [];
const fixed = [];
const changed = [];
const pattern = /^(feat|fix|refactor|perf|build|style|docs|test|chore)(\(.*?\))?\!?:\s(.+)$/;
for (const line of lines) {
const m = line.match(pattern);
if (!m) continue;
const [, type, scope, msg] = m;
const entry = scope
? '**' + scope.slice(1, -1) + '**: ' + msg
: msg;
switch (type) {
case 'feat': added.push(entry); break;
case 'fix': fixed.push(entry); break;
case 'refactor': case 'perf': case 'style': changed.push(entry); break;
}
}
// Build changelog section
let entry = '## [' + version + '] - ' + today;
let hasContent = false;
if (added.length) {
entry += '\n\n### Added\n\n' + added.map(e => '- ' + e).join('\n');
hasContent = true;
}
if (fixed.length) {
entry += '\n\n### Fixed\n\n' + fixed.map(e => '- ' + e).join('\n');
hasContent = true;
}
if (changed.length) {
entry += '\n\n### Changed\n\n' + changed.map(e => '- ' + e).join('\n');
hasContent = true;
}
if (!hasContent) {
entry += '\n\nMaintenance release.';
}
// Insert into CHANGELOG.md
const changelog = fs.readFileSync('CHANGELOG.md', 'utf-8');
const marker = '\n## [';
const idx = changelog.indexOf(marker);
if (idx !== -1) {
const before = changelog.slice(0, idx);
const after = changelog.slice(idx);
fs.writeFileSync('CHANGELOG.md', before + '\n\n' + entry + '\n' + after);
} else {
fs.writeFileSync('CHANGELOG.md', changelog.trimEnd() + '\n\n' + entry + '\n');
}
// Output entry for preview
process.stdout.write(entry);
")
ok "CHANGELOG.md"
# Show the generated changelog for review
echo ""
printf '%b--- changelog preview ---%b\n' "$DIM" "$NC"
echo "$CHANGELOG_ENTRY"
printf '%b--- end preview ---%b\n' "$DIM" "$NC"
echo ""
read -p "Does the changelog look good? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo ""
echo "Edit CHANGELOG.md manually, then run:"
echo " git add package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json CHANGELOG.md"
echo " git commit -m \"chore: release v${NEW_VERSION}\""
echo " git tag -a v${NEW_VERSION} -m \"Release v${NEW_VERSION}\""
echo " git push origin main --tags"
exit 0
fi
# ---------------------------------------------------------------------------
# Git commit and tag
# ---------------------------------------------------------------------------
step "Creating release commit"
git add package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json CHANGELOG.md
git commit -m "chore: release v${NEW_VERSION}"
ok "Committed"
step "Creating tag v${NEW_VERSION}"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
ok "Tagged"
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo ""
printf '%b========================================%b\n' "$GREEN" "$NC"
printf '%b Released v%s%b\n' "$GREEN" "$NEW_VERSION" "$NC"
printf '%b========================================%b\n' "$GREEN" "$NC"
echo ""
echo "Next steps:"
echo " 1. Review the commit: git show HEAD"
echo " 2. Push to trigger CI: git push origin main --tags"
echo ""
echo " CI will build cross-platform binaries and create a draft GitHub release."
echo ""
echo "To undo this release:"
echo " git tag -d v${NEW_VERSION} && git reset --soft HEAD~1"
Make it executable:
chmod +x scripts/release.sh
How the Script Works
Let's break down the key parts.
Version Bumping
The script updates version strings in three files simultaneously:
-
package.json-- the canonical version source, also used bynpm -
src-tauri/tauri.conf.json-- Tauri reads this for the app version. The__VERSION__placeholder in your workflow'stagName: v__VERSION__is resolved from here. -
src-tauri/Cargo.toml-- the Rust crate version. Must match for consistency.
All three must stay in sync. The script uses a single node -e invocation to update all of them atomically.
Cargo.lock Regeneration
After bumping Cargo.toml, the lockfile is out of date:
(cd src-tauri && cargo generate-lockfile --quiet)
This regenerates it without compiling anything. If you skip this step, CI will see a lockfile mismatch and may produce unexpected results.
Changelog Generation
The script parses your git history using conventional commits:
| Prefix | Changelog Section |
|---|---|
feat: |
Added |
fix: |
Fixed |
refactor:, perf:, style:
|
Changed |
chore:, docs:, test:, build:
|
Skipped |
Scoped commits like feat(ui): add dark mode become **ui**: add dark mode in the changelog.
The generated entry is inserted into CHANGELOG.md before the previous version, maintaining the reverse-chronological order expected by Keep a Changelog.
You get a preview and a chance to bail out and edit manually before the commit is created.
Safe Pre-flight Checks
Before doing anything destructive, the script verifies:
- Working tree is clean -- no uncommitted changes that would be included accidentally
-
Branch check -- warns if you're not on
main(you might be on a feature branch by accident) - Tag availability -- confirms the target tag doesn't already exist
The Undo Escape Hatch
If something went wrong:
git tag -d v0.1.1 && git reset --soft HEAD~1
This deletes the local tag and undoes the commit while keeping your changes staged. Clean recovery.
npm Script Entry Point
Add this to your package.json for convenience:
{
"scripts": {
"release:new": "bash scripts/release.sh"
}
}
Usage:
npm run release:new -- patch # 0.1.0 -> 0.1.1
npm run release:new -- minor # 0.1.0 -> 0.2.0
npm run release:new -- major # 0.1.0 -> 1.0.0
npm run release:new -- 2.0.0 # explicit version
The Full Release Flow
Here's the complete end-to-end process:
1. Prepare
Make sure all your changes are committed and pushed. The release script requires a clean working tree.
git status # Should be clean
2. Run the Release Script
npm run release:new -- patch
Output looks like:
Release
current : 0.2.0
next : 0.3.0
==> Running pre-flight checks
ok Working tree clean
ok On branch main
ok Tag v0.3.0 is available
Proceed with release v0.3.0? (y/n) y
==> Updating version numbers
ok package.json
ok src-tauri/tauri.conf.json
ok src-tauri/Cargo.toml
==> Regenerating Cargo.lock
ok Cargo.lock
==> Generating changelog
ok CHANGELOG.md
--- changelog preview ---
## [0.3.0] - 2026-02-19
### Added
- **ui**: add dark mode toggle
- **i18n**: add Japanese translations
### Fixed
- **auth**: fix PIN validation edge case
--- end preview ---
Does the changelog look good? (y/n) y
==> Creating release commit
ok Committed
==> Creating tag v0.3.0
ok Tagged
========================================
Released v0.3.0
========================================
Next steps:
1. Review the commit: git show HEAD
2. Push to trigger CI: git push origin main --tags
3. Review and Push
git show HEAD # Review the release commit
git push origin main --tags # Trigger CI
4. Monitor the Build
Go to your repository's Actions tab. You'll see three jobs running:
-
release (macos-latest, aarch64-apple-darwin)-- ~10-15 min -
release (macos-latest, x86_64-apple-darwin)-- ~10-15 min -
release (windows-latest, x86_64-pc-windows-msvc)-- ~8-12 min
The macOS builds take longer because of the notarization step -- Tauri uploads the signed app to Apple and waits for the response.
5. Review the Draft Release
Once all three jobs complete, go to Releases in your repository. You'll find a draft release with:
-
YourApp_0.3.0_aarch64.dmg-- macOS Apple Silicon installer -
YourApp_0.3.0_x64.dmg-- macOS Intel installer -
YourApp_0.3.0_x64-setup.exe-- Windows installer -
YourApp_0.3.0_x64-setup.nsis.zip-- Windows NSIS archive -
latest.json-- Updater manifest -
.sigfiles -- Updater signatures
Edit the release notes if needed, then click Publish release.
Verifying Your Release
macOS Verification
Download the .dmg and check that the app is properly signed and notarized:
# Check code signing
codesign --verify --deep --strict /Applications/YourApp.app
# Check notarization
spctl -a -v /Applications/YourApp.app
The spctl command should output:
/Applications/YourApp.app: accepted
source=Notarized Developer ID
If you see "rejected", the notarization failed. Check the CI build logs for errors.
Windows Verification
On Windows, right-click the .exe installer > Properties > Digital Signatures tab. You should see your certificate listed.
If SmartScreen still shows a warning, your certificate is new and hasn't built reputation yet. This improves over time as more users install your app. You can accelerate this by submitting your binary to Microsoft's file submission portal.
Troubleshooting
"Resource not accessible by integration"
Your GITHUB_TOKEN doesn't have write permissions. Go to Settings > Actions > General > Workflow permissions and enable Read and write permissions.
macOS: "No signing identity found"
The certificate wasn't imported correctly. Common causes:
-
APPLE_CERTIFICATEsecret doesn't contain valid base64 -
APPLE_CERTIFICATE_PASSWORDis wrong - The certificate has expired
Check the "Import Apple signing certificate" step output -- it prints available identities. If the list is empty, the import failed.
macOS: Notarization fails with "invalid credentials"
- Verify
APPLE_IDis your Apple account email - Verify
APPLE_PASSWORDis an app-specific password, not your Apple ID password - Verify
APPLE_TEAM_IDis correct (check your membership page) - Make sure 2FA is enabled on your Apple ID (required for app-specific passwords)
Windows: "relic: command not found"
Relic needs to be installed on the Windows runner. Add this step before the build:
- name: Install relic
if: runner.os == 'Windows'
run: go install github.com/sassoftware/relic/v8@latest
Go is pre-installed on GitHub Actions Windows runners.
Windows: Azure Key Vault 403 / access denied
Check that your App Registration has both required roles on the Key Vault:
- Key Vault Certificate User
- Key Vault Crypto User
Also verify that the AZURE_CLIENT_SECRET hasn't expired.
Build succeeds but no artifacts on the release
This usually means the tagName doesn't match. The v__VERSION__ placeholder is replaced with the version from tauri.conf.json. If you tagged v0.3.0 but tauri.conf.json says 0.2.0, the action creates a release for v0.2.0 (which doesn't match your tag) and things get confused.
Always use the release script to keep versions in sync.
Slow builds
First builds are the slowest because there's no Rust compilation cache. The swatinem/rust-cache@v2 action caches the target/ directory, so subsequent builds should be significantly faster.
For macOS, notarization adds 2-5 minutes per build. There's no way around this -- Apple needs time to scan your binary.
Conclusion
Here's what we've set up across both parts:
- macOS code signing with a Developer ID Application certificate
- macOS notarization via Apple ID + app-specific password
- Windows code signing via Azure Key Vault + relic
- Tauri updater signing for secure in-app updates
- GitHub Actions workflow that builds for 3 targets (macOS ARM, macOS Intel, Windows x64)
- Release automation script that bumps versions, generates changelogs, and creates tags
- Draft releases for review before publishing
The result: run npm run release:new -- patch, push, wait ~15 minutes, review the draft, and publish. Your users get signed, notarized, verified installers on every platform.
The entire pipeline shown here is used in production by Fortuna, an open-source personal wealth management app built with Tauri v2. Feel free to browse the repository for the full implementation and to participate in the project with a PR if you wish!
Quick Reference
# One-time setup
# 1. Create Apple Developer ID certificate (see Part 1)
# 2. Set up Azure Key Vault for Windows signing (see Part 1)
# 3. Generate Tauri updater keys:
npx tauri signer generate -w ~/.tauri/myapp.key
# 4. Add all 11 secrets to GitHub (see Part 1 summary)
# 5. Enable Actions write permissions in repo settings
# Every release
npm run release:new -- patch # or minor, major, x.y.z
git push origin main --tags # triggers CI
# Wait for builds, review draft release, publish
Top comments (0)