- 複雑なCIをGitHub Actionsに戻す過程で、merge queue、複数のランナー、Rustビルド、Dockerイメージ、統合テストが絡み合い、設定そのものよりもデバッグのコストの方が大きくなった
main に入る変更はすべてテストを通過しなければならず、フォーマット・未使用依存関係・リンターのような 些細なエラーは自動修正 され、CI成果物はリリース成果物と同一である必要がある
- merge queue の前後の検証をどちらも強制するには、2段階の ジョブ名を同一に そろえる必要があり、そうしないとチェックが止まったり、失敗した変更がマージされたりする可能性がある
GITHUB_TOKEN、workflow の permissions、カスタムトークン、fork・self-hosted runner の例外が絡み合い、セキュリティモデル を理解しづらく、ミスの余地が大きい
- Dockerコンテナ実行、YAMLワークフロー、限定的なローカルテストのせいで開発速度は落ちたが、新しいCIスクリプトはマージ時間を大幅に短縮した
GitHub Actionsに戻った複雑なCI
- この2週間、CIスクリプトをGitHub Actionsで書き直していた
- 今回でCI構成の刷新は3回目
- 最初はGitHub Actions
- その後 Earthly に移行
- Earthlyが終了したため、再びGitHub Actionsに戻った
- 現在のCIは merge queue、複数のランナー、Rustビルド、Dockerイメージ、重い統合テストをまとめて扱っている
- ランナーは self-hosted、blacksmith.sh、GitHub-hosted を併用している
- 1つのPRをマージするたびに、複数の並列ランナーで合計約1時間のCI時間を消費する
CIが満たすべき条件
main に入るすべての変更は すべてのテスト を通過しなければならない
- フォーマット、未使用依存関係、リンターの問題のような些細なミスは、失敗で終わるのではなく 自動修正 されるべき
- CIでテストした成果物は、実際のリリース成果物と同一でなければならない
- 開発者体験のため、CIは速く終わるべき
- GitHub Actionsはこれらの条件を技術的にはサポートしているが、設定過程には隠れた落とし穴、一貫しない挙動、難しいデバッグがつきまとう
Merge queueとステータスチェック
- クリーンな
main ブランチを維持する鍵は、GitHubの merge queue にある
- merge queue はCI実行前にPRを
main の上へ rebase する
- 必要なCI実行は2段階に分かれる
- キューに入る前にCIを実行して些細な問題を自動修正する
- キューの中で再度CIを実行し、最終的なマージ状態を検証する
- GitHub Actionsで両方の実行を必須にするには、2段階の ジョブ名を同一に 指定しなければならない
- GitHubが2つの実行を同じチェックとして扱い、両方が成功して初めてマージ可能になる
- この方法は、数時間のデバッグの末に Stack Overflowの回答 から見つけた
- 別のやり方では、ステータスチェックがキュー投入前に待機状態になってジョブが始まらなかったり、merge queue 内で失敗すべきジョブが失敗してもマージされたりする結果につながりうる
理解しづらいGitHub Actionsのセキュリティモデル
- 最近、人気のGitHub Actionが 侵害された事件 の後、「依存関係をハッシュで固定せよ」という対応が出たが、コメント欄では実際にはほとんど誰もそうしていないという反応が続いた
- GitHub Actionsにはデフォルトトークンである
GITHUB_TOKEN がある
- このトークンはデフォルト権限で初期化される
- デフォルト権限はリポジトリ設定の Actions → General → Workflow Permissions で設定できる
GITHUB_TOKEN のデフォルト権限が制限的なら、必要な action やコマンド実行のために権限を引き上げる必要があり、デフォルト権限が寛容すぎるなら、workflow ファイル側で一部の権限を削ることができる
- よりよいデフォルトは、権限なし から始めて必要な権限だけをユーザーが追加する方式だろう
- 権限の種類は 多く、GitHubの専門家でなければ、それぞれが何を守っているのか把握しにくい
トークンと権限の例外
softprops/action-gh-release でGitHubリリースを自動生成するときは、カスタムトークン CI_RELEASE を使っている
- name: Release on GitHub
if: env.version_exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ env.CURRENT_VERSION }}
generate_release_notes: true
make_latest: true
token: ${{ secrets.CI_RELEASE }}
- デフォルトトークンでもリリース自体は完了するが、リリース後のワークフローはトリガーされない
- 別に表示もないため、同じ問題に遭った人が残した issue を探して初めて原因が分かった
- workflow YAML の中で権限を引き上げることもできる
- 守りたいコードの中で権限を引き上げる構造には違和感がある
- GitHubドキュメントによれば、
permissions キーで fork リポジトリの読み取り権限を追加・削除できるが、通常は書き込み権限は付与できない
- 例外は、管理者がGitHub Actions設定で Send write tokens to workflows from pull requests オプションを選んだ場合
- 例外と落とし穴が多く、GitHub Actionsのセキュリティモデルは強力である一方、攻撃面とミスの可能性も広げている
Self-hosted runnerの不確実性
- GitHubドキュメントは公開リポジトリで self-hosted runner を使うことを推奨していない
- 公開リポジトリの fork がPR経由で self-hosted runner マシン上で危険なコードを実行できてしまうため
- GitHubには、外部コントリビューターのPR実行を承認必須にする self-hosted runner 設定もある
- この設定と self-hosted runner を併用すれば安全なのかについて、ドキュメントには明確な答えがなく、インターネット上でも合意がない
- 複雑さが高く、100%の確信を持ちにくい状態のままになっている
DockerとGitHub Actionsの衝突
- GitHub Actionsではジョブを コンテナ内で実行 できる
- 毎回依存関係をインストールせず、dev container にあらかじめパッケージ化しておける利点がある
- 実際に使うと ファイル権限 の問題が繰り返し起きる
- コンテナはあるユーザーでファイルをビルドするが、GitHub runner は別の uid/gid で動くことがある
- その結果、コンテナ内のファイルや GitHub workspace、一時ホストディレクトリにアクセスできなくなることがある
$HOME ディレクトリも食い違うことがある
- dev container は
/home/ubuntu にツールをインストールできる
- GitHub Actions内では
$HOME が /github/home になることがある
$HOME 配下のファイルに依存するツールは、必要なファイルを見つけられないことがある
- ホストシステムと相互作用する action も壊れうる
- GitHubキャッシュは10GBに制限されているため、blacksmith の sticky disk action でキャッシュ用NVMeドライブをマウントしている
- コンテナ内では動作せず、blacksmith.sh の修正後に解決した
container フィールド自体にも制限がある
- entrypoint を override できない
- 一部の step だけをコンテナ内で実行し、残りを外で実行する方式も不可能
YAMLワークフローの開発とデバッグ
- GitHub ActionsのロジックはYAMLで書かれ、複雑になるほどミスしやすい
- RustRoverのGitHub YAML linter チェックは役立ったが、よりよい 静的検査 が必要
- ローカルでは実際のCI挙動を十分に試しにくい
- act は有名なツールだが、CIでやりたいことのうち一部しか対応していない
- 最善のデバッグ方法は、同じリポジトリをもう1つ作り、CIが期待通りに動くまで
git commit -a -m "wip" && git push test-ci branch を繰り返すことだった
ワークフロー分割と成果物の再利用
- 毎回CIパイプライン全体を回さないように、個々の workflow は小さく保っている
- 各 workflow の最後で成果物をアップロードし、その後の workflow がそれをダウンロードして最初から再ビルドしないようにしている
- 前回実行の成果物をダウンロードして workflow を分離テストすることもできる
- ただし前回実行から取得する際は、
download-artifact action にトークンを渡す必要がある
- このトークンはデフォルトトークンでもよいが、なぜそれでも明示が必要なのかは未解決の疑問として残っている
- メインの workflow ファイルは、他のYAMLファイルを呼び出すチェーンになる
jobs:
invoke-build-rust:
name: Build Rust
uses: ./.github/workflows/build-rust.yml
invoke-build-java:
name: Build Java
uses: ./.github/workflows/build-java.yml
invoke-tests-unit:
name: Unit Tests
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/test-unit.yml
invoke-tests-adapter:
name: Adapter Tests
needs: [invoke-build-rust]
uses: ./.github/workflows/test-adapters.yml
secrets: inherit
invoke-build-docker:
name: Build Docker
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/build-docker.yml
invoke-tests-integration:
name: Integration Tests
needs: [invoke-build-docker]
uses: ./.github/workflows/test-integration.yml
invoke-tests-java:
name: Java Tests
needs: [invoke-build-java]
uses: ./.github/workflows/test-java.yml
- 一部のジョブには
secrets: inherit が必要
- workflow が別の workflow を呼び出すと、secret はデフォルトでは共有されない
- CIパイプライン全体では失敗するのに、個別の step 実行では動く問題の原因だった
速くなったマージ、それでも高いデバッグコスト
- 新しいCIスクリプト によってマージ時間は大幅に短縮され、結果には満足している
- ただし、その状態にたどり着くまでにあまりにも多くの時間がかかり、問題が起きたときはもっとデバッグしやすくあるべきだ
まだコメントはありません。