DevOps

Required Status Check を CI Result 1つに集約している

  • GitHub Actions
  • GitHub
  • CI/CD
  • DevOps

GitHub の Required Status Check は、どのワークフローのジョブかを見ていません。Troubleshooting rules に “Required status checks do not take workflow, matrix, or event trigger types into account.” とあるとおり、照合に使われるのは Check Run の名前だけです。

ラビーでは CI を ci.yaml 1本にまとめ、全ジョブの結果を集める CI Result ジョブだけを Required にしています。

  ci-result:
    name: CI Result
    if: always()
    needs: [lint, test, build]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Check all jobs passed
        run: |
          failing=$(echo "$NEEDS_JSON" | jq -r 'to_entries[] | select(.value.result != "success") | "\(.key): \(.value.result)"')
          if [ -n "$failing" ]; then
            echo "Failed jobs:"
            echo "$failing"
            exit 1
          fi
        env:
          NEEDS_JSON: ${{ toJSON(needs) }}

この形にした理由、always() を外すと落ちたジョブが成功扱いになる根拠、そして Ruleset が何を保証して何を保証しないかを順に書きます。ワークフローの中身を安全側に倒す話は前の記事に書いたので、今回はその外側にあるリポジトリ設定の話です。

ブログ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 で「適用しない」を選んだ判断軸を記録します。

Required Status Check は Check Run の名前で照合する

冒頭の Troubleshooting rules は Check の名前の形式を “Workflow: The name format is <job name>.” と定めていて、REST API のリファレンスでも required_status_checkscontext は “The status check context name that must be present on the commit.” と定義されています。条件は、コミットにその名前の Check Run が存在して、それが通っているかどうかです。

name: を付けていないジョブは、id がそのまま表示名になります。Workflow syntaxjobs.<job_id>.name の説明は “Use jobs.<job_id>.name to set a name for the job, which is displayed in the GitHub UI.” です。

照合が名前だけなので、ワークフローが2本あって同じジョブ名を使っていれば、Ruleset からは区別がつきません。逆に言えば、名前さえ一意なら、ワークフローを何本に分けても1本にまとめても、Ruleset の書き方は変わりません。

Required にするのは CI Result だけ

ラビーのリポジトリでは、Lint・テスト・ビルドを ci.yaml 1本に書き、末尾に冒頭の ci-result ジョブを置いています。Ruleset の Required Status Check に書く Context は CI Result の1つだけです。公開している SwiftMilkdown.github/workflows/ci.yaml がこの形です。

ci-resultneeds に全ジョブを並べ、toJSON(needs) で各ジョブの result を受け取り、success 以外が1つでもあれば exit 1 します。needs コンテキストneeds.<job_id>.result は “Possible values are success, failure, cancelled, or skipped.” なので、失敗だけでなくキャンセルとスキップもここで止まります。

Required Check を1つに絞るのは、ジョブを足したり減らしたりしても Ruleset を触らずに済むからです。needs の配列を書き換えるだけで、Ruleset 側は CI Result を待ち続けます。逆に needs に足し忘れたジョブは判定に含まれないので、ジョブ一覧と needs を突き合わせるチェックを CI に入れるのが次の作業です。

発行元のアプリも Ruleset で指定します。Available rules for rulesets に “When you add a required status check rule, you can select an app as the expected source of status updates.” とあり、GitHub Actions を選んでおくと、別のアプリや人が同じ名前のステータスを立てても “merging won’t be allowed” です。

always() を外すと、落ちたジョブが成功扱いになる

集約ジョブの if: always() は飾りではありません。Workflow syntax の jobs.<job_id>.needs にこうあります。

“If a job fails or is skipped, all jobs that need it are skipped unless the jobs use a conditional expression that causes the job to continue.”

always() がないと、テストが1つ落ちた時点で CI Result はスキップされます。そしてスキップされたジョブは、Required Check としては成功扱いです。Status checks のリファレンスが明記しています。

“A job that is skipped will report its status as “Success”. It will not prevent a pull request from merging, even if it is a required check.”

always() があれば、落ちたジョブは CI Result の失敗として Ruleset から見える。なければ CI Result がスキップされ、Ruleset には成功として見えてマージできてしまう

つまり always() を外した集約ジョブは、テストが落ちても「通った」と報告する仕組みになります。Troubleshooting required status checks の表にも “A job depends on a failed job” の対処として “Use always() with needs for required checks that depend on other jobs.” と書かれていて、この形は GitHub 自身が案内しているものです。

needs の説明は、always() の使いどころも “If you would like a job to run even if a job it is dependent on did not succeed, use the always() conditional expression in jobs.<job_id>.if.” と書いています。集約ジョブは、まさにその「依存先が成功しなくても走らせたいジョブ」です。

strict は外し、Auto-merge はリポジトリ側で許可する

strict_required_status_checks_policyfalse にしています。UI で言う “Require branches to be up to date before merging” で、有効にすると、ベースブランチに追いついていないブランチはマージできなくなります(UI の説明は “The topic branch must be up to date with the base branch before merging”)。

ラビーの Renovate 設定は、公開している組織プリセットplatformAutomerge: true にしています。strict だとベースブランチが進むたびに Renovate の PR は古くなり、次の Renovate の実行でリベースされて CI が回り直すまでマージされません。

Renovate のドキュメントも、strict でないときの挙動を “GitHub might automerge a Renovate branch even if it’s behind the base branch at the time.” と書いています。

古いベースに対してテストが通った PR がマージされうる、というのが strict を外すトレードオフです。主な対象が依存更新の PR である以上、そこは受け入れました。

もう1つ、platformAutomerge が効くにはリポジトリ側の設定が要ります。同じページに “It falls back to Renovate-based automerge if the platform-native automerge is not available.” とあり、GitHub では Allow auto-merge がその条件です。ラビーでも有効にしているリポジトリがあります。

同じ名前のジョブが2つのワークフローにあった

個人アカウント側のリポジトリにも同じ形を入れました。そこで踏んだ2つは、ラビーでは起きなかったものの、集約ジョブに寄せるときに誰でも踏みうるものです。例に出す SnackTime は個人アカウントのリポジトリですが、公開しています。

SnackTime では test.yaml(ユニットテスト)、e2e.yaml(Playwright の E2E)、storybook.yaml の3本が PR ごとに走っていて、Ruleset では test という Context を Required にしていました。

問題は、test.yamle2e.yaml の両方がジョブの idtest にしていたことです。name: がないので、同じコミットに test という Check Run が2つ付きます。統合前のコミットを API で見ると、GitHub Actions 名義の test が別々のワークフロー実行から2つ付いていました。

# test.yaml(抜粋)
name: Unit Tests
jobs:
  test:
    runs-on: ubuntu-latest

# e2e.yaml(抜粋)
name: E2E Tests
jobs:
  test:
    runs-on: ubuntu-latest

ワークフロー名は Unit TestsE2E Tests で違いますが、Ruleset の照合には使われません。PR の Checks タブにはワークフロー名も並びますが、それは表示の話で、Ruleset が見るのは Context 名と、任意で指定する発行元アプリだけです。REST API の required_status_checkscontextintegration_id の2つしか持ちません。

同じ名前が2つあるとき、GitHub がどちらを採るのかは書かれていません。About protected branches は “Using the same job name in multiple workflows can cause ambiguous status check results and block pull requests from being merged.” と、曖昧になることだけを書いています。Check と Commit Status が同名のときは “If a check and a commit status have the same name, both must pass when that name is required.” と Troubleshooting required status checks にありますが、同じアプリが立てた同名の Check Run が2つある場合の規則はどこにもなく、E2E だけを落として確かめる実験もしていません。

はっきりしているのは、test を Required にした時点で「E2E を通すこと」を要求したつもりが、設定としてはそう書けていなかったことです。

同じ名前の Check Run が別のアプリから立つ例も、目の前にありました。統合後の PR のコミットには zizmor という Check Run が2つ付いていて、1つは GitHub Actions、もう1つは Code Scanning が立てたものです。

照合が名前だけである以上、名前の衝突は自分のワークフローの中だけで起きるものではありません。発行元のアプリを Ruleset で指定しておく理由がここにあります。

3本のワークフローを ci.yaml に統合し、ジョブの idtest / e2e / storybook / zizmor に分けて、それぞれに Unit Tests / E2E Tests / Storybook Build Check / zizmor という name を付けました。Required にするのは CI Result の1つだけです。

Branch Protection と Ruleset は両方とも効く

About rulesets の rule layering の節にこうあります。

“If the same rule is defined in different ways across the aggregated rulesets, the most restrictive version of the rule applies. As well as layering with each other, rulesets also layer with protection rules targeting the same branch or tag.”

優先順位ではなく両方が効いて、同じルールが両方にあれば厳しい方が勝ちます。設定画面で Branch Protection 側に allow_force_pushes: true が見えていても、同じブランチに non_fast_forward を持つ Ruleset があれば Force Push は止まっています。穴があるのではなく、画面の表示と実際の挙動が食い違って見えるだけです。

古い Branch Protection を消して Ruleset に一本化するなら、消す前に設定を項目ごとにダンプして、Ruleset 側で1つずつカバーできていることを確認します。required_linear_history のように Branch Protection 側にしか無いルールがあると、消した時点で線形履歴の強制が黙って外れます。先に Ruleset へ足してから消す順番です。

Ruleset の更新 API は全置換になる

Organization ではなく個人アカウントの話になりますが、個人アカウントには Org レベルの Ruleset がありません。About rulesets が複数リポジトリへの適用を “multiple repositories in an organization for customers on GitHub Team and GitHub Enterprise plans” に限っているとおりで、リポジトリごとに書くしかなく、十数本もあると設定の食い違いは気づかないうちに生まれます。

そこで共通設定を API で当てようとすると、Update a repository ruleset の PUT が全置換だという壁に当たります。送った rules で Ruleset 全体が置き換わるので、deletionnon_fast_forward だけを送れば Required Check も required_linear_history も消えます。ドキュメントには部分更新か全置換かの明記がなく、実際に試して分かったことです。

既存の Ruleset を GET して rules を足し、その全体を PUT で送り返す形にしています。

Fork からの PR は ci.yaml ごと書き換えられる

Required Check を入れ終わってから、この仕組みが第三者の PR には効かないことを確かめました。pull_request イベントのワークフローは、PR のマージコミットにある定義で実行されます。

Events that trigger workflowspull_request_target について、ベースリポジトリのデフォルトブランチにある定義で動くと説明したうえで、“rather than in the context of the merge commit, as the pull_request event does.” と pull_request との違いを書いています。

Fork から PR を出す側は .github/workflows/ci.yaml も書き換えられます。ci-result から needs を外して exit 0 するだけの内容にすれば、GitHub Actions 名義で CI Result という名前の成功した Check Run が立ちます。Ruleset が見るのは名前と発行元アプリだけなので、それで Required Check は満たされます。

実際に試しました。CI Result だけを Required にしているリポジトリで、ci.yaml を「CI Result という名前のジョブが exit 0 するだけ」の内容に書き換えた PR を出すと、Lint もテストもビルドも走らないまま Check Run CI Result は Success になり、PR のマージ状態は CLEAN(Required Check を満たした状態)になりました。同じリポジトリ内のブランチからの PR なので承認の層は挟まっていませんが、Ruleset の側が ci.yaml の中身を見ていないことはこれで確かめられます。

Fork からの PR は ci.yaml ごと書き換えられるので、pull_request ワークフローは PR 側の定義で動き、GitHub Actions 名義の CI Result という Check Run を立てられる。Ruleset は名前と発行元しか見ないので止まらない。止まるのは、ワークフローを走らせる前の承認

GitHub 自身も、2026年6月の changelog でこの性質をそのまま書いています。

“Previously, a workflow ran based on the workflow file in the commit that triggered it. An attacker with repository access could modify that file to run malicious code.”

Fork からの PR に Secrets は渡らず、GITHUB_TOKEN も読み取り専用です。Events that trigger workflows の pull_request の節に “With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. The GITHUB_TOKEN has read-only permissions in pull requests from forked repositories.” とあります。

ワークフローを書き換えられても、それ自体で直接の被害はありません。問題は「CI Result が通っているから」を根拠にマージしてしまうことです。

余談ですが、Claude Code Action のように GitHub App 認証でトークンを交換するアクションには、ワークフローファイルがデフォルトブランチの内容と一致しないときにトークン発行を拒む仕組みがあります。anthropics/claude-code-action#443 で “It checks to verify that the workflow being run matches what’s on the default branch.” と説明されています。守れるのはそのアクションのトークンだけで、CI Result のような自前の Check Run には効きません。

ここで「ベース側の定義で動く pull_request_target に変えれば、Fork が ci.yaml を書き換えても効かなくなる」と考えたくなりますが、この選択肢は採っていません。

pull_request_target は、Fork の PR に対してベースリポジトリの Secrets と書き込み権限を渡すイベントで、その上で PR のコードを Checkout して実行するのが脆弱性の典型例として知られています。GitHub Security Lab の Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests は、こう書いています。

“Combining pull_request_target workflow trigger with an explicit checkout of an untrusted PR is a dangerous practice that may lead to repository compromise.”

GitHub のドキュメントも同じ節で警告しています。

“Running untrusted code on the pull_request_target trigger may lead to security vulnerabilities. These vulnerabilities include cache poisoning and granting unintended access to write privileges or secrets.”

同じ節は “Avoid using this event if you need to build or run code from the pull request.” とも書いています。CI はまさに PR のコードをビルドしてテストするものなので、pull_request_target に乗せる対象ではありません。Required Check を Fork に対して意味のあるものにしたい、という動機でこのイベントを使うと、止めたかったものより大きい穴を開けます。

効くゲートは Required Check ではなく、Fork からの PR のワークフロー実行に承認を要求する設定です。公開リポジトリでは “Require approval for all external contributors” にします。説明は “All users that are not a member or owner of this repository and not a member of the organization will require approval to run workflows.” です。承認する前にワークフローの差分を読む、がルールになります。