メルコインにおけるGitHub Actions活用術

こんにちは。メルコインのバックエンドエンジニアのiwataです。
この記事は、Merpay Advent Calendar 2023 の23日目の記事です。

私はいまメルコインのCoreチームに属しています。Coreチームでは主にお客さまからの暗号資産の売買注文を受け付ける部分のマイクロサービスを開発運用しています。

メルコインではCI環境としてGitHub Actions self-hosted runnerを使用しています。またCIだけでなく、さまざまな自動化のためのワークフローの構築もこの環境を用いて実行しています。この記事では私の所属しているCoreチームにおいてGitHub Actions上に構築しているオートメーションについて紹介したいと思います。

PR-Agent

PR-AgentはOpenAI APIを使って、PRのコードレビューなどを自動化してくれるActionです。LayerXさんの紹介記事を読んで導入しました。
機能はたくさんあるのでここでは詳細は割愛しますが、主に活用しているのはPR作成時にコメントしてくれるコードレビューと/describeコマンドで生成されるPRのタイトルと説明の自動生成です。
PR Analytics
PR-Agentによるコードレビュー

あまり具体的な例を記事中にだすことができませんが、例えば上記画像のような内容をコメントしてくれます。これによりレビュアーがぱっとこのPRの内容を理解するのに役立つことができます。また/describeを使うと自分のようにSSIAなどで説明文を端折ってしまう面倒くさがりな人でもいい感じのタイトルと説明文をAIが考えてくれて非常に便利です。/add_docsを使うとコードコメントをSuggestしてくれてこれも便利です。

OpenAIのAPI Keyさえあれば簡単に導入できる点もよいです。一方でGitHub自体にも似たような機能がリリースされているので試してみたいなと思っています。

Lint

いくつかのLintツールを併用していますが、ここではYAMLで記述されるGitHub Actions(GHA)のワークフローファイルに対するLintについて紹介します。実際のワークフローは以下です。

name: Actions Lint
on:
  pull_request:
    paths:
      - ".github/workflows/*.yml"
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
jobs:
  actionlint:
    runs-on: self-hosted
    permissions:
      checks: "write"
      contents: "read"
      pull-requests: "write"
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - uses: reviewdog/action-actionlint@82693e9e3b239f213108d6e412506f8b54003586 # v1.39.1
        with:
          fail_on_error: true
          filter_mode: nofilter
          level: error
          reporter: github-pr-review
  ghalint:
    runs-on: self-hosted
    permissions:
      contents: "read"
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - name: Setup aqua
        uses: ./actions/setup-aqua
        with:
          aqua_version: v2.21.0
      - name: ghalint run
        run: ghalint run

このワークフローではactionlintghalintの2つのLinterを実行しています。それぞれセキュリティを含めたベストプラクティスに則っているかをチェックしてくれるので非常に有益です。

ワークフローについては社内のセキュリティガイドラインに準拠する形で記述しています。3rd Party ActionはFull Change Hashで固定(このフォーマットでもDependabotおよびRenovateを使うことで自動更新可能) し、 permissionsは最低限の権限を使うようにしています。(以後記載するワークフローファイルはすべてこのガイドラインに則って記述してあります。)

またghalintのバージョン管理にはaqua を使っています。aquaはCLIツールのバージョンマネージャでチェックサムの検証ができたり、Lazy Installなど便利な機能もあるため使用しています。ghalint以外にもgolangci-lintgciなど開発に必要なさまざまなCLIツールをaquaで管理しています。(aquaについてより詳しく知りたい方はaqua CLI Version Manager 入門をご参照ください) したがってaquaは他のさまざまなワークフローで利用することになるため、以下のComposite Actionを作って再利用しやすいように工夫しています。

name: Setup aqua with caching
describe: Install tools via aqua and manage caching
inputs:
  aqua_version:
    required: true
    description: |
      aqua version for installer, e.g. v2.9.0
  aqua_opts:
    required: false
    default: -l
    description: |
      aqua i's option. If you want to specify global options, please use environment variables
  policy_allow:
    required: false
    default: ""
    description: |
      If this is true", the aqua policy allow command is run. If a Policy file path is set, aqua policy allow "policy_allow" is run
  require_checksum:
    required: false
    default: "true"
    description: |
      Set an environment variable as `AQUA_REQUIRE_CHECKSUM`
  cache_version:
    description: The prefix of cache key
    required: false
    default: "v1"
runs:
  using: "composite"
  steps:
    # ref. https://aquaproj.github.io/docs/products/aqua-installer/#-caching
    - name: Restore aqua tools
      uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3.3.2
      id: restore-aqua
      with:
        path: ~/.local/share/aquaproj-aqua
        key: ${{ inputs.cache_version }}-aqua-installer-${{hashFiles('.aqua/*.yaml')}}
        restore-keys: |
          ${{ inputs.cache_version }}-aqua-installer-
    - name: Aqua install
      uses: aquaproj/aqua-installer@928a2ee4243a9ee8312d80dc8cbaca88fb602a91 # v2.2.0
      with:
          aqua_version: ${{ inputs.aqua_version }}
          aqua_opts: ${{ inputs.aqua_opts }}
          policy_allow: ${{ inputs.policy_allow }}
      env:
        AQUA_REQUIRE_CHECKSUM: ${{ inputs.require_checksum }}
    - name: add path
      shell: bash
      run: |
        echo "$HOME/.local/share/aquaproj-aqua/bin" >> "$GITHUB_PATH"

Auto Correct

Lintとともにgoimportsなどのコードフォーマッタの活用も重要です。golangci-lintによってgoimportsなどのフォーマッタのかけ忘れを弾くことは可能ですが、GHA上でフォーマットしてあげて自動でコミットをしてあげるとさらに便利です。コードフォーマッタだけでなく、wireなど自動生成ツールも使っているのでそれらもあわせて実行し、差分があればGHA上でコミットするようにしています。使っているツールをまとめると以下のようになります。

これらのツールはすべてaquaでバージョン管理しています。pinactはワークフローファイル内のバージョンをFull Change Hashに自動で固定してくれるツールでとても有用です。Auto Correctのワークフローは以下になります。

name: Correct codes by auto generation
on:
  pull_request:
    paths:
      - ".github/**/*.ya?ml"
      - "**.go"
      - "**/go.mod"
      - "**.sql"
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
defaults:
  run:
    shell: bash
jobs:
  auto-correct:
    runs-on: self-hosted
    permissions:
      contents: "read"
    steps:
      - name: Check out
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
        with:
          go-version-file: "go.mod"
          cache-dependency-path: "**/go.sum"
      - name: Setup aqua
        uses: ./actions/setup-aqua
        with:
          aqua_version: v2.21.0
      - name: Auto generation
        run: make gen # make taskでformatter, linter, code generationを実行
      - name: pinact run
        run: pinact run
      - name: Generate token
        id: generate_token
        uses: suzuki-shunsuke/github-token-action@350d7506222e3a0016491abe85b5c4dd475b67d1 # v0.2.1
        with:
          github_app_id: ${{ secrets.GH_APP_ID }}
          github_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - name: Push diff
        run: |
          set -euo pipefail
          if git diff --quiet; then
            echo "::notice :: There is no difference."
            exit 0
          fi
          echo "::notice :: There are some differences, so a commit is pushed automatically."
          if ! ghcp -v; then
            echo "::error :: int128/ghcp isn't installed. To push a commit, ghcp is required."
            exit 1
          fi
          branch=${GITHUB_HEAD_REF:-}
          if [ -z "$branch" ]; then
            branch=$GITHUB_REF_NAME
          fi
          git diff --name-only |
            xargs ghcp commit -r "$GITHUB_REPOSITORY" -b "$branch" \
              -m "chore(gen): auto correct some files"
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}

このワークフローは複雑になっているので順をおって説明しようと思います。

Protected Branchの設定

マージ先のブランチはProtected Branchで保護されています。このワークフローに関連する設定としては、署名つきコミットステータスチェックを必須にしている点です。すなわちAuto Correctによるコミットがこれらを満たせるようにワークフローを構築しておかないとマージできなくなってしまいます。

署名つきコミット

ワークフロー内でgitコマンドを使ってコミットをすると署名がつきません。これを簡単に回避する方法としてはGitHub APIを使う方法があります。GitHub API で生成したコミットにはGitHubが署名してくれます。ghcpを使うとGitHub APIを使ったコミットを簡単に作成できるのでこれを使ってコミットするようにします。次のコードが実際にコミットをしている部分になります。

git diff --name-only |
            xargs ghcp commit -r "$GITHUB_REPOSITORY" -b "$branch" \
              -m "chore(gen): auto correct some files"

差分がでたファイル名をパイプで渡してコミットを生成しています。

GitHub Appを使ったトークンの生成

コミットに使うGitHubトークンにも注意が必要です。GitHubのドキュメントに以下のような記述があります。

When you use the repository’s GITHUB_TOKEN to perform tasks, events triggered by the GITHUB_TOKEN, with the exception of workflow_dispatch and repository_dispatch, will not create a new workflow run.

つまりよく使われるsecrets.GITHUB_TOKENを使ってコミットをするとそのコミットをトリガーに他のワークフローを起動できません。ワークフローが起動しないということはCIが実行されず、したがってProtected Branchのステータスチェックをパスすることができません。

これを回避するためにGitHub Appから生成したトークンを使ってコミットをする必要があります。上記のワークフローではsuzuki-shunsuke/github-token-actionを使ってトークンを生成しています。

     - name: Generate token
        id: generate_token
        uses: suzuki-shunsuke/github-token-action@350d7506222e3a0016491abe85b5c4dd475b67d1 # v0.2.1
        with:
          github_app_id: ${{ secrets.GH_APP_ID }}
          github_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}

このGitHub Appには次の権限が必要になります。

  • contents:write
  • workflows:write

リポジトリに自分で用意したGitHub Appをインストールして使います。GitHub Apoの準備は最初は面倒ですが、一度設定すると自動化ができることが格段に増えるので便利になります。

aquaの自動更新

CLIツールはaquaで管理しています。バージョンの更新は公式に提供されているRenovate Presetを使うことで可能です。詳細はRenovateによる自動updateを参照してください。(GitHubのDependabotlにはPresetのような機能がないため、aquaの自動更新はRenovate前提になっています。) 前述しましたが、aquaではチェックサムの検証ができます。aquaではaqua-checksums.jsonでチェックサムを管理しており、バージョン更新時でもチェックサム検証をパスするためには、一緒にこのファイルのチェックサムも更新する必要があります。便利なことにそのためのReusable Workflowが公式に提供されているのでこれを使うことでチェックサムの更新も自動化することができます。

name: Update aqua-checksums.json automatically
on:
  pull_request:
    paths:
      - .aqua/aqua.yaml
      - .aqua/aqua-checksums.json
      - .github/workflows/update-aqua-checksums.yaml
jobs:
  update-aqua-checksums:
    uses: aquaproj/update-checksum-workflow/.github/workflows/update-checksum.yaml@3598c506108a2e0e9e31a0c6ef9c202c77049420 # v0.1.9
    permissions:
      contents: read
    with:
      aqua_version: v2.21.0
      prune: true
    secrets:
      gh_app_id: ${{ secrets.GH_APP_ID }}
      gh_app_private_key:  ${{ secrets.GH_APP_PRIVATE_KEY }}

このワークフローにおいても前述のコミットの問題が発生するので、GitHub Appを使う必要があります。このAppにはcontents:write権限があれば十分です。

リリースフローに関する自動化

ブランチ管理

Coreチームではgit-flowを簡素化したブランチ管理を採用しています。main、develop、feature、hotfixブランチはそのままですが、releaseブランチは作成せず、リリースの際にdevelopブランチをmainブランチにマージしてリリースするようにしています。これらのブランチのうち、Protected Branchの設定しているのはmainとdevelopブランチになります。

git flow
オリジナルのgit-flow、releaseブランチに違いがある (出典: atlassian.com)

定期的なリリースタイミングでdevelopをmainブランチにマージし、タグを作成すると本番環境にデプロイできるようになっています。

develop to mainのPull Request作成

リリース時にはdevelop to mainのPRが必要になるため、developブランチへPushがあると自動でmainブランチへのPRを作成するようにしています。

name: git-pr-release
on:
  push:
    branches:
      - develop
jobs:
  git-pr-release:
    runs-on: self-hosted
    permissions:
      contents: read
      pull-requests: write
    container:
      image: ruby:3.2@sha256:e3f503db7f451e6fd48221ecafbf1046ad195cddec98825538b35a82538b8387
    steps:
      - name: Check out
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
        with:
          fetch-depth: 0 # git-pr-release needs the git histories
      - name: Install git-pr-release
        run: gem install --no-document git-pr-release --version 2.2.0
      - name: Update git config
        run: git config --global --add safe.directory "$(pwd)"
      - name: Create PR
        run: git-pr-release --squashed
        env:
          GIT_PR_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GIT_PR_RELEASE_BRANCH_PRODUCTION: main
          GIT_PR_RELEASE_BRANCH_STAGING: develop
          GIT_PR_RELEASE_LABELS: Release
          GIT_PR_RELEASE_TEMPLATE: .github/PR_RELEASE_TEMPLATE.erb
          TZ: Asia/Tokyo

PRの作成にはgit-pr-releaseを使っています。このワークフローにより次の画像のようなPRが自動で生成されるようになります。各PR毎にチェックボックスがつくので、リリース時にPR内容を確認してもらって問題なければチェックをいれるようにしてからリリースしています。
mainへのPR

リリースの作成

mainブランチにマージした後はGitHub UI上からリリースをパブリッシュすることでタグを作成します。この際のリリース作成も自動化しています。

name: Release Drafter
on:
  pull_request:
    branches:
      - main
    types:
      - closed
jobs:
  release-draft:
    runs-on: self-hosted
    if: github.event.pull_request.merged
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: release drafter
        uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5.25.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

mainブランチへのPull Requestがマージされると、release-drafterを使ってリリースをドラフト状態で作成します。これはかなり便利で、release-drafter導入前はマニュアルでリリースを作成していましたが次のような課題がありました。

  • バージョン番号を自分でインクリメントしないといけないのが地味に面倒
  • デフォルトブランチがdevelopになっているので、ターゲットブランチをmainに切り替え忘れるとインシデントになってしまう

自動化によりこれらは解消することができました。

Hotfixに関する自動化

hotfixブランチはdevelopブランチを経由せず、直接mainブランチにマージします。hotfixブランチのマージの際はpatchバージョンをインクリメントするようにしています。

name: Release Drafter Label
on:
  pull_request:
    branches:
      - main
    types:
      - opened
jobs:
  release-draft-label:
    runs-on: self-hosted
    if: github.event.pull_request.head.ref != 'develop'
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: detect version label
        uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1.1.3
        with:
          labels: patch

このワークフローによってhotfixブランチのPRが作成されるとpatchラベルがつくようになっています。このラベルがつくとrelease-drafterがpatchバージョンをあげるように設定してあります。

またhotfixの差分はdevelopブランチにもマージする必要があります。この作業は面倒は意外と面倒です。なぜかというと、直接mainからdevelopへのPRを作成することができないためです。またこの作業はよく忘れてしまうので自動化しておくのが得策です。それを実現するのが以下のワークフローです。hotfixがmainにマージされると起動します。

name: Create a pull request to merge hotfix into develop
on:
  pull_request:
    branches: [main]
    types: [closed]
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
defaults:
  run:
    shell: bash
jobs:
  create-pull-request:
    runs-on: self-hosted
    if: github.event.pull_request.merged == true && github.head_ref != 'develop'
    permissions: {}
    steps:
      - name: Generate token
        id: generate_token
        uses: suzuki-shunsuke/github-token-action@350d7506222e3a0016491abe85b5c4dd475b67d1 # v0.2.1
        with:
          github_app_id: ${{ secrets.GH_APP_ID }}
          github_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - name: Decide a branch name
        id: decide-branch
        run: |
          branch=main-to-develop/hotfix-${{ github.event.pull_request.head.sha }}
          echo "branch=${branch}" >> "$GITHUB_OUTPUT"
      - name: Create a pull request
        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
        with:
          github-token: ${{ steps.generate_token.outputs.token }}
          script: |
            const {owner, repo} = context.repo
            const mainBranch = "main"
            const devBranch = "develop"

            // fetch commit sha of develop branch
            const {data} = await github.rest.git.getRef({
              owner,
              repo,
              ref: `heads/${devBranch}`,
            })

            // create a new branch
            const branch = "${{ steps.decide-branch.outputs.branch }}"
            await github.rest.git.createRef({
              owner,
              repo,
              ref: `refs/heads/${branch}`,
              sha: data.object.sha,
            })

            const {actor, payload} = context
            const {title, number} = payload.pull_request

            // merge main into a new branch
            await github.rest.repos.merge({
              owner,
              repo,
              base: branch,
              head: mainBranch,
              commit_message: `Merge ${title}`,
            })

            // create a pull request
            const pull = await github.rest.pulls.create({
              owner,
              repo,
              base: devBranch,
              head: branch,
              title: `Merge hotfix to ${devBranch}: ${title}`,
              body: `Merge #${number} for ${devBranch} branch, too`,
            })

            // assign an actor as reviewer
            github.rest.pulls.requestReviewers({
              owner,
              repo,
              pull_number: pull.data.number,
              reviewers: [actor],
            })

github-script用のScriptが長いですが次のことをやっています。

  1. developブランチからPR用のブランチを作成
  2. 作成したブランチにmainブランチをマージ
  3. 上記ブランチからdevelopへのPRを作成
  4. hotfixをマージしたアカウントをPRのレビュワーにアサイン

このワークフローにおいてもコミットの問題が発生するので、GitHub Appからトークンを生成しています。このAppでは次の3つの権限が必要になります。

  • contents:write
  • pull-requests:write
  • workflows:write

レビューワーも設定しているのでマージ忘れがないように工夫しています。

まとめ

GitHub Actionsを使った自動化の事例を紹介してきました。セキュリティと自動化とは相反するところもあるので、両立するためにはバランス感覚と知識の更新が不可欠だなと思っています。だいぶ長い記事になってしまいましたが、そういった面で参考になれば幸いです。

明日の記事は poohさんです。引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加