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

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

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

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

## 環境
- fastlane は使用しない
- CocoaPods / Carthage 両方使用
- Xcode 11.1
- 複数 Target あり ( 今回は Today Extension )
- Manual Signing ( [Automatic Signing に関して後述します](#a-automatic-signing) )

## 注意

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

# コード

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

※ `name` や `on` などご自分でいじってください。

```yml: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](https://appstoreconnect.apple.com/access/api) で各種キーを取得します。
`Issuer ID` `キーID` と、`AuthKey_<キーID>.p8` ファイルを取得できます。

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

### .gitignore へ追記

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

- `*.xcarchive/`
- `build/`


## A. Setup

### Xcode のバージョン指定

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

```sh
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 を作成しました。
[詳しくは後述します。](#%E3%82%AA%E3%83%AA%E3%82%B8%E3%83%8A%E3%83%AB-action)
他の 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 に登録します。
[ドキュメントには記載が無い](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/software-installed-on-github-hosted-runners)感じがしますが、`jq` はインストール済みなようです。

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

### 秘密鍵 P12

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

##### イ. キーチェーンアクセス から
キーチェーンアクセス.app を開いて、該当の証明書を右クリックし、cerとp12ファイルを書き出します。
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/1f87f496-e397-0e55-accb-446a0f52209b.png">


##### ロ. Xcode から

Preferences > Accounts > 該当のアカウント > ManageCertificates > 該当の証明書を右クリック > Export Certificate
<img width="700" alt="スクリーンショット 2019-12-10 15.32.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42826/d6782da6-ab2a-6c04-b3cf-92879728ed5b.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** を取得<br>
※ Xcode 11 から変更があったため、`agvtool` では素直に取れませんでした。<br>
※ 参照: https://stackoverflow.com/questions/56722677/how-to-read-current-app-version-in-xcode-11-with-script
2. 現在の **Version** の App Store Connect 上での **ID** を取得する<br>
[/v1/preReleaseVersions](https://developer.apple.com/documentation/appstoreconnectapi/list_prerelease_versions) と filter を駆使します。
3. その **ID** をもとに **Build番号** の最大値を取得します<br>
[/v1/builds](https://developer.apple.com/documentation/appstoreconnectapi/list_builds) と filter 
 と sort を駆使します。
4. `agvtool` コマンドを利用し、インクリメントした **Build番号** に変更

**Build番号** のインクリメントに関しては、[考察を後述します](#b-build%E7%95%AA%E5%8F%B7-%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%AF%E3%83%AA%E3%83%A1%E3%83%B3%E3%83%88%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)。


### Provisioning Profile を取得

App Store Connect API 経由で Profile を取得します。
Profile が 1 つの場合は [/v1/profiles/{id}](https://developer.apple.com/documentation/appstoreconnectapi/read_and_download_profile_information) で直接指定すればいいと思います。
複数必要な場合は [/v1/profiles](https://developer.apple.com/documentation/appstoreconnectapi/list_and_download_profiles) を使用して `filter` などを使いましょう。

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

### xcodebuild でビルド

#### 1. xcpretty
`xcpretty` は[インストール済み](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/software-installed-on-github-hosted-runners#xcode-support-tools)なのですぐ使用できます。
ちゃんと出力結果をパイプしましょう。公式ドキュメントにも [CI 上での利用注意](https://github.com/xcpretty/xcpretty#usage) が書いてあります。

#### 2. exportOptionPlist について

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

```xml: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種類](https://help.apple.com/app-store-connect/#/devb1c185036)用意されています。
そのなかでCLIから使用できる `(xcrun) altool` を使用してアップロードします。その際の認証方法が2種類。

### ユーザ名 と パスワード

`-u ユーザ名 -p パスワード` で認証します。
1つ突っかかるのが、開発者アカウントは2段階認証を必須化されているはずなので、ただパスワードを指定するだけでは出来ないと思います。
[appleid.apple.com](https://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

## コード

必要最低限のコードです。最終的なものは[ソースコード](https://github.com/yuki0n0/action-appstoreconnect-token)を御覧ください。

```yml: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'
```

```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コンテナ のアクションを使用できます](https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/about-actions#types-of-actions)。

### B. Inputs の値の使用方法
2種類の取得方法があります。

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

### C. Marketplace へ公開

新しい release を作成する際に、`Publish this Action to the GitHub Marketplace` に[チェックを入れるだけで簡単に公開](https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/publishing-actions-in-github-marketplace)できます。

`action.yml` で、[Marketplace 上で表示されるアイコン](https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions#branding)を簡易的にカスタマイズできます。

# 補足 / 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](https://qiita.com/SCENEE/items/c170bca6b8e8bcb2769f#%E3%83%9D%E3%82%A4%E3%83%B3%E3%83%88)

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


参照: 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://developer.apple.com/documentation/appstoreconnectapi/list_and_download_certificates) を通じて取得できないと思います。
少なくとも自分は取得できませんでした。

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

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

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

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

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

## F. 成功 / 失敗 通知

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

```yml
- 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 のこととか書いたら面白そうなんですけどね。
地味な内容で申し訳ないです！誰かの参考になれば...！

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