なんとなくから脱却する
GitHub Actionsグッドプラクティス11選

本記事のテーマはGitHub Actionsです。個人的にもっと早く知りたかった!と考えているグッドプラクティスを、厳選してお届けします。想定読者は次のとおりです。

  • 普段GitHub Actionsを雰囲気で運用している人
  • GitHub Actionsをコピペや生成AIで乗り切っている人
  • 他者が書いたコードの意味をより深く理解したい人

本記事でGitHub Actionsの基本は説明しません。グッドプラクティスを含めて基礎から学びたい人は、拙著GitHub CI/CD実践ガイドを読んでみてください。GitHub Actionsの基本構文から運用のコツまで、網羅的に解説しています。さて書籍紹介はこれぐらいにして、さっそく本題へ進みます。

GitHub Actionsの設計指針

GitHub ActionsはCI/CDや各種自動化で役立つ、汎用的なワークフローエンジンです。一般的に長期で運用され、実行頻度も高いです。そこで設計時は次のような観点を意識しましょう。

  • フェイルファスト:すばやく失敗を検出し、効果的にフィードバックを得る
  • ムダの最小化:不要なワークフロー実行を避け、待ち時間とコストを減らす
  • セキュリティ:最小権限の原則を守り、ワークフローの侵害リスクを下げる
  • メンテナンス性:デバッグや動作確認がしやすく、読み解きやすいコードにする

本記事ではこれらの設計指針を念頭に、11個のグッドプラクティスを説明します。そして紹介したグッドプラクティスを最後に集約し、便利なテンプレートコードへ落とし込みます。

フェイルファスト

GitHub Actionsは繰り返し実行します。そのためすばやく失敗を検出し、効果的にフィードバックを得ることが大切です。このようなフェイルファストの実現には、⁠タイムアウト」⁠デフォルトシェル」⁠actionlint」の3つが役立ちます。

タイムアウトを常に指定する

GitHub Actionsのデフォルトタイムアウトは360分です。無制限の通信待ちなどが発生すると、6時間も動き続けます。これはさすがに長すぎます。タイムアウトを指定しましょう。実装例は次のとおりです。

timeout-minutes: 5 # 分単位でタイムアウトを指定

これで意図せぬ異常が発生しても、すばやく検知できます。ユースケースに合わせて、適切なタイムアウトを設定しましょう。筆者自身はたいてい5分に設定します。

デフォルトシェルでBashのパイプエラーを拾う

Ubuntuランナーではシェル指定省略時にBashが起動します。ところがこのBash、パイプ処理中のエラーを無視します。エラー時に意図せず処理を継続するため、結果として不具合の温床になります。デバッグの難易度も上がるため、パイプエラーを無視するメリットは皆無です。

そこでパイプ処理中のエラーを拾えるよう、Bashの挙動を変更しましょう。通常のBashならpipefailオプションを有効化して、パイプエラーを拾うのが定石です。ただGitHub Actionsには、もっと楽な方法があります。次のようにデフォルトシェルを定義するのです。

defaults:
  run:
    shell: bash # ワークフローで使うシェルをまとめて指定

GitHub ActionsではBashの利用を明示的に宣言すると、なぜかpipefailオプションが有効化されます[1]。つまりデフォルトシェルを定義するだけで、pipefailオプションが全ステップで有効になるわけです。より正確にいえば、次のオプションでBashが起動します。

bash --noprofile --norc -eo pipefail {0}

デフォルトシェルにデメリットはほぼないです。あらゆるワークフローへ組み込みましょう。

「actionlint」ですばやく構文エラーをチェックする

actionlintはGitHub Actions向けの静的解析ツールです。.github/workflowsディレクトリ配下のYAMLファイルをまとめてチェックし、構文エラーや非推奨構文などを検出します[2]。お試しならDockerイメージの利用が手軽です。次のように実行します。

docker run --rm -v "$(pwd):$(pwd)" -w "$(pwd)" rhysd/actionlint

actionlintの強みは、GitHub Actionsで実行する前に問題を検出できる点です。ワークフロー構文のタイプミスなども拾えるため、実運用では想像以上に活躍します。

ムダの削減

待ち時間ほどムダなものはありません。GitHub Actionsで待ち時間を削減するもっともよい方法は、不要なワークフローを実行しないことです。そもそも実行しなければ、待ち時間はゼロです。うれしいことにコストも削減できます。そこでムダを削減するプラクティスとして、⁠自動キャンセル」⁠イベントフィルタリング」⁠Ubuntuランナー」の3つを紹介します。

Concurrencyで古いワークフローを自動キャンセルする

プルリクエストで起動するワークフローは、最新コード以外での実行がたいてい不要です。自動テストや静的解析はその典型で、古いコードで実行されるワークフローはムダです。そこで次のように実装し、古いワークフローは自動キャンセルしましょう。

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true # 実行中ワークフローのキャンセルを有効化

キャンセル条件はgroupキーへ指定します。このコード例では「同じプルリクエスト」「同じワークフロー」が複数起動したら、古いワークフローを自動的にキャンセルします。キャンセル条件を調整したい場合は、公式ドキュメントを参照しましょう。

不要なイベントで起動しないようにフィルタリングする

GitHub Actionsはイベントをトリガーにします。このトリガーになるイベントですが、特定の条件でフィルタリングできます。pull_requestイベントやpushイベントなら、ファイルパスやブランチで起動条件を調整可能です。Globも利用でき、たとえば次のように実装します。

on:
  pull_request:
    paths: ['**.go']   # Goのファイルが変更されたら実行
  push:
    branches: ['main'] # デフォルトブランチなら実行
    tags: ['v*']       # バージョンタグが作成されたら実行

pull_requestイベントのように実行頻度が高い場合、少しチューニングするだけでワークフローの起動が劇的に減ります。公式ドキュメントではアクティビティタイプなど、他のフィルタリング手法も紹介されています。ぜひ一度ワークフローの起動条件を見直してみましょう。

最安値のUbuntuランナーを優先する

プライベートリポジトリの場合、GitHub Actionsは使用時間に応じて課金されます[3]。この使用時間はワークフローの実行時間へ、ランナーごとに設定されている乗率をかけて計算します。

  • 「使用時間」「実行時間」×「ランナーごとに異なる乗率」

この乗率はUbuntuランナーがもっとも小さいです。そのため可能な限りUbuntuランナーを選択しましょう。これだけでコスト削減につながります。実装例は次のとおりです。

runs-on: ubuntu-latest

セキュリティ

GitHub Actionsは便利ですが、セキュリティと無縁ではいられません。最小権限を徹底し、侵害リスクを下げる設計が重要です。そこで「ジョブレベルのパーミッション」「アクションの固定」という、2つのプラクティスを紹介します。

GITHUB_TOKENのパーミッションはジョブレベルで定義する

GITHUB_TOKENはワークフロー実行時に、自動生成されるクレデンシャルです。このクレデンシャルを利用すれば、ワークフローからGitHub APIへ簡単にアクセスできます。

GITHUB_TOKENの権限はパーミッションで制御し、最小権限での運用が鉄則です。最小権限で運用すれば、攻撃されても被害を小さくできます。またパーミッションの設定方法は3つあり、次のような優先順位が存在します。

  1. ジョブレベルへ定義したパーミッション
  2. ワークフローレベルへ定義したパーミッション
  3. リポジトリに設定されたデフォルトパーミッション

それでは最小権限のパーミッションを設定しましょう。まず実践したいのが、一度ワークフローレベルで全パーミッションを無効化することです

permissions: {} # 全パーミッションの無効化

これでデフォルトパーミッションが使われなくなります。ただしこのままだと、コードの参照すらできません。そこであらためて、必要最小限のパーミッションをジョブレベルで定義します

permissions:
  contents: read # コードの読み込みを許可

このようにすれば多少記述量は増えますが、不要なパーミッションが自然と排除されます。ほぼ思考コストなしに最小権限を達成できるため、習慣にする価値はあります。

アクションはコミットハッシュで固定する

アクションのバージョンはよく、次のようにGitタグで指定します。

- uses: actions/checkout@v4

しかしGitタグは可変です。攻撃者がコードを改ざんしてGitタグも上書きすれば、アクション経由でワークフローを侵害できます[4]。そこでコミットハッシュの出番です。コミットハッシュはコミットごとに生成され、事実上一意な値になります[5]。そのためコミットハッシュを次のように指定すれば、アクションを不変リソースとして扱えます

- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1

これで仮にアクションのコードが改ざんされても、影響を受けなくなります。ただしコミットハッシュのバージョンが安全か、確認するのはあなたの仕事です。コミットハッシュで固定しても、そもそも侵害されていたら無意味です。面倒でもコードを読む必要があります。

メンテナンス性

GitHub Actionsもコードの一種です。デバッグや動作確認がしやすく、読み解きやすいコードが望まれます。そこで役立つプラクティスが、⁠Bashのトレーシングオプション」⁠workflow_dispatchイベント」⁠背景情報の形式知化」の3つです。

Bashトレーシングオプションでログを詳細に出力する

デバッグの第一歩は、なにが起きているか把握することです。そこで役立つのが、Bashのトレーシングオプションです。set -xコマンドで有効化できます。これは簡易的なPrintデバッグで、「実行したコマンド」「実行結果」をログへ出力できます。実装例は次のとおりです。

steps:
  - run: |
      set -x # 最初に一行追加するだけで、ログ出力が詳細になる
      date
      hostname

これで次のようにログへ出力される情報が増やせます。

Bashトレーシングオプションの有効化

単純ですがアクティブに開発している間だけでも入れておくと、デバッグが捗ります。

workflow_dispatchイベントで楽に動作確認する

ワークフローには「特定のブランチでのみ起動する」ものや、⁠リリースタグ作成時にのみ起動する」ものがあります。これらのワークフローは動作確認しづらいです。動くかどうかも分からないのにデフォルトブランチへマージし、検証することが多くなります。一発で正常に動作するケースはまれです。コミットログも汚くなり、なんだか微妙な罪悪感まで抱いてしまいます。

そこで活用したいのがworkflow_dispatchイベントです。このイベントを次のように組み込むと、ワークフローの手動実行が可能になります。

on:
  workflow_dispatch:

workflow_dispatchイベントは起動タイミングが自由なだけでなく、任意のブランチで起動可能です。つまりデフォルトブランチへマージする前に、ワークフローの動作確認ができます[6]。特定のイベントでしか取得できない値がある場合は、次のように入力パラメータで注入します。

env:
  PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }}

こうすることでワークフローのロジックは、workflow_dispatchイベントでもほとんど検証できます。任意のタイミング・任意のブランチで実行できるため、動作確認がとても楽になります。

ワークフローの背景情報をコメントへ書き残す

実装者にとって、ワークフローは自明に感じることが多いです。つい「読めば分かる」と考えてしまいます。しかし長期で運用していると、思いのほか理解するのが難しくなっていきます。

コードがあるので「なにをしているか」は読み解けます。しかしなぜこのワークフローが存在するのかは読み解けません。なんらかの目的があって実装されたはずですが、それは容易く失われます。しばしば影響範囲も不明確になります。たとえばワークフローでエラーが発生したとして、誰が困るのでしょうか。そんな基本的なことすら、いずれ把握できなくなります。

そこでワークフローの目的や影響範囲を、コードの冒頭にコメントで書きましょう。ワークフローの背景情報が数行書いてあるだけで、読む人の理解を大きく助けます。

# なにをするワークフローか手短に記述
#
# ワークフローの目的や影響範囲、参考URLなどを数行で書く。
# 実装詳細ではなく、コードから読み取れない背景情報を中心にする。
---
name: ...

賭けてもいいです。あなたが実装したワークフローは、他人にとって自明ではありません。大作にする必要はないので、手がかりをきちんと残しましょう。効果は絶大です。

テンプレートコード

最後に本記事で登場した、11個のグッドプラクティスをおさらいします。

  1. タイムアウトを常に指定する
  2. デフォルトシェルでBashのパイプエラーを拾う
  3. 「actionlint」ですばやく構文エラーをチェックする
  4. Concurrencyで古いワークフローを自動キャンセルする
  5. 不要なイベントで起動しないようにフィルタリングする
  6. 最安値のUbuntuランナーを優先する
  7. GITHUB_TOKENのパーミッションはジョブレベルで定義する
  8. アクションはコミットハッシュで固定する
  9. Bashトレーシングオプションでログを詳細に出力する
  10. workflow_dispatchイベントで楽に動作確認する
  11. ワークフローの背景情報をコメントへ書き残す

個々のプラクティスは難しくないですが、少し数が多いです。そこでグッドプラクティスを実践しやすいよう、テンプレートコードを作成しました。よければご活用ください。

# なにをするワークフローか手短に記述
#
# ワークフローの目的や影響範囲、参考URLなどを数行で書く。
# 実装詳細ではなく、コードから読み取れない背景情報を中心にする。
---
name: Example
on:
  # 動作確認しやすいように手動起動をサポート
  workflow_dispatch:
  # プルリクエストはファイルパスでフィルタリング
  pull_request:
    paths: [".github/workflows/**.yml", ".github/workflows/**.yaml"]

# ワークフローレベルでパーミッションをすべて無効化
permissions: {}

# デフォルトシェルでパイプエラーを有効化
defaults:
  run:
    shell: bash

# ワークフローが複数起動したら自動キャンセル
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  example:
    # もっとも安価なUbuntuランナーを利用
    runs-on: ubuntu-latest
    # 6時間も待たされないようにタイムアウトを設定
    timeout-minutes: 5
    # ジョブレベルで必要最小限のパーミッションを定義
    permissions:
      contents: read
    steps:
      # アクションはコミットハッシュで固定
      - name: Checkout
        uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1

      # Bashトレーシングオプションの有効化でログを詳細化
      - name: Run actionlint
        run: |
          set -x
          docker run --rm -v "$(pwd):$(pwd)" -w "$(pwd)" rhysd/actionlint:1.7.3

まとめ

本記事ではGitHub Actionsのグッドプラクティスを紹介し、最後にテンプレートコードを実装しました。盛りだくさんでしたが、本記事で登場していないプラクティスは他にも多数あります。

GitHub Actionsをもっと詳しく知りたい人は、GitHub CI/CD実践ガイドがお勧めです。これほど優れた技術書はみたことないと褒められるぐらい、読みやすく具体的で網羅性の高い一冊に仕上がっています。GitHubでソフトウェア開発をしているなら必見です。

おすすめ記事

記事・ニュース一覧