Appleプラットフォーム向けのアプリで、lintとテストはMac miniに置いたSelf-Hosted Runnerで走らせています。TestFlightとApp Store Connectへの配布はXcode Cloudに任せていて、GitHub Actions側には配布のジョブがありません。
jobs:
test:
runs-on: [self-hosted, macOS, ARM64]
env:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
runs-on に配列を書くと、「すべてのラベルに一致するランナー」という意味になります。Choosing the runner for a job に “your workflow will execute on any runner that matches all of the specified runs-on values” とあります。self-hosted は登録時に既定で付くラベルで、Using self-hosted runners in a workflow にあるとおり --no-default-labels を渡せば外せます。
自前で持つことにしたのは無料枠の減り方が理由で、配布まで載せなかったのは署名鍵を共有マシンに置きたくなかったからです。順に、なぜ自前なのか、どこまでを載せてどこで区切ったのか、区切った結果として何を踏むことになったのかを書きます。以下のコードは複数のアプリのワークフローから抜き出したもので、1本のファイルにまとまっているわけではありません。
ブログGitHub Actions の workflow を 4 項目で安全側に倒したorg 配下の workflow を `defaults.run.shell: bash`、`actions/checkout` の `persist-credentials: false`、サードパーティアクションの SHA pin、`permissions` 最小化の4項目で揃えた経緯と、`git push` を含む `release.yml` や `submodules: true` を併用する workflow で「適用しない」を選んだ判断軸を記録します。無料枠は実行時間ではなくジョブの数で減る
GitHub-hostedのランナーは、OSごとに分単価が違います。Actions runner pricing に載っている標準ランナーの単価は、Linux 2コアが1分あたり0.006ドル、macOSの3コアまたは4コアが1分あたり0.062ドルです。
ただ、効いてきたのは単価ではなく、どの単位で切り上げるかでした。同じページにこうあります。
“GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute.”
ワークフロー単位でも、月の合計でもありません。ジョブ1件ごとに切り上げられます。1本のCIをlint・テスト・UIテスト・結果集約に分けていると、25秒で終わるlintも7秒の集約も、それぞれ1分として数えられます。
この挙動については、2022年に個人のブログに書いたことがあります。すぐ終わるジョブをN個並列で走らせると、ワークフロー全体が短時間で終わっていても請求はN分になる、という話です。当時は200並列で踏みました。今回はジョブを4つに分けているだけですが、効き方は同じです。
ゆるりとGitHub Actionsで並列処理を作っていてヒヤッとした話 - ゆるりと具体的な並列処理に関しては、会社ブログなどで公開するとは思いますが、GitHub Actionsで並列処理を作っていてヒヤッとした話を書いておきます。 TL; DR 1Workflowの合計時間ではなく、1Jobごとの時間でBillable timeを算出 10sec程度で終わるJobを100並列にすると、Workflowの合計時間が20分程度だとしてもBillable timeは100分になる Jobを変に並列化するとすぐに無料枠が消費される Action実行後すぐにBillable timeを見ても0なので、必ず時間をおいて確認する GitHub Actionsの課金について About …7月はどれくらい走っていたのかを集計しました。実行が110本あまり、ジョブにして約350件です。各ジョブの開始と終了の時刻から計算した実時間の合計は146分でした。同じデータをジョブごとに切り上げて数え直すと399分になります。2.7倍です。時間を使ったから増えたのではなく、ジョブを分けたから増えています。
この集計から cancelled になったジョブは除いています。7月は11件あり、そのうち1件は結果集約のジョブが実行中のまま8時間17分止まって、最後は cancelled で終わったものでした。ランナーが応答しなくなっていた間の待ち時間が、そのジョブの実行時間として記録されています。11件を戻すと実時間692分・切り上げ951分になり、増えたぶんのほとんどがこの1件です。
なお、この399分は時刻から計算した値で、GitHubが請求として確定させた値ではありません。請求時間は実行直後には0と表示され、数時間かけて埋まります。画面の数字と突き合わせるなら、その反映を待ってからになります。
macOSのジョブは、この分が10倍になって無料枠から引かれます。GitHub Team の無料枠は月3,000分なので、399分は3,990分に当たり、7月だけで枠を使い切ったうえに1,000分ほどはみ出す計算です。1回のCIが40秒で終わっていても、月の枠には収まりません。
倍率が掛かるのは無料枠を減らすところまでで、枠を超えたあとの単価には掛かりません。コミュニティのディスカッションに当時のドキュメントの注記が引用されていますが、貼ったのは一般のユーザーで、GitHubからの回答ではありません。倍率のページ自体も今は料金表へのリダイレクトになっていて、10倍という数字を一次情報で確かめる手段は今のところありません。
Self-Hosted Runnerの利用そのものは課金されません。About billing for GitHub Actions に “GitHub Actions usage is free for self-hosted runners and for public repositories that use standard GitHub-hosted runners.” とあります。常時電源の入ったMac miniが1台あれば、無料枠を気にする必要がなくなります。
非公開リポジトリでしか使わない
Self-Hosted Runnerを公開リポジトリで使わないのは、料金とは別の理由からです。Security hardening for GitHub Actions が、はっきり書いています。
“As a result, self-hosted runners should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment.”
ワークフローは on: pull_request で起動します。フォークは誰でも作れて、フォーク側ではワークフローも書き換えられます。そのプルリクエストのジョブが runs-on にSelf-Hosted Runnerを指定していれば、他人の書いたコードがそのMacで動きます。
secretsと GITHUB_TOKEN を読まれるところまでは、使い捨ての仮想マシンでも同じです。self-hostedで増えるものが2つあります。次のジョブが拾う場所に細工を仕込めること、そして同じマシンで動いている別のリポジトリのジョブにも手が届くことです。どちらも、マシンが残ることが原因です。
GitHub-hostedとの違いは、ジョブをまたいで残る場所があるかどうか、ほぼこれだけです。Self-Hosted Runnerを使っているのは非公開のリポジトリだけで、公開しているものはGitHub-hostedのランナーで動かしています。
同じ段落には続きがあります。
“Similarly, be cautious when using self-hosted runners on private or internal repositories, as anyone who can fork the repository and open a pull request (generally those with read access to the repository) are able to compromise the self-hosted runner environment, including gaining access to secrets and the
GITHUB_TOKENwhich, depending on its settings, can grant write access to the repository.”
非公開なら安全だ、とは書かれていません。フォークしてプルリクエストを開ける人がいれば同じことが起きる、と条件が示されているだけです。今は読み取り権限が絞られているので、フォークしてプルリクエストを開ける人がそもそもいません。効いているのは公開範囲ではなく権限の絞り方のほうです。リポジトリを公開に切り替える日が来たら、フォークからのプルリクエストに承認を挟む設定が先に要ります。
署名鍵はランナーに置かない
CIが動いたので、リリースも同じランナーに載せようとしました。macOSアプリをDeveloper IDで直接配るときの経路です。xcodebuild archive から codesign、xcrun notarytool submit --wait、xcrun stapler staple までを1本のジョブでつなぎます。ここで止まりました。Developer ID Applicationの秘密鍵をランナーにどう渡すかが決まらなかったためです。
1つ目は、p12をGitHubのsecretに置いてジョブごとに読み込む方式です。GitHubのドキュメントが案内している標準的なやり方で、証明書のbase64・p12のパスワード・キーチェーンのパスワードの3つをsecretに置きます。却下しました。secretストア自体から漏れる経路と、同じジョブに紛れ込んだアクションが持ち出す経路の2つが残るためです。
2つ目は、ランナーに証明書を常駐させる方式です。ワークフローは最も簡素になり、secretは1つも要りません。これも却下しました。ランナーは1台を共有しているので、このマシンを奪われた時点で会社名義でどのアプリにも署名できます。lintとテストしか走らせていないマシンに、それだけの権限を持たせることになります。
3つ目が、Appleのクラウド管理証明書です。App Store Connect APIキーだけで済み、p12を持ち回る必要がありません。Cloud-managed certificates に “Cloud-managed certificates are associated with your Apple Developer Program membership and managed remotely.” とあります。
どこで区切るかは、ここで決まりました。CIが壊れても、困るのはテスト結果が返ってこないことだけです。署名鍵が漏れると、会社の名前で署名されたものが配られます。同じマシンに置いてよいものではありません。
App Storeで配信しているアプリのうち、Xcode Cloudに寄せたものについては、ビルドから配布までをそちらが受け持ちます。ビルド番号は Setting the next build number for Xcode Cloud builds の説明どおり自動で増えます。
“Xcode Cloud assigns a build number to each build it performs. A build number is an integer value that Xcode Cloud automatically increases with each build, starting from
1.”
起動条件もワークフロー側で選べます。Configuring start conditions に “Xcode Cloud starts a new build if you create or update a Git tag.” とあるので、mainへのpushでTestFlight、タグのpushでApp Store Connectという2本を置いています。すべてのアプリがこの形に揃っているわけではなく、fastlaneから配布しているものも残っています。
GitHub Actions側に残ったのは、lintとテストと結果集約です。UIテストまで走らせるリポジトリでは、もう1つ増えます。署名鍵に触れるジョブが1つもないため、ランナーを共有していること自体は問題になりません。
1台を使い回すと出てくる問題
ここからは、そう決めた結果として踏むことになった問題です。GitHub-hostedのランナーは、Choosing the runner for a job に “each job runs in a fresh instance of a runner image specified by runs-on” とあるとおり、ジョブごとに作り直されます。Self-Hostedは違います。About self-hosted runners が利点として挙げているのは “Don’t need to have a clean instance for every job execution.” です。
毎回作り直さずに済むことが利点として挙げられていますが、裏を返せばそのまま落とし穴になります。
踏んだ問題は3種類に分かれました。1つ目は、前のジョブが残したものを後のジョブが拾うことから来るものです。シミュレーターの中身とgitの設定がこれに当たります。2つ目は、その1台を複数のリポジトリで同時に使うことから来るものです。どのXcodeを使うかの切り替え、シミュレーターの取り合い、その1台が止まったときの話がここに入ります。3つ目は、そのMacの面倒を自分で見ることから来るものです。Xcodeが勝手に上がることと、原因をまだ切り分けられていない挙動が該当します。以下は分類の順ではなく、踏んだ順に書きます。
Xcodeの切り替えをジョブの中だけで済ませる
Xcodeを切り替える方法として最初に思いつくのは sudo xcode-select --switch ですが、共有マシンでは使えません。man xcode-select にこうあります。
“This command must be run with superuser permissions (see sudo(8)), and will affect all users on the system. To set the path without superuser permissions or only for the current shell session, use the DEVELOPER_DIR environment variable instead”
引用はここで切っていますが、原文はこのあと (see ENVIRONMENT) と続きます。別のジョブが走っている最中にシステム全体の設定を書き換えると、そのジョブのビルドが途中から別のXcodeを見ます。環境変数ならプロセスの中で閉じます。
env:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
Appleのドキュメントが --switch の説明の中で代わりに使うものとして DEVELOPER_DIR を名指ししているので、選ぶのに迷う場面ではありません。
シミュレーターのランタイムを毎回取りに行く
XcodeはMac側で自動更新されます。上がった直後は新しいバージョン向けのシミュレーターランタイムがまだ落ちてきておらず、テストがdestinationを見つけられずに落ちます。人がXcodeを起動すればダイアログで気づきますが、ランナーの前には誰もいません。
- name: Ensure iOS simulator runtime
run: xcodebuild -downloadPlatform iOS
xcodebuild -help での説明は “download the platform NAME” の1行だけです。すでに入っていれば短時間で終わるので、テストの前に無条件で置いています。
前回の実行のデータが残っている
シミュレーターは使い回されます。初回起動を前提に書いたUIテストは、前回の実行が残したデータがあるだけで落ちます。SwiftDataをインメモリにしていても、取り込んだPDFの実体はアプリのDocumentsに残る、といった食い違いも起きます。
対象アプリだけを消すか、デバイスごと消すかは、何が残っているかで変わります。xcrun simctl help の説明を読めば、粒度の違いが分かります。uninstall は “Uninstall an app from a device.”、erase は “Erase a device’s contents and settings.” です。
アプリを消すだけでは足りないのは、アプリの外に残るものがあるときです。写真を1枚選ぶ画面を通るUIテストでは、実行のたびにフィクスチャを1枚ずつ足していくと、フォトライブラリに写真がたまっていきます。ディスクを食うだけでなく、ピッカーの先頭に何が出るかも実行のたびに変わります。ここはデバイスごと消してから起動し、フィクスチャを1枚だけ入れ直しています。起動した直後はPhotosがまだ応答せず simctl addmedia が失敗することがあるので、数回リトライを挟んでいます。
別のプルリクエストが同じシミュレーターを触る
ワークフローのトップレベルに置いた concurrency は、github.ref をグループ名に含めている限り、同じブランチへの連続pushしか抑えません。別のプルリクエストから始まった実行は別のグループなので、同時に走ります。
同じMacに1つしかないCoreSimulatorを、2つの実行が同時に触ります。片方が simctl shutdown all を呼ぶと、もう片方が使っていたシミュレーターまで止まります。
concurrency:
group: ${{ github.workflow }}-simulator
cancel-in-progress: false
ジョブ単位でグループを分け、そこからブランチ名を外すことでリポジトリ全体を直列にしました。Using concurrency に “if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending” とあります。待たせたいので cancel-in-progress は立てません。ここを true にすると、後から来たプルリクエストが先に走っていたビルドを止めます。シミュレーターの取り合いを、誰かのビルドを落とすことで解決したことになってしまいます。
並列テストが返ってこない
-parallel-testing-enabled YES を付けたテストが、Test suite ... started とだけ出して先へ進まなくなりました。タイムアウトまで何も出力しません。ローカルで再現させてフラグを1つずつ落としていったところ、このフラグに行き当たりました。切ると同じスイートが10秒足らずで終わります。この症状が出たのは一部のリポジトリだけで、そちらでは NO に倒しています。
xcodebuild -help のこのフラグの説明は “overrides the per-target setting in the scheme” だけです。並列実行がシミュレーターをどう増やすのかについて、developer.apple.com の文書には記載を見つけられませんでした。-maximum-concurrent-test-simulator-destinations や -parallel-testing-worker-count といった近くに並んでいるフラグから、複数のdestinationとワーカーが要ることは読めます。仕組みの説明は付けないまま、フラグを切れば通るという運用だけを残しています。
同じ場所でもう1つ外したものがあります。テンプレートが最初から作るlaunch testです。runsForEachTargetApplicationUIConfiguration の説明はこうです。
“The test system calculates a set of configurations that encompasses all the possible combinations of those supported settings, and provides each configuration to an iteration of your tests when you call
launch()onXCUIApplication.”
外観と向きとローカライズの組み合わせすべてに展開されます。対応言語が10を超えるアプリでは、これだけで数十回の起動になります。ソース側でこのプロパティを false に固定したうえで、CI側でも -skip-testing で外しました。二重になっていますが、テストファイルを消したり作り直したりするとプロパティが既定値に戻って展開が復活するので、CI側にも残しています。スクリーンショットを添付するだけでアサーションが1つもないテストなので、失うものもありませんでした。
ランナーが止まると、ジョブは落ちずに待ち続ける
実行履歴をさかのぼると、8時間を超えたものが同じ日に4つ並んでいました。開いてみると、ジョブの実行時間は大半が1分足らずで、いちばん長いものでも10分ほどでした。8時間はジョブが始まるのを待っていた時間です。4つとも別のリポジトリなのに、走り出した時刻が数分のうちに固まっています。ランナーが応答しなくなっていた間、ジョブがキューに積まれ、戻ってきた瞬間に全部がいっせいに走り出した、という形です。
timeout-minutes はジョブが始まってからの時間にしか効きません。始まるのを待っている間は数えないので、この8時間は切れません。しかも待っている間は失敗扱いにならないので、GitHubの画面を見ても赤くならず、進んでいないことに気づきにくいのが厄介です。
GitHub-hostedならランナーの用意はGitHub側の仕事で、こちらが気にすることはありません。1台に寄せた時点で、そのMacが起きているかどうかが自分の責任範囲に入ります。
共有マシンのgit設定を汚さない
社内で共有しているSwiftパッケージをSSHで参照しているプロジェクトがあります。ランナーに鍵は置いていないので、ジョブの中でGitHub Appのトークンを発行し、insteadOf でHTTPSに書き換えて解決させています。
git config --global はランナーを動かすアカウントの設定です。書き換えたまま放置すると、次に同じマシンで走る別のリポジトリのジョブがその設定を引き継ぎます。トークンには寿命があるので、期限切れのトークンが入った設定が残っているほうが、何も無いよりも厄介です。
- name: Clean up git URL rewrite
if: always()
run: |
git config --global --unset url."...".insteadOf || true
if: always() を付けて、テストが落ちても外れるようにしています。設定を足すステップを書いたら、外すステップも同じジョブに書きます。このランナーではこれが決まりになりました。
2台目を用意するかどうか
今は1台で回しています。ランナーが止まれば全部が待つという弱点は、そのまま2台目を用意する理由になります。
2台にして減る手間より、増える手間のほうがはっきりしています。Xcodeとシミュレーターランタイムを同じ状態に保つ作業が2台ぶんになります。片方だけXcodeが上がれば、同じワークフローが片方でだけ落ちる形になり、原因の切り分けにマシンの違いが混ざります。1台のうちは、少なくともここが疑いから外れます。
一方で、2台目があれば片付く宿題も溜まっています。並列テストを切った原因が、このMac固有の状態にあるのかXcode側にあるのかは、いまだに切り分けられていません。同じフラグを別のマシンで試せば済む話ですが、比べる相手がありません。Xcodeの自動更新を止めるかどうかも同じで、固定した1台と自動更新のままの1台を並べれば、固定したときに何を見落とすようになるかが実際に分かります。
増やすとしたら、まずはこの2つを片付けるために立てることになります。

