【ITニュース解説】Mastering GitHub Actions: Insights and pitfalls of "if conditions"
2025年10月02日に「Dev.to」が公開したITニュース「Mastering GitHub Actions: Insights and pitfalls of "if conditions"」について初心者にもわかりやすく解説しています。
ITニュース概要
GitHub Actionsの`if`条件は便利だが、依存ジョブがスキップされると後続も意図せず停止する落とし穴がある。これはワークフローがDAG(有向非巡回グラフ)として処理され、スキップ状態が伝播するためだ。解決策は、ジョブ依存ではなくステップ内で条件分岐を使うこと。DAGを理解し、シンプルな設計が重要となる。
ITニュース解説
システムエンジニアを目指す皆さんにとって、ソフトウェア開発の現場では「CI/CD」という言葉をよく聞くだろう。これは継続的インテグレーションと継続的デリバリーの略で、プログラムのテストやリリースを自動化し、開発を効率化する仕組みのことだ。このCI/CDを実現するツールの一つに、GitHub上で利用できる「GitHub Actions」がある。
GitHub Actionsを使うと、コードがGitHubにプッシュされた時など、特定のイベントをきっかけに自動でテストを実行したり、アプリケーションをビルドしたり、サーバーにデプロイしたりできる。この自動化された一連の作業を「ワークフロー」と呼び、ワークフローは複数の「ジョブ」で構成され、さらにジョブは複数の「ステップ」で構成される。このワークフローの中で、特定の条件が満たされた場合にのみジョブやステップを実行するための機能が「if条件」だ。
if条件にはいくつかの種類がある。
if: success() は、そのジョブが依存するすべてのジョブが成功した場合に実行される。
if: failure() は、依存するジョブのどれか一つでも失敗した場合に実行される。
if: cancelled() は、依存するジョブがキャンセルされた場合に実行される。
if: always() は、依存するジョブの状態に関わらず常に実行されるが、ワークフロー自体がユーザーによってキャンセルされた場合でも実行されるため注意が必要だ。
そのため、GitHubが推奨するより安全な選択肢が if: !cancelled() だ。これは、依存するジョブがキャンセルされた場合を除き、常に実行されるという条件になる。
これらのif条件は一見すると単純に見えるが、複数のジョブが互いに依存し合ったり、複数のブランチをトリガーとする複雑なワークフローでは、予期せぬ挙動を引き起こすことがある。
実際にあった事例として、メインブランチとリリースブランチの両方に対応するCI/CDワークフローを構築していた時の問題を見てみよう。このワークフローには以下の4つのジョブがあった。
run-test-cases: すべてのブランチでテストを実行する。run-script-on-main: メインブランチにプッシュされた時のみ、特定のスクリプトを実行する。docker-build-push:run-test-casesとrun-script-on-mainの両方が完了した後、Dockerイメージをビルドしてプッシュする。このジョブにはif: ${{ !cancelled() }}が設定されていた。これは、run-script-on-mainがリリースブランチではスキップされることを考慮し、その場合でもdocker-build-pushは実行されるべきだと考えていたためだ。trigger-deployment:run-test-casesとdocker-build-pushが完了した後、デプロイをトリガーする。
筆者は、メインブランチではすべてのジョブが順に実行され、リリースブランチではrun-script-on-mainがスキップされた後、docker-build-pushが実行され、続いてtrigger-deploymentが実行されることを期待していた。
しかし、実際にワークフローを実行すると、メインブランチでは期待通りに動作した一方で、リリースブランチでは問題が発生した。run-script-on-mainは期待通りスキップされたが、その後docker-build-pushは実行されたものの、なぜかtrigger-deploymentがスキップされてしまったのだ。trigger-deploymentジョブはrun-script-on-mainに直接依存していないため、この結果は非常に混乱を招いた。
この問題の根本原因は、GitHub Actionsがワークフロー全体を「有向非巡回グラフ(Directed Acyclic Graph: DAG)」として扱っていることにある。DAGとは、ジョブを「ノード」、ジョブ間の依存関係を「エッジ(辺)」として表現し、実行の流れが一方向に進み、循環がない構造のことだ。GitHub Actionsは、ワークフローが開始される前にこのDAGを構築し、それに基づいてジョブの実行順序を決定する。ジョブのneeds:セクションは、このDAGにおける依存関係の辺を定義する役割を果たす。
たとえば、ジョブA、B、Cがあり、BがAに、CがAとBに依存している場合、DAGは「A → B → C」という形になる。もしAが失敗すると、それに依存するBとCも自動的にスキップされる。 これと同様に、あるジョブが条件によってスキップされた場合も、そのジョブは「スキップされた」状態のノードとしてDAG内に存在する。そして、そのスキップされたジョブに依存している後続のジョブは、その「スキップされた」状態を継承してしまうのだ。これは「DAGの枝刈り(pruning)」と呼ばれ、あるジョブがスキップされると、それに依存するすべてのジョブも原則として実行されなくなることを意味する。
今回のケースでは、リリースブランチでrun-script-on-mainジョブがメインブランチの条件に合致しなかったためスキップされた。docker-build-pushジョブはrun-script-on-mainにneeds:で依存していたが、if: ${{ !cancelled() }}条件が付いていたため、run-script-on-mainがキャンセルされたわけではないと判断し、実行された。しかし、trigger-deploymentジョブはdocker-build-pushだけでなくrun-test-casesにも依存していた。そして、run-script-on-mainがスキップされたことが、DAGを通じて最終的にtrigger-deploymentにも伝播し、「上流のジョブがスキップされたため、このジョブもスキップされる」という結果になってしまったのだ。trigger-deploymentが直接run-script-on-mainに依存していなくても、DAGの経路のどこかにスキップされたジョブがあると、その影響を受けてしまうことがある。
この問題に対処するため、筆者はワークフローの構造を見直した。最もシンプルで堅牢な解決策は、docker-build-pushジョブがオプションのジョブ(run-script-on-main)に依存しないようにすることだった。具体的には、run-script-on-mainジョブを削除し、その中身のロジックをdocker-build-pushジョブ内の「条件付きステップ」として組み込んだのだ。
修正後のdocker-build-pushジョブは、run-test-casesのみに依存するようになり、常に実行される。そして、そのジョブの内部で、メインブランチの場合にのみ特定のスクリプト(以前run-script-on-mainで実行していたもの)を実行するステップを追加した。
このように変更したことで、ジョブレベルでのスキップがなくなり、DAGが簡潔になった。docker-build-pushは常に実行され、その中のステップがブランチ条件に基づいて実行されるため、スキップされたジョブの影響が後続に伝播する問題は解消され、ワークフローは期待通りに動作するようになった。
この経験から得られる重要な教訓はいくつかある。
一つは、needs:を使ってオプションのジョブに依存させる際には注意が必要だということだ。ジョブがスキップされると、意図せず後続のジョブもスキップされてしまう可能性がある。
二つ目は、if: ${{ !cancelled() }}はif: always()よりも安全だが、DAGの枝刈りによる複雑な依存関係の問題を完全に解決するわけではないということだ。
そして最も重要な教訓は、ワークフローを設計する際には、ジョブ全体をDAGとして捉えることだ。ジョブを直線的なステップの連続と考えるのではなく、ノードと辺で構成されるグラフとしてモデル化することで、どのジョブがいつ実行され、いつスキップされるのか、その予測がしやすくなる。
疑問がある場合は、条件付きのジョブを増やすよりも、単一のジョブ内に条件付きのステップを配置する方が、DAGをシンプルに保ち、予期せぬ問題の発生を防ぐことができる。GitHub Actionsの世界では、「すべてはDAGである」という原則を理解することが、より予測可能で堅牢なワークフローを構築するための鍵となる。