Featured image of post GoReleaser:给 Go CLI 做跨平台发布

GoReleaser:给 Go CLI 做跨平台发布

记录用 GoReleaser 给 Go CLI 做多平台构建、压缩包、checksum、GitHub Release 和 GitHub Actions 自动发布。

Go 写 CLI 有个很舒服的地方:交叉编译比较简单。

1
2
3
GOOS=linux GOARCH=amd64 go build -o dist/app-linux-amd64
GOOS=darwin GOARCH=arm64 go build -o dist/app-darwin-arm64
GOOS=windows GOARCH=amd64 go build -o dist/app-windows-amd64.exe

命令本身不难,但真到发布的时候,事情会变多:多平台构建、文件命名、压缩包、checksum、GitHub Release、changelog、tag 和版本号。

这些东西手写脚本当然也能做,但维护起来不太舒服。GoReleaser 适合解决的就是这类问题:把 Go 项目的发布流程配置化。

我对 GoReleaser 的理解不是“它帮我编译 Go 程序”,而是“它帮我把发布动作固定下来”。一次发布最容易出错的地方往往不是 go build,而是漏传一个平台、checksum 忘了生成、tag 和 changelog 对不上、Release 里残留了上一次的产物。

初始化配置

本地可以直接安装:

1
2
3
go install github.com/goreleaser/goreleaser/v2@latest

goreleaser --version

在 Go CLI 项目根目录执行:

1
goreleaser init

它会生成 .goreleaser.yaml。刚开始不要急着把配置写复杂。一个简单 CLI 项目,我通常先这样配:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
version: 2

project_name: mycli

before:
  hooks:
    - go mod tidy

builds:
  - id: mycli
    main: ./cmd/mycli
    binary: mycli
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64

archives:
  - id: mycli
    formats:
      - tar.gz
    format_overrides:
      - goos: windows
        formats:
          - zip
    name_template: >-
      {{ .ProjectName }}_
      {{ .Version }}_
      {{ .Os }}_
      {{ .Arch }}

checksum:
  name_template: "checksums.txt"

snapshot:
  version_template: "{{ incpatch .Version }}-next"

changelog:
  sort: asc

main 是入口路径。很多 CLI 项目入口会放在 cmd/mycli,这里不要写错。

CGO_ENABLED=0 是为了尽量减少对系统动态库的依赖。不是所有项目都适合关 CGO,比如依赖 SQLite CGO 驱动的时候就要注意。

本地先跑 snapshot

第一次配 GoReleaser,不建议直接发布。可以先跑 snapshot:

1
goreleaser release --snapshot --clean

这个命令不会创建正式 GitHub Release,更适合本地检查构建产物。

如果这里都构建不过,先别放到 CI 里折腾。大部分问题本地就能看出来,比如入口路径不对、CGO 配置不对、包名冲突、依赖下载失败。

我会把 snapshot 当成发布前演练。它的价值是让你在没有污染 GitHub Release 的情况下,先看到最终产物大概长什么样。

给 CLI 注入版本号

CLI 工具一般会有一个 version 命令,至少要能看到当前版本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "fmt"

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

func main() {
    fmt.Printf("version=%s commit=%s date=%s\n", version, commit, date)
}

然后在 GoReleaser 里通过 ldflags 注入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
builds:
  - id: mycli
    main: ./cmd/mycli
    binary: mycli
    env:
      - CGO_ENABLED=0
    ldflags:
      - -s -w
      - -X main.version={{ .Version }}
      - -X main.commit={{ .Commit }}
      - -X main.date={{ .Date }}

这里有个细节:-X main.version=... 的包路径要和变量所在包匹配。如果变量不在 main 包,就不能照抄这个路径。

这个坑很常见。很多项目会把版本变量放到 internal/version,这时 -X main.version=... 看起来配置了,实际上没有注入成功。发布前一定要跑一下二进制的 version 命令,而不是只看构建成功。

接入 GitHub Actions

正式发布可以用 GitHub Actions 触发 tag。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: release

on:
  push:
    tags:
      - "v*"

permissions:
  contents: write

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: stable

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

fetch-depth: 0 很重要。GoReleaser 需要读取 tag、commit 历史来生成版本和 changelog,浅克隆有时候会导致信息不完整。

触发条件是 tag:

1
2
git tag v0.1.0
git push origin v0.1.0

发布前我一般会固定跑:

1
2
3
go test ./...
goreleaser check
goreleaser release --snapshot --clean

GoReleaser 帮我省掉的不是一条 go build 命令,而是发布时那一串容易遗漏的小事:多平台构建、压缩包、checksum、Release 上传、版本号和 changelog。

如果项目依赖 CGO,我会先只发布自己能验证的平台。不要为了看起来完整,一上来就把 Linux、macOS、Windows、amd64、arm64 全打开。纯 Go 项目可以激进一点,CGO 项目要保守一点。

参考资料

记录工程实践和排障过程。
使用 Hugo 构建
主题 StackJimmy 设计