【2021年版】GitHub × Go製ツールのリリースフロー

22 min読了の目安(約20500字TECH技術記事

はじめに

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があります。

goreleaser/goreleaser-action

従来のフロー

私自身これまで利用してきた、よくあるフローは次の2つの図のような流れです。

  1. Test
    1. CommitのPushを受けて
    2. Testを実施する
  2. Release
    1. 新しいバージョンを表すTagのPushを受けて
    2. Testを実施して
    3. ビルド結果をReleaseとして載せる

分岐して並行させたり、間に処理を追加したり、といった多少の差異は省略しています。

従来のフローの問題点

これはこれできちんと冒頭の「リリース」が実現できているのですが、
多少の問題をはらんでいます。

  • Testが二回行われており、リソースが無駄
  • Testの通らないTagがPushできてしまう

特に後者は「対象ブランチのCIが通るのを待てばよいだけ」とも言えるので、特に大きな問題ではありません。
ですが、私個人の特性ゆえ、特に後者のミスを頻発したので、これを解決したいという欲にかられました。

解決に向けた検討

コミットから直列につなぐ

前述のフローの問題点をそのままひるがえしてみます。

  • Testは一つのコミットに対して一度のみ実行する
  • TagはTestが通ったことを確認した後にPushする

直列につなぐと、次のような形になります。

  1. CommitのPushを受けて
  2. Testを実施して
  3. 自動でTagを打ち
  4. ビルド結果をReleaseとして載せる

ですが図中に表現したとおり、これでは「自動でTagを打とうにも何のバージョンを指すべきか判断できない」という問題が残ります。
つまり、何らかの形でCommit〜Release (publish)までの間に、今回のリリースでバージョンをBump-upする方法(Patch, Minor or Patch)の指定を受け取らなくてはなりません。

Workflow Dispatch

上手くBump-upを渡す方法について悩んでいた翌月にGitHub Actionsの、Workflow Dispatchという仕組みができました。

これを使えば

  • ブランチの指定
  • 任意のユーザーの入力

を受け取って起動するWorkflowを作ることができます。

コレを使えば、先のフローも次のような2つのWorkflowに分けることができます。


  1. Test
    1. CommitのPushを受けて
    2. Testが走る
  2. Release
    1. Workflow DispatchでPatch/Minor/Majorの指定を受け取り
    2. Testを実施し
    3. 自動でTagを打ち
    4. ビルド結果をリリースとして載せる

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がこちらです。

  1. Test
    1. CommitのPushを受けて
    2. Testを実施して
    3. CommitのStatusをSuccessにする
  2. Release
    1. Workflow DispatchでPatch/Minor/Majorの指定を受け取り
    2. CommitのStatusがSuccessであることを確認して
    3. 自動でTagを打ち
    4. ビルド結果をリリースとして載せる

これで、冒頭で宣言した「目指したいリリース」の形を実現することができました。

Workflow設定

構築したWorkflowの図と対比して、実際のGitHub ActionsのWorkflow設定を解説します。

Workflowは

  • Test: .github/workflows/test.yaml
  • Release: .github/workflows/release.yaml
    の2つのファイルで構成されます。

ここでは図中のステップごとに解説しますが、次節で設定ファイルの全貌も掲載します。

Test-1: Commitを受ける

.github/workflows/test.yaml
on:
  push:
    branches:
      - '*'

Test-2: Testを実施する

これは1つ以上のジョブとして定義するのが良いです。
下記の例ではMatrixを使って様々なOSでTestを実施しています。

.github/workflows/test.yaml
  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"とすることで表現しています。

.github/workflows/test.yaml
  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_dispatchinputsmethodという名前のユーザー入力を受け取ります。

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を打ちます。

.github/workflows/release.yaml
      - 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の全貌は、次のようなファイルです。

.github/workflows/test.yaml
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
.github/workflows/release.yaml
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ブランチ
  • 入力のmethodpatch

という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ライフを。

.github/workflows/test.yaml
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@v1
        with:
          fail_ci_if_error: true
          file: 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
.github/workflows/release.yaml
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}}
脚注
  1. patch,minor,majorといった「公開」する意図を持ったバージョン。pre-releaseの版はその限りでない ↩︎

  2. この機能を満たすActionが意外とないので自作しました。なぜどのActionもGitHub APIを叩こうとして無邪気にGITHUB_TOKENを要求するのか…Pushしたくないという要請はマニアックかもしれませんが ↩︎

  3. Writer以上?未確認 ↩︎

この記事に贈られたバッジ