LaravelでGitHubのWebhooksを利用して自動化デプロイ

# はじめに

今回はタイトルにもある通り、LaravelでGitHubのWebhooksという機能を利用してデプロイを自動化する方法をご紹介しようと思います。

**処理の流れ:**

GitHubのリモートリポジトリで特定のブランチがマージ
↓
GitHubのWebhooksで設定したPayload(URL)に**POST**リクエストがGitHubから送信される
↓
リクエストを受け取った際にシェルスクリプトをバックグラウンドで実行するよう、Laravel側で実装
※ なぜバックグラウンドかは後ほど説明します
↓
ChatWorkなどのSNSツールにエラー通知

# それでは早速

# GitHub側の設定

まずはGitHub側からpostリクエストを送信するようリポジトリに対して設定しなければいけません。

![スクリーンショット 2024-08-04 12.51.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3829908/2aa8a51a-2d76-b3c4-f5ff-60cace82819b.png)

※ 念の為画像も載せておきます。

まずは、GitHubで自動化デプロイを実装したいアプリのリポジトリ画面に移動 -> Settings -> Webhooks -> Settings の画面に移動してください。

**Payload URL**

`Payload URL`に`https://sample.com/webhook/github/deploy/`と設定しておきましょう。

※ `Payload URL`は各自のアプリに従って決めてください。

**Content type**

`application/json`を選択

**Secret**

`sampleSecret`と設定

**SSL verification**

`Enable SSL verification`を選択

**Which events would you like to trigger this webhook?**

`Let me select individual events.`を選択し、`Pull requests`を選択。

**Active**

チェックを入れておいてください。

<br>

これでGitHub側の設定は終了です。設定は開発しながら決定していく内容もありますが、今回はスムーズに実装を進めるため最初に全て設定しておきました。

# デプロイシェルスクリプト

EC2などサーバーで、プロジェクトのファイルをgitで運用している前提です。

```htdocs/deploy.sh
#!/bin/bash

# gitディレクトリのある階層へ移動(ここではhtdocs/../のディレクトリ)
cd ../

# メインブランチからプル
git fetch origin
git pull origin $GIT_BRANCH

```

# それではLaravelで実際に実装

ここではLaravelプロジェクトのエントリーポイントであり、ウェブサーバーがアクセスするディレクトリを`htdocs`として扱っています。

**envファイル**

まずはLaravelで必要なGitHubのデータを設定ファイル `.env` に記述しておきます。

```htdocs/.env
# webhooks
WEBHOOK_GITHUB_DEPLOY_SECRET=sampleSecret
GIT_BRANCH=master
```

※ WEBHOOK_GITHUB_DEPLOY_SECRET... GitHubのwebhooksで設定した`Secret`の値
※ GIT_BRANCH... このブランチがマージされた時にデプロイシェルスクリプトを実行

また、これらの設定値を`config`で参照できるように

```htdocs/config/webhook.php
<php

return [
    'github' => [
        'deploy_secret' => env('WEBHOOK_GITHUB_DEPLOY_SECRET', 'null'),
        'deploy_branch' => env('GIT_BRANCH', 'null'),
    ]
]
```

を作成しておきます。

※ env関数の第二引数である`null`は設定値が定義されていなければ`null`を返すというものです。

**ミドルウェア**

誰でも`Payload URL`にアクセスできるとセキュリティ的によくありませんので、GitHub側で設定した`Secret`の値を利用してリクエストを検証するミドルウェアを作成します。

```htdocs/app/Http/Middleware/Deploy.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class Deploy
{
    /**
     * Webhookからのリクエストを検証
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        $secret = config('webhook.github.deploy_secret');

        // リクエストヘッダーから署名を取得
        $signature = $request->header('X-Hub-Signature-256');

        // 署名が存在しない場合
        if (!$signature) {
            return response()->json(['message' => 'Signature not provided'], 403);
        }

        // ハッシュを計算
        $hash = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);

        // 署名が一致しない場合
        if (!hash_equals($hash, $signature)) {
            return response()->json(['message' => 'Invalid signature'], 403);
        }

        return $next($request);
    }
}
```

※ コントローラーでは、`response()->json(['message' => ...`として、`body`コンテンツを指定する第一引数に何を入れても、GitHubに送ることはできませんので、ステータスコードのみを指定しても構いません。(ミドルウェアではGitHubに`body`コンテンツを返すことができます。)

例によってミドルウェアの登録をします。

```htdocs/app/Http/Kernel.php
protected $routeMiddleware = [
    // 他のミドルウェア...
    'deploy' => \App\Http\Middleware\Deploy::class,
];
```

**ルーティング**

```htdocs/routes/web.php
Route::post('/webhook/github/deploy', 'DeployController@deploy')
->middleware('Deploy')
```

```htdocs/app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    // Deploy
    '/webhook/github/deploy'
]
```

:::note warn
GithubからのリクエストはPOSTリクエストなので`Route::post`としましょう。
:::

※ また、正直あまり関係ないとは思いますがCsrfトークンの検証をスルーするよう設定しておきました。(私は)

**コントローラー**

```htdocs/app/Http/Controllers/DeployController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Exception;

class DeployController extends Controller
{
    public function deploy(Request $request)
    {
        // GitHub Webhookのペイロードを取得し、扱えるデータ形式に変換
        $requestBodyJson = $request->getContent();
        $requestBodyArray = json_decode($requestBodyJson, true);

        // プルリクエストの情報を取得
        $baseRef = $requestBodyArray['pull_request']['base']['ref'] ?? null;
        $isMerged = $requestBodyArray['pull_request']['merged'] ?? false;

        // baseのブランチがmergeされた場合
        if ($baseRef === config('webhook.github.deploy_branch') && $isMerged) {

            try {

                // 実行するシェルスクリプトのパス
                $scriptPath = base_path('./deploy.sh');

                // シェルスクリプトをバックグラウンドで実行
                exec("bash $scriptPath > /dev/null 2>&1 &");

                // シェルスクリプトが実行された場合
                return response()->json([], 200, ['message' => 'Deployment executed', 'scriptPath' => $scriptPath]);

            } catch (Exception $e) {

                Log::error($e->getMessage());
                report($e);
                return response()->json([], 500, ['message' => 'Deployment failed', 'ErrorMessage' => $e->getMessage()]);

            }
        // baseのブランチがmergeされた場合以外は無視
        } else {
            return response()->json([], 204, ['message' => "Pull request is not merged into $baseRef branch, deploy process was skipped"]);
        }
    }
}
```

:::note warn
GitHubはPOSTリクエストを送信して`10秒以上`応答がなければタイムアウトして500系が返されてしまいます。シェルスクリプトの実行完了まで10秒以上かかる場合はバックグラウンドでシェルスクリプトを実行するようにしてください。
:::

※ baseのブランチとは、アクションが起こったブランチです。そのbaseのブランチを、プロジェクトでメインとなるブランチ(おそらく大体`master`や`main`)と同じものかどうかを判定し、さらにマージされるとシェルスクリプト実行としています。

※ `response()->json([], 200, ['message' => ...`としているのは、この記事の前半あたりで述べたように、コントローラーではGitHubに`body`コンテンツを返すことができません。ですので`header`情報として第三引数にデータを入れています。(非推奨でもないが推奨もしません。)

# まとめ

やり方は一つではありませんが、あくまで一例として私のやり方を見ていただけると幸いです。
当初はLaravelとGitHubのWebhooksに関する記事が少ないと感じたので、この記事が誰かのお役に立てることを願っています。

# おまけ
設定ファイルを変更した際は下記のコマンドを実行してみてください。

```htdocs
php artisan config:clear
php artisan cache:clear
```