はじめに

以下の記事で Go で自作した CLI ツールについて紹介しました。

今回はこのツールを題材に Go で作成した CLI ツールの CI やリリースについてざっくり紹介します。なお本記事は、Go モジュールの CI について書いたこの記事の CLI ツール版になります。静的解析、脆弱性チェック、テストについてはリンク元とほぼほぼ同じなので割愛しています。

Makefile

CI やリリースに絡んでくるので、先にタスクランナーとして使っている Makefile について紹介します。

BIN := alpen
ifeq ($(OS),Windows_NT)
BIN := $(BIN).exe
endif

GOBIN ?= $(shell go env GOPATH)/bin
VERSION := $$(make -s show-version)
REVISION := $(shell git rev-parse --short HEAD)
LDFLAGS := "-s -w -X main.Version=$(VERSION) -X main.Revision=$(REVISION)"

HAS_LINT := $(shell command -v $(GOBIN)/golangci-lint 2> /dev/null)
HAS_VULNCHECK := $(shell command -v $(GOBIN)/govulncheck 2> /dev/null)
HAS_GOBUMP := $(shell command -v $(GOBIN)/gobump 2> /dev/null)

BIN_LINT := github.com/golangci/golangci-lint/cmd/golangci-lint@latest
BIN_GOVULNCHECK := golang.org/x/vuln/cmd/govulncheck@latest
BIN_GOBUMP := github.com/x-motemen/gobump/cmd/gobump@latest

export GO111MODULE=on

.PHONY: build
build: clean
    go mod tidy
    go build -ldflags "-X main.Version=$(VERSION) -X main.Revision=$(REVISION)" -o $(BIN) .

.PHONY: put
put: build
    cp $(BIN) $(GOBIN)/$(BIN)

.PHONY: check
check: test cover golangci-lint govulncheck

.PHONY: deps
deps: deps-lint deps-govulncheck deps-gobump

.PHONY: deps-lint
deps-lint:
ifndef HAS_LINT
    go install $(BIN_LINT)
endif

.PHONY: deps-govulncheck
deps-govulncheck:
ifndef HAS_VULNCHECK
    go install $(BIN_GOVULNCHECK)
endif

.PHONY: deps-gobump
deps-gobump:
ifndef HAS_GOBUMP
    go install $(BIN_GOBUMP)
endif

.PHONY: test
test:
    go test ./... -v -cover -coverprofile=cover.out

.PHONY: cover
cover:
    go tool cover -html=cover.out -o cover.html

.PHONY: golangci-lint
golangci-lint: deps-lint
    golangci-lint run ./... -v

.PHONY: govulncheck
govulncheck: deps-govulncheck
    $(GOBIN)/govulncheck -test ./...

.PHONY: show-version
show-version: deps-gobump
    $(GOBIN)/gobump show -r .

.PHONY: check-git
ifneq ($(shell git status --porcelain),)
    $(error git workspace is dirty)
endif
ifneq ($(shell git rev-parse --abbrev-ref HEAD),main)
    $(error current branch is not main)
endif

.PHONY: publish
publish: deps-gobump check-git
    $(GOBIN)/gobump up -w .
    git commit -am "bump up version to $(VERSION)"
    git push origin main

.PHONY: release
release: check-git
    git tag "v$(VERSION)"
    git push origin "refs/tags/v$(VERSION)"

.PHONY: clean
clean:
    go clean
    rm -f $(BIN) cover.out cover.html

使うツールややることはモジュールの場合と大差ないのですが、以下が違います。

  • ビルドも定義している
  • ビルド時はバージョンとリビジョンを指定する
  • リリース直前のプッシュとリリースを分離している (それぞれでワークフローが走るため)

リリース直前の commit/push は make publish で行います。モジュールの時はこのコマンドでタグをプッシュしてリリースまでやっていましたが、今回はバイナリを作ってリリースページに置く必要があるので分離しています。

.PHONY: publish
publish: deps-gobump check-git
    $(GOBIN)/gobump up -w .
    git commit -am "bump up version to $(VERSION)"
    git push origin main

make publish すると GitHub Actions で静的解析、脆弱性チェック、テストが走ります。これを見届けて結果が OK であればリリース可能な状態とみなします。make release すると今度はタグを作ってプッシュし、リリースのためのワークフローが起動します。

.PHONY: release
release: check-git
    git tag "v$(VERSION)"
    git push origin "refs/tags/v$(VERSION)"

CI

繰り返しになりますが、GitHub Actions のワークフローは以下 2 つを用意しています。

  • ci.yml: 静的解析、脆弱性チェック、テスト
  • release.yml: リリース
├── .github
│   └── workflows
│       ├── ci.yml
:       └── release.yml

ci.yml は以下のような設定です。

  • main または master ブランチに対するプッシュ、プルリクエストを検知して起動
  • ただし変更が README だけの場合は起動しない
  • タグをプッシュする場合も起動しない (別のワークフローを起動するため)
  • Go セットアップ、ビルド、静的解析、脆弱性チェック、テストを順次実行 (ここでのビルドは確認の意味)
  • モジュールではなくアプリであり最終的にバイナリを配布するので、Go のバージョンは最新のみを対象とする
  • OS も ubuntu のみを対象としている
  • ubuntu のみなので、ビルドやテストでは make コマンドをそのまま書いている
name: CI
on:
  push:
    branches:
      - main
      - master
    paths-ignore:
      - "README.md"
    tags-ignore:
      - "v[0-9]+.[0-9]+.[0-9]+"
  pull_request:
    branches:
      - main
      - master
    paths-ignore:
      - "README.md"

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup go
        uses: actions/setup-go@v4
        with:
          go-version: 1.21.4
          cache: false

      - name: Unshallow
        run: git fetch --prune --unshallow --tags

      - name: Build
        run: make build

      - name: Run golangci-lint
        uses: golangci/golangci-lint-action@v3
        with:
          version: v1.54
          args: --timeout=10m
          # https://github.com/golangci/golangci-lint-action/issues/244
          skip-pkg-cache: true
          skip-build-cache: true

      - name: Run govulncheck
        uses: golang/govulncheck-action@v1
        with:
          go-version-input: 1.21
          go-package: ./...
          cache: false

      - name: Run tests
        run: |
          git diff --cached --exit-code
          make test
          make cover

      - name: Archive code coverage results
        uses: actions/upload-artifact@v3
        with:
          name: code-coverage-report
          path: cover.html

リリース

リリースのワークフローは以下のように非常に単純です。GoReleaser というツールがすべてうまくやってくれるからです。

name: Release
on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"
jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Setup go
        uses: actions/setup-go@v4
        with:
          go-version: 1.21.4

      - name: Run goreleaser
        uses: goreleaser/goreleaser-action@v4
        with:
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GoReleaser に設定を読ませるための YAML が別途必要です。リポジトリのルートに .goreleaser.yml を配置します。

project_name: alpen
env:
  - GO111MODULE=on
before:
  hooks:
    - go mod tidy
builds:
  - main: ./
    binary: alpen
    ldflags:
      - -s -w
      - -X github.com/nekrassov01/alpen/main.Version={{.Version}}
      - -X github.com/nekrassov01/alpen/main.Revision={{.ShortCommit}}
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - windows
      - darwin
archives:
  - format: tar.gz
    name_template: >-
      {{ .ProjectName }}_
      {{- title .Os }}_
      {{- if eq .Arch "amd64" }}x86_64
      {{- else if eq .Arch "386" }}i386
      {{- else }}{{ .Arch }}{{ end }}
      {{- if .Arm }}v{{ .Arm }}{{ end }}
    format_overrides:
      - goos: windows
        format: zip
checksum:
  name_template: "checksums.txt"
snapshot:
  name_template: "{{ .Version }}-devel"
changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

GoReleaser は CLI ツールなので、インストールして goreleaser init すればテンプレートが吐かれます。これを少し手直しするだけで十分だと思います。ここまでで、make release を叩いてバージョンをインクリメントすると自動的に各 OS 向けのバイナリ作成、GitHub へのリリースまでが行われるようになりました。

注意点

ここまでやっていざリリースをした際、ワークフローが権限不足で失敗したことがありました。GitHub リポジトリで以下の設定変更が必要でした。

  • Settings > Actions > General > Workflow permissions を Read and write permissions に変更

おわりに

かなりざっくりですが、Go 製 CLI アプリの CI およびリリースについて紹介しました。周辺ツールが充実していて簡単に CI 環境を構築できるので、さほど難しさは感じませんでした。