0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Biome / Vitest で「安く・段階的に」フロントエンドCIを整備してみた

0
Posted at

背景

これの続き

設計方針はバックエンドと完全に同じ。

  • 変更ファイルだけをチェックする段階的導入
  • setup(変更検出)→ quality/test(本処理)→ gate(判定)の3ジョブ構成
  • ローカルと CI で同じツール・同じ変更検出ロジック

なので今回は「フロントだとここが違う」ってところを中心に書いていく。

概要

フロント側で選んだのはこのあたり。

  • Linter / Formatter は Biome(Rust製)を採用。ESLint + Prettier を1個に集約 & CI が速い
  • 型チェックは tsc --noEmit。ただし変更ファイルだけ型チェックしたいので tsc-files を併用
  • ユニットテストは Vitest、ビルド確認は Vitevite build
  • バックエンドと同じく、変更ファイルベースの段階的導入 & 3ジョブ構成 & Status Gate
  • pull_requestpush(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変更ファイルだけに対して呼んでる。

.github/workflows/frontend-quality.yml
- 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、シングルクォート、セミコロンあり)だけ寄せてる。

client/biome.json
{
  "$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.ts will cause TypeScript to simply ignore your tsconfig.json.

the specified files will not be forwarded — instead, they will be put at the files property of a temporary config that will be generated next to your original tsconfig.json.

要は、渡したファイルを一時的な tsconfigfiles に流し込んでくれる。これで tsconfig.json を尊重したまま、変更ファイルだけ型チェックできる。

全体型チェックは別途やる
tsc-files は「変更ファイルだけ」を見るので、他ファイルへの波及エラーは拾えないことがある。なので main への回帰検出としては、vite build(や全体 tsc --noEmit)でプロジェクト全体もビルドして担保しておく。段階導入の lint とは役割を分けてる。

変更ファイル対応方針(= 段階的導入)

ここはバックエンドとまったく同じ思想。既存コードが大量にあるところに、いきなり全ファイル lint を入れると地獄になる(数百件の指摘 → 巨大 PR → レビュー不能)。

なので「PR で変更したファイルだけ」をチェック対象にする。触ってないファイルは今は汚くてもスルー。触ったところから少しずつクリーンにしていく。

変更ファイルの検出

バックエンドは git diff をワークフロー内に直接書いてたけど、フロントは共通スクリプト client/scripts/list-changed-client-files.sh に切り出した。CI でもローカルでも同じスクリプトを呼ぶことで、検出ロジックを1本化してる。

client/scripts/list-changed-client-files.sh
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 で対象を切り替えられる。allclient/ の md 以外)と tsclient/src/**/*.{ts,tsx} だけ)の2種
client/scripts/list-changed-client-files.sh
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ジョブ構成。

  1. setup: 変更ファイルを検出するだけ。軽いので常に走る
  2. quality(or test): 実際に Biome / tsc-files / Vitest をかける。has_changes == 'true' のときだけ実行
  3. frontend-quality(or frontend-test): 結果を判定するゲート。これが Required check の本体
.github/workflows/frontend-quality.yml
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」を明示的に判定してる。中身は素直なシェル分岐。

.github/workflows/frontend-quality.yml
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 等)を考慮して、空ツリーをフォールバックにしてる。地味だけど、これが無いと差分計算が落ちる。

.github/workflows/frontend-test.yml
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)を叩くだけ。

.github/workflows/frontend-test.yml
- 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 で変更ファイルだけ):

client/scripts/run-biome.sh
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 を呼ぶ。

client/scripts/run-typecheck.sh
npx tsc-files --noEmit --pretty false "${CHANGED_FILES[@]}"

これらを package.jsoncheck-changed でまとめてあるので、ローカルでは1コマンドで「CI と同じ対象・同じツール」を通せる。

client/package.json
"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-nodecache: 'npm'cache-dependency-path: client/package-lock.json で効かせてる。2回目以降の依存インストールが速い。

おまけ: サプライチェーン対策

これもバックエンドと同様、サードパーティ Action はフルコミット SHA でピン留めした。タグ(@v4)だと、タグが裏で別コミットに付け替えられたときに気づけないので。

.github/workflows/frontend-quality.yml
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.2.2
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.2.0

GitHub 公式も推奨してる方式。コメントで # v4.2.2 みたいに人間用のバージョンも添えておくと、更新するときに分かりやすい。

あとがき

これで、CIの基本は終わったので、あとは、CVE対策と、
壮大なリファクタリングの戦いが待っている・・ :sweat_smile:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?