Help us understand the problem. What is going on with this article?

GitHub Actions と App Store Connect API を活用して ipa を自動アップロード

はじめて Advent Calendar に参加することになりました。
キャッチーで面白いことを書いたほうがいいんだろうなと思いつつ、めちゃ地味な内容になってしまいました笑

前提

割とながくなっちゃったので、右の目次を活用してもらえると幸いです!

目標

  • GitHub Actions で .ipa を生成して App Store Connect へアップロード
  • App Store Connect API を活用
  • オリジナルの Actions を作成して Marketplace に公開
  • セキュリティ的にも、精神衛生的にも、git管理下にファイルを増やしたくない

環境

注意

当記事内で P8 や P12 ファイルなどの秘密鍵、パスワード等を扱います。
扱いに注意し、自己責任でお願いします。

コード

下記ファイルを .github/workflows/ 内に置きます。
下に各解説を記しておきます。コメントのアルファベットは解説と一致しています。

nameon などご自分でいじってください。

action.yml
on: push

jobs:
  main:
    runs-on: macOS-latest

    env:
      KEYCHAIN: '/Library/Keychains/System.keychain'
      ASC_KEY_ID: AAAAAAAAAA
      ASC_ISSUER_ID: 00000000-0000-0000-0000-000000000000

    steps:

    # A. Setup
    - name: Setup | Checkout
      uses: actions/checkout@v1

    - name: Setup | Xcode 11.1
      run: sudo xcode-select --switch /Applications/Xcode_11.1.app

    - name: Setup | App Store Connect API
      id: asc
      uses: yuki0n0/action-appstoreconnect-token@v1.0
      with:
        key id: ${{ env.ASC_KEY_ID }}
        issuer id: ${{ env.ASC_ISSUER_ID }}
        key: ${{ secrets.P8_APPSTORECONNECT_API }}

    # B. Cache
    - name: Cache | cocoapods
      uses: actions/cache@v1
      with:
        path: Pods
        key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
        restore-keys: ${{ runner.os }}-pods-

    - name: Cache | carthage
      uses: actions/cache@v1
      with:
        path: Carthage
        key: ${{ runner.os }}-carthage-${{ hashFiles('**/Cartfile.resolved') }}
        restore-keys: ${{ runner.os }}-carthage-

    # C. Install
    - name: Install | cocoapods
      run: pod install --repo-update

    - name: Install | carthage
      env:
        GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: carthage bootstrap --platform iOS --cache-builds

    # D. Keychain
    - name: Keychain | cer
      env:
        CERTIFICATE_ID: "AAAAAAAAAA"
      run: |
        JSON=`curl -sS -H "Authorization:Bearer ${{ steps.asc.outputs.token }}" https://api.appstoreconnect.apple.com/v1/certificates/$CERTIFICATE_ID?fields[certificates]=certificateContent`
        echo $JSON | jq -r .data.attributes.certificateContent > ios_distribution.cer.txt
        base64 --decode ios_distribution.cer.txt > ios_distribution.cer
        sudo security import ios_distribution.cer -k $KEYCHAIN -T /usr/bin/codesign

    - name: Keychain | p12
      run: |
        echo "${{ secrets.P12_BASE64 }}" > ios_distribution.p12.txt
        base64 --decode ios_distribution.p12.txt > ios_distribution.p12
        sudo security import ios_distribution.p12 -k $KEYCHAIN -P ${{ secrets.P12_PASSWORD }} -T /usr/bin/codesign

    # E. Build
    - name: Build | increment build number
      env:
        APP_ID: "0000000000"
      run: |
        VERSION=`sed -n '/MARKETING_VERSION/{s/MARKETING_VERSION = //;s/;//;s/^[[:space:]]*//;p;q;}' *.xcodeproj/project.pbxproj`
        JSON=`curl -sS -H "Authorization:Bearer ${{ steps.asc.outputs.token }}" https://api.appstoreconnect.apple.com/v1/preReleaseVersions?filter[app]=$APP_ID&filter[version]=$VERSION&limit=1`
        PRE_RELEASE_VERSION_ID=`echo $JSON | jq -r .data[0].id`

        JSON=`curl -sS -H "Authorization:Bearer ${{ steps.asc.outputs.token }}" https://api.appstoreconnect.apple.com/v1/builds?filter[preReleaseVersion]=$PRE_RELEASE_VERSION_ID&sort=-version&limit=1`
        BUILD_NUMBER=`echo $JSON | jq -r .data[0].attributes.version`

        agvtool new-version $(( BUILD_NUMBER + 1 ))

    - name: Build | provisioning profile
      run: |
        JSON=`curl -sSg -H "Authorization:Bearer ${{ steps.asc.outputs.token }}" https://api.appstoreconnect.apple.com/v1/profiles?filter[name]=<name>`
        LEN=`echo $JSON | jq .data | jq length`

        mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
        for i in `seq 0 $(($LEN - 1))`; do
          uuid=`echo $JSON | jq -r .data[$i].attributes.uuid`
          echo $JSON | jq -r .data[$i].attributes.profileContent > $uuid.txt
          base64 --decode $uuid.txt > ~/Library/MobileDevice/Provisioning\ Profiles/$uuid.mobileprovision
        done

    - name: Build | xcodebuild archive
      run: set -o pipefail && xcodebuild -workspace <workspace>.xcworkspace -scheme <scheme> -configuration Release archive -archivePath ./archive | xcpretty

    - name: Build | xcodebuild export
      run: set -o pipefail && xcodebuild -exportArchive -archivePath ./archive.xcarchive -exportPath ./build -exportOptionsPlist ./<project>/exportOptions.plist | xcpretty

    # F. Upload
    - name: Upload | altool
      run: |
        mkdir ~/private_keys; echo "${{ secrets.P8_APPSTORECONNECT_API }}" > ~/private_keys/AuthKey_$ASC_KEY_ID.p8
        xcrun altool --upload-app -f ./build/<project>.ipa -t ios --apiKey $ASC_KEY_ID --apiIssuer $ASC_ISSUER_ID

解説

準備

API 用の各種キー取得

appstoreconnect.apple.com/access/api で各種キーを取得します。
Issuer ID キーID と、AuthKey_<キーID>.p8 ファイルを取得できます。

秘密鍵の登録

github.com/:user/:repository/settings/secrets ページで登録します。
P8ファイルの中身をコピペして、今回は P8_APPSTORECONNECT_API という名前にします。

.gitignore へ追記

下記を追記しておきます。

  • *.xcarchive/
  • build/

A. Setup

Xcode のバージョン指定

現在のデフォルトバージョンは 11.1 ですが、予期せぬ変更に備えて指定するようにしておく。

sudo xcode-select --switch /Applications/Xcode_11.1.app

デフォルトのバージョンと、その他のインストールされているバージョンとそのパスは下記に書いてあります。
https://help.github.com/en/actions/automating-your-workflow-with-github-actions/software-installed-on-github-hosted-runners#macos-1015

App Store Connect API の token 取得

この部分に関して、オリジナルの Actions を作成しました。
詳しくは後述します。
他の step から ${{ steps.asc.outputs.token }} で API token にアクセスできます。

B. Cache

キャッシュできるようになったのは先月(2019/11)頃の話です。
( GitHub Actions 自体の beta が取れたのも同じ頃。)
さすがにできないと他の CI から乗り換えられませんね。carthage とか時間かかり過ぎちゃう。

ご丁寧に、様々な環境でのキャッシュアクションの記述例が下記に載っています。
https://github.com/actions/cache/blob/master/examples.md

C. Install

Carthage

GitHub の API 制限に引っかかるので、環境変数 GITHUB_ACCESS_TOKEN を用意しておきます。
こういうときに ${{ secrets.GITHUB_TOKEN }} で簡単に取得できるから嬉しいですね。
参考: http://yudoufu.hatenablog.jp/entry/2016/06/09/011754

D. Keychain

下記で指定するものはどちらも Type: iOS Distribution です。

証明書 Certificate

App Store Connect API を使用して取得します。
取得できるデータは、base64エンコードされているものなので、デコードして、Keychain に登録します。
ドキュメントには記載が無い感じがしますが、jq はインストール済みなようです。

CERTIFICATE_ID は、証明書のページに移動してURLから見つけることが出来ます。
https://developer.apple.com/account/resources/certificates/download/:id

秘密鍵 P12

1. P12ファイルを書き出す

書き出す際にパスワードを指定するので忘れないように。書き出し方は複数あります。

イ. キーチェーンアクセス から

キーチェーンアクセス.app を開いて、該当の証明書を右クリックし、cerとp12ファイルを書き出します。

ロ. Xcode から

Preferences > Accounts > 該当のアカウント > ManageCertificates > 該当の証明書を右クリック > Export Certificate
スクリーンショット 2019-12-10 15.32.44.png

2. P12 を Base64 エンコードする

$ base64 <filepath> でエンコードしてコピーしておきます。

3. Secrets に P12 と パスワード を登録

https://github.com/:user/:repository/settings/secrets のページで登録します。
ここでの名称は P12_BASE64 , P12_PASSWORD とします。
ここで登録した値は、GitHub Actions 上からは ${{ secrets.<変数名> }} でアクセスできます。

E. Build

Build番号 をインクリメント

同一番号では App Store Connect にアップロードできないため、インクリメントします。
現在の Version の最大の Build番号 を App Store Connect API から取得し、+1する段取りです。
すんなりとれなかったので下記のような手順になっています。

  1. 現在の Version を取得
    ※ Xcode 11 から変更があったため、agvtool では素直に取れませんでした。
    ※ 参照: https://stackoverflow.com/questions/56722677/how-to-read-current-app-version-in-xcode-11-with-script
  2. 現在の Version の App Store Connect 上での ID を取得する
    /v1/preReleaseVersions と filter を駆使します。
  3. その ID をもとに Build番号 の最大値を取得します
    /v1/builds と filter と sort を駆使します。
  4. agvtool コマンドを利用し、インクリメントした Build番号 に変更

Build番号 のインクリメントに関しては、考察を後述します

Provisioning Profile を取得

App Store Connect API 経由で Profile を取得します。
Profile が 1 つの場合は /v1/profiles/{id} で直接指定すればいいと思います。
複数必要な場合は /v1/profiles を使用して filter などを使いましょう。

証明書の際と同様に、取得された値は Base64 エンコードされているので、デコードしてファイルに落とし込みます。
ファイルは ~/Library/MobileDevice/Provisioning Profiles/ へ置きます。

xcodebuild でビルド

1. xcpretty

xcprettyインストール済みなのですぐ使用できます。
ちゃんと出力結果をパイプしましょう。公式ドキュメントにも CI 上での利用注意 が書いてあります。

2. exportOptionPlist について

必ず plist ファイルを用意しなければなりません。
指定する値の説明は $ xcodebuild -help で全部出てきます。

exportOptions.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store</string>
    <key>provisioningProfiles</key>
    <dict>
        <key>com.sample.subdomain</key>
        <string>PP Name or UUID</string>
        <key>com.sample.subdomain.subsub</key>
        <string>PP Name or UUID</string>
    </dict>
</dict>
</plist>

F. Upload

App Store Connect にビルドをアップロードする方法は3種類用意されています。
そのなかでCLIから使用できる (xcrun) altool を使用してアップロードします。その際の認証方法が2種類。

ユーザ名 と パスワード

-u ユーザ名 -p パスワード で認証します。
1つ突っかかるのが、開発者アカウントは2段階認証を必須化されているはずなので、ただパスワードを指定するだけでは出来ないと思います。
appleid.apple.com で「App用パスワード」を生成するのがよくある解決策かなと思います。

APIキー (今回はこちら)

--apiKey <キーID> --apiIssuer <IssuerID> で認証します。
App Store Connect API と同様の jwt 認証です。

$ xcrun altool -h 抜粋
--apiKey <api_key>
This option will search the following directories in sequence for a private key file with the name of 'AuthKey_<api_key>.p8': './private_keys', '~/private_keys', '~/.private_keys', and '~/.appstoreconnect/private_keys'.

とのことなので、今回は ~/private_keys 以下に AuthKey_<api_key>.p8 を用意します。

オリジナル Action

なんども App Store Connect API の token を使用する場面があり、オリジナルのアクションにしてみました。
ついでにマーケットプレイスに公開してみました。
https://github.com/marketplace/actions/app-store-connect-api-token

コード

必要最低限のコードです。最終的なものはソースコードを御覧ください。

action.yml
name: 'App Store Connect API Token'
description: 'App Store Connect API token generator.'

inputs:
  issuer id:
    description: 'UUID. Can get from App Store Connect.'
    required: true
  key id:
    description: 'Key ID. Can get from App Store Connect.'
    required: true
  key:
    description: 'P8 private key. Can get from App Store Connect.'
    required: true

outputs:
  token:
    description: 'Generated token to use App Store Connect API.'

runs:
  using: 'node12'
  main: 'main.js'
main.js
const jwt = require('jsonwebtoken')
const core = require('@actions/core')

const keyId = process.env.INPUT_KEY_ID
const issuerId = process.env.INPUT_ISSUER_ID
const key = process.env.INPUT_KEY

const payload = { iss: issuerId, aud: 'appstoreconnect-v1', exp: Math.floor(Date.now() / 1000) + 20 * 60 }
const signedToken = jwt.sign(payload, key, { algorithm: 'ES256', keyid: keyId })

core.setOutput('token', signedToken)

ポイント

ドキュメントも整ってるし、日本語訳されてるし、簡単なので、そんな解説することはないです。少しだけ書いておきます。

A. 実行環境

基本 JavaScript で書きます。Linux 環境からのみ Dockerコンテナ のアクションを使用できます

B. Inputs の値の使用方法

2種類の取得方法があります。

  • INPUT_<変数名> という名称で環境変数が生成されているのでそれを使用。
    変数名はすべて大文字、空白は _ に変換されています。
  • @actions/core パッケージを使用して取得。
    core.getInput('key id')

C. Marketplace へ公開

新しい release を作成する際に、Publish this Action to the GitHub Marketplaceチェックを入れるだけで簡単に公開できます。

action.yml で、Marketplace 上で表示されるアイコンを簡易的にカスタマイズできます。

補足 / Tips

A. Automatic Signing

当記事はすべて Manual Signing の設定として書いています。
もし Automatic Signing をオンにしたまま にしたい場合は、下記のような変更が必要です。

1. Keychain に development の 証明書 / 秘密鍵 も登録しなくてはならない

Automatic Signing の場合、build/archive は Development 証明書 + Provisioning Profile で署名しなくてはならないためです。
また、export の際には Distribution 証明書 + Provisioning Profile で署名します。(配布前提)
つまり、Keychain には Development 証明書 / Development 秘密鍵 / Distribution 証明書 / Distribution 秘密鍵 4つ登録しなくてはなりません。

参照: https://qiita.com/SCENEE/items/c170bca6b8e8bcb2769f

2. Provisioning Profile を持っていく

ローカルの Xcode で自動生成された Profile を、CI に持っていかなくてはなりません。
つまり、App Store Connect API からの DL はできません。
もし Profile をリポジトリ内で管理する場合は git 管理下にファイルも増えてしまいます。

一方で、exportOptions.plist での Profile を指定する必要はなくなります。

参照: https://stackoverflow.com/a/39598052

B. Build番号 のインクリメントについて

今回は「App Store Connect API を活用する」という名目があったので上記のように行いましたが、冗長ですね。
参考までに別の選択肢を書いておきます。

  • UNIXTIME を使用する
    秒単位でビルドが重複することは考えづらいので、基本的には値がかぶらないことが期待される。
    ただ工夫次第だろうけど値が大きくなりすぎる。
  • agvtool でインクリメント
    $ agvtool next-version -all
  • git リビジョン情報を使用
    $ git rev-list HEAD | wc -l | tr -d ' '
    これいいね。

参照: https://qiita.com/aqubi/items/3e4e14c1fdb19d7f7879

C. exportOptions で使う plist について

今回作成したファイルくらいのデータ量なら defaults, plutil, PlistBuddy あたりを使ってコマンドライン上からファイル生成しちゃいたい。

D. App Store Connect API で証明書取得に関して

Xcode 11 から使える新しい証明書のタイプ Apple Development / Distribution がありますが、
現状では /v1/certificates を通じて取得できないと思います。
少なくとも自分は取得できませんでした。

参照: https://forums.developer.apple.com/thread/123302

E. GitHub Actions で環境変数の扱い

とある step 内で環境変数を定義しても、別の step からは参照できません。
これは最初わりと厄介に感じます。

また、外部アクションを使用する際の step.with.* 変数に環境変数を渡したいときは、下記のようにしなければいけません。

- uses: yuki0n0/action-appstoreconnect-token@v1.0
  with:
    key: $VAR           # NG
    key: ${{ env.VAR }} # OK

F. 成功 / 失敗 通知

成功 / 失敗 の通知があるといいですね。
下記例は LINE Notify ですが、Slack なり何なりお好きにどうぞ。

- if: success()
  run: curl -H "Authorization: Bearer <token>" -d "message=成功!" https://notify-api.line.me/api/notify

- if: failure()
  run: curl -H "Authorization: Bearer <token>" -d "message=失敗〜" https://notify-api.line.me/api/notify

後記

今 Apple 関連なら Swift UI のこととか書いたら面白そうなんですけどね。
地味な内容で申し訳ないです!誰かの参考になれば...!

いいね、コメント、お待ちしてます!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした