🔁

Next.js製のプロジェクトでGHAを使ってPRごとにプレビューサイトをデプロイする

#tech#CI

2023-9-19

某所でNext.js製の,GitHub PagesにデプロイされるWebサイトを作ることがあった.

しかしあるとき,手元でyarn devしたときとGitHub Pages上での挙動が異なる点があることに気付いた.このときPRごとにGitHub Actionsが動いてプレビューサイトを作成してくれるくんがいるとこうした事態にmainブランチへのマージ前に気付けるなあと思ったので整備した.この記事ではその内容について触れる.

前提

workflow達の概要

全部で3つのworkflowを用意した.内容は末尾で示すとしてそれぞれのファイル名と大まかな役割を示す:

  • deploy.ymlmainブランチへの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_preview

真面目に全てを紹介しても良いのだが,deploy.ymlとかは本記事末尾を見て雰囲気で理解してほしい.説明が必要なのは恐らくdeploy_previewと,もしかしたらdelete_previewくらいだと思うので以下この2つについて述べる.

deploy_previewでは,Checkoutの後,まずはデプロイ対象のリポジトリ名(当然この時点ではまだ作られていない)を取得する.このリポジトリ名は,delete_previewでも用いるので,新たに.github/actions/get_preview_repo/action.ymlを作成して,そこにリポジトリ名等を返す処理をまとめた.

この内容も末尾に示すが新たに作成するリポジトリの所有者,リポジトリ名,デプロイ先のURLなどがoutputsとして吐かれる.

よってdeploy_previewでは

yaml
- name: Get preview repository name
  uses: ./.github/actions/get_preview_repo
  id: preview_repo

としておいて後のステップでこれらを参照できるようにしておく.

次に,この名前のリポジトリを新規作成する必要があるかどうか判定する必要がある.これは単にPRがopenedかreopenedであるときには作成すれば良いから次のように書ける:

yaml
- 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なときにのみ実行すれば良いから次のように書ける:

yaml
- 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する際には,単に実行するだけでなく,次のようにしている:

yaml
- 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内で

JavaScript
const urlPrefix = process.env.URL_PREFIX ? '/' + process.env.URL_PREFIX : '';

module.exports = {
    /* 中略 */
    assetPrefix: urlPrefix,
    basePath: urlPrefix,
    trailingSlash: true,
    publicRuntimeConfig: { urlPrefix },
};

のようにしてやれば正しく認識してくれるようになる.

ここまででビルドが完了したので後はこれを

yaml
- 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を有効になっていない場合は,有効にしてやる必要がある.これは次のようにして判定及び有効化できる.

yaml
- 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を用いるから,これをソースにするよう指定する.

delete_preview

削除対象のリポジトリ名は,deploy_previewと同様に,get_preview_repoアクションを用いることで取得できる:

yaml
- name: Get preview repo name
  uses: ./.github/actions/get_preview_repo
  id: preview_repo

ここからそのままリポジトリを消しに行っても良いが,念のために次のような処理を挟んでプレビューページが存在するまで待機する処理を入れた.

yaml
- 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が作成したリポジトリ内でのデプロイが進行途中であるのにマージされた場合を想定している.

ここを突破できれば,後は単純に

yaml
- 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
.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
.github/workflows/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
.github/workflows/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
.github/workflows/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 }}