macOSアプリのリリースは、署名、公証、staple、zip化、appcast.xmlの更新、GitHub Releasesへの公開と、手順が多い。タグ(v*)をpushしたときに、これらをすべて自動で実行するGitHub Actionsのワークフローを紹介する。
$ git tag v1.0.1
$ git push origin v1.0.1
v1.0.1のタグをpushすると、ワークフローが起動する。署名と公証を済ませたアプリのzipと、更新後のappcast.xmlが、GitHub Releasesで公開される。実行例は、Xcode 27.0のランナー(runs-on: xcode-27)のもの。
Sparkleの更新まで通す前提のため、署名と公証、Sparkleの各記事の内容を使う。
参考: 【notarytool】アプリを公証してstaple(公証チケットを添付)する
参考: 【Sparkle】EdDSA署名鍵を生成して公開鍵をInfo.plistに設定する
参考: 【GitHub Actions】一時keychainに証明書をインポートして署名する
- 前の記事: 【GitHub Actions】CIでだけ署名設定をDeveloper IDに切り替える
- 親記事: GitHubを使い、macOSアプリを署名・公証・自動アップデート付きで公開する
- 次の記事: 【Sparkle】古いバージョンから更新できることを動作確認する
ワークフローの全体
.github/workflows/release.ymlの全体は、次のとおりである。
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
runs-on: xcode-27
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Show Xcode version
run: xcodebuild -version
- name: Import signing certificate
env:
CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }}
CERTIFICATES_P12_PASSWORD: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
run: |
echo "$CERTIFICATES_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security create-keychain -p actions "$RUNNER_TEMP/temp.keychain-db"
security default-keychain -s "$RUNNER_TEMP/temp.keychain-db"
security unlock-keychain -p actions "$RUNNER_TEMP/temp.keychain-db"
security import "$RUNNER_TEMP/certificate.p12" -k "$RUNNER_TEMP/temp.keychain-db" -P "$CERTIFICATES_P12_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k actions "$RUNNER_TEMP/temp.keychain-db"
- name: Archive
env:
TEAM_ID: ${{ secrets.TEAM_ID }}
VERSION: ${{ github.ref_name }}
BUILD_NUMBER: ${{ github.run_number }}
run: |
xcodebuild -project [プロジェクト名].xcodeproj -scheme [スキーム名] -configuration Release \
-archivePath build/[アプリ名].xcarchive -derivedDataPath build/DerivedData \
CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="Developer ID Application" DEVELOPMENT_TEAM="$TEAM_ID" \
MARKETING_VERSION="${VERSION#v}" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" archive
- name: Export archive
env:
TEAM_ID: ${{ secrets.TEAM_ID }}
run: |
sed -i '' "s/YOUR_TEAM_ID/$TEAM_ID/" ExportOptions.plist
xcodebuild -exportArchive -archivePath build/[アプリ名].xcarchive -exportPath build -exportOptionsPlist ExportOptions.plist
- name: Notarize and staple
env:
APPLE_ID: ${{ secrets.NOTARIZATION_APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.NOTARIZATION_PASSWORD }}
TEAM_ID: ${{ secrets.TEAM_ID }}
run: |
ditto -c -k --keepParent build/[アプリ名].app build/notarize.zip
xcrun notarytool submit build/notarize.zip --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait
xcrun stapler staple build/[アプリ名].app
xcrun stapler validate build/[アプリ名].app
- name: Create distribution ZIP
env:
VERSION: ${{ github.ref_name }}
run: |
mkdir -p appcast-input
ditto -c -k --keepParent build/[アプリ名].app "appcast-input/[アプリ名]-${VERSION#v}.zip"
- name: Generate appcast
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
REPOSITORY: ${{ github.repository }}
run: |
curl -fsSL -o appcast-input/appcast.xml "https://github.com/${REPOSITORY}/releases/latest/download/appcast.xml" || echo "No previous appcast found"
echo "$SPARKLE_PRIVATE_KEY" | build/DerivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast --ed-key-file - appcast-input/
cat appcast-input/appcast.xml
- name: Create Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: |
appcast-input/[アプリ名]-*.zip
appcast-input/appcast.xml
draft: false
prerelease: false
Secretsには、次の6つを登録しておく。
| Secrets | 内容 |
|---|---|
CERTIFICATES_P12 | Developer ID Applicationの証明書と秘密鍵(.p12)をbase64にした文字列 |
CERTIFICATES_P12_PASSWORD | .p12のパスワード |
TEAM_ID | Apple DeveloperのTeam ID |
NOTARIZATION_APPLE_ID | 公証に使うApple ID |
NOTARIZATION_PASSWORD | 公証に使うApp用パスワード |
SPARKLE_PRIVATE_KEY | Sparkleの署名に使う秘密鍵 |
トリガーと権限
on:
push:
tags:
- 'v*'
permissions:
contents: write
on.push.tagsにv*を指定すると、v1.0.0やv1.0.1のように、vで始まるタグをpushしたときだけ、ワークフローが実行される。
GitHub Releasesにリリースを作成するには、GITHUB_TOKENにリポジトリの内容への書き込み権限が必要である。ワークフローのpermissionsで、contents: writeを指定する。
実行時間の上限と同時実行
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
runs-on: xcode-27
timeout-minutes: 20
timeout-minutesは、ジョブの実行時間の上限である。公証が終わらずに長引いたときも、20分で打ち切られる。検証したアプリでは、ワークフロー全体が約90秒で終わった。
concurrencyは、同じグループのワークフローを同時に実行しないようにする。グループには、ワークフローの名前とタグの名前(github.ref)を含めている。同じタグに対する実行だけが、同じグループになる。
cancel-in-progressがtrueであれば、同じグループの新しい実行が始まったときに、実行中のワークフローがキャンセルされる。リリースでは、公証やGitHub Releasesへの公開の途中で、実行が中断されるのを避けたい。そのため、falseにしている。
cancel-in-progressのtrueとfalseの違いによる挙動は、このワークフローでは確認していない。
番号の付け方
アーカイブの手順では、バージョン番号とビルド番号を、xcodebuildの引数で指定している。
env:
VERSION: ${{ github.ref_name }}
BUILD_NUMBER: ${{ github.run_number }}
run: |
xcodebuild ... MARKETING_VERSION="${VERSION#v}" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" archive
github.ref_nameは、タグの名前(v1.0.1)である。${VERSION#v}で先頭のvを取り除き、MARKETING_VERSIONを1.0.1にする。github.run_numberは、ワークフローの実行ごとに増える番号で、CURRENT_PROJECT_VERSION(ビルド番号)になる。
参考: 【Sparkle】ビルド番号とバージョン番号を使い分ける
公証とstaple
run: |
ditto -c -k --keepParent build/[アプリ名].app build/notarize.zip
xcrun notarytool submit build/notarize.zip --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait
xcrun stapler staple build/[アプリ名].app
xcrun stapler validate build/[アプリ名].app
CIでは、キーチェーンにプロファイルを保存せず、notarytool submitにApple ID、Team ID、App用パスワードを直接渡す。
notarytool submit --waitは、公証がInvalidでも、終了コードが0である。公証に失敗したときは、続くstapler stapleが、チケットが見つからずに失敗する。GitHub Actionsのシェルは、既定で最初に失敗したコマンドで終了するため、ステップが失敗し、リリースは作成されない。
参考: 【notarytool】公証に失敗した理由をログから調べる
appcast.xmlを更新する
run: |
curl -fsSL -o appcast-input/appcast.xml "https://github.com/${REPOSITORY}/releases/latest/download/appcast.xml" || echo "No previous appcast found"
echo "$SPARKLE_PRIVATE_KEY" | build/DerivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast --ed-key-file - appcast-input/
過去のappcast.xmlを、generate_appcastの対象のディレクトリ(appcast-input/)の中にダウンロードする。保存先を間違えると、過去のエントリが引き継がれない。初回のリリースでは、過去のappcast.xmlがなく、curl -fが失敗するが、||で処理を続ける。
generate_appcastは、アーカイブの手順で-derivedDataPath build/DerivedDataを指定したため、build/DerivedData/SourcePackages/artifacts/sparkle/Sparkle/bin/にある。秘密鍵は、--ed-key-file -で、標準入力から渡す。
参考: 【Sparkle】generate_appcastでappcast.xmlを作る
Releaseを作成する
- name: Create Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: |
appcast-input/[アプリ名]-*.zip
appcast-input/appcast.xml
softprops/action-gh-releaseが、タグに対応するリリースを作り、filesに指定したファイルを添付する。ワイルドカードで、zipを指定している。
サードパーティのアクションは、タグ(v3など)ではなく、コミットのSHAで指定し、バージョンをコメントに書いている。タグは、あとから別のコミットに付け替えられるためである。SHAで固定すると、実行する内容が変わらない。
実行結果を確認する
v1.0.0をpushすると、ワークフローが起動する。
$ gh run list --repo [ユーザー名]/[リポジトリ名] --workflow Release --limit 1
completed success [コミットメッセージ] Release [タグ名] push [実行ID] [実行時間] [日時]
各ステップの所要時間は、次のとおりである。
| ステップ | 所要時間 |
|---|---|
| Archive | 約44秒 |
| Export archive | 約6秒 |
| Notarize and staple | 約20秒 |
| Generate appcast | 約1秒 |
| Create Release | 約8秒 |
作成されたリリースには、zipとappcast.xmlが添付される。
$ gh release view v1.0.0 --repo [ユーザー名]/[リポジトリ名] --json tagName,isDraft,isPrerelease,assets --jq '{tagName,isDraft,isPrerelease,assets:[.assets[]|{name,size}]}'
{"assets":[{"name":"appcast.xml","size":814},{"name":"[アプリ名]-1.0.0.zip","size":1000648}],"isDraft":false,"isPrerelease":false,"tagName":"v1.0.0"}
リリースのzipを展開したアプリは、公証とstapleが済んでいる。
$ gh release download v1.0.0 --repo [ユーザー名]/[リポジトリ名] --dir release
$ ditto -x -k release/[アプリ名]-1.0.0.zip release/x
$ xcrun stapler validate release/x/[アプリ名].app
The validate action worked!
$ spctl --assess --type execute -vv release/x/[アプリ名].app
release/x/[アプリ名].app: accepted
source=Notarized Developer ID
origin=Developer ID Application: [名前] ([Team ID])
初回のリリースのappcast.xmlには、1.0.0のエントリが1件だけ入る。
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.0.0</sparkle:shortVersionString>
2回目のリリースでappcast.xmlが引き継がれる
続けてv1.0.1をpushすると、appcast.xmlに1.0.1と1.0.0の2件が入る。ビルド番号は、github.run_numberにより、2回目の実行で2になる。
$ grep -E 'shortVersionString|<sparkle:version>' appcast.xml
<sparkle:version>2</sparkle:version>
<sparkle:shortVersionString>1.0.1</sparkle:shortVersionString>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.0.0</sparkle:shortVersionString>
連続してタグをpushすると、前のリリースのlatest/download/の反映が間に合わず、古いappcast.xmlを取得する可能性がある。タグのpushは、間隔を空ける。
参考: 【GitHub Releases】appcast.xmlを固定URLで配信する
