TypeScriptの小さなプロジェクト(ブラウザゲーム)にCI/CDを組んだ。PR時に lint・型チェック・単体テスト・ビルド、masterマージ時にはドメイン固有のゲートを通してから本番へ自動デプロイする、という定番の構成である。
ただ、素直に組むと1つ不満が残る。PRのChecksタブが「ログページへのリンク集」にしかならないことだ。ジョブを選んでも結果は読めず、毎回Actionsのログページに飛ばされる。
本稿では、一般的なTSプロジェクトにそのまま流用できる形で、この不満の解消を含むCI/CDの要点を4つに絞って書く。
要点1: ジョブを分けすぎない
最初はlint・型・テスト・ビルドを4つのジョブに分けた。「どれが落ちたか一目で分かる」のが狙いだったが、2つの問題があった。
- ジョブ標準のCheck(リンクにしかならないもの)が4つ並び、Checksタブが埋まる。これはGitHubが自動生成するもので消せない
- 並列ジョブは早く落ちたものしか結果が見えない。lintで落ちたPRの「テストは通っていたのか」が分からない
そこでジョブは1本に統合し、各ステージを continue-on-error で最後まで走らせてから、まとめて合否判定する形にした。
jobs:
ci:
name: CI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- name: ESLint
id: lint
continue-on-error: true # 落ちても後続ステージを走らせる
run: ...
- name: 型チェック
id: typecheck
continue-on-error: true
run: ...
# ...テスト・ビルドも同様...
- name: 合否判定
run: |
test "${{ steps.lint.outcome }}" = "success" \
&& test "${{ steps.typecheck.outcome }}" = "success" \
&& test "${{ steps.test.outcome }}" = "success" \
&& test "${{ steps.build.outcome }}" = "success"
「どれが落ちたか」は次の要点2で作るCheck runが担うので、ジョブを分ける理由は消える。lintが落ちてもテスト結果まで全部見えるようになり、修正が1往復で済む。
要点2: Checks APIで右ペインに結果を出す
Checksタブの右ペインに本文を表示するには、Checks APIの output にMarkdownを載せたCheck runを自分で発行する。GITHUB_TOKEN に checks: write 権限を与えれば actions/github-script から呼べる。
再利用できるようコンポジットアクションにする。これが本稿の核で、このファイル1つでどのリポジトリでも使い回せる。
# .github/actions/publish-check/action.yml
name: publish-check
description: レポート本文をChecksタブの右ペインに表示する
inputs:
name: { required: true } # Checksタブの左リストに出る名前
title: { required: true } # 右ペインの見出し
outcome: { required: true } # 対象ステップの outcome
report-file: { required: true } # 表示するMarkdown
runs:
using: composite
steps:
- uses: actions/github-script@v7
env:
CHECK_NAME: ${{ inputs.name }}
CHECK_TITLE: ${{ inputs.title }}
CHECK_OUTCOME: ${{ inputs.outcome }}
REPORT_FILE: ${{ inputs.report-file }}
with:
script: |
const fs = require('fs');
let summary = '(レポートなし)';
if (fs.existsSync(process.env.REPORT_FILE)) {
summary = fs.readFileSync(process.env.REPORT_FILE, 'utf8');
// ツールがCI上でも色付き出力することがあるのでANSIを除去
summary = summary.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
// summaryの上限は65535文字
if (summary.length > 60000) summary = summary.slice(0, 60000) + '\n…(省略)';
}
// PRイベントではマージ用の仮コミットではなくブランチ先頭に付ける
const head_sha = context.payload.pull_request?.head?.sha ?? context.sha;
await github.rest.checks.create({
...context.repo,
name: process.env.CHECK_NAME,
head_sha,
status: 'completed',
conclusion: process.env.CHECK_OUTCOME === 'success' ? 'success' : 'failure',
output: { title: process.env.CHECK_TITLE, summary },
});
使う側は、各ステージの出力をMarkdownに整形してから渡すだけだ。
- name: ESLint
id: lint
continue-on-error: true
run: |
set -o pipefail
{
echo '## ESLint'
echo '```'
npx eslint . --format stylish 2>&1 | tee /dev/stderr || RC=$?
echo '```'
exit ${RC:-0}
} > lint-report.md
- uses: ./.github/actions/publish-check
with:
name: ESLint 結果
title: ${{ steps.lint.outcome == 'success' && '指摘なし' || '指摘あり' }}
outcome: ${{ steps.lint.outcome }}
report-file: lint-report.md
これでChecksタブの左リストに「ESLint 結果」「型チェック 結果」…が並び、選ぶと右ペインで指摘の一覧やテスト出力がそのまま読める。リンクだけの項目は統合したジョブの1個だけになる。
実際の画面がこれだ。わざとESLintの指摘を入れたPRで、「ESLint 結果」を選ぶと右ペインに指摘がそのまま出る。


ハマりどころを2つ。
- ANSIカラーコード: vitestなどはCI上でも色付きで出力するため、そのまま載せると
[1m[46mのような文字化けになる。ワークフローにenv: NO_COLOR: "1"を置き、上のアクション側でも除去している(二重の保険) - head_shaの取り違え: PRイベントの
context.shaはマージ用の仮コミットを指す。pull_request.head.shaを優先しないと、Check runがPRに表示されない
なお、テスト結果表示の定番である dorny/test-reporter も試したが、手元ではCheck runの作成に失敗した(ログ上はサマリ生成まで進むが実体が作られない)。自前の仕組みに統一したことで、lint・テスト・デプロイまで同じ書き方で揃ったのは結果的に良かった。
要点3: プロジェクト固有の「成立条件」をゲートにする
lintとテストは汎用品だが、CIの価値が跳ね上がるのはそのプロジェクトが壊れる固有の条件をゲートにしたときだ。
今回の題材はローグライクなので、「地下100階まで実際に踏破できるか」が成立条件になる。自動プレイのボットを固定12シードで走らせ、次を機械判定するステージをmasterマージ時に入れた。
踏破数 >= 2 (難しすぎ検出)
踏破数 <= 10 (簡単すぎ検出)
平均到達階 >= 55
ポイントは、ゲームの乱数がシード付きで決定的なこと。同じシードなら同じダンジョンが生成され、結果は毎回同じになる。つまりこのゲートが落ちたら「揺らぎ」ではなく「バランスを変えた」ことを意味する。flakyにならない。
一般化すると: 乱数・時刻・外部IOに依存する検証は、シード固定・時刻注入・記録再生で決定的にしてからゲートに入れる。確率的なままCIに入れると、falkyなテストとして早晩無効化される運命にある。
もう一段安いゲートとして、成立条件そのものを単体テストにも入れてある。このゲームなら「レベルアップに必要な経験値の総量 ≦ 全99階で得られる経験値の総量」という不等式で、これが破れると数学的にクリア不能になる(実際、初期実装は79倍破っていて誰にもクリアできなかった)。実行しなくても検証できる不変条件は、シミュレーションより単体テストに置く方が速い。
要点4: デプロイ鍵は「漏れても被害が限定される」形にする
CDの本質はSSH秘密鍵をGitHub Secretsに置くことなので、漏洩時の被害を先に設計しておく。サーバーのマスター鍵をそのまま置くのは論外で、デプロイ専用鍵+サーバー側での強制コマンドが定石だ。
# デプロイ専用の鍵を新規作成(パスフレーズなし)
ssh-keygen -t ed25519 -N "" -C "myapp-deploy" -f id_deploy
サーバー側の ~/.ssh/authorized_keys には、rsyncの制限ラッパー rrsync を強制コマンドとして付けて登録する。
restrict,command="/usr/bin/rrsync -wo /var/www/myapp" ssh-ed25519 AAAA... myapp-deploy
この1行で、この鍵ができることは「/var/www/myapp への書き込み」だけになる。導入時に3点を実測して確認した。
| 操作 | 結果 |
|---|---|
| 対象ディレクトリへの rsync | ✅ 通る |
.. を使ったパストラバーサル |
❌ rrsyncが拒否 |
| SSHでシェルを取る | ❌ 強制コマンドが拒否 |
ワークフロー側は素朴にrsyncするだけでよい。
- name: rsyncで本番へ配置
run: |
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
rsync -rlcz --delete \
-e "ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes" \
site/ "deploy@${{ vars.DEPLOY_HOST }}:/"
デプロイ後にHTTPで疎通確認し、その結果も要点2の仕組みで「デプロイ 結果」としてChecksに出す。コミット履歴を遡れば、いつ何がどのHTTPステータスでリリースされたかが全部残る。
なお「公開リポジトリだと誰でもデプロイできてしまうのでは」という心配については、フォークからのPRにはSecretsが渡らず、masterにマージできるのはwrite権限者だけなので、構造的には起きない。それでも上記の最小権限化はやっておく価値がある。Secretsは漏れる前提で設計するものだ。
まとめ
- ジョブは分けすぎない。
continue-on-error+ 一括判定で「全部の結果が毎回見える」CIにする - Checksタブは Checks API の
outputで「読めるレポート」になる。コンポジットアクション1つで使い回せる - プロジェクト固有の成立条件をゲートに入れる。乱数は決定的にしてから
- デプロイ鍵は専用鍵 + 強制コマンドで、漏れても被害をディレクトリ1つに限定する
題材にしたゲームは pocraft.net/dungeon/ で遊べる。バランス調整を自動プレイで詰めた話は前回の記事に書いた。


コメント