
はじめに
GitHub Actions の hosted runner は、5〜8秒で終わる job でも 1 job あたり1分に切り上げて課金されます。
「受付判定(admit)→ 本実行(verify)」という2 job 構成の reusable workflow では、判定が不要なPRでも2 job 分の課金が発生していました。
この記事では、受付 job を特定の作成者のPRでだけ起動し、それ以外では本実行 job が同じ判定 step をインラインで実行する形に変えて、hosted job を1本減らした方法を紹介します。
required check の名前や判定ルールは変えず、課金だけを削るのがポイントです。
あわせて、job を skip するときに踏みやすい落とし穴と、その対策もまとめます。
こんな人におすすめ
- GitHub Actions の実行時間課金を減らしたい方
- reusable workflow を複数リポジトリで共有して運用している方
- required status check と
if:による job skip の関係を整理したい方 - Dependabot など特定ボットのPRだけ別経路で処理している方
まず「どこが削れるか」を実測する
最適化の前に、課金の内訳を経路別に集計しました。
14日分・340 run attempt を調べた結果、削れるのは「判定が不要な経路の受付 job」だけで、目安として約217分/14日(約15分/日)でした。
「もっと削れるはず」という当初の仮説は、全体でも1日40分程度しかないため届かないと分かりました。
数字を出してから目標を置き直せたのは大きな収穫です。
1分切り上げの課金では、短い job が多いほど「job の本数」そのものがコストになります。
実行時間ではなく本数を数えるのがコツです。
受付jobを条件付きで起動する
受付 job に if: を付け、対象のボットが作ったPRでだけ起動します。
jobs:
admit:
# 対象外のPRでは起動しない。payload の作成者は「hosted job を1本減らす振り分け」
# にだけ使い、判定の入力には使わない。
if: github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-latest
ここで重要なのは、github.event の payload を振り分けにだけ使うことです。
判定そのものの入力は、API で取り直した値だけにします。
payload を信じて判定まで省略すると、payload と実体のずれがそのまま抜け穴になります。
required check の job は skip しない
required status check に登録された job を if: で skip すると、check run が生成されず、PRが恒久的に merge 不能になります。
今回 skip したのは required ではない受付 job 側で、required の本実行 job は常に走らせています。
逆に言うと「skip しうる job を required に登録してはいけない」という制約が生まれます。
この制約はドキュメントに明記しておくと安全です。
判定stepを本実行jobにインラインで持たせる
受付 job が skip されたときは、本実行 job が同じ判定を行います。
呼び出し側から見える workflow outputs は || で寄せると、呼び出し側のコードを変えずに済みます。
outputs:
mode:
value: ${{ jobs.admit.outputs.mode || jobs.verify.outputs.mode }}
reason:
value: ${{ jobs.admit.outputs.reason || jobs.verify.outputs.reason }}
受付 job が動いたときはその出力、skip されたときは本実行 job の出力が返ります。
経路の不一致はfail closedにする
本実行 job の最後で、受付結果をまとめます。
payload では対象外なのに、API 再取得では「隔離実行の対象」と判定された場合は、成功扱いにも hosted 実行にもせず失敗させます。
mode="${ADMIT_MODE}"
reason="${ADMIT_REASON}"
if [ "${ADMIT_RESULT}" = "skipped" ]; then
mode="${INLINE_MODE}"
reason="${INLINE_REASON}"
# payload では対象外なのに、API では隔離実行の対象になった場合は黙って通さない
if [ "${mode}" = "verify" ]; then
mode="fail"
reason="受付経路の不一致(payloadでは対象外、APIでは隔離実行の対象)"
fi
fi
受付 job を通った場合はその判定をそのまま使い、通らなかった場合だけインライン判定の結果を採用します。
想定外の組み合わせは「たぶん大丈夫」で通さず、赤にして原因を調べる方が安全です。
コピーしたstepの一致をテストで固定する
同じ shell を2つの job に書くと、片方だけ直されて規則がずれても CI は緑のままです。
そこで run: 本文を抽出して cmp で比べるテストを足しました。
for step in pr decide; do
extract "${step}" "${TMP}/${step}.1" 1 # 受付 job 側
extract "${step}" "${TMP}/${step}.2" 2 # 本実行 job 側
cmp -s "${TMP}/${step}.1" "${TMP}/${step}.2" || fail=1
done
片方だけ書き換える変異テストを行い、きちんと FAIL になることも確認しました。
テストが本当に差分を検出できるかは、わざと壊して確かめるのが確実です。
つまづきやすいポイント
- 同名 id の step を job をまたいで使う場合: 抽出用の awk 関数に「何番目の出現か」を渡せるようにしないと、2つのコピーを個別に取り出せません。
- required check との相性: skip した job を required に登録すると merge 不能になります。登録対象は常に走る job に限ります。
- payloadを判定に使ってしまう: 振り分けと判定の入力を分けないと、payload のずれが抜け穴になります。
- rollbackの手順: 変更が reusable workflow 内で閉じていれば、floating tag を変更前のリリースに戻すだけで巻き戻せます。
まとめ
hosted runner の1分切り上げ課金では、短い job の「本数」を減らすのが効果的です。
受付 job は必要なPRでだけ起動し、判定は本実行 job にインラインで寄せます。
required check の job は skip せず、経路の不一致は fail closed、コピーした step はテストで一致を固定します。
まず実測してから目標を置くと、削れる範囲を見誤らずに済みます。

