Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Go製バイナリを配布するためのGitHubワークフロー

前置き

以前、BuildInfoからバージョンを取得する方法を紹介しました。

blog.lufia.org

go installで正規の公開されたバージョンをインストールした場合は、以下の出力においてmodの行が示すように、sum.golang.orgチェックサム等が検証されてバイナリのメタデータに埋め込まれます。

$ go version -m dotsync
dotsync: go1.22.2
    path    github.com/lufia/dotsync
    mod github.com/lufia/dotsync    v0.0.2  h1:JWm92Aw8pSKJ4eHiQZIsE/4rgwk3h5CjEbJ/S30wiOU=
    build   -buildmode=exe
    build   -compiler=gc
    build   -trimpath=true
    build   DefaultGODEBUG=httplaxcontentlength=1,httpmuxgo121=1,panicnil=1,tls10server=1,tlsrsakex=1,tlsunsafeekm=1
    build   CGO_ENABLED=0
    build   GOARCH=amd64
    build   GOOS=linux
    build   GOAMD64=v1

上記の出力から「dotsyncのバージョン0.0.2をgo1.22.2でビルドした」ことを読み取れますね。チェックサムhttps://sum.golang.org/lookup/<module-path>@<version> のようなURLにアクセスすると、正規のものかどうかの確認を行えます。完全なURLの仕様はGo Modules Reference/Checksum databaseをみてください。

このチェックサムが一度でも登録されてしまった後は、消したり変更したりできません。消せないのは困ると思うかもしれませんが、proxy.golang.org

I removed a bad release from my repository but it still appears in the mirror, what should I do?

への回答があるので、不備などではなく意図してデザインされていることが読み取れます。削除はできないものの、Goで非推奨(Deprecated)や撤回(Retracted)を明示する方法のようにすれば意思を表明することは可能です。

正しくソースコードからビルドされたことを検証する

主に以下の条件を満たす*1場合、Go 1.21以降では生成するバイナリが完全に一致するので、同じパラメータを与えて手元でビルドしてみると検証できます。

  • Goコンパイラのバージョンが同じ
  • GOOS, GOARCH, GOAMD64 などターゲットが同じ
  • cgoを使わない
  • os/user, netなどで動的リンクをしない
  • ビルドするディレクトリ名が同じ、または-trimpathオプションを与える

以下の例はlegoコマンドをLinuxPlan 9でビルドしたものですが、同じハッシュ値になっている様子が分かると思います。

Linuxでビルド

$ go version
go version go1.22.2 linux/amd64

$ export GOTOOLCHAIN=go1.22.1
$ export GOOS=plan9
$ export GOARCH=amd64
$ export GOAMD64=v1
$ export CGO_ENABLED=0
$ go install -trimpath github.com/go-acme/lego/v4/cmd/lego@v4.16.1

$ sha1sum lego
ee2e9c121604c1f52cb53c0d0824288d772de1e7

Plan 9でビルド

% go version
go version go1.22.1 plan9/386

% GOTOOLCHAIN=go1.22.1
% GOOS=plan9
% GOARCH=amd64
% GOAMD64=v1
% CGO_ENABLED=0
% go install -trimpath github.com/go-acme/lego/v4/cmd/lego@v4.16.1

% sha1sum lego
ee2e9c121604c1f52cb53c0d0824288d772de1e7

再現可能なビルド

このように、第三者が特定のソースコードから生成されたものであると検証できるような概念は「再現可能なビルド」とか「再現性のあるビルド」と呼ばれるようです。

また、Go 1.21以降は、Goのコンパイラやライブラリも再現可能になっているようです。

コード署名とは違うのか

コード署名は、誰がビルドしたものなのかを検証できますが、特定のソースコードから生成されたものかどうかは保証しません。例えばビルドプロセスの途中で改ざんが行われた場合、コードの署名は正しく検証を通ってしまいます。

go buildの場合は正規のバージョンが入らない

ようやく本題です。この記事の冒頭で挙げたエントリでも書いたように、手元にソースコードを置いてgo buildした場合などでは、メインモジュールのバージョンやチェックサムが埋め込まれずに (devel) という文字列になります。

$ go version -m dotsync
dotsync: go1.22.2
    path    github.com/lufia/dotsync
    mod github.com/lufia/dotsync    (devel) 
    ...

(devel) の代わりに(おそらくv1.0.1-0.20240418xxxのような)疑似バージョンを埋め込む提案がcmd/go: stamp the pseudo-version in builds generated by go buildで承認されていますが、それでも正規のバージョンとは区別されていますし、少なくともGo 1.22の時点ではまだ実装されていません。

GoReleaserでビルドしたバイナリはメインモジュールのチェックサムを持たない

上記と同様に、2024年5月時点では、GoReleaserGoReleaser Actionでビルドしたバイナリは公開された正規なバージョンを持ちません。

実用上は致命的に困るものではないけれど、どうせなら検証可能になっていたほうが嬉しいですね。

GitHub Releasesでリリースしたらビルドして成果物に追加するワークフロー

というわけで、正規のバージョンが埋め込まれたバイナリをリリースするためのワークフローを作ってみました。以下のワークフローは、GitHub Releasesで新しいバージョンをPublishすると開始して、最終的にバイナリをリリースに添付します。

name: Release

on:
  release:
    types:
    - published
jobs:
  release:
    strategy:
      matrix:
        os:
        - linux
        - darwin
        - windows
        arch:
        - amd64
        - arm64
        include:
        - format: tgz
        - os: windows
          format: zip
        exclude:
        - os: darwin
          arch: amd64
        - os: windows
          arch: arm64
    runs-on: ubuntu-22.04
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-go@v5
      with:
        go-version: stable
    - name: Build the package
      uses: lufia/workflows/.github/actions/go-install@v0.4.0
      with:
        package-path: github.com/lufia/dotsync
        version: ${{ github.ref_name }}
      env:
        GOOS: ${{ matrix.os }}
        GOARCH: ${{ matrix.arch }}
        CGO_ENABLED: 0
      id: build
    - name: Create the asset consists of the build artifacts
      uses: lufia/workflows/.github/actions/upload-asset@v0.4.0
      with:
        tag: ${{ github.ref_name }}
        path: >
          ${{ steps.build.outputs.target }}
          LICENSE
          README.md
        name: dotsync-${{ github.ref_name }}.${{ matrix.os }}-${{ matrix.arch }}
        format: ${{ matrix.format }}

  upload:
    needs: release
    permissions:
      contents: write
    runs-on: ubuntu-22.04
    steps:
    - uses: actions/download-artifact@v4
      with:
        path: assets
        merge-multiple: true
    - name: Upload the assets to the release
      run: gh release upload -R "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME" assets/*
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

どうでしょうか。記述量は多いですが、1/3くらいはビルド用のマトリクスを作っているところなので、第一印象ほど複雑ではないかなと思います。

ワークフローの途中で読んでいる複合アクションは以下の2つなので、興味があれば眺めてみてください。

*1:他にもあるかもしれないけど、これだけ揃えればだいたい同じになるはず