Tauri アプリを GitHub Actions でビルドし Apple の署名・公証をして配布する

2026-07-24 01:33 (18 hours ago)
Tauri アプリを GitHub Actions でビルドし Apple の署名・公証をして配布する

Tauri で作った macOS アプリを配布するとき、無署名や ad-hoc 署名のままだと、ダウンロードしたユーザーの環境で Gatekeeper に「開発元を確認できないため開けません」と弾かれる。macOS 15 (Sequoia) では右クリック→開くのバイパスすら廃止され、システム設定から手動で許可させる必要がある。これを消すには Developer ID 署名 + Apple の公証 (notarization) が要る。

この記事では、Tauri アプリを GitHub Actions で macOS / Windows 両方ビルドし、macOS 版に Developer ID 署名 + 公証をかけて GitHub Release に公開するまでの手順を、コミット SHA 固定やバージョン自動採番といった実運用の細部まで含めて書く。ビルドは push で自動起動せず、pnpm release コマンドで手動トリガーする構成にする。

前提

  • Apple Developer Program のメンバーシップ ($99/年) が必要。公証は Developer ID 証明書でしか通らない。無料の Apple ID では不可。
  • ローカルの Keychain に 「Developer ID Application」証明書とその秘密鍵が入っていること。Xcode か Apple Developer サイトで作成する。
  • リポジトリは GitHub。public リポジトリなら GitHub Actions の macOS / Windows ランナーは無料・無制限で使える (private は macOS が 10 倍、Windows が 2 倍の分数消費レート)。
  • パッケージマネージャは pnpm を想定 (npm / yarn でも読み替え可)。

Apple Silicon は無署名バイナリを実行できないため、「署名しない」という選択肢は実質存在しない。最低でも ad-hoc 署名が要り、配布するなら Developer ID + 公証まで通すのが正解になる。

ワークフローの全体像

  • トリガー: workflow_dispatch のみ。push では自動ビルドしない。
  • ビルド: matrix で macOS (universal dmg) と Windows (NSIS exe) を並列ビルドし、tauri-apps/tauri-actionv<version>draft Release にアップロードする。全プラットフォームのビルドが成功したら、後続の publish ジョブが draft を公開する。
  • 署名: macOS ジョブでだけ Developer ID 証明書を一時 keychain にインポートし、tauri-action に署名・公証用の環境変数を渡す。
  • 採番: ローカルの pnpm release スクリプトが version を bump してコミット・push し、workflow を起動して完了まで見守る。

1. tauri.conf.json で署名方針を決める

src-tauri/tauri.conf.json の bundle に、macOS の署名 identity を書く。ローカルビルド用に ad-hoc ("-") を指定しておく。

{
  "bundle": {
    "active": true,
    "targets": "all",
    "macOS": {
      "signingIdentity": "-"
    }
  }
}

ここがポイントで、CI では環境変数 APPLE_SIGNING_IDENTITY がこの config の値を上書きする。tauri-cli のソース (crates/tauri-cli/src/interface/rust.rs) を読むと、署名 identity は次のように解決されている。

let signing_identity = match std::env::var_os("APPLE_SIGNING_IDENTITY") {
    Some(signing_identity) => Some(/* env の値を使う */),
    None => config.macos.signing_identity,   // env が無ければ config
};

つまり env があればそれが勝つ。よって config に "-" を残しておけば、環境変数を渡さないローカルビルドは ad-hoc 署名 (すぐ動く)、環境変数を渡す CI は Developer ID 署名、という両立ができる。config から signingIdentity を消してしまうと、ローカルビルドが tauri による署名なしになるので残しておく。

2. リリースワークフロー release.yml

.github/workflows/release.yml を作る。matrix で mac / win を並列ビルドし、draft Release にアップロードしてから publish ジョブで公開する。

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

      # (署名ステップは次章で追加)

      - name: Build and upload to GitHub Release (draft)
        uses: tauri-apps/tauri-action@<COMMIT_SHA>  # v0 を SHA 固定 (後述)
        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

releaseDraft: true と publish ジョブの分離が肝心。matrix の 2 ジョブは同じ v<version> の Release に成果物を追加していくが、releaseDraft: false にすると先に終わった片方のプラットフォームだけの不完全な Release がその場で公開されてしまう (mac の universal ビルドは win の NSIS の倍近く時間がかかる)。draft で作り、全ジョブ成功後に publish ジョブ (needs: build で全 matrix leg の成功を待つ) が gh release edit --draft=false で公開すれば、片方が失敗したときは draft のまま残り、公開されない。

gh release edit <tag> は、まだ tag が存在しない draft Release でも tag 名で解決できる (gh CLI の draft fallback)。Actions の GITHUB_TOKEN でそのまま動く。

3. Developer ID 署名と公証のステップ

macOS ジョブだけで、証明書を一時 keychain にインポートするステップを、ビルドステップの前に追加する。tauri-action は証明書を自分でインポートしないので、自前で用意する。

      - 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"

keychain のパスワードは使い捨てランナー内でしか使わないので、openssl rand で生成して secret にしない。default-keychain -s で作った keychain を既定にしておくと、後続のビルドステップ (別ステップ) で codesign が identity を見つけられる。

ビルドステップに、署名・公証用の環境変数を渡す。

      - 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 }}

APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID の 4 つが揃うと、tauri-action は署名した上で公証まで自動で行い、公証チケットをアプリに staple する。APPLE_ID はアカウントのメール、APPLE_PASSWORD は通常のパスワードではなく App 用パスワード (後述)、APPLE_TEAM_ID は 10 桁の Team ID。

環境変数の値を ${{ matrix.platform == 'macos-latest' && secrets.X || '' }} で macOS ジョブに限定しているのは、Windows ジョブの action に Apple の認証情報を渡さないため。Windows の Tauri bundler は macOS の署名処理を実行しないので機能上は無害だが、secret の露出面はできるだけ狭くする。

tauri-action を commit SHA に固定する

上のワークフローで tauri-apps/tauri-action@<COMMIT_SHA> としているのは意図的で、@v0 のような可変タグを使わない。この action は Apple の秘密鍵入り証明書と認証情報を受け取るので、もしタグが差し替えられたら secret を抜かれる。GitHub の Secure Use ガイドラインでも third-party action は full commit SHA に固定することを推奨している。使いたいバージョンの commit SHA は次で取れる。

gh api repos/tauri-apps/tauri-action/git/refs/tags/v0 --jq '.object.sha'
# annotated tag の場合はさらに
gh api repos/tauri-apps/tauri-action/git/tags/<上のSHA> --jq '.object.sha'

得られた SHA を uses: tauri-apps/tauri-action@<SHA> # v0 の形で固定し、コメントで元のバージョンを残す。

4. GitHub Secrets を登録する

6 つの secret を登録する。証明書とパスワードは人間の手作業になる。

証明書を .p12 で書き出す: Keychain Access で「Developer ID Application」証明書を選び、秘密鍵がぶら下がっていることを確認して、右クリック→書き出す で .p12 を作る (書き出し用パスワードを付ける)。

App 用パスワードを発行する: https://account.apple.com/ にサインインし、「サインインとセキュリティ」→「App 用パスワード」で生成する。2 ファクタ認証が有効な Apple ID でのみ発行できる。通常の Apple ID パスワードでは公証は通らない。

secret を登録する:

base64 -i cert.p12 | gh secret set APPLE_CERTIFICATE
gh secret set APPLE_CERTIFICATE_PASSWORD    # .p12 のパスワード
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 用パスワード (xxxx-xxxx-xxxx-xxxx)

APPLE_SIGNING_IDENTITY の文字列は、security find-identity -v -p codesigning の出力にあるダブルクォート内の文字列と一致させる。使う Apple ID はその Team のメンバーで、開発者契約に同意済みであること。未同意だと署名は通っても公証で弾かれる。

.p12 を登録し終えたらローカルのファイルは消す (rm cert.p12)。秘密鍵の平文コピーを残さない。

secret は 1 個にまとめず、配布を自動化する

6 個を個別に登録するのは、同じ署名を複数のアプリに入れるとなると手間が増える。「全部を env ファイル形式にして 1 個の secret (例 APPLE_BUILD_ENV) に押し込む」という発想が出るが、これはやめたほうがいい。

GitHub は登録済み secret の値をログ出力時に自動でマスク (***) する。6 個個別なら、証明書パスワードも .p12 の base64 もそれぞれ独立してマスクされる。ところが 1 個の塊にまとめると、GitHub がマスクするのはその塊の文字列そのものだけで、ワークフロー内で source して展開した個別の値はマスク対象外になる。サードパーティ action の env ダンプ、set -xACTIONS_STEP_DEBUG のいずれか一つで、署名鍵のパスワードが平文でログに残る。署名 credential でこのマスクを捨てるのは割に合わない。加えて、署名 identity のスペースや base64 のパディングを env ファイルでクォートし忘れると source が壊れるという脆さもある。

本当に減らしたいのは「リポジトリごとに 6 回入力する手作業」なので、そこを自動化する。ローカルに 1 個の env ファイルを置き、そこから 6 個のマスク済み secret をリポジトリに流し込むスクリプトにすれば、GitHub 側は 6 個の個別 secret のまま (マスク維持) で、手作業はリポジトリごとに 1 コマンドになる。

ローカル ~/.config/apple-signing.env (リポジトリの外に置き、gitignore する):

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

配布スクリプト push-apple-secrets.sh:

#!/usr/bin/env bash
set -euo pipefail
REPO="$1"                                    # 例: 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"

使い方:

./push-apple-secrets.sh yourname/app-a
./push-apple-secrets.sh yourname/app-b

これで複数アプリに同じ署名を横展開するときも 1 リポジトリ 1 コマンドで済む。GitHub 上は 6 個の正しくマスクされた secret のままで、ワークフロー側は変更不要。減らすべきは「secret の個数」ではなく「登録作業」だという整理になる。

5. バージョンを自動採番する release スクリプト

公開済みと同じ version で workflow を再実行すると、tauri-action が draft 状態の不一致でエラーになる。つまり version は毎回インクリメントする必要がある。手で上げ忘れると必ずここで転ぶので、採番をスクリプトに巻き取る。

scripts/release.sh を作り、package.json の scripts に "release": "bash scripts/release.sh" を足す。pnpm publish は npm registry への publish を行う pnpm 組み込みコマンドで scripts で上書きできないため、名前は 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

# gh を先に検証する。push 後に gh が使えないと、bump コミットだけが main に
# 載って workflow が起動されず、公開されない version が取り残される。
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; }

# 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; }

# 現行 version を厳密な X.Y.Z としてだけ受け付けて bump する。
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}")

# tauri.conf.json / package.json の version を、両方の置換成功を確認してから書き込む。
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 は run ID を返さないので、トリガー前の最新 run と異なる ID を拾う。
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

いくつか実運用の細部が入っている。version の検証を正規表現で行っているのは、[maj,min,pat].some(Number.isNaN) のような素朴なチェックだと Number.isNaN(undefined)false になるため 1.2 (→ 1.2.NaN) や 1.2.3.4 (→ 1.2.4) を素通しするから。2 ファイルの version 置換は両方の成功を確認してから書き込み、片方だけ更新される事態を避ける。gh の検証をコミット・push の前に置いているのは、push が済んだ後に gh が使えないと、公開されない version が main に取り残されるのを防ぐため。

6. リリースを実行して検証する

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

スクリプトが version を上げてコミット・push し、workflow を起動して完了まで watch する。完了したら、公開された dmg をダウンロードして、署名と公証が本当に効いているか実機で確認する。

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

spctlsource=Notarized Developer ID を返し、stapler validate が成功すれば、公証チケットがアプリに添付されていて、ユーザーがダウンロードして開いても Gatekeeper 警告は一切出ない。codesign の flags に runtime が含まれているのは hardened runtime が有効ということで、これは公証の必須要件。

設計上の判断と落とし穴

  • signingIdentity: "-" は消さずに残す。CI では APPLE_SIGNING_IDENTITY env が上書きするので、残しておけば「CI は Developer ID、ローカルは ad-hoc」が両立する。消すとローカルビルドが署名なしになる。
  • Developer ID 署名だけで公証を省くのは中途半端。署名だけだとダウンロード配布では警告が残り、macOS 15 では右クリック→開くも廃止されてシステム設定からの許可が必要。署名するなら公証まで通す。自己署名証明書も Gatekeeper 上は ad-hoc と同じなので配布には無意味。
  • draft → publish を分けないと不完全な Release が公開される。matrix ビルドは片方が先に終わるので、必ず draft で作って全 leg 成功後に公開する。
  • version は毎回上げる。同じ version での再実行は tauri-action の draft 状態不一致で失敗する。採番を自動化して bump 忘れを構造的に消す。
  • pnpm publish は使えない。pnpm 組み込みコマンドと衝突するので、スクリプト名は release にする。
  • secret を受け取る action は commit SHA で固定する。可変タグはタグ差し替えで secret を抜かれるリスクがある。
  • 6 個の secret を 1 個の env ブロブにまとめない。GitHub の per-secret ログマスキングが効かなくなり、署名鍵のパスワードが平文で漏れうる。手間は「1 個にまとめる」のではなく「登録の自動化」で減らす (第 4 章)。
  • Windows は署名なしで割り切ることが多い。コード署名証明書 (OV/EV) は有料で CI 連携も面倒なので、社内・小規模配布なら未署名 (SmartScreen 警告は「詳細情報→実行」で回避) で済ませる判断もある。
評価をお願いします (会員登録・ログイン不要)
まだ評価がありません
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ