こんにちは。メルコインのバックエンドエンジニアの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-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
このワークフローではactionlintとghalintの2つのLinterを実行しています。それぞれセキュリティを含めたベストプラクティスに則っているかをチェックしてくれるので非常に有益です。
ワークフローについては社内のセキュリティガイドラインに準拠する形で記述しています。3rd Party ActionはFull Change Hashで固定(このフォーマットでもDependabotおよびRenovateを使うことで自動更新可能) し、 permissions
は最低限の権限を使うようにしています。(以後記載するワークフローファイルはすべてこのガイドラインに則って記述してあります。)
またghalint
のバージョン管理にはaqua を使っています。aqua
はCLIツールのバージョンマネージャでチェックサムの検証ができたり、Lazy Installなど便利な機能もあるため使用しています。ghalint
以外にもgolangci-lintやgciなど開発に必要なさまざまな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上でコミットするようにしています。使っているツールをまとめると以下のようになります。
- コードフォーマッタ
- Linter
golangci-lint
(auto fix)
- 自動生成
wire
- gomockhandler
- yo
- GitHub Workflow
これらのツールはすべて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、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ブランチにマージした後は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が長いですが次のことをやっています。
- developブランチからPR用のブランチを作成
- 作成したブランチにmainブランチをマージ
- 上記ブランチからdevelopへのPRを作成
- hotfixをマージしたアカウントをPRのレビュワーにアサイン
このワークフローにおいてもコミットの問題が発生するので、GitHub Appからトークンを生成しています。このAppでは次の3つの権限が必要になります。
- contents:write
- pull-requests:write
- workflows:write
レビューワーも設定しているのでマージ忘れがないように工夫しています。
まとめ
GitHub Actionsを使った自動化の事例を紹介してきました。セキュリティと自動化とは相反するところもあるので、両立するためにはバランス感覚と知識の更新が不可欠だなと思っています。だいぶ長い記事になってしまいましたが、そういった面で参考になれば幸いです。
明日の記事は poohさんです。引き続きお楽しみください。