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/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_P12Developer ID Applicationの証明書と秘密鍵(.p12)をbase64にした文字列
CERTIFICATES_P12_PASSWORD.p12のパスワード
TEAM_IDApple DeveloperのTeam ID
NOTARIZATION_APPLE_ID公証に使うApple ID
NOTARIZATION_PASSWORD公証に使うApp用パスワード
SPARKLE_PRIVATE_KEYSparkleの署名に使う秘密鍵

トリガーと権限

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で配信する