git pushするだけでGo製アプリケーションをリリースするGitHub Actionsのワークフローを整えた

Go製アプリケーションのリリース自動化

Goで書いたアプリケーションをリリースする際にやらないといけないことはいろいろある。

まず当然のこととしてコンパイルして成果物を作り、それをリリースする。 せっかくクロスビルドが容易なGoを使うので配布する成果物も可能な限り幅広い環境に対応させたい。

また、リリースに含まれる変更の概要をまとめたいわゆるChangelogのようなものも簡単に作れるとなおよい。

GitHubでホストするリポジトリのリリースについて主に考えたいので、リリースのホスティングGitHubのReleasesになる。 各Releaseの説明をChangelogとし、変更がもたらされたPull Requestのリンクも添えたい。

リリースするということは新しいバージョンを決めて、Gitのタグを打つ必要もある。

ひとつひとつはよくある作業だし特段難しいことはないが、いちいち手でやっていられないのでCIに任せたい。 掲題の通りGitHub Actionsでこれら一連の作業を自動化させるという話。

GitHub Actionsによるリリース自動化それ自体は目新しいトピックではない。しかしこの記事で紹介するワークフローはgit pushする以外に開発者がとるべきアクションはない。 具体的には、世の中の先行事例はGitのタグ発行は開発者の手作業で、それを契機にパイプラインを開始する……というものばかりだがそれも不要になる。

goreleaserにビルドまわりを任せる

最近はgoreleaserというツールがよく使われているのでビルドまわりはこれに任せる。

goreleaserには:

  • アプリケーションをビルドする
  • ビルドした成果物をGitHub Releasesにアップロードする

……という2点を任せる。

アップロード対象のリリースは実行したリポジトリに存在する最新のタグから同定される。 言い換えるとタグを打つのはgoreleaserの責務外となる。

semantic-releaseにバージョニングを任せる

[semantic-releaser][]というツールがある。JavaScript (Node) で実装されたCLIツールでConventional Commitsに従ってコミットログを書いておくとよしなに次のリリースバージョンを決めてくれる。

Conventional CommitsはSemantic Versioningを参照し、たとえば feat: blah blah ... とか書くと新機能の追加なのでminorの更新を含意する、などのルールを定義している。 Conventional Commitsに従うとコミットログから次のバージョンを決めるルールがツールを越えて相互運用できる。

他にもプラグインGitHub Releasesを作ったりGitタグを打ったり、あるいはNPMにアップロードするなどができる。

JavaScriptで書かれていることからもわかるように元々NPMパッケージのリリースを主眼に置いてエコシステムが整えられたツールだが、前述のようにリリースバージョンの決定以外はプラグインとして実装されているので、NPMパッケージのリリース以外にも使える。 必要なのはConventional Commitsに従ってコミットすることだけ。

つまり semantic-releaseを使ってGitタグの発行を含むバージョンアップ作業を自動化し、発行された新しいタグに成果物を作成・紐付ける作業をgoreleaserで自動化する というのがワークフローのあらましになる。

ワークフロー例

実際のワークフローを例に説明する。

pkgboundaries/ci.yml at main · aereal/pkgboundaries

上記ファイルから抜粋・簡略化したYAMLが以下。

  determine_release:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    outputs:
      will_release: ${{ steps.determine_release.outputs.new_release_published }}
    steps:
      - uses: actions/checkout@v3
      - id: determine_release
        uses: cycjimmy/semantic-release-action@v3.0.0
        with:
          dry_run: true
        env:
          GITHUB_TOKEN: ${{ github.token }}
  release:
    runs-on: ubuntu-latest
    needs:
      # - test
      - determine_release
    if: ${{ needs.determine_release.outputs.will_release }}
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - uses: cycjimmy/semantic-release-action@v3.0.0
        env:
          GITHUB_TOKEN: ${{ github.token }}
      - uses: actions/setup-go@v3
        with:
          go-version: '1.18.x'
      - uses: actions/cache@v3
        with:
          path: ~/go/pkg/mod
          key: go-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            go-
      - uses: goreleaser/goreleaser-action@v2.9.1
        with:
          version: latest
          args: release --rm-dist
        env:
          GITHUB_TOKEN: ${{ github.token }}

determine_releaseとreleaseという2つのジョブに分けている。

determine_releaseは次のリリース予定を調べるジョブで「実行時点で新しいバージョンが発行されそうか」を示す真偽値 will_release を出力する。 cycjimmy/semantic-release-actionというsemantic-releaseを実行するActionがあり、その outputs.new_release_published という真偽値を参照している。 determine_releaseでは dry_run: true を指定し実際のリリースは行わず、バージョンアップ予定だけを調べる。

releaseジョブは実際にリリースを行う。 まず needs にdetermine_releaseを指定する。これは単に依存関係を宣言することに加えて needs コンテキスト経由でdetermine_releaseジョブの出力にアクセスする目的もある。 if: ${{ needs.determine_release.outputs.will_release }} で「 needs.determine_release.outputs.will_release がtrueだったらreleaseジョブを実行する」という意味になる。 新しいバージョンアップを要する変更がコミットされていなければリリース作業は行われない。

参考: - Workflow syntax for GitHub Actions - GitHub Docs - Contexts - GitHub Docs

続いてsemantic-releaseを実行し、GitHub Releasesを作る。 以下にsemantic-releaseの設定を引用する:

{
  "branches": [
    "main"
  ],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/github"
  ]
}

from pkgboundaries/.releaserc.json at 8f58c3dd5880534c1fd8cc8f513795722b52b279 · aereal/pkgboundaries

特別な設定は必要としない。デフォルトでNPMパッケージを公開する @semantic-release/npm が有効になっているのでそれ以外の有用なプラグインだけを明示しているだけに留まる。

最後にgoreleaserを実行する。 goreleaser-actionというインストールから実行までをよしなにやってくれるActionがあるのでこれを使う。 goreleaser/goreleaser-action: GitHub Action for GoReleaser

goreleaserはYAMLでいろいろ挙動をカスタマイズできるのだが、今回重要なのは release.mode になる。

release:
  mode: keep-existing

from pkgboundaries/.goreleaser.yml at 8f58c3dd5880534c1fd8cc8f513795722b52b279 · aereal/pkgboundaries

goreleaserもリリースノートの作成やGitHub Releasesの作成ができるが、今回のワークフローではそれらはsemantic-releaseに任せている。 mode: keep-existing を選ぶとタグに対応するGitHub Releasesが既に存在したらタイトルや本文を置き換えず成果物だけアップロードするという挙動になる。

こうすることでsemantic-releaseとうまく共存できる。

今後

リリース時に人間による確認を挟みたいという場合には、Environmentsが使えるかもしれない。

参考: Using environments for deployment - GitHub Docs

EnvironmentsはDeploymentsに関連する概念で、その名の通りデプロイ対象の環境を表す。 Environmentsはそれぞれデプロイ時に必須となるcommit statusやレビュアーを指定できる。 これら機能はBranch protection rulesと似ていて、たとえばproduction環境では所定のチームやユーザのレビューなしにデプロイできない、といった設定ができる。

参考:

GitHubにおけるDeploymentsは単にイベント履歴とそれらに応じたWebhookでしかない。 最近追加されたGitHub Actionsとの統合では、ジョブに environment: production のように記述することである環境の利用を宣言できる。 このジョブ実行開始時にDeployments APIでdeploymentとdeployment_statusが作成される。完了するとsuccessもしくはfailureの記録がGitHub Actionsによって行われる。

実際のデプロイ処理はジョブのstepとして自由に定義できるので、この記事で紹介したワークフローをデプロイ処理として記述することもできる。

するとEnvironmentsの機能でDeploymentsの開始時にチーム・ユーザによる確認を挟んだ上でリリースする、といったことが実現できる。

これは規模の大きいチームで厳格な統制を図りたい時に便利かもしれない。