DEV Community

Thomas Cosialls
Thomas Cosialls

Posted on

Ship Your Tauri v2 App Like a Pro: GitHub Actions and Release Automation (Part 2/2)

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:

  1. A GitHub Actions workflow that builds, signs, and publishes your app for macOS and Windows
  2. A release automation script that bumps versions, generates changelogs, and triggers the pipeline with a single command
  3. 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

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*"
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Standard checkout. Nothing special here.

Node.js + npm

- name: Install Node.js
  uses: actions/setup-node@v4
  with:
    node-version: "lts/*"
    cache: "npm"
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

This is the most involved step. Here's what each line does:

  1. Decode the certificate -- the base64-encoded .p12 from your secret is written to a temp file
  2. 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
  3. Set it as the default -- so codesign uses it automatically
  4. Configure keychain settings -- -lut 21600 sets a 6-hour lock timeout (enough for the longest build)
  5. Unlock the keychain -- so signing doesn't prompt for a password
  6. Import the certificate -- -A allows all apps to access it, -t cert -f pkcs12 specifies the format
  7. Set partition list -- this is the critical step that allows codesign to access the imported key without a GUI prompt. Without this, signing fails silently.
  8. Register the keychain -- adds it to the user's keychain search list
  9. List identities -- a debugging aid that prints available signing identities in the build log

Why not let tauri-action handle the certificate import? You can -- tauri-action does 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 }}
Enter fullscreen mode Exit fullscreen mode

tauri-action does the heavy lifting:

  1. Runs npm run tauri build with the specified --target argument
  2. Tauri compiles the Rust backend, bundles the frontend, and creates the installer
  3. During bundling, Tauri calls codesign (macOS) or your signCommand (Windows) to sign the binaries
  4. For macOS, Tauri submits the signed app to Apple for notarization and waits for the result
  5. If createUpdaterArtifacts is enabled, it generates .sig files and latest.json
  6. Creates (or updates) a draft GitHub Release for the tag
  7. 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:

  1. Go to your repository on GitHub
  2. Settings > Actions > General
  3. Scroll to Workflow permissions
  4. Select Read and write permissions
  5. Save

Adding Secrets

Go to Settings > Secrets and variables > Actions and add each secret:

macOS:

  • APPLE_CERTIFICATE
  • APPLE_CERTIFICATE_PASSWORD
  • APPLE_SIGNING_IDENTITY
  • APPLE_TEAM_ID
  • APPLE_ID
  • APPLE_PASSWORD

Windows:

  • AZURE_CLIENT_ID
  • AZURE_TENANT_ID
  • AZURE_CLIENT_SECRET

Updater:

  • TAURI_SIGNING_PRIVATE_KEY
  • TAURI_SIGNING_PRIVATE_KEY_PASSWORD

GITHUB_TOKEN is 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:

  1. Bumps the version in all three files (package.json, tauri.conf.json, Cargo.toml)
  2. Regenerates Cargo.lock
  3. Auto-generates a changelog from conventional commits
  4. Creates a git commit and tag
  5. 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x scripts/release.sh
Enter fullscreen mode Exit fullscreen mode

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 by npm
  • src-tauri/tauri.conf.json -- Tauri reads this for the app version. The __VERSION__ placeholder in your workflow's tagName: 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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Working tree is clean -- no uncommitted changes that would be included accidentally
  2. Branch check -- warns if you're not on main (you might be on a feature branch by accident)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Run the Release Script

npm run release:new -- patch
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

3. Review and Push

git show HEAD         # Review the release commit
git push origin main --tags  # Trigger CI
Enter fullscreen mode Exit fullscreen mode

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
  • .sig files -- 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
Enter fullscreen mode Exit fullscreen mode

The spctl command should output:

/Applications/YourApp.app: accepted
source=Notarized Developer ID
Enter fullscreen mode Exit fullscreen mode

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_CERTIFICATE secret doesn't contain valid base64
  • APPLE_CERTIFICATE_PASSWORD is 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_ID is your Apple account email
  • Verify APPLE_PASSWORD is an app-specific password, not your Apple ID password
  • Verify APPLE_TEAM_ID is 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. macOS code signing with a Developer ID Application certificate
  2. macOS notarization via Apple ID + app-specific password
  3. Windows code signing via Azure Key Vault + relic
  4. Tauri updater signing for secure in-app updates
  5. GitHub Actions workflow that builds for 3 targets (macOS ARM, macOS Intel, Windows x64)
  6. Release automation script that bumps versions, generates changelogs, and creates tags
  7. 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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)