背景
これの続き
設計方針はバックエンドと完全に同じ。
- 変更ファイルだけをチェックする段階的導入
-
setup(変更検出)→quality/test(本処理)→gate(判定)の3ジョブ構成 - ローカルと CI で同じツール・同じ変更検出ロジック
なので今回は「フロントだとここが違う」ってところを中心に書いていく。
概要
フロント側で選んだのはこのあたり。
- Linter / Formatter は Biome(Rust製)を採用。ESLint + Prettier を1個に集約 & CI が速い
- 型チェックは
tsc --noEmit。ただし変更ファイルだけ型チェックしたいので tsc-files を併用 - ユニットテストは Vitest、ビルド確認は Vite の
vite build - バックエンドと同じく、変更ファイルベースの段階的導入 & 3ジョブ構成 & Status Gate
-
pull_requestとpush(main)でワークフローを分ける(品質チェックは PR 限定、テストは main 反映も担保) - サードパーティ Action はフルコミット SHA でピン留め
以下、フロント固有の中身。
なぜ Biome なのか(= コスト削減)
バックエンドで Ruff(Rust製)を選んだのと同じ理由。CI のコスト = 実行時間 × 単価 なので、速いツールを選ぶ=安い。
フロントの定番だと ESLint + Prettier だけど、今回は両方をまとめて Biome に寄せた。Biome は公式がこう言ってる。
Biome is a fast formatter for JavaScript, TypeScript, JSX, TSX, JSON, HTML, CSS and GraphQL that scores 97% compatibility with Prettier, saving CI and developer time.
フォーマッタは Prettier 比で ~35x faster(公式ベンチ、17万行・2,104ファイルを Intel Core i7 1270P で計測)とある。しかも中身は Rust 製。
Fast — Built with Rust and an innovative architecture inspired by rust-analyzer.
Biome とは?
JavaScript / TypeScript / JSX / CSS などを対象にした、Rust 製のフォーマッタ&リンタを1つにまとめたツールチェイン。biome format(整形)と biome lint(lint)、両方まとめて走らせる biome ci / biome check がある。ESLint + Prettier の置き換えとして使える。
CI ではこんな感じで、biome ci を変更ファイルだけに対して呼んでる。
- name: typecheck & biome (changed files)
run: |
FILES_OUTPUT=$(bash scripts/list-changed-client-files.sh --scope ts --strip-client-prefix --base "$PR_BASE" --head "$PR_HEAD")
if [ -z "$FILES_OUTPUT" ]; then
echo "変更された TypeScript ファイルはありません — typecheck/biome スキップ"
exit 0
fi
mapfile -t FILES <<<"$FILES_OUTPUT"
npx tsc-files --noEmit --pretty false "${FILES[@]}"
npx biome ci --config-path biome.json "${FILES[@]}"
設定は client/biome.json に集約。recommended ルールを有効化しつつ、整形ルール(スペース2、シングルクォート、セミコロンあり)だけ寄せてる。
{
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "includes": ["src/**/*.{ts,tsx}"] },
"formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 },
"linter": { "enabled": true, "rules": { "recommended": true } },
"javascript": { "formatter": { "quoteStyle": "single", "semicolons": "always" } }
}
バージョンは package.json の devDependencies で @biomejs/biome を固定。npm ci で lockfile 通りに入るので、ローカルと CI でバージョンがズレない。
ハマりどころ: tsc にファイルを直接渡すと tsconfig が無視される
「変更したファイルだけ型チェックしたい」と思って tsc --noEmit file1.ts file2.ts ってやると、TypeScript が tsconfig.json を丸ごと無視する。paths とか strict とかの設定が効かなくなって、ローカルと挙動が変わる。
これは TypeScript 側の仕様で、tsc にファイルを渡した瞬間に tsconfig.json を見なくなる。
そこで tsc-files を使う。tsc-files はこの問題をこう解決してる。
Unfortunately passing specific files like
tsc --noEmit file1.ts file2.tswill cause TypeScript to simply ignore yourtsconfig.json.
the specified files will not be forwarded — instead, they will be put at the
filesproperty of a temporary config that will be generated next to your originaltsconfig.json.
要は、渡したファイルを一時的な tsconfig の files に流し込んでくれる。これで tsconfig.json を尊重したまま、変更ファイルだけ型チェックできる。
全体型チェックは別途やる
tsc-files は「変更ファイルだけ」を見るので、他ファイルへの波及エラーは拾えないことがある。なので main への回帰検出としては、vite build(や全体 tsc --noEmit)でプロジェクト全体もビルドして担保しておく。段階導入の lint とは役割を分けてる。
変更ファイル対応方針(= 段階的導入)
ここはバックエンドとまったく同じ思想。既存コードが大量にあるところに、いきなり全ファイル lint を入れると地獄になる(数百件の指摘 → 巨大 PR → レビュー不能)。
なので「PR で変更したファイルだけ」をチェック対象にする。触ってないファイルは今は汚くてもスルー。触ったところから少しずつクリーンにしていく。
変更ファイルの検出
バックエンドは git diff をワークフロー内に直接書いてたけど、フロントは共通スクリプト client/scripts/list-changed-client-files.sh に切り出した。CI でもローカルでも同じスクリプトを呼ぶことで、検出ロジックを1本化してる。
collect_files() {
if [[ -n "$BASE_SHA" && -n "$HEAD_SHA" ]]; then
git -C "$REPO_ROOT" diff --name-only --diff-filter=d "$BASE_SHA...$HEAD_SHA"
return
fi
# ローカル: origin/main との差分 + 未コミット差分
{
git -C "$REPO_ROOT" diff --name-only --diff-filter=d origin/main...HEAD
git -C "$REPO_ROOT" diff --name-only --diff-filter=d HEAD
}
}
ポイントはバックエンドと同じ。
-
--diff-filter=dで削除ファイルを除外(消したファイルを lint しようとして落ちるのを防ぐ) -
--scopeで対象を切り替えられる。all(client/の md 以外)とts(client/src/**/*.{ts,tsx}だけ)の2種
if [[ "$SCOPE" == "ts" ]]; then
FILES=$(awk '/^client\/src\/.*\.(ts|tsx)$/' <<<"$ALL_FILES")
else
FILES=$(awk '/^client\// && $0 !~ /\.md$/' <<<"$ALL_FILES")
fi
--scope ts は Biome / 型チェックに渡す用、--scope all は「そもそも client を触ったか?」の変更検出用、という使い分け。--strip-client-prefix を付けると client/ 始まりのパスを落として、working-directory: client のジョブからそのまま渡せる形になる。
3ジョブ構成で「変更が無ければ即終了」
これもバックエンドと同じ3ジョブ構成。
-
setup: 変更ファイルを検出するだけ。軽いので常に走る -
quality(ortest): 実際に Biome / tsc-files / Vitest をかける。has_changes == 'true'のときだけ実行 -
frontend-quality(orfrontend-test): 結果を判定するゲート。これが Required check の本体
quality:
needs: setup
if: needs.setup.outputs.has_changes == 'true' # ← 変更なしならスキップ
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
# ... biome / tsc-files / vite build ...
なぜ判定用のゲートジョブを別に立てるのか? ジョブ2をスキップすると Status は「skipped」になる。これを Required check に指定すると、スキップ時に PR がマージできなくなる(or 永遠に pending)。
そこで、常に走るゲートジョブを Required check にして、「スキップ=OK」「失敗=NG」を明示的に判定してる。中身は素直なシェル分岐。
frontend-quality:
name: frontend-quality
needs: [setup, quality]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check quality gate result
run: |
set -euo pipefail
SETUP_RESULT="${{ needs.setup.result }}"
HAS_CHANGES="${{ needs.setup.outputs.has_changes }}"
QUALITY_RESULT="${{ needs.quality.result }}"
if [ "$SETUP_RESULT" != "success" ]; then
echo "変更検出失敗: $SETUP_RESULT"
exit 1
fi
if [ "$HAS_CHANGES" = "false" ]; then
echo "変更なし — スキップ (OK)"
exit 0
fi
if [ "$QUALITY_RESULT" = "success" ]; then
echo "品質チェック成功"
exit 0
fi
echo "品質チェック失敗: $QUALITY_RESULT"
exit 1
client/ を触ってない PR では、重い npm ci も Biome もまるごとスキップされる。無駄な課金を出さない。
Quality と Test でトリガーを分ける
ここもバックエンドと同じ。「品質チェック」と「ユニットテスト」でワークフローを分けてる。トリガーの考え方が違うので。
| ワークフロー | トリガー | ねらい |
|---|---|---|
Quality(frontend-quality.yml) |
pull_request のみ |
変更ファイルベースの段階導入チェック。PR でだけ意味を持つ |
Test(frontend-test.yml) |
push(main) + pull_request
|
main 反映時の回帰検出も担保したい |
frontend-test.yml 側は push のとき github.event.before が無いケース(ブランチ初push 等)を考慮して、空ツリーをフォールバックにしてる。地味だけど、これが無いと差分計算が落ちる。
EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="$EMPTY_TREE"
fi
4b825dc6...
Git の「空ツリー(empty tree)」を指す固定ハッシュ。比較相手が存在しないとき、これを base にすると「最初のコミットからの全ファイル」を差分として取れる。初回 push や履歴が浅いときの差分計算用。
テストジョブ自体はシンプル。npm ci してから npm run test(= vitest run)を叩くだけ。
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test
ローカルと CI のズレを無くす
「push してから CI で初めて気づく」を減らしたいのもバックエンドと同じ。CI で落とすたびに Runner 時間を消費するので。
なので、ローカルにも「変更ファイルだけ」を対象にするラッパースクリプトを用意した。CI と同じ list-changed-client-files.sh を内部で呼んでるので、対象ファイルが一致する。
Biome 用ラッパー(--changed-only で変更ファイルだけ):
if [ "${1:-}" = "--changed-only" ]; then
shift
CHANGED_FILES_OUTPUT=$(bash "$LIST_SCRIPT" --scope ts --strip-client-prefix)
if [ -z "$CHANGED_FILES_OUTPUT" ]; then
echo "変更された TypeScript ファイルはありません"
exit 0
fi
mapfile -t CHANGED_FILES <<<"$CHANGED_FILES_OUTPUT"
# ...
npx biome "$@" --config-path "$CLIENT_DIR/biome.json" "${CHANGED_FILES[@]}"
fi
型チェック用ラッパーも同じ作りで、中身は CI と同じ tsc-files を呼ぶ。
npx tsc-files --noEmit --pretty false "${CHANGED_FILES[@]}"
これらを package.json の check-changed でまとめてあるので、ローカルでは1コマンドで「CI と同じ対象・同じツール」を通せる。
"check": "npm run format && npm run lint && npm run typecheck && npm run test && npm run build",
"check-changed": "bash scripts/run-biome.sh --changed-only format && bash scripts/run-biome.sh --changed-only ci && npm run typecheck:changed && npm run test && npm run build"
ローカルで npm run check-changed を通してから push する運用にすれば、CI とローカルの結果が一致するので、CI で無駄に落とすことが減る。
npm ci のキャッシュは、actions/setup-node の cache: 'npm' + cache-dependency-path: client/package-lock.json で効かせてる。2回目以降の依存インストールが速い。
おまけ: サプライチェーン対策
これもバックエンドと同様、サードパーティ Action はフルコミット SHA でピン留めした。タグ(@v4)だと、タグが裏で別コミットに付け替えられたときに気づけないので。
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.2.2
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.2.0
GitHub 公式も推奨してる方式。コメントで # v4.2.2 みたいに人間用のバージョンも添えておくと、更新するときに分かりやすい。
あとがき
これで、CIの基本は終わったので、あとは、CVE対策と、
壮大なリファクタリングの戦いが待っている・・ ![]()