Go 製ツールの開発フロー with Github Actions 覚書

Github 上での Go 製ツールの開発フローとそれを支える Github Actions が自分の中で一旦まとまったので書き残す。

ワークフローの要件

以下を満たすようなワークフローが欲しい:

  • master ブランチでの commit 及びプルリクエストが出ているブランチへの push の際に自動でテスト & ビルドを実行する
  • 自分で作成したプルリクエストはテストがパスした後に自動でマージする
  • ツールはバージョン毎に Github Releases で公開し、複数 OS 向けのバイナリを置く
  • Releases の作成も特定のフォーマットのプルリクエストによって自動化する

実装

上の要件を満たすように作った例がこちら。

ディレクトリ構造は以下の通り。

.
├── .github
│   └── workflows
│       ├── close-release-pr.yml
│       ├── pr.yml
│       ├── push-master.yml
│       └── release-pr.yml
├── .gitignore
├── CHANGELOG.md
├── Makefile
├── README.md
├── cmd
│   └── hello
│       └── main.go
└── go.mod

以下、開発に関わるファイルについて。

Makefile

Makefile にはローカル・CI 両方で利用するテスト実行やビルド用タスクを定義する。

ロスコンパイル周りの設定は x-motemen/ghq などの Makefile を参考にした。

THIS_GOVERSION=$(shell go version)
GOOS=$(word 1,$(subst /, ,$(lastword $(THIS_GOVERSION))))
GOARCH=$(word 2,$(subst /, ,$(lastword $(THIS_GOVERSION))))
VERSION=$(shell git rev-parse --short HEAD)
ifeq ($(GOOS),windows)
EXT=.exe
else
EXT=
endif

PRODUCT=hello
BUILD_DIR=$(CURDIR)/build
TARGET_DIR_NAME=$(PRODUCT)-$(GOOS)-$(GOARCH)
TARGET_DIR=$(BUILD_DIR)/$(TARGET_DIR_NAME)
EXEFILE=$(TARGET_DIR)/hello$(EXT)
ARTIFACT=$(TARGET_DIR).zip

.PHONY: test
test:
    go test ./...

.PHONY: run
run:
    go run ./cmd/hello/main.go $(ARGS)

.PHONY: build
build: $(EXEFILE)

$(EXEFILE): $(wildcard $(PWD)/cmd/hello/*)
    go build -o $@ -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/hello

.PHONY: release
release: $(ARTIFACT)

$(ARTIFACT): build
    cd $(BUILD_DIR) && zip $@ $(TARGET_DIR_NAME)/*

.PHONY: clean
clean:
    rm -fR $(BUILD_DIR)
タスク 説明
test Go のテストを実行
run go run でコマンドを実行
build build/ 以下にバイナリをビルド(GOOS, GOARCH を指定することでクロスコンパイルが可能)
release build で生成したバイナリをリリース用に zip で圧縮

push-master.yml

master ブランチにコミットがあった時に起動するワークフロー。テスト実行とビルドを行う。

on:
  push:
    branches: ["master"]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ^1.14
      id: go
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    - name: Use Cache
      uses: actions/cache@v1
      with:
        path: ~/go/pkg/mod
        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
        restore-keys: |
          ${{ runner.os }}-go-
    - name: Get dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      run: |
        go mod download
    - name: Run Test
      run: make test
    - name: Build
      run: |
        make build GOOS=linux GOARCH=amd64
        make build GOOS=darwin GOARCH=amd64
        make build GOOS=windows GOARCH=amd64
    - name: Save Artifact
      uses: actions/upload-artifact@v2
      with:
        name: binaries
        path: build/*/hello*
  smoke-test:
    strategy:
      matrix:
        os: [macos-latest, windows-latest, ubuntu-latest]
        include:
          - os: macos-latest
            osarch: darwin-amd64
            extention: ''
          - os: windows-latest
            osarch: windows-amd64
            extention: '.exe'
          - os: ubuntu-latest
            osarch: linux-amd64
            extention: ''
      fail-fast: false
    needs: [unit-test]
    runs-on: "${{ matrix.os }}"
    steps:
      - name: Download Artifact
        uses: actions/download-artifact@v2
        with:
          name: binaries
          path: binaries
      - name: Smoke Test
        run: |
          chmod a+x "binaries/hello-${{ matrix.osarch }}/hello${{ matrix.extention }}"
          binaries/hello-${{ matrix.osarch }}/hello${{ matrix.extention }}

やっていることは以下の通り

  • テスト実行
  • Linux, Mac, Windows 向けにビルド
  • ビルドしたバイナリを各 OS 上で実行ができるかのスモークテスト *1

unit-test ジョブではテスト実行後、各 OS 向けにビルドしたバイナリをアーティファクトとして保存している。これはワークフロー終了後に UI からダウンロードできるだけでなく、後続ジョブでも利用できる。後続の smoke-test ジョブでは各 OS 毎に matrix を定義して、アーティファクトから対応するバイナリのスモークテストを実施する。

pr.yml

head ブランチ名が release/* 以外 のプルリクエストに更新があった時にワークフロー。テスト実行とビルドに加え、プルリクエストの自動マージを行う。

on: ["pull_request"]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    if: "!startsWith(github.head_ref, 'release/') || github.event.pull_request.head.fork"
    steps:
    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ^1.14
      id: go
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    - name: Use Cache
      uses: actions/cache@v1
      with:
        path: ~/go/pkg/mod
        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
        restore-keys: |
          ${{ runner.os }}-go-
    - name: Get dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      run: |
        go mod download
    - name: Run Test
      run: make test
    - name: Build
      run: |
        make build GOOS=linux GOARCH=amd64
        make build GOOS=darwin GOARCH=amd64
        make build GOOS=windows GOARCH=amd64
    - name: Save Artifact
      uses: actions/upload-artifact@v2
      with:
        name: binaries
        path: build/*/hello*
  smoke-test:
    strategy:
      matrix:
        os: [macos-latest, windows-latest, ubuntu-latest]
        include:
          - os: macos-latest
            osarch: darwin-amd64
            extention: ''
          - os: windows-latest
            osarch: windows-amd64
            extention: '.exe'
          - os: ubuntu-latest
            osarch: linux-amd64
            extention: ''
      fail-fast: false
    needs: [unit-test]
    runs-on: "${{ matrix.os }}"
    steps:
      - name: Download Artifact
        uses: actions/download-artifact@v2
        with:
          name: binaries
          path: binaries
      - name: Smoke Test
        run: |
          chmod a+x "binaries/hello-${{ matrix.osarch }}/hello${{ matrix.extention }}"
          binaries/hello-${{ matrix.osarch }}/hello${{ matrix.extention }}
  auto-merge:
    needs: [smoke-test]
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    runs-on: ubuntu-latest
    steps:
      - name: Auto Merge
        if: contains(github.event.pull_request.body, '[auto merge]') && !contains(github.event.pull_request.title, '[WIP]') && !contains(github.event.pull_request.title, '[DNM]') && !github.event.pull_request.head.fork
        run: |
          # Request to merge button API
          hub api -X PUT /repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.number }}/merge

基本的には push-master と同じだが、smoke-test ジョブの後続ジョブとして auto-merge ジョブを追加している。auto-merge ジョブはプルリクエストが以下の条件を満たしている時に、プルリクエストを自動でマージする。

  • プルリクエストの説明に [auto merge] という文字列を含めている
  • タイトルに [WIP][DNM] も含まれていない
  • fork リポジトリからのプルリクエストではない

プルリクエストのマージにはコンテナにデフォルトでインストールされている hub を利用している。 hub を使う際には環境変数GITHUB_TOKEN を指定する必要があるが、Github Action 上では Secrets にデフォルトで入っている。

デフォルトの GITHUB_TOKEN は fork リポジトリか否かで権限範囲が変わるので、よそから勝手にマージされるような心配はない。

release-pr.yaml

head ブランチ名が release/* のプルリクエストに更新があった時に起動するワークフロー。テスト実行とビルドに加え、ドラフトリリースの作成を行う。

on:
  pull_request:
    branches: ["master"]

jobs:
  prepare-release:
    runs-on: ubuntu-latest
    if: "startsWith(github.head_ref, 'release/') && !github.event.pull_request.head.fork"
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ^1.14
      id: go
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
      with:
        ref: ${{ github.event.pull_request.head.sha }}
    - name: Check existing release
      run: |
        VERSION=${GITHUB_HEAD_REF#release/}
        echo target version is ${VERSION}
        if hub release -f "%T:%s%n" | grep -x -F "${VERSION}:" >/dev/null; then
          echo ${VERSION} is already published
          exit 1
        fi
    - name: Use Cache
      uses: actions/cache@v1
      with:
        path: ~/go/pkg/mod
        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
        restore-keys: |
          ${{ runner.os }}-go-
    - name: Get dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      run: |
        go mod download
    - name: Build artifacts
      run: |
        VERSION=${GITHUB_HEAD_REF#release/}
        make release GOOS=windows GOARCH=amd64 VERSION="${VERSION}"
        make release GOOS=darwin GOARCH=amd64 VERSION="${VERSION}"
        make release GOOS=linux GOARCH=amd64 VERSION="${VERSION}"
    - name: Create release
      run: |
        VERSION=${GITHUB_HEAD_REF#release/}

        if hub release --include-drafts -f "%t:%S%n" | grep -x -F "${VERSION}:draft" >/dev/null; then
          echo Delete existing release
          hub release delete "${VERSION}"
        fi

        hub release create "${VERSION}" -t "${{ github.event.pull_request.head.sha }}" $(ls build/*.zip | xargs -n 1 echo -a) -m "${VERSION}" --draft

テストを実施するところまでは pr と同じ。その前後で Github Releases でリリースするための準備を行う。

対象のリリース名は head ブランチの suffix から決める。例えば release/v0.1.0 ブランチからの PR ではリリース v0.1.0 の準備を行う。ワークフローは対象のリリースの状態に応じて以下を行う:

  • まだリリースがない場合: テスト・ビルド後に、ドラフトリリースを作成する
  • 同名のドラフトリリースがある場合: テスト・ビルド後に、ドラフトリリースを削除 & 再作成する
  • 同名の publish 済みリリースがある場合: テスト・ビルド前にワークフローが失敗する

ドラフトを作成する場合、各 OS 向けにビルドしたバイナリをリリースアセットとしてアップロードする。

ドラフトリリースのタグ対象をワークフローをトリガーしたリビジョンにするために、 actions/checkout@v2 のパラメータに head ブランチの sha を明示的に渡している(デフォルトでは actuions/checkout@v2 は暗黙に head ブランチから base ブランチへマージし、そのマージコミットのリビジョンを checkout する)。

作成したドラフトリリースの publish は次のワークフローで実施する。

close-release-pr

head ブランチ名が release/* のプルリクエストがクローズした時に起動するワークフロー。対応するドラフトリリースの publish もしくは破棄を行う。

on:
  pull_request:
    branches: ["master"]
    types: ["closed"]

jobs:
  cleanup-release:
    runs-on: ubuntu-latest
    if: "startsWith(github.head_ref, 'release/') && !github.event.pull_request.head.fork"
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    - name: Publish
      run: |
        VERSION=${GITHUB_HEAD_REF#release/}
        echo target version is ${VERSION}

        RELEASE=$(hub release --include-drafts -f "%T:%S%n" | grep -e "^${VERSION//./\\.}:")
        if [ -z "${RELEASE}" ]; then
          echo "Release ${VERSION} is not found"
          exit 1
        fi
        STATE=$(echo "${RELEASE}" | cut -d : -f 2)
        if [ "${STATE}" != draft ]; then
          echo "Release ${VERSION} is already published"
          exit 0
        fi

        if [ "${{ github.event.pull_request.merged }}" = true ]; then
          echo "Publish ${VERSION} from draft"
          hub release edit --draft=false -m "" "${VERSION}"
        else
          echo "Delete ${VERSION}"
          hub release delete "${VERSION}"
        fi

ワークフローの処理対象となるリリース名は release-pr と同様。ワークフローは対象のリリースの状態に応じて以下を行う:

  • リリースが存在しない or publish 済みの場合: 何もせずにワークフローを失敗させる
  • リリースがドラフトでプルリクエストがマージされた場合: リリースを publish する
  • リリースがドラフトでプルリクエストがクローズされた場合: リリースを削除する

リリースの流れ

v0.1.0 をリリースするためには、以下のフローを取る:

  • master から release/v0.1.0 というブランチを切り、リリースに向けた作業(E.g. Changelog の更新)を行う
  • プルリクエストを作成し、自動で作成したドラフトリリースに問題ないかを確認する
  • publish するためにマージする(もしくは破棄するためにクローズする)

TODO

  • Changelog 更新の自動化
  • Slack などへの通知
  • よりよいワークフローの命名

*1:一見不要そうだが、かつてビルド後にダウンロードしたら一部の OS 上で実行できない不具合を踏んだのでやっている