【2021年版】GitHub × Go製ツールのリリースフロー
はじめに
GoでCLIをよく作る身として、 どのように素晴らしいリリースフローを構築するか に心を砕いています。
2019年末にリリースされたGitHub Actionsがすっかり成熟し、GitHub Container Repository(β)もお目見えとなった2020年も暮れを迎えたところで、新たなリリースフロー構築を検討し、一定の結論を得ましたので、少しでもGophersのたすけになることを願って、記事として公開します。
背景
実現したい「リリース」
クロスコンパイルしたバイナリでの配布
Goでツールを作ることで、クロスコンパイルの手軽さによって、様々な環境で、ダウンロードすればすぐに実行できるバイナリでの配布が可能です。
$ curl -O bin.tar.gz https://github.com/kyoh86/gogh/releases/download/v1.7.1/gogh_1.7.1_linux_amd64.tar.gz
$ tar -xzf bin.tar.gz
$ ./bin/gogh --help
Semantic Versioningに従ったTag
また、GophersにとってはモジュールやGo製ツールがSemantic Versioningに則って公開されていることはとても重要です。
$ go get github.com/kyoh86/gogh@v1.7.1
ノイズのないVersions
そして、Semantic Versioningに則って公開されている[1]すべてのTagに対して
- クロスコンパイルされたバイナリがGitHub Releaseに載っている
- Testが通る
これらを両立することを目指して、より良いリリースフローを、と考えています。
GoReleaser
クロスコンパイルとReleaseに載せる作業は、GoReleaserがやってくれるのでとても便利です。
- Semantic Versioningに則ったTagを探す
- 設定されたすべての環境に対してクロスコンパイルする
- main.goにversion情報をldflags経由で埋め込む機能
- 結果をArchiveにまとめる
- tar.gzもOK
- 特定の環境だけZipでArchiveする機能
- GitHub Releaseに載せる
- Pre-releaseのTagから載せるときはPre-releaseとしてマークする機能
- そこまでのCommitログをNoteとして載せる機能
- (HomebrewのFormulaとして公開する)
これらを一手に担ってくれるので、使わない手はありません。
GitHub Actionsにも、goreleaserが公開しているActionがあります。
従来のフロー
私自身これまで利用してきた、よくあるフローは次の2つの図のような流れです。
- Test
- CommitのPushを受けて
- Testを実施する
- Release
- 新しいバージョンを表すTagのPushを受けて
- Testを実施して
- ビルド結果をReleaseとして載せる
分岐して並行させたり、間に処理を追加したり、といった多少の差異は省略しています。
従来のフローの問題点
これはこれできちんと冒頭の「リリース」が実現できているのですが、
多少の問題をはらんでいます。
- Testが二回行われており、リソースが無駄
- Testの通らないTagがPushできてしまう
特に後者は「対象ブランチのCIが通るのを待てばよいだけ」とも言えるので、特に大きな問題ではありません。
ですが、私個人の特性ゆえ、特に後者のミスを頻発したので、これを解決したいという欲にかられました。
解決に向けた検討
コミットから直列につなぐ
前述のフローの問題点をそのままひるがえしてみます。
- Testは一つのコミットに対して一度のみ実行する
- TagはTestが通ったことを確認した後にPushする
直列につなぐと、次のような形になります。
- CommitのPushを受けて
- Testを実施して
- 自動でTagを打ち
- ビルド結果をReleaseとして載せる
ですが図中に表現したとおり、これでは「自動でTagを打とうにも何のバージョンを指すべきか判断できない」という問題が残ります。
つまり、何らかの形でCommit〜Release (publish)までの間に、今回のリリースでバージョンをBump-upする方法(Patch, Minor or Patch)の指定を受け取らなくてはなりません。
Workflow Dispatch
上手くBump-upを渡す方法について悩んでいた翌月にGitHub Actionsの、Workflow Dispatchという仕組みができました。
これを使えば
- ブランチの指定
- 任意のユーザーの入力
を受け取って起動するWorkflowを作ることができます。
コレを使えば、先のフローも次のような2つのWorkflowに分けることができます。
- Test
- CommitのPushを受けて
- Testが走る
- Release
- Workflow DispatchでPatch/Minor/Majorの指定を受け取り
- Testを実施し
- 自動でTagを打ち
- ビルド結果をリリースとして載せる
Tagを打つより前にTestが通ってることを保証することはできましたが、こちらは「Testが2回行われる」方が解決できていません。
Statuses APIを活用する
Testを一回にまとめるためには、
- Commitに応じるTestのWorkflow
- Workflow Dispatchに応じるReleaseのWorkflow
を依存関係で結べば良いのですが、GitHub ActionsのWorkflowにはJob間の依存(needs:
)はあってもWorkflow間の依存がありません。
そこで、 GitHub Statuses API を使います。
GitHub Statuses APIでは、特定のCommit(など)に対して Success,Failure(など)をマークすることができます。
したがって、
- Commitに応じるTestのWorkflowでは結果をStatusにマーク
- Workflow Dispatchに応じるReleaseのWorkflowではStatusにマークされた結果を確認
することで、擬似的な依存関係を構築できます。
結論
検討を踏まえ、構築したWorkflowがこちらです。
- Test
- CommitのPushを受けて
- Testを実施して
- CommitのStatusをSuccessにする
- Release
- Workflow DispatchでPatch/Minor/Majorの指定を受け取り
- CommitのStatusがSuccessであることを確認して
- 自動でTagを打ち
- ビルド結果をリリースとして載せる
これで、冒頭で宣言した「目指したいリリース」の形を実現することができました。
Workflow設定
構築したWorkflowの図と対比して、実際のGitHub ActionsのWorkflow設定を解説します。
Workflowは
- Test:
.github/workflows/test.yaml
- Release:
.github/workflows/release.yaml
の2つのファイルで構成されます。
ここでは図中のステップごとに解説しますが、次節で設定ファイルの全貌も掲載します。
Test-1: Commitを受ける
on:
push:
branches:
- '*'
Test-2: Testを実施する
これは1つ以上のジョブとして定義するのが良いです。
下記の例ではMatrixを使って様々なOSでTestを実施しています。
test:
name: Test
strategy:
fail-fast: false
max-parallel: 3
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Sources
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Test Go
run: go test -race ./...
Test-3: CommitのStatusをSuccessにする
先のTest(job test
)の結果を受けて( needs: test
)、すべてのTestが成功したことをStatuses APIで"success"とすることで表現しています。
test-status:
name: Test status
runs-on: ubuntu-latest
needs: test
steps:
- name: Set Check Status Success
uses: Sibz/github-status-action@v1.1.1
with:
context: test-status
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
Statusは一つのCommitに複数設定できるので、ここでは context: test-status
として、後ほどStatusをチェックする際に使用する名前(context
)を与えています。
Release-1: Workflow DispatchでPatch/Minor/Majorの指定を受け取る
workflow_dispatch:
inputs:
method:
description: |
Which number to increment in the semantic versioning.
Set 'major', 'minor' or 'patch'.
required: true
workflow_dispatch
のinputs
でmethod
という名前のユーザー入力を受け取ります。
Release-2: CommitのStatusがSuccessであることを確認する
Test-3: CommitのStatusをSuccessにする
のところで設定したStatusを確認します。
設定したStatusをcontext:
で特定して取得し、次のStepはこれが'success'
ではなかった場合に実行され、exit 1
しています。
- name: Wait Tests
id: test_result
uses: Sibz/await-status-action@v1.0.1
with:
contexts: test-status
authToken: ${{ secrets.GITHUB_TOKEN }}
timeout: 30
- name: Check Test Result
if: steps.test_result.outputs.result != 'success'
run: |
echo "feiled ${{ steps.test_result.outputs.failedCheckNames }}"
echo "status ${{ steps.test_result.outputs.failedCheckStates }}"
exit 1
Release-3: 自動でTagを打つ
リポジトリをCheckoutして、次のバージョンのTagを打ちます。
- name: Checkout Sources
uses: actions/checkout@v2
- name: Bump-up Semantic Version
uses: kyoh86/git-vertag-action@v1.1
with:
# method: "major", "minor" or "patch" to update tag with semver
method: "${{ github.event.inputs.method }}"
kyoh86/git-vertag-action@v1.1
は最新のSemantic Version TagをFetchし、ActionsのWorkspaceローカルで(Pushせずに)次のバージョンのTagを打ちます。[2]
TagをPushする役割は、次段のGoReleaserに任せています。
こうすることで、Tagは打たれたが、ビルドやTestが通らない
事態を避けることができます。
Release-4: ビルド結果をリリースとして載せる
そしてビルドして、リリースに載せます。
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: release --rm-dist
goreleaser/goreleaser-action@v2は、 GoReleaser を使って、現在の最新のバージョンTagに基づいてクロスコンパイル、そしてTagとともにReleaseを公開します。
全Workflow
これらのWorkflowの全貌は、次のようなファイルです。
name: Test CLI
on:
push:
branches:
- '*'
jobs:
test:
name: Test local sources
strategy:
fail-fast: false
max-parallel: 3
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Sources
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Test Go
run: go test -race ./...
test-status:
name: Test status
runs-on: ubuntu-latest
needs: test
steps:
- name: Set Check Status Success
uses: Sibz/github-status-action@v1.1.1
with:
context: test-status
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
name: Release CLI to the GitHub Release
on:
workflow_dispatch:
inputs:
method:
description: |
Which number to increment in the semantic versioning.
Set 'major', 'minor' or 'patch'.
required: true
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Wait Tests
id: test_result
uses: Sibz/await-status-action@v1.0.1
with:
contexts: test-status
authToken: ${{ secrets.GITHUB_TOKEN }}
timeout: 30
- name: Check Test Result
if: steps.test_result.outputs.result != 'success'
run: |
echo "feiled ${{ steps.test_result.outputs.failedCheckNames }}"
echo "status ${{ steps.test_result.outputs.failedCheckStates }}"
exit 1
- name: Checkout Sources
uses: actions/checkout@v2
- name: Bump-up Semantic Version
uses: kyoh86/git-vertag-action@v1.1
with:
# method: "major", "minor" or "patch" to update tag with semver
method: "${{ github.event.inputs.method }}"
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: release --rm-dist
もう一歩前へ
こうしてかなり堅いWorkflowができたわけですが、ここでは "プラスアルファ" を紹介します。
ActionのActorを絞る
このWorkflowは、Workflow Dispatchを叩ける人[3]なら誰でもReleaseできるものです。
時にはこれを制限したいこともあるかもしれません。そんなときには次のようなステップをRelease前に挟むと良いでしょう。
- name: Check Actor
if: github.actor != 'kyoh86'
run: exit 1
Actionの対象ブランチを絞る
また、Workflowを実行するときはブランチ(Tagまで候補に入ってくる)を選択することになるのですが、Defaultブランチをいじっているリポジトリなどで、この選択を誤る可能性もあります。
これを防止したい場合は、次のようなステップをRelease前に挟むと良いでしょう。
- name: Check Branch
if: github.ref != 'refs/heads/<処理を実行するブランチ>'
run: exit 1
Test側でReleaseのTest(dry-run)を回しておく
ReleaseのWorkflowをKickすること自体は良しとしても、 Kickしてみたら失敗した みたいなケースは、なんだかんだ言っても嫌ですね。
できるだけReleaseしよう!とおもうより先にRelease可能なのかは常に確認しておきたいものです。
なので、Testのジョブにこんなのをぶら下げても良いかもしれません。
- name: Checkout Sources
uses: actions/checkout@v2
- name: Try Bump-up Semantic Version
uses: kyoh86/git-vertag-action@v1.1
with:
method: "patch"
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Run GoReleaser (dry-run)
uses: goreleaser/goreleaser-action@v2
with:
args: release --rm-dist --skip-publish --snapshot
patch
で新しいバージョンTagを作ってみて、Goreleaserを--skip-publish
付きで呼び出す。
こうすることで、 実際にTagをPushしたり、リリースをPublishしたりする直前まで はテストされた状態になります。
ツールではなく単なるModuleの場合
CLIのようにバイナリをReleaseに載せるのではなく、
単に「テストが通ったらSemantic Versioningで新しいタグを打つ」も、少し変更するだけで実現できます。
- name: Bump-up Semantic Version
uses: kyoh86/git-vertag-action@v1.1
with:
# method: "major", "minor" or "patch" to update tag with semver
method: "${{ github.event.inputs.method }}"
+ push: true
- - name: Setup Go
- uses: actions/setup-go@v2
- with:
- go-version: 1.15
- - name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v2
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- args: release --rm-dist
要は、ReleaseをPublishするGoReleaserのStepを消して、TagだけPushしています。
CLIからキックしたい
いちいちWorkflow Dispatchを叩きに行くために、GitHubのリポジトリを開いて該当のWorkflowを開いてプルダウンでBranch選んでpatchと入力して…というのが地味に面倒くさいですね。
CLIからKickするために、GitHub CLI (gh
)を使うと簡単にDispatchをKickできます。
main固定でpatchのbump-upをしたい場合
- 対象は
main
ブランチ - 入力の
method
はpatch
という2つのパラメータを伴って次のようにKickします。
$ echo '{"ref":"main","inputs":{"method":"patch"}}' | \
gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -
gh
はエイリアスを作ることもできるので、
$ gh alias set patch --shell "echo '{\"ref\":\"main\",\"inputs\":{\"method\":\"patch\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
$ gh alias set minor --shell "echo '{\"ref\":\"main\",\"inputs\":{\"method\":\"minor\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
$ gh alias set major --shell "echo '{\"ref\":\"main\",\"inputs\":{\"method\":\"major\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
なんて設定をして、gh patch
gh minor
gh major
みたいな叩き方をできるようにしても良いかもしれません。
現在のローカルブランチ依存でbump-upをしたい場合
任意のブランチ(呼び出したローカルでの現在のブランチ)で実行したければ
$ gh alias set patch --shell "echo '{\"ref\":\":branch\",\"inputs\":{\"method\":\"patch\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
$ gh alias set minor --shell "echo '{\"ref\":\":branch\",\"inputs\":{\"method\":\"minor\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
$ gh alias set major --shell "echo '{\"ref\":\":branch\",\"inputs\":{\"method\":\"major\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
というふうに、エイリアス内のmain
を:branch
とすることで現在のブランチを埋めて実行してくれます。
$ git switch develop
$ gh patch
リモートのデフォルトブランチ依存でbump-upをしたい場合
リモートのデフォルトブランチ依存で実行する場合は
$ gh alias set patch --shell "echo '{\"ref\":\"'\$(git symbolic-ref refs/remotes/origin/HEAD | awk -F'/' '{print \$NF}')'\",\"inputs\":{\"method\":\"patch\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
$ gh alias set minor --shell "echo '{\"ref\":\"'\$(git symbolic-ref refs/remotes/origin/HEAD | awk -F'/' '{print \$NF}')'\",\"inputs\":{\"method\":\"minor\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
$ gh alias set major --shell "echo '{\"ref\":\"'\$(git symbolic-ref refs/remotes/origin/HEAD | awk -F'/' '{print \$NF}')'\",\"inputs\":{\"method\":\"major\"}}' | gh api -X POST repos/:owner/:repo/actions/workflows/release.yaml/dispatches --input -"
というふうに、エイリアス内のmain
を'\$(git symbolic-ref refs/remotes/origin/HEAD | awk -F'/' '{print \$NF}')'
とすることで現在のリモートのデフォルトブランチを埋めて実行してくれます。
ただし、この「デフォルトブランチ」は、Clone時に設定された後はFetchしても更新されません。
GitHub側でデフォルトブランチを変えたときはCloneし直すか
$ git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/<新しいデフォルトブランチ名>
のように更新し直しましょう。
また、remoteの設定をorigin
以外にしている場合は、個別に設定してやらねばならんでしょう。
それが面倒くさい場合は、gh api
経由でRepositoryの設定を持ってきて、jq
で見つけても良いかもしれません。
$ gh patch
最後に
完全に単なるメモの域ですが、ここで紹介したプラスアルファや、以下の処理を追加した特盛Workflow設定を掲載しておきます。
- CoverageをとってCodecovにUpload
- Linterを実行(golangci-lint)
- Docker imageとしてGitHub Container Registryに載せる
- TagはLatestと、Semantic Versioningに則ったタグ
2021年も、よきGopherライフを。
name: Test CLI
on:
push:
branches:
- '*'
jobs:
test:
name: Test local sources
strategy:
fail-fast: false
max-parallel: 3
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Sources
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Test Go
run: go test -race ./...
test-others:
name: Test others
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout Sources
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Search diagnostics
uses: golangci/golangci-lint-action@v2
with:
version: v1.32
- name: Take coverage
run: go test -coverprofile=coverage.txt -covermode=atomic ./...
- name: Send coverage
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: true
files: coverage.txt
- name: Get Semantic Version
id: vertag
uses: kyoh86/git-vertag-action@v1.1
with:
method: "patch"
- name: Run GoReleaser (dry-run)
uses: goreleaser/goreleaser-action@v2
with:
args: release --rm-dist --skip-publish --snapshot
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build Docker image
uses: docker/build-push-action@v2
with:
context: .
push: false
tags: |
ghcr.io/${{ github.repository_owner }}/hoge:latest
ghcr.io/${{ github.repository_owner }}/hoge:${{steps.vertag.outputs.vertag}}
test-status:
name: Test status
runs-on: ubuntu-latest
needs: test-others
steps:
- name: Set Check Status Success
uses: Sibz/github-status-action@v1.1.1
with:
context: test-status
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
name: Release CLI to the GitHub Release
on:
workflow_dispatch:
inputs:
method:
description: |
Which number to increment in the semantic versioning.
Set 'major', 'minor' or 'patch'.
required: true
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Check Actor
if: github.actor != 'kyoh86'
run: exit 1
- name: Check Branch
if: github.ref != 'refs/heads/main'
run: exit 1
- name: Wait Tests
id: test_result
uses: Sibz/await-status-action@v1.0.1
with:
contexts: test-status
authToken: ${{ secrets.GITHUB_TOKEN }}
timeout: 30
- name: Check Test Result
if: steps.test_result.outputs.result != 'success'
run: |
echo "feiled ${{ steps.test_result.outputs.failedCheckNames }}"
echo "status ${{ steps.test_result.outputs.failedCheckStates }}"
exit 1
- name: Checkout Sources
uses: actions/checkout@v2
- name: Bump-up Semantic Version
id: vertag
uses: kyoh86/git-vertag-action@v1.1
with:
# method: "major", "minor" or "patch" to update tag with semver
method: "${{ github.event.inputs.method }}"
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.15
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: release --rm-dist
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- name: Build Docker image and push to GitHub Container Registry
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/hoge:latest
ghcr.io/${{ github.repository_owner }}/hoge:${{steps.vertag.outputs.vertag}}
Discussion
ありがとうございます。
記事中のiconはどのツールのものでしょうか
ご質問の意図を掴みかねます。iconとは、ツールとは何の話でしょうか?
あ、画像のことを意味しました。どのツールを用いて作られたのか聞きたかったんですw
すみません!
なるほど。
GIMPを使って描いております。