🤖

GitHub Actions で Vercel bot みたいなプレビューデプロイをつくる

2022/05/04に公開

Vercel というウェブサイトをデプロイできるサービスがあって、それを GitHub と連携すると、

GitHub で push したりプルリクエストを開いたりしたときに、自動でいろいろやってくれるんですよ。

デフォルトブランチに push したら自動で再デプロイしてくれたり

deploy

デフォルト以外のブランチに push したら自動で preview version をデプロイしてくれて、

PR を開くと自動でコメントしてくれたり

PR comment

再度 push すると再度プレビューをデプロイしてくれたりします。

特に PR 開くと自動でプレビューをつくってくれるのは、さくっと動作確認できてレビューにもすごく便利です。

Vercel を使っているときはこれを GitHub のリポジトリと連携するだけで、特に設定しなくてもこういう便利なことをやってくれるんですが、

使っていないときにもこれができたらなあと思ったわけです。

ということで、GitHub Actions でこのプレビューデプロイと似たようなことをやってみましょう!

そもそも vercel[bot] は何をやっているのか

どの GitHub のイベントがどういうふうにプレビューデプロイや PR コメントをトリガーしているのか観察してみると、

  • push したときにデプロイがはじまる
  • PR を開いたときにコメントする
    • デプロイが終わっていなければ Preview: In Progress とコメントする
    • デプロイが終わり次第コメントを Preview URL をのせたものに更新する
    • デプロイがすでに終わっていれば Preview URL をすぐにコメントする

こんな感じかと思います。

PR を開いたときにはじめてデプロイをするという方法もあり、ワークフローがひとつで済むので簡単ですが、

push してから PR を開くまでの間にデプロイをすすめておいて、PR を開いたらすぐにコメントしてくれるほうが時短になるので、これを実現したいです。

処理の内容を図にすると次のようになります。

workflows-diagram

デプロイが終わるのと PR を開くのと、どちらが先に終わるかによって通るフローが変わってきます。

デプロイが先に終わっていれば PR が開かれたらすぐに preview URL をコメントするし、

PR が先に開いていればデプロイが完了するまではデプロイ中であるとコメントし、デプロイが完了すれば preview URL をのせたコメントに更新します。

ワークフローを書く

見たところワークフローが GitHub に対して必要になる処理は、

  • PR にコメントを作成・更新する
  • デプロイが完了しているか確認する
  • 対応する PR が開いているか確認する

いずれも GitHub API を使うことで実現できます。

PR コメントに関しては peter-evans/find-commentpeter-evans/create-or-update-comment を使うのが簡単かと思います。

「デプロイが完了しているか確認する」に関しては、

GitHub の environment を作成して、デプロイ前に「デプロイ中」のステータスを付与し、デプロイ後に「完了」のステータスを付与することで、

GitHub API によってデプロイ状態を取得できます。

これは bobheadxi/deployments を使うと簡単です。

今回のコード例ではこれらを使用します。

push したときの処理

push したときには、デプロイをし、デプロイ後にそのブランチに対応する PR が存在するかチェックし、存在していたらコメントを残します。

コード例は以下のようになります。

name: Deploy preview

on:
  push:
    branches:  # branches other than main
      - '*'
      - '!main'

env:
  ENV_NAME: preview-${{ github.ref_name }}  # preview-[branch name]

jobs:
  deploy:
    name: Deploy
    
    runs-on: ubuntu-latest
    
    permissions:
      deployments: write  # bobheadxi/deployments needs this
    
    outputs:
      url: ${{ steps.deploy-url.outputs.url }}
    
    steps:
      - name: Start deployment
        uses: bobheadxi/deployments@v1
        id: deployment
        with:
          step: start
          token: ${{ github.token }}
          env: ${{ env.ENV_NAME }}
      
      # Authenticate and deploy steps...
      
      - name: Deploy
        id: deploy-url
        run: # Output the deployment URL
      
      - name: Update deployment status
        uses: bobheadxi/deployments@v1
        if: always()
        with:
          step: finish
          token: ${{ github.token }}
          status: ${{ job.status }}
          env: ${{ steps.deployment.outputs.env }}
          env_url: ${{ steps.deploy-url.outputs.url }}
          deployment_id: ${{ steps.deployment.outputs.deployment_id }}

  check-if-pr-exists:
    needs: deploy

    name: Check if associated PR exists

    runs-on: ubuntu-latest

    permissions:
      pull-requests: read

    outputs:
      pr-number: ${{ steps.get-pr-number.outputs.pr-number }}

    steps:
      - name: Get associated PRs
        id: prs
        run: >
          echo "pulls=$(gh api -H "Accept: application/vnd.github.v3+json" -X GET
          /repos/${{ github.repository }}/pulls
          -f state=open
          -f head=${{ github.repository_owner }}:${{ github.ref_name }})" >> $GITHUB_OUTPUT
        env:
          GH_TOKEN: ${{ github.token }}

      - name: Get PR number
        id: get-pr-number
        run: |
          if [ "${{ fromJSON(env.PULL) }}" ]; then
            echo "pr-number=${{ fromJSON(env.PULL).number }}" >> $GITHUB_OUTPUT;
          else
            echo "pr-number=0" >> $GITHUB_OUTPUT;
          fi
        env:
          PULL: ${{ toJSON(fromJSON(steps.prs.outputs.pulls)[0]) }}

  comment:
    needs:
      - deploy
      - check-if-pr-exists

    name: Comment on PR

    if: needs.check-if-pr-exists.outputs.pr-number != 0

    runs-on: ubuntu-latest

    permissions:
      contents: read        # actions/checkout needs this
      issues: write         # peter-evans/create-or-update-comment needs this
      pull-requests: write  # peter-evans/create-or-update-comment needs this

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Find Comment
        uses: peter-evans/find-comment@v2
        id: fc
        with:
          issue-number: ${{ needs.check-if-pr-exists.outputs.pr-number }}
          comment-author: github-actions[bot]
          body-includes: preview

      - name: Get datetime for now
        id: datetime
        run: echo "value=$(date)" >> $GITHUB_OUTPUT
        env:
          TZ: Asia/Tokyo

      - name: Create or update comment
        uses: peter-evans/create-or-update-comment@v2
        with:
          issue-number: ${{ needs.check-if-pr-exists.outputs.pr-number }}
          comment-id: ${{ steps.fc.outputs.comment-id }}
          body: |
            :eyes: Visit the **preview** for this PR (updated for commit ${{ github.sha }}):
            [![View the preview deployment at ${{ needs.deploy.outputs.url }}](https://img.shields.io/badge/-View_Preview-eee)](${{ needs.deploy.outputs.url }})
            <sub>(:clock3: updated at ${{ steps.datetime.outputs.value }})</sub>
          edit-mode: replace

長いな…

deploy check-if-pr-exists comment の 3 つの job があり、直列になっています。

deploy では実際のデプロイの処理を GitHub のデプロイ開始、終了の処理で挟み込んでいます。

check-if-pr-exists では、指定の author、ブランチ名で開いている PR が存在するかをチェックし、存在していればそのような PR の最初のものの番号を、なければ 0 を返します。

needsdeploy を指定することで、デプロイ完了時にはじめて PR の存在チェックをするようにします。

comment は、check-if-pr-exists で取得した PR 番号と deploy で取得したデプロイ先の URL にもとづき、PR が存在すれば URL をコメントに残します。

PR を開いたときのワークフローでコメントを残すので、そのコメントの ID を peter-evans/find-comment で取得し、そのコメントを新しいコメント内容で更新するようにしています。

これで main 以外のブランチに push したときに自動でデプロイされ、完了時に PR が開かれていればコメントされます。

また、GitHub の Environments 欄にこんな感じにデプロイが表示されたりします。

GitHub Environments

PR を開いたときの処理

PR を開いたときには、デプロイが完了しているか確認し、結果によって異なるコメントを PR に残します。

デプロイ完了時にコメントを残す処理は push のワークフローに定義しているため、ここでは考えなくても大丈夫です。

コード例は以下のようになります。

name: Comment on PR about deployment

on:
  pull_request:
    types:
      - opened

env:
  ENV_NAME: preview-${{ github.head_ref }}  # preview-[branch name]

jobs:
  check-if-deployment-exists:
    name: Check if a successful deployment exists

    runs-on: ubuntu-latest

    permissions:
      deployments: read

    env:
      GH_TOKEN: ${{ github.token }}

    outputs:
      state: ${{ steps.get-status.outputs.state }}
      url: ${{ steps.get-status.outputs.url }}

    steps:
      - name: Get associated deployments
        id: get-deployments
        run: >
          echo "deployments=$(gh api -H "Accept: application/vnd.github.v3+json"
          /repos/${{ github.repository }}/deployments?environment=${{ env.ENV_NAME }})" >> $GITHUB_OUTPUT

      - name: Get statuses of the deployment
        id: get-statuses
        run: >
          echo "statuses=$(gh api -H "Accept: application/vnd.github.v3+json"
          /repos/${{ github.repository }}/deployments/${{ env.DEP_ID }}/statuses)" >> $GITHUB_OUTPUT
        env:
          DEP_ID: ${{ fromJSON(steps.get-deployments.outputs.deployments)[0].id }}

      - name: Get latest status of the deployment
        id: get-status
        run: |
          echo "state=${{ fromJSON(env.STATUS).state }}" >> $GITHUB_OUTPUT
          echo "url=${{ fromJSON(env.STATUS).environment_url }}" >> $GITHUB_OUTPUT
        env:
          STATUS: ${{ toJSON(fromJSON(steps.get-statuses.outputs.statuses)[0]) }}

  comment:
    needs: check-if-deployment-exists

    name: Comment on PR

    if: needs.check-if-deployment-exists.outputs.state != null

    runs-on: ubuntu-latest

    permissions:
      contents: read
      issues: write
      pull-requests: write

    steps:
      - name: Checkout
        uses: actions/checkout@v3
      
      - name: Get datetime for now
        id: datetime
        run: echo "value=$(date)" >> $GITHUB_OUTPUT
        env:
          TZ: Asia/Tokyo

      - name: Comment on PR if the deployment has completed
        if: needs.check-if-deployment-exists.outputs.state == 'success'
        uses: peter-evans/create-or-update-comment@v2
        with:
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            :eyes: Visit the **preview** for this PR (updated for commit ${{ github.sha }}):
            [![View the preview deployment at ${{ needs.check-if-deployment-exists.outputs.url }}](https://img.shields.io/badge/-View_Preview-eee)](${{ needs.check-if-deployment-exists.outputs.url }})
            <sub>(:clock3: updated at ${{ steps.datetime.outputs.value }})</sub>

      - name: Create comment if the deployment has not completed
        if: needs.check-if-deployment-exists.outputs.state == 'in_progress'
        uses: peter-evans/create-or-update-comment@v2
        with:
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            :hourglass_flowing_sand: preview deployment is ongoing...

こちらは check-if-deployment-existscomment の 2 つの job があり、直列になっています。

check-if-deployment-exists では GitHub API を使って、対応する deployments を取得し、最新のものの statuses を取得し、その最新のものの state とデプロイ先の URL を取得しています。

取得している情報としては「この PR を出しているブランチにひもづく最新の deployment は今どういう状態か(進行中か完了しているか)」です。

これらの情報を次の job comment に渡し、デプロイが成功 success であれば URL をコメント、未完了 in_progress であればデプロイ中だと示すコメントを残します。

これで PR を開いたときにコメントを残してくれるようになります。


以上で、プレビューデプロイを作成し PR にコメントする処理を GitHub Actions で実行することができるようになりました。

他にも、PR がマージされたらプレビューデプロイを削除したり、リリースを作成したら production 環境にデプロイしたりといった Action を作ることができます。

この記事が参考になったらうれしいです。

ではまた 👋

参考

https://zenn.dev/matken/articles/preview-deploy-on-cloud-run

Discussion