PRマージで自動デプロイ!GitHub Actionsでブログのリリースを自動化した話

概要

今回は、ブログの記事PRがマージされたタイミングで、自動的に本番デプロイまで走るCI/CDを実装した話について紹介していきます。

このブログでは記事をPRでレビューしてからマージする運用にしているのですが、マージした後の本番反映はずっと手動でビルド→デプロイのコマンドを叩くという状態でした(^^;

マージしたのに反映を忘れる、忙しくて反映が翌日になる、みたいなことが何度かあって、「マージしたら勝手にリリースされてほしい」という気持ちがずっとありました。

そこでGitHub ActionsでPRマージ後に自動でビルド・デプロイまで実行する仕組みを組んでみました。

GitHub Actionsの設定は初めてだと呪文っぽく見える部分も多いと思うので、1つ1つの設定が何をしているのかまで噛み砕いて説明していきます。

実は実装の途中で認証エラーにハマったりもしたので、そのあたりの実体験も含めて書いていきます。

それではやっていきましょう!

目次

今までのリリースフローと課題

まず前提として、このブログの記事は執筆用リポジトリでPRを作り、レビューしてからmainにマージするフローになっています。

問題はマージした後の本番反映が完全に手動だったことです。

具体的には、マージ後に自分のPCで以下を実行していました。

コマンド

1
2
3
cd site
npm run build
npm run deploy

npm run buildがサイトの生成、npm run deployが生成済みファイルを本番リポジトリのmainブランチへpushする処理です。

デプロイの中身自体は、Gitベースで生成物をpushするスクリプトやパッケージ(ツールごとに実装は違いますが、いずれも最終的にはgit pushを叩いている)です。

そのpushをCloudflare側のGit連携が検知して、自動でビルド・公開してくれます。

つまり「手元でnpm run deployさえ叩けば、あとは自動で本番に届く」状態ではあったのですが、その「手元で叩く」の部分がボトルネックになっていました。

「PR承認時に自動リリース」を実際どう作るか

最初は「PR承認時に自動でリリースしたい」というざっくりした要望から始めたのですが、ここで一つ整理が必要でした。

GitHubでは「レビュー承認(approve)」と「マージ」は別のイベントなのです。

承認だけしてマージし忘れる、ということも普通に起こり得ます。

なので実装前に、次の2点を決めることにしました。

  • トリガーは「マージされた時点」にする
    • 承認だけでは何もせず、実際にmainへマージされたタイミングでデプロイする(承認と同時に自動マージまでやる案もあったが、誤操作のリスクが上がるため見送り)
  • 認証はPersonal Access Token(PAT)で行う
    • 本番リポジトリだけに絞ったFine-grained PATを発行し、GitHub Actionsのsecretsに登録する(理由は後述)

もう一つ、絶対に外したくない前提がありました。

本番のインフラ(Cloudflare Workerプロジェクト)は、以前Git連携の設定を触ったことが原因で障害を起こした経緯があります。
そのため今回は、インフラ側の設定には一切触れず、「手元で叩いていたビルド・デプロイコマンドをGitHub Actions上で自動実行するだけ」にスコープを絞りました。

全体像

実装後のパイプラインはこんな流れになりました。

PRマージ時の自動デプロイパイプライン(finalize-dateジョブ→deployジョブ→Cloudflare自動ビルド)
PRマージ時の自動デプロイパイプライン図。記事PRがマージされるとfinalize-dateジョブで公開日を補正し、deployジョブでビルドとデプロイを実行、最後にCloudflareのGit連携が自動ビルド・公開する流れを示す

ポイントは、既存のワークフローに乗っかったことです。

このブログにはもともと、PRマージ時に記事の公開日(frontmatterのdate:)をマージ日に補正するfinalize-article-date.ymlというワークフローがすでにありました。

このワークフローのにデプロイ処理を追加すれば、日付補正済みの最新状態から本番反映できる、というわけです。

実装:finalize-dateジョブの後にdeployジョブを追加

既存のワークフローに、新しくdeployジョブをneeds: finalize-dateで追加しました。

.github/workflows/finalize-article-date.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
deploy:
needs: finalize-date
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read

steps:
- name: Checkout repository (latest, post date-fix)
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.ref }}

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: site/package-lock.json

- name: Install dependencies
working-directory: site
run: npm ci

- name: Configure git push credentials for production repo
env:
PROD_REPO_PAT: ${{ secrets.PROD_REPO_PAT }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global url."https://x-access-token:${PROD_REPO_PAT}@github.com/".insteadOf "https://github.com/"

- name: Build and deploy
working-directory: site
run: |
npm run build
npm run deploy

初見だと情報量が多くて「うっ」となると思うので、上から順に何をしているかを整理していきますね!

  • needs: finalize-date
    • このジョブはfinalize-dateジョブが終わってから実行される、という依存関係の指定(詳しくは後述)
  • permissions: contents: read
    • このジョブ自身のリポジトリに対しては読み取りだけで十分(本番への書き込みは後述のPAT経由で行うため書く権限は不要)
  • Checkout repository
    • ref: ${{ github.event.pull_request.base.ref }}で、マージ先ブランチ(通常はmain)の最新状態を取得する
  • Setup Node.js / Install dependencies
    • サイト生成に必要なNode.jsと依存パッケージを用意する。cache: "npm"でキャッシュを効かせて毎回のインストールを高速化する
  • Configure git push credentials for production repo
    • 本番リポジトリへpushするための認証情報を仕込む(詳しくは後述)
  • Build and deploy
    • 実際にサイトを生成し、本番リポジトリへpushする

needs: finalize-dateが今回のキモです。

GitHub Actionsでは、1つのワークフロー内に複数のジョブがあると、指定がなければ並列に実行されます。

needs: finalize-dateと書くことで、finalize-dateジョブが成功してから実行する」という順序関係を明示できます。

これによって、日付補正のジョブが終わってから改めてリポジトリをチェックアウトし、生成処理に入る順序を保証できます。

finalize-dateとdeployを同時に走らせてしまうと、日付補正コミットが入るの古い状態でサイトを生成してしまう可能性があったので、ここは明確に順序を分けました。

もう一つのポイントは、デプロイ側の設定ファイル(push先のリポジトリ・ブランチを指定するだけの設定)自体は一切書き換えていないことです。

デプロイ設定(例)

1
2
3
deploy:
repo: https://github.com/example/example.github.io
branch: main

この設定ファイルには、押し先のURLとブランチが書いてあるだけで、認証情報は含まれていません。

普段(手元でnpm run deployを叩くとき)は、自分のPCにログイン済みのGit認証情報がそのまま使われるので、この設定だけで問題なくpushできています。

ただしGitHub Actionsの実行環境には、自分のPCのようなログイン済みの認証情報はありません。

そこで登場するのが、次のセクションのgit configの設定です。

git config --global url.insteadOfが何をしているか

自分も最初に書いたときは「これ何のおまじない?」と二度見したポイントなので、丁寧に分解します。

コマンド

1
git config --global url."https://x-access-token:${PROD_REPO_PAT}@github.com/".insteadOf "https://github.com/"
  • git config --global url."A".insteadOf "B"
    • Bから始まるURLへのgit通信は、すべてAに読み替える」というgit自体の設定(デプロイツール側の設定ではない)
  • https://x-access-token:${PROD_REPO_PAT}@github.com/
    • https://ユーザー名:パスワード@ホスト名/という、URLに直接ログイン情報を埋め込む昔ながらの書き方
    • x-access-tokenは、GitHubがトークン認証のために決めているお作法上のユーザー名(実際のパスワード役はPATそのもの)
  • insteadOf "https://github.com/"
    • https://github.com/から始まるURLへのアクセスは、全部さっきのURLに差し替える」という指定

つまり、npm run deploy側は普段通りhttps://github.com/...にpushしているつもりのまま、実際にはgitが裏でPAT付きのURLにこっそり差し替えてくれる、という仕組みです。

デプロイ側のツールやスクリプトを一切改造せずに済むのが、このやり方のメリットです。

実装:認証用PATの発行とSecrets登録

ここまでの説明で「PATが必要」と繰り返し出てきたので、気になった方もいると思います。

そもそもなぜGitHub Actionsに元から備わっている認証情報だけでは足りないのかを先に補足しますね。

GitHub Actionsのワークフローには、実行のたびに自動で発行されるGITHUB_TOKENという認証情報が最初から用意されています。

ただしこのトークンは、ワークフローが動いているリポジトリ自身にしか使えません。

今回は執筆用リポジトリのワークフローから、別の本番リポジトリへpushする必要があるため、本番リポジトリへの権限を持つPersonal Access Token(PAT)を自分で発行する必要がありました。

発行から登録までの手順は、以下の通りです。

  • 1. PAT発行画面を開く
    • GitHubのSettings → Developer settings → Personal access tokens → Fine-grained tokensからGenerate new tokenを選ぶ
  • 2. Repository accessを本番リポジトリだけに絞る
    • Only select repositoriesを選び、本番リポジトリ1つだけにチェックする(執筆用リポジトリには権限を渡さない)
  • 3. Permissionsを最小限にする
    • Repository permissionsContentsRead and writeに変更する。それ以外はデフォルトのままでよい
  • 4. トークンを発行してコピーする
    • Generate tokenを押すと表示される。この画面を閉じると二度と表示されないので、コピーを忘れずに
  • 5. 執筆用リポジトリのSecretsに登録する
    • Settings → Secrets and variables → Actions → New repository secretを開き、Name欄にPROD_REPO_PAT、Secret欄にコピーしたトークンを貼り付けて保存する

「本番リポジトリだけ」「Contentsだけ」に権限を絞っておくと、万が一この認証情報が漏れても被害範囲を最小限にできるので安心です(^^

ハマったポイント:Invalid username or tokenエラー

実装が終わって、実際にテスト記事のPRをマージしてみたところ、以下のエラーでデプロイが失敗しました。

1
2
remote: Invalid username or token. Password authentication is not supported for Git operations.
fatal: Authentication failed for 'https://github.com/example/example.github.io/'

一瞬「PATの権限設定を間違えたか?」とヒヤッとしました(- -;

ただ実際の原因はもっとシンプルで、Secretsの登録が完了する前にテストを走らせてしまっていたことでした。

PROD_REPO_PATが空、もしくは未登録の状態だと、insteadOfで組み込まれるトークン部分が空文字になり、GitHub側からは「トークン無しでpushしようとしている」ように見えてしまいます。

GitHubはユーザー名・パスワード方式の認証をすでに廃止しているので、トークンが空だとこのメッセージで弾かれるというわけです。

改めてSecretsにPROD_REPO_PATを登録し直してから同じPRをマージし直したところ、無事にdeployジョブが成功し、本番リポジトリへのpushからCloudflareの自動ビルドまで一気通貫で確認できました(^^

CI/CDあるあるだと思うのですが、「実装は合っているのにSecrets設定が抜けている」だけで同じようなエラーが出るので、認証エラーが出たらまずSecretsの中身を疑うのがよさそうです。

まとめ

今回実装した内容を整理すると、こんな感じです。

  • トリガー
    • PR承認ではなく「マージされた時点」(既存のfinalize-article-dateワークフローと同じイベント)
  • 順序保証
    • needs: finalize-dateで、日付補正のにデプロイが走るようにする
  • 認証
    • GITHUB_TOKENでは別リポジトリにpushできないため、本番リポジトリだけに絞ったFine-grained PATをgit config --global url.insteadOfで差し込む
  • スコープ
    • インフラ側の設定には触れず、手動デプロイ工程だけを自動化する

「マージしたら勝手にリリースされる」状態になったことで、反映忘れ・反映遅れがなくなったのはかなり大きいです。

以上となります。
システムが自分の手から少しずつ離れてく感じ好きです!
それではお疲れさまでした。