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 }}
やっていることは以下の通り
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
ジョブはプルリクエストが以下の条件を満たしている時に、プルリクエストを自動でマージする。
プルリクエストのマージにはコンテナにデフォルトでインストールされている 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
*1:一見不要そうだが、かつてビルド後にダウンロードしたら一部の OS 上で実行できない不具合を踏んだのでやっている