🔁
Next.js製のプロジェクトでGHAを使ってPRごとにプレビューサイトをデプロイする
#tech
#CI
2023-9-19
某所でNext.js製の,GitHub PagesにデプロイされるWebサイトを作ることがあった.
しかしあるとき,手元でyarn dev
したときとGitHub Pages上での挙動が異なる点があることに気付いた.このときPRごとにGitHub Actionsが動いてプレビューサイトを作成してくれるくんがいるとこうした事態にmain
ブランチへのマージ前に気付けるなあと思ったので整備した.この記事ではその内容について触れる.
- Next.js製のプロジェクトをGitHub Pagesにデプロイする
- デプロイにはpeaceiris/actions-gh-pagesを,GitHub REST APIを用いた操作の多くはoctokit/request-actionを用いる
全部で3つのworkflowを用意した.内容は末尾で示すとしてそれぞれのファイル名と大まかな役割を示す:
deploy.yml
:main
ブランチへのpush時に作動して,本番環境にデプロイする.尚main
ブランチにはbranch protectionを張っておりpushにはPRが必須となっている.deploy_preview.yml
: PRが[opened, synchronize, reopened]で発火し,PR名に紐づいたリポジトリを生成して,そこにyarn build
した成果物をコミットする.その後はそのリポジトリでGitHub Pagesが無効になっているなら有効にしてからデプロイする.デプロイ先のURLは,PRのコメントに自動で付くようになっている.delete_preview.yml
: PRが閉じられると発火し,deploy_preview
で作ったリポジトリを削除する.これにより自動でプレビューサイトも閉じられる.
真面目に全てを紹介しても良いのだが,deploy.yml
とかは本記事末尾を見て雰囲気で理解してほしい.説明が必要なのは恐らくdeploy_preview
と,もしかしたらdelete_preview
くらいだと思うので以下この2つについて述べる.
deploy_preview
では,Checkoutの後,まずはデプロイ対象のリポジトリ名(当然この時点ではまだ作られていない)を取得する.このリポジトリ名は,delete_previewでも用いるので,新たに.github/actions/get_preview_repo/action.yml
を作成して,そこにリポジトリ名等を返す処理をまとめた.
この内容も末尾に示すが新たに作成するリポジトリの所有者,リポジトリ名,デプロイ先のURLなどがoutputsとして吐かれる.
よってdeploy_preview
では
- name: Get preview repository name
uses: ./.github/actions/get_preview_repo
id: preview_repo
としておいて後のステップでこれらを参照できるようにしておく.
次に,この名前のリポジトリを新規作成する必要があるかどうか判定する必要がある.これは単にPRがopenedかreopenedであるときには作成すれば良いから次のように書ける:
- name: Check if new repository is needed
id: new_repo
run: echo "result=$new_repo" >> $GITHUB_OUTPUT
env:
new_repo: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }}
- name: Create preview repo
if: steps.new_repo.outputs.result == 'true'
uses: octokit/request-action@v2.x
with:
route: POST /user/repos
accept: 'application/vnd.github+json'
name: ${{ steps.preview_repo.outputs.name }}
private: 'true'
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
次に,必要に応じてプレビューサイトのURLをPRのコメントに投稿する.これはPRがopenedなときにのみ実行すれば良いから次のように書ける:
- name: Post preview URL comment to PR
if: github.event.action == 'opened'
uses: octokit/request-action@v2.x
with:
route: POST /repos/{owner}/{repo}/issues/{pr_number}/comments
accept: 'application/vnd.github+json'
owner: ${{ github.event.repository.owner.login }}
repo: ${{ github.event.repository.name }}
pr_number: ${{ github.event.pull_request.number }}
body: >
"😎 Preview URL: ${{ steps.preview_repo.outputs.gh_page_url }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ここまでで下準備が整った.このため後は適当にyarn build
まで行ってからデプロイする.
ここでyarn build
する際には,単に実行するだけでなく,次のようにしている:
- run: yarn build
env:
URL_PREFIX: ${{ steps.preview_repo.outputs.name }}
すなわち,URL_PREFIXを環境変数として渡している.これはNext.jsのルーティングを調整するためである.
今回デプロイしたい対象のURLはhttps://${owner}.github.io/${reposname}
のようになるため,root直下ではない.しかしNext.jsはこのhttps://${owner}.github.io
に様々なリソースがあると信じて見に行ってしまうので,多くのリソースが404になってしまう.実際はhttps://${owner}.github.io/${reposname}
下に様々があるのでこれを認識してもらうためにURL_PREFIXにリポジトリ名を渡しておいて,next.config.js
内で
const urlPrefix = process.env.URL_PREFIX ? '/' + process.env.URL_PREFIX : '';
module.exports = {
/* 中略 */
assetPrefix: urlPrefix,
basePath: urlPrefix,
trailingSlash: true,
publicRuntimeConfig: { urlPrefix },
};
のようにしてやれば正しく認識してくれるようになる.
ここまででビルドが完了したので後はこれを
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
external_repository: ${{ steps.preview_repo.outputs.full_name }}
publish_dir: ./out
とすれば目的の,PRに紐づくリポジトリにout
ディレクトリの内容をデプロイできる.
しかしここまででは不十分で,対象のリポジトリでGitHub Pagesを有効になっていない場合は,有効にしてやる必要がある.これは次のようにして判定及び有効化できる.
- name: Check if pages is enabled
id: pages_enabled
run: |
TOKEN=${{secrets.PERSONAL_ACCESS_TOKEN}}}
REPO=${{steps.preview_repo.outputs.full_name}}
echo GH_PAGES=$( \
curl -L -H "Accept: application/vnd.github+json" \
-H "Authorization: token $TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/$REPO/pages | \
jq '.message' \
) >> $GITHUB_OUTPUT
- name: Enable GitHub Pages
uses: octokit/request-action@v2.x
if: steps.pages_enabled.outputs.GH_PAGES == '"Not Found"'
continue-on-error: true
with:
route: POST /repos/{repo_name}/pages
accept: 'application/vnd.github+json'
repo_name: ${{ steps.preview_repo.outputs.full_name }}
data: '{"source":{"branch":"gh-pages"}}'
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
https://api.github.com/repos/$REPO/pages
は対象リポジトリのGitHub Pagesの状態を返す.Pagesが有効になっていない場合,messageに"Not Found"が入って返ってくるのでこれを取り出して判定してやればよい.
peaceiris/actions-gh-pagesはデプロイブランチとしてgh-pagesを用いるから,これをソースにするよう指定する.
削除対象のリポジトリ名は,deploy_previewと同様に,get_preview_repoアクションを用いることで取得できる:
- name: Get preview repo name
uses: ./.github/actions/get_preview_repo
id: preview_repo
ここからそのままリポジトリを消しに行っても良いが,念のために次のような処理を挟んでプレビューページが存在するまで待機する処理を入れた.
- name: Wait for preview GitHub Pages
run: >
timeout 300s bash -c '
until curl -If --no-progress-meter "$URL"; do
sleep 10;
done
'
env:
URL: ${{ steps.preview_repo.outputs.gh_page_url }}
これはdeploy_previewが作成したリポジトリ内でのデプロイが進行途中であるのにマージされた場合を想定している.
ここを突破できれば,後は単純に
- name: Delete preview repo
uses: octokit/request-action@v2.x
with:
route: DELETE /repos/{owner}/{repo_name}
accept: 'application/vnd.github+json'
owner: ${{ steps.preview_repo.outputs.owner }}
repo_name: ${{ steps.preview_repo.outputs.name }}
env:
GITHUB_TOKEN: ${{ secrets.DELREPO }}
などとして該当のリポジトリを削除すればよい.当然,この処理によりプレビューページもクローズされる.
.github/actions/get_preview_repo/action.yml
name: Get Preview Repository
description: Get preview repository info
runs:
using: composite
steps:
- name: Set repository owner name
id: owner
run: echo "result=lapla-cogito" >> $GITHUB_OUTPUT
shell: bash
- name: Set repository name
uses: actions/github-script@v6
id: repo
with:
script: |
function sanitize(str) {
return str.toString().replace(/[^\w-]/g, "");
}
const repoName = context.repo.repo;
const prNumber = context.payload.number;
const branchName = context.payload.pull_request.head.ref;
return `${sanitize(repoName)}-pr-${sanitize(prNumber)}-${sanitize(branchName)}`;
result-encoding: string
outputs:
owner:
description: Repository owner name
value: ${{ steps.owner.outputs.result }}
name:
description: Repository name
value: ${{ steps.repo.outputs.result }}
full_name:
description: Repository name including the owner's name
value: ${{ steps.owner.outputs.result }}/${{ steps.repo.outputs.result }}
gh_page_url:
description: GitHub Pages URL
value: https://${{ steps.owner.outputs.result }}.github.io/${{ steps.repo.outputs.result }}/
deploy.yml
name: GitHub Pages
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '19'
- name: Install dependencies
run: yarn --immutable --immutable-cache
- run: yarn run format:check
- run: yarn build
- name: Add nojekyll and CNAME
run: |
touch ./out/.nojekyll
cp CNAME ./out/
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: ${{ github.ref == 'refs/heads/main' }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./out
deploy_preview.yml
name: Deploy PR to preview repository
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
deploy-preview:
permissions: write-all
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout files
uses: actions/checkout@v3
- name: Get preview repository name
uses: ./.github/actions/get_preview_repo
id: preview_repo
- name: Check if new repository is needed
id: new_repo
run: echo "result=$new_repo" >> $GITHUB_OUTPUT
env:
new_repo: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }}
- name: Create preview repo
if: steps.new_repo.outputs.result == 'true'
uses: octokit/request-action@v2.x
with:
route: POST /user/repos
accept: 'application/vnd.github+json'
name: ${{ steps.preview_repo.outputs.name }}
private: 'true'
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Post preview URL comment to PR
if: github.event.action == 'opened'
uses: octokit/request-action@v2.x
with:
route: POST /repos/{owner}/{repo}/issues/{pr_number}/comments
accept: 'application/vnd.github+json'
owner: ${{ github.event.repository.owner.login }}
repo: ${{ github.event.repository.name }}
pr_number: ${{ github.event.pull_request.number }}
body: >
"😎 Preview URL: ${{ steps.preview_repo.outputs.gh_page_url }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '19'
- name: Install dependencies
run: yarn --immutable --immutable-cache
- run: yarn run format:check
- run: yarn build
env:
URL_PREFIX: ${{ steps.preview_repo.outputs.name }}
- name: Add nojekyll
run: touch ./out/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
personal_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
external_repository: ${{ steps.preview_repo.outputs.full_name }}
publish_dir: ./out
- name: Check if pages is enabled
id: pages_enabled
run: |
TOKEN=${{secrets.PERSONAL_ACCESS_TOKEN}}}
REPO=${{steps.preview_repo.outputs.full_name}}
echo GH_PAGES=$( \
curl -L -H "Accept: application/vnd.github+json" \
-H "Authorization: token $TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/$REPO/pages | \
jq '.message' \
) >> $GITHUB_OUTPUT
- name: Enable GitHub Pages
uses: octokit/request-action@v2.x
if: steps.pages_enabled.outputs.GH_PAGES == '"Not Found"'
continue-on-error: true
with:
route: POST /repos/{repo_name}/pages
accept: 'application/vnd.github+json'
repo_name: ${{ steps.preview_repo.outputs.full_name }}
data: '{"source":{"branch":"gh-pages"}}'
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
delete_preview.yml
name: Delete preview repository
on:
pull_request:
types: [closed]
jobs:
delete-preview:
permissions: write-all
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout files
uses: actions/checkout@v3
- name: Get preview repo name
uses: ./.github/actions/get_preview_repo
id: preview_repo
- name: Wait for preview GitHub Pages
run: >
timeout 300s bash -c '
until curl -If --no-progress-meter "$URL"; do
sleep 10;
done
'
env:
URL: ${{ steps.preview_repo.outputs.gh_page_url }}
- name: Delete preview repo
uses: octokit/request-action@v2.x
with:
route: DELETE /repos/{owner}/{repo_name}
accept: 'application/vnd.github+json'
owner: ${{ steps.preview_repo.outputs.owner }}
repo_name: ${{ steps.preview_repo.outputs.name }}
env:
GITHUB_TOKEN: ${{ secrets.DELREPO }}