Laravel Sail と GitHub Actions で効率的なビルド環境を手に入れろ！

## 目的

Laravel Sail環境でのCIの設定はプロジェクトの中ではとても重要です。
CI上でSail自体をビルドすると多くの時間を要するため、より効率的な方法を共有します。
(Laravel Sailと銘打ちましたが、結局Laravel SailはGitHub Actions上で使わないほうが高速でした)

## 関連記事

https://qiita.com/ucan-lab/items/0ef01c94e7e07a061311

## .github/workflows/ci.yaml

次のファイルを配置します。

```yaml:.github/workflows/ci.yaml
name: Continuous Integration
on:
  push:
    branches:
      - 'main'
  pull_request:
  workflow_dispatch:
env:
  DB_CONNECTION: mysql
  DB_HOST: 127.0.0.1
  DB_PORT: 3306
  DB_DATABASE: laravel
  DB_USERNAME: sail
  DB_PASSWORD: password
jobs:
  ci-backend:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    services:
      db:
        image: mysql/mysql-server:8.0
        ports:
          - 3306:3306
        env:
          MYSQL_DATABASE: 'laravel'
          MYSQL_USER: 'sail'
          MYSQL_PASSWORD: 'password'
          MYSQL_ROOT_PASSWORD: 'password'
          MYSQL_ALLOW_EMPTY_PASSWORD: 1
        options: >-
          --health-cmd "mysqladmin ping"
          --health-start-period 30s
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - name: Setup PHP with composer v2
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          tools: composer:v2
      - name: Cache Vendor
        id: cache-vendor
        uses: actions/cache@v4
        with:
          path: ./vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-
      - name: Install Dependencies
        if: steps.cache-vendor.outputs.cache-hit != 'true'
        run: composer install --quiet --prefer-dist --no-progress --no-interaction --no-scripts --no-ansi
      - name: Laravel Setting
        run: |
          cp .env.example .env
          php artisan optimize
          git config --local core.fileMode false
          chmod -R 777 storage bootstrap/cache
      - name: PHP Version
        run: php --version
      - name: Composer Version
        run: composer --version
      - name: Laravel Version
        run: php artisan --version
      - name: Composer Validate
        run: composer validate
      - name: Run Migrate
        run: php artisan migrate
      - name: Run Migrate Refresh
        run: php artisan migrate:refresh
      - name: Run Seeding
        run: php artisan db:seed
      - name: Run IDE Helper Models
        run: |
          php artisan ide-helper:models --write --reset
          ./vendor/bin/pint app/Models
          if ! git diff --exit-code; then
            echo "Error: The phpdoc for the model ide-helper is not updated!"
            echo "Run: php artisan ide-helper:models --write --reset"
            exit 1
          fi
      - name: Cache Pint
        uses: actions/cache@v4
        with:
          path: ./.pint.cache
          key: ${{ runner.os }}-pint-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-pint-
      - name: Run Pint
        run: ./vendor/bin/pint --test
      - name: Cache Rector
        uses: actions/cache@v4
        with:
          path: ./storage/rector/cache
          key: ${{ runner.os }}-rector-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-rector-
      - name: Run Rector
        run: ./vendor/bin/rector process --dry-run
      - name: Cache PHPStan
        uses: actions/cache@v4
        with:
          path: ./storage/phpstan
          key: ${{ runner.os }}-phpstan-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-phpstan-
      - name: Run PHPStan
        run: ./vendor/bin/phpstan analyze
      - name: Cache Pest
        uses: actions/cache@v4
        with:
          path: ./storage/pest/cache
          key: ${{ runner.os }}-pest-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-pest-
      - name: Run Pest
        env:
          SESSION_DRIVER: array
          DB_CONNECTION: sqlite
          DB_DATABASE: ":memory:"
        run: ./vendor/bin/pest --parallel --cache-directory storage/pest/cache
  ci-frontend:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '>=20.17.0'
          cache: 'pnpm'
      - name: Install Dependencies
        run: pnpm install
      - name: Run Build
        run: pnpm run build
```

### ワークフロー解説

設定の羅列だけでは、何をしているかわからないと思うので各行それぞれ解説します。

```yaml:.github/workflows/ci.yaml
name: Continuous Integration
on:
  push:
    branches:
      - 'main'
  pull_request:
  workflow_dispatch:
env:
  DB_CONNECTION: mysql
  DB_HOST: 127.0.0.1
  DB_PORT: 3306
  DB_DATABASE: laravel
  DB_USERNAME: sail
  DB_PASSWORD: password
```

- name: ワークフロー名を設定します(任意の名前を付ける)
- on: ワークフローが実行されるタイミング
  - workflow_dispatch があるとGitHub上の画面からワークフローを手動実行できる
    - 動作確認したい時にはあると便利です
- env: 今回はMySQLの接続情報を使うために利用

MySQLパスワードはCI実行で使うものなので平文で良いかなと思いました。
ポイントは `DB_HOST: 127.0.0.1` です。
SailのDBではなくGitHub Actions上のDBコンテナを指すためです。

```yaml:.github/workflows/ci.yaml
jobs:
  ci-backend:
    # ...
  ci-frontend:
    # ...
```

PHPとNodeが必要なものと分かれるので、並列で実行してCI時間の短縮を図っています。
ci-backendのジョブから解説します。

```yaml:.github/workflows/ci.yaml
  ci-backend:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    services:
      mysql:
        image: mysql/mysql-server:8.0
        ports:
          - 3306:3306
        env:
          MYSQL_DATABASE: ${{ env.DB_DATABASE }}
          MYSQL_USER: ${{ env.DB_USERNAME }}
          MYSQL_PASSWORD: ${{ env.DB_PASSWORD }}
          MYSQL_ALLOW_EMPTY_PASSWORD: 1
        options: >-
          --health-cmd "mysqladmin ping"
          --health-start-period 30s
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
```

`timeout-minutes` ジョブの実行時間の上限値を設定できます。(ステップ単位でも指定可能)
デフォルトは360分(6時間)なため、万が一CIが止まらなかった場合は 360/2000 と20%近くも無料枠を消費してしまいます。
タイムアウト時間を指定しておくと無難です。

SailのDBではなく、GitHub Actions上で定義できるサービスにDB定義しています。
SailのDockerビルドを行うと時間がかかるためです。(5分くらい伸びる)
マイグレーションの箇所で落ちると困るので、ヘルスチェックコマンドを入れて起動確認しています。

ci-backendのジョブステップは多いので分割して解説します。

```yaml:.github/workflows/ci.yaml
      - uses: actions/checkout@v4
      - name: Setup PHP with composer v2
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          tools: composer:v2
```

[shivammathur/setup-php](https://github.com/shivammathur/setup-php) アクションを使って、PHPとComposerをインストールしています。

これもSail自体をビルドすると時間がかかってしまうからです。

```yaml:.github/workflows/ci.yaml
      - name: Cache Vendor
        id: cache-vendor
        uses: actions/cache@v4
        with:
          path: ./vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-
      - name: Install Dependencies
        if: steps.cache-vendor.outputs.cache-hit != 'true'
        run: composer install --quiet --prefer-dist --no-progress --no-interaction --no-scripts --no-ansi
```

[actions/cache](https://github.com/actions/cache) アクションを使って、 `vendor` ディレクトリをキャッシュしています。

- `id` はステップ間で出力の値を利用するためidを設定
  - https://docs.github.com/ja/actions/writing-workflows/choosing-what-your-workflow-does/passing-information-between-jobs
  - `jobs.<job_id>.outputs` といった感じで取得できる
- path: キャッシュしたいファイルorディレクトリ
- key: キャッシュの保存と検索に利用されるキー
- restore-keys: keyでキャッシュヒットしない場合にキャッシュの検索に利用されるキー。前方一致でキャッシュを検索
- if: steps.cache-vendor.outputs.cache-hit
  - ステップを実行するかの判定条件を書けます。キャッシュがあった場合に実行する
- run: composer install --quiet --prefer-dist --no-progress --no-interaction --no-scripts --no-ansi
  - --quiet: 通常のログ出力を非表示
  - --prefer-dist: Gitクローンの代わりに圧縮されたzipでダウンロード(通信量が減り、高速化に繋がる)
  - --no-progress: 進捗バーを非表示(CIで表示する必要がないので)
  - --no-interaction: 対話式プロンプトを無効
  - --no-scripts: Composerスクリプトを無効
  - --no-ansi: 色付きの出力を無効

```yaml:.github/workflows/ci.yaml
      - name: Laravel Setting
        run: |
          cp .env.example .env
          php artisan optimize
          git config --local core.fileMode false
          chmod -R 777 storage bootstrap/cache
      - name: PHP Version
        run: php --version
      - name: Composer Version
        run: composer --version
      - name: Laravel Version
        run: php artisan --version
      - name: Composer Validate
        run: composer validate
```

Laravelの設定を行ってます。
必要に応じて `.env.example` の代わりに `.env.ci` みたいなファイルを用意して良いかもです。

optimize でConfigやRoutingのキャッシュを行ってます。
本番環境ではキャッシュした状態で動かすので、キャッシュがちゃんと動作するかを兼ねています。

storage と bootstrap/cache 配下は書き込みできる必要があるので、設定しています。
後のステップでファイル差分をチェックしているので、パーミッションの変更のファイル差分が出ないように無効化しています。

`composer validate` コマンドでは `composer.json` と `composer.lock` の記述が正しいか検証しています。

```yaml:.github/workflows/ci.yaml
      - name: Run Migrate
        run: php artisan migrate
      - name: Run Migrate Refresh
        run: php artisan migrate:refresh
      - name: Run Seeding
        run: php artisan db:seed
      - name: Run IDE Helper Models
        run: |
          php artisan ide-helper:models --write --reset
          ./vendor/bin/pint app/Models
          if ! git diff --exit-code; then
            echo "Error: The phpdoc for the model ide-helper is not updated!"
            echo "Run: php artisan ide-helper:models --write --reset"
            exit 1
          fi
```

Laravelのデータベースマイグレーションが実行できることを確認しています。
ロールバックテストもCIに含めておかないと壊れていることに気付けないのでテストするようにしています。

`php artisan ide-helper:models --write --reset` を実行すると
モデルファイルのphpdocなどにIDE補完情報が追記されます。

この時は実際にデータベースを読みにいくので、データベースマイグレーションを実行した後に行なっています。
コードスタイルルールに沿わない形式で出力されるため、Pintを実行させてからファイル差分がないか `git diff` を行なっています。

```yaml:.github/workflows/ci.yaml
      - name: Cache Pint
        uses: actions/cache@v4
        with:
          path: ./.pint.cache
          key: ${{ runner.os }}-pint-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-pint-
      - name: Run Pint
        run: ./vendor/bin/pint --test
```

キャッシュ部分は上述で説明した通りなので省きますが、
`Cache Vendor` の時と違って、 `Run Pint` の時にifがないのはキャッシュファイルがある時はキャッシュを参照してコマンドを実行したいからです。

`./vendor/bin/pint --test` ここではコードスタイルのチェックを行っています。
[Pint](https://readouble.com/laravel/11.x/ja/pint.html)はLaravel公式が提供しているphp-cs-fixerのラッパーライブラリのコードフォーマッターです。

```yaml:.github/workflows/ci.yaml
      - name: Cache Rector
        uses: actions/cache@v4
        with:
          path: ./storage/rector/cache
          key: ${{ runner.os }}-rector-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-rector-
      - name: Run Rector
        run: ./vendor/bin/rector process --dry-run
```

キャッシュ部分は上述で説明した通りなので省きます。
`./vendor/bin/rector process --dry-run` ここではアップグレードとリファクタリングのチェックを行なっています。

[Rector](https://github.com/rectorphp/rector)はPHPのコードをアップグレードとリファクタリングを行ってくれるツールです。
Laravel用にカスタマイズされた[rector-laravel](https://github.com/driftingly/rector-laravel)が提供されているのでこちらを利用しています。

```yaml:.github/workflows/ci.yaml
      - name: Cache PHPStan
        uses: actions/cache@v4
        with:
          path: ./storage/phpstan
          key: ${{ runner.os }}-phpstan-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-phpstan-
      - name: Run PHPStan
        run: ./vendor/bin/phpstan analyze
```

キャッシュ部分は上述で説明した通りなので省きます。
`./vendor/bin/phpstan analyze` ここでは静的解析のチェックを行なっています。

[PHPStan](https://phpstan.org)はPHPコードの静的解析ツールです。
Laravel用にカスタマイズされた[larastan](https://github.com/larastan/larastan)が提供されているのでこちらを利用しています。

```yaml:.github/workflows/ci.yaml
      - name: Cache Pest
        uses: actions/cache@v4
        with:
          path: ./storage/pest/cache
          key: ${{ runner.os }}-pest-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-pest-
      - name: Run Pest
        env:
          SESSION_DRIVER: array
          DB_CONNECTION: sqlite
          DB_DATABASE: ":memory:"
        run: ./vendor/bin/pest --parallel --cache-directory storage/pest/cache
```

キャッシュ部分は上述で説明した通りなので省きます。
`./vendor/bin/pest --parallel --cache-directory storage/pest/cache` では自動テストを行っています。

[Pest](https://pestphp.com)はシンプルにテストを書けるテスティングフレームワークです。
[PHPUnit](https://phpunit.de/index.html)のラッパーライブラリなため、PHPUnitのまま書き方でもテストは書けるので移行しやすいです。

並列テストが標準搭載されていたり、アーキテクチャテストを行える点が気に入っています。

また、[2.0](https://pestphp.com/docs/announcing-pest2)から数多くの便利オプションが提供されています。

```yaml:.github/workflows/ci.yaml
  ci-frontend:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '>=20.17.0'
          cache: 'pnpm'
      - name: Install Dependencies
        run: pnpm install
      - name: Run Build
        run: pnpm run build
```

`ci-frontend` ではアセットファイルのビルドを行います。

[pnpm/action-setup](https://github.com/pnpm/action-setup) のActionsを利用しています。
キャッシュ設定は公式通りにしています。

[pnpm](https://pnpm.io/ja) はnpmと比較して高速、ディスク容量を効率化、厳格なパッケージ管理の特徴があります。
npm or yarn を利用しても問題ありません。

## 実行速度

Laravelプロジェクトインストール時点だと、
キャッシュがない場合のci-backendは80秒ほどで完了します。
キャッシュがある場合のci-backendは50秒ほどで完了します。
キャッシュがない場合のci-frontendは15秒ほどかかります。
キャッシュがある場合のci-frontendは10秒ほどで完了します。

ci-backendとci-frontendを並列で実行させていますが、プロジェクトの初期状態だとあまり効果的ではないです。
ci-frontendが育ってくると効果を発揮していくでしょう。

## 各種ツールの設定ファイル等

### Pint

https://qiita.com/ucan-lab/items/8807a092eb6d87ddfe34

### その他

いつか書きます。
需要がありそうなら早めに書きます。