Plan 9とGo言語のブログ

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

GitHubの複数リポジトリで共通したワークフローを(ある程度)まとめて管理する

TL;DR

  • Reusable workflowにタグを付けて、参照する側のリポジトリはDependabotなどで更新するといいと思う
  • リポジトリごとにDependabotのプルリクエストをマージする手間は必要になる
  • GitHub Actionsのscheduleトリガーでcron式を書けるが、60日以上更新がないリポジトリでは無効になるので使いづらい

背景

個人で複数のリポジトリを管理しているとき、それぞれだいたい同じようなワークフローを管理しているのではないかと思います。例えば私はGoを使って開発することが多いのですが、その場合、リポジトリには以下のようなワークフローを作ります。

jobs:
  test:
    strategy:
      matrix:
        os: ['ubuntu-22.04', 'windows-2022', 'macos-12']
        go: ['1.20.x', '1.19.x']
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: ${{ matrix.go }}
    - run: go test -race -covermode=atomic -coverprofile=prof.out
      shell: bash
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        path-to-profile: prof.out
        parallel: true
        flag-name: ${{ matrix.os }}_go${{ matrix.go }}
  finish:
    needs: test
    runs-on: ubuntu-latest
    steps:
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        parallel-finished: true

このワークフローは、以下のイベントごとに更新していくことになります。

  • OSに更新があったとき(1〜2年に1回)
  • Goのバージョンに更新があったとき(半年ごと)
  • 各アクションのアップデート(随時)

1つ2つならいいけれど、いくつもあるリポジトリ全てを更新するのは大変ですね。

Reusable workflowにする

GitHubでは、Reusable workflowとしてアクションの一部を別のリポジトリで管理できます。上記のワークフローをReusable workflowに書き換えると、以下のようになります。

name: Test

on:
  workflow_call:
    inputs:
      os-versions:
        description: 'Stringfied JSON object listing GitHub-hosted runnners'
        default: '["ubuntu-22.04", "windows-2022", "macos-12"]'
        required: false
        type: string
      go-versions:
        description: 'Stringfied JSON object listing target Go versions'
        default: '["1.20.x", "1.19.x"]'
        required: false
        type: string

jobs:
  test:
    strategy:
      matrix:
        os: ${{ fromJson(inputs.os-versions) }}
        go: ${{ fromJson(inputs.go-versions) }}
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: ${{ matrix.go }}
    - run: go test -race -covermode=atomic -coverprofile=prof.out ./...
      shell: bash
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ github.token }}
        path-to-profile: prof.out
        parallel: true
        flag-name: ${{ matrix.os }}_go${{ matrix.go }}
  finish:
    needs: test
    runs-on: ubuntu-latest
    steps:
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ github.token }}
        parallel-finished: true

inputsでOSやGoのバージョンをJSON文字列で受け取れるように実装していますが、なぜかというと現在の仕様ではリストをワークフローに渡す手段がないからですね。この方法はArray input type support in reusable workflowsのコメントで紹介されていました。

参照する側はこのように。

jobs:
  test:
    uses: lufia/workflows/.github/workflows/go-test.yml@main
    with:
      os-versions: '["ubuntu-22.04"]'  # ワークフローごとに環境を変更したい場合は文字列を渡す

これでOSやGoのバージョンを上げたいときなどはReusable workflowだけを更新すればよくなります。

Reusable workflowを更新したときワークフローを実行したい

ところで、Reusable workflowでOSやGoのバージョンを更新したとき、参照している側のリポジトリでも更新後の環境でテストなどを実行したくなります。ですが今のままではReusable workflowに更新があったことを伝える手段がありません。

GitHub Actionsではscheduleトリガーでcron式を書けるので、これで定期的に実行するよう設定できますが、GitHub側の制約によりscheduleは「60日以上更新のないリポジトリ」では自動的に無効化されます。手動で有効に戻すことはできますがちょっと面倒ですし、必要のない時でもワークフローが動作することになるので、あまりよくないですね。

on:
  schedule:
  - cron: '0 14 10 * *'

本当はトリガーで更新を受け取れるといいのですが、そのようなトリガーイベントはありません。代わりに、Reusable workflowにタグを付けておいて、参照する側のリポジトリはDependabot*1でReusable workflowのバージョンを上げていく方法がいいんじゃないかなと思います。

上記でmainとしていた部分をタグに変更しておくと、Dependabotはプルリクエストを作成します。あとはCIなどを確認したうえでマージすればいいでしょう。

jobs:
  test:
    uses: lufia/workflows/.github/workflows/go-test.yml@v0.1.0

ただし、このタグはReusable workflowを管理するリポジトリ(上記で言えばlufia/workflows)全体のタグです。1つのリポジトリで複数のReusable workflowを扱っている場合は、それぞれのワークフローごとにタグを打ちたくなりますが、個別にタグを打つ方法は分かりませんでした。何か分かったら追記します。

おまけ: go.modのgoディレクティブはどうするか

Go Modulesでは、モジュールが必要とするGoのバージョンをgo.modファイルのgoディレクティブで管理しています。Dependabotはgoディレクティブの更新をしないので、Reusable workflowを更新するプルリクエストをマージしても、このバージョンはずっと変化せず古いバージョンのままです。

// go.mod
module github.com/lufia/backoff

go 1.16

数年前のバージョンが残っていると、外から見たときに「今のバージョンで正しく動くのかな」とは思いますが、とはいえバージョンの不一致で壊れた場合はどこかのCIで気付くでしょうし、Go公式のProposalで検討されているいくつかの仕様*2ではmainモジュールのバージョンではなくモジュールごとに挙動が変わるような仕様で考えられているので、goディレクティブに古いバージョンが残っていても大きな問題にはならないんじゃないかなと思っています。

*1:Renovateでも同様にできるかもしれませんが調べてません

*2:例えばredefining for loop variable semanticsなど