自作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タブのサイドバーがこうなる。

- 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_start が CheckRun を返す以上、ジョブは必ず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-check は github-script で checks.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 に残してある。


コメント