GitHubのChecksタブを合否掲示板にする ― GitHub App × Actions × Checks の棲み分け

GitHubのChecksタブを合否掲示板にする DevOps

自作x86エミュレータ rustx86 のCIを整備していて、PRのChecksタブがこうなった。

✓ コードチェック
✓ CPU照合
⊘ OS起動回帰
✓ OK
✓ CI 結果
✓ CPU照合 結果

6行。しかも上の4行はクリックしてもActionsのログページに飛ぶだけのリンクで、本当に見たい「何が合格で何が落ちたか」は下の2行にしかない。整理しようとジョブを削ったら、今度は結果の本文が読めなくなった

一連の試行錯誤の原因は、GitHub App・GitHub Actions・GitHub Checks の3者の関係を誤解していたことだった。最終的にChecksタブは「合否掲示板」になったので、3者の関係の整理と実装をまとめる。

最終形: 2つの束

先に完成形を見せる。PRのChecksタブのサイドバーがこうなる。

PRのChecksタブの構造図

  • GitHub Actions の束 = 素のジョブ。実行の単位。畳める
  • rustx86-ci(自前App)の束 = 合否掲示板。1関門=1行で、開いた瞬間に✓/✗が読める。クリックすると右ペインにレポート本文(何が落ちたか・ログ・表)が出る

確認の導線は「PRを開く→Checksタブ」の1回。Actionsタブに潜る必要がなくなった。

3者の関係をRustの構造体で書く

GitHubのドキュメントを行き来するより、型で書いたほうが早い。Checksまわりの世界はこう見える。

/// Checksタブの「1行」。コミットに紐づく検査結果の最小単位
struct CheckRun {
    name: String,             // 行の名前 (例: "lint")
    head_sha: CommitSha,      // どのコミットの検査か
    conclusion: Conclusion,   // Success / Failure / Skipped …
    output: Option<Output>,   // 右ペインに出るMarkdown本文。
                              // None なら行はログへのリンクにしかならない
}

/// Checksタブの「束(サイドバーのグループ)」。持ち主は必ずApp
struct CheckSuite {
    app: AppId,               // ← 束はApp単位でしか分かれない
    runs: Vec<CheckRun>,
}

/// コミットから見た全体像
struct Commit {
    sha: CommitSha,
    suites: HashMap<AppId, Vec<CheckSuite>>,
}

そして今回の設計のすべてを決めた制約が、このシグネチャに現れる。

struct AppToken { app: AppId }   // GitHub Appとして署名して得るトークン
struct UserToken;                // PAT (Personal Access Token)

mod checks {
    /// Check runを作れるのは AppToken だけ。
    /// UserToken を受け取るオーバーロードは存在しない
    pub fn create(auth: AppToken, run: NewCheckRun) -> CheckRun { /* … */ }
}

Check runの作成はGitHub Appにしか許されていない。PATでは作れない。これはChecks APIの実際の仕様で、作られたCheck runは auth.app の束に入る。

では、ワークフローのジョブがChecksタブに出てくるのはなぜか。

/// GitHub Actions は特別な存在ではなく、GitHubが運営する1つのApp
const GITHUB_ACTIONS: AppId = AppId(15368);

/// ジョブは開始時に Actions App 名義の CheckRun として自動発行される。
/// output は None ── だから選んでもログへのリンクにしかならない
impl Job {
    fn on_start(&self) -> CheckRun {
        checks::create(
            AppToken { app: GITHUB_ACTIONS },
            NewCheckRun { name: self.name.clone(), output: None, /* … */ },
        )
    }
}

さらにもう1つ。ワークフローの中で使える GITHUB_TOKEN の正体は、Actions Appのインストールトークンである。

/// GITHUB_TOKEN ≒ AppToken { app: GITHUB_ACTIONS }
/// これで checks::create しても、束は Actions のまま

この3つの型を眺めると、私の2つの失敗が両方読める。

失敗1: ジョブはChecksタブから隠せない

「初段のジョブはChecksに出さず、結果だけ出したい」と考えた。できない。Job::on_startCheckRun を返す以上、ジョブは必ず1行になる。表示層にフィルタは無い ── Checksタブは「そのコミットの全 CheckRun を列挙する画面」であって、門番(ブランチ保護の必須チェック)の指定単位でもあるから、勝手に間引けない設計になっている。

だから戦えるのは「行の数=ジョブの数」だけ。ジョブを削って行を減らすことはできる。

失敗2: Step Summary は右ペインに出ない

ならばと、ジョブを結果の単位まで削って(「CI 結果」「CPU照合 結果」の2ジョブ)、レポートを $GITHUB_STEP_SUMMARY に書いた。ジョブ名=Check行になり、行数は2行になった。……が、行をクリックしても右ペインにレポートが出ない

構造体を見れば当然だった。右ペインの本文は CheckRun.output であり、ジョブ由来のCheck runは output: None で固定。Step SummaryはActionsのRunページに出る別物で、Checksタブの右ペインとは配線がつながっていない。

右ペインに本文を出せるのは、Checks APIで output 付きのCheck runを自前発行したときだけ

束を分ける鍵: 「App限定」の逆利用

自前発行のCheck runを GITHUB_TOKEN で作ると、Actionsの束に同居する(型の通り、GITHUB_TOKEN はActions Appのトークンだから)。ジョブの行とレポートの行が同じ束に混ざって、6行のリンク集に逆戻りする。

ここで最初の制約が効いてくる。束はApp単位でしか分かれない。そしてCheck runを作れるのはAppだけ。つまり ── 自前のGitHub Appを1つ作って、そのAppのトークンで発行すれば、独立した束になる

// 自前App「rustx86-ci」の名義で発行する
let billboard = AppToken { app: AppId(4_564_834) };

for gate in ["テスト", "整形", "lint", "wasmビルド", "CPU照合"] {
    checks::create(billboard, NewCheckRun {
        name: gate.into(),
        output: Some(report_markdown(gate)),  // 右ペインに本文
        /* … */
    });
}

「App限定」は普段は面倒な制約だが、ここでは束を分けるための唯一の合法な手段になる。

実装

Appの用意は3分で終わる。Settings → Developer settings → GitHub Apps → New GitHub App で、

  • Webhook: 無効(チェックを外す。URLも不要になる)
  • Repository permissions: Checks: Read and write だけ
  • 作成後に App ID を控え、Generate a private key で .pem を取得
  • Install App で対象リポジトリにインストール

App IDと秘密鍵はリポジトリのSecrets(CI_APP_ID / CI_APP_PRIVATE_KEY)に入れる。ワークフロー側は公式の create-github-app-token でインストールトークンを都度発行する。

      - name: 掲示板トークン
        id: app
        if: ${{ always() && env.HAVE_APP == 'true' }}   # Secrets未設定なら飛ばす
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.CI_APP_ID }}
          private-key: ${{ secrets.CI_APP_PRIVATE_KEY }}

      - uses: ./.github/actions/publish-check
        if: always()          # lintが落ちても掲示する (それが掲示板の仕事)
        with:
          name: lint
          title: ${{ steps.lint.outcome == 'success' && 'clippy 指摘なし' || 'clippy 指摘あり' }}
          outcome: ${{ steps.lint.outcome }}
          report-file: report-lint.md
          token: ${{ steps.app.outputs.token || github.token }}  # 退避つき

publish-checkgithub-scriptchecks.create を叩くだけの小さなコンポジットアクションで、token に渡されたものの名義で発行する。最後の行がフォールバック設計 ── Secrets未設定でも壊れず、GITHUB_TOKEN に退避してActionsの束に出る(束が同居するだけで機能は同じ)。App設定は後回しにできるし、SecretsがもらえないフォークからのPRでも死なない。

// publish-check の中身 (要点だけ)
await github.rest.checks.create({
  ...context.repo,
  name: process.env.CHECK_NAME,
  head_sha: context.payload.pull_request?.head?.sha ?? context.sha,
  status: 'completed',
  conclusion: outcome === 'success' ? 'success' : 'failure',
  output: { title, summary: reportMarkdown },   // ← これが右ペイン
});

1つだけ罠がある。PRイベントの context.sha はマージ用の仮コミットを指すので、pull_request.head.sha に付けないとPRのChecksタブに出てこない

メリット・デメリット

メリット 確認の導線がChecksタブ1回になる(0クリックで全関門の✓/✗、1クリックで本文)
掲示板の行はジョブではないので、粒度を上げてもランナー消費ゼロ。1関門=1行が無料
ブランチ保護の必須チェックを「lint」「CPU照合」のような関門単位で指名できる
App権限がChecks書き込みだけなので、鍵が漏れても被害面が小さい
デメリット App・秘密鍵・Secretsという管理物が増える(鍵のローテも自分持ち)
ジョブの行そのものは消せない(束が2つ並ぶのが最終形。1束にはできない)
束の畳み状態は記憶されない(開くたびに展開されている)
発行アクションという自作コードのメンテが増える

何のために使うのか

3者の棲み分けを一言ずつにするとこうなる。

  • GitHub Actions = 実行基盤。「走らせる」担当。ジョブ・ランナー・ログ
  • GitHub Checks = コミットに紐づく合否の台帳。「見せる・門番にする」担当。ブランチ保護はここを見る
  • GitHub App = 行為の名義と権限。「誰として書くか」担当

CIが小さいうちはActionsだけで足りる。ジョブ=関門が1対1のうちは、ジョブの行がそのまま掲示板だからだ。崩れるのは1つのジョブに複数の関門を同居させたとき(うちでは、ビルドキャッシュを共有したい4つの検査を1ジョブに束ねた瞬間)で、そこからは「実行の単位(ジョブ)」と「報告の単位(関門)」がずれていく。そのずれを埋める配線が、自前AppによるCheck run発行だった。

エミュレータ本体の話はリポジトリと過去記事にある。CIの設計判断の台帳は docs/reference/ci.md に残してある。

コメント

タイトルとURLをコピーしました