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-actionでv<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 -x、ACTIONS_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
spctl が source=Notarized Developer ID を返し、stapler validate が成功すれば、公証チケットがアプリに添付されていて、ユーザーがダウンロードして開いても Gatekeeper 警告は一切出ない。codesign の flags に runtime が含まれているのは hardened runtime が有効ということで、これは公証の必須要件。
設計上の判断と落とし穴
signingIdentity: "-"は消さずに残す。CI ではAPPLE_SIGNING_IDENTITYenv が上書きするので、残しておけば「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 警告は「詳細情報→実行」で回避) で済ませる判断もある。
開発相談をお待ちしています。