パブリックリポジトリでGitHub Actionsの実行時間を集計する

主にパブリックリポジトリでの使用を目的とした、リポジトリ単位で指定した期間のGitHub Actions実行時間を集計するツールを作成しました。

github.com

GitHub Actionsのアクションとしても公開しており、GitHub Actionsで実行した場合は下記のようにジョブのサマリーとして集計結果を出力します。

これがあると開発が便利になる類のツールではないですが、自分のパブリックリポジトリでどれだけGitHub Actionsを使っているか気になっている方がいらっしゃいましたら使って頂けると嬉しいです。

使い方

詳しくはREADMEに記載していますが、例えばGitHub Actions上で毎月の頭に前月の実行時間を集計する場合、以下のようになります。

name: Calculate usage

on:
  schedule:
    - cron:  '5 0 1 * *'
    
jobs:
  calc-usage:
    runs-on: ubuntu-latest
    steps:
      - name: Set Start Date
        run: echo "START_DATE=$(date -d "$(date +'%Y%m01') 1 month ago" +'%Y-%m-%d')" >> $GITHUB_ENV
      - name: Set End Date
        run: echo "END_DATE=$(date -d "$(date +'%Y%m01') 1 days ago" +'%Y-%m-%d')" >> $GITHUB_ENV
      - uses: muno92/gha-usage@v0.1.0
        with:
          repo: ${{ github.repository }}
          start-date: ${{ env.START_DATE }}
          end-date: ${{ env.END_DATE }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

またコマンドライン版もGitHubのリリースページに添付していますので、手元で動かしたい方はそちらをご利用下さい。

使用する際に注意頂きたい点として、指定した期間に含まれるGitHub Actionsの実行数が1000以下である必要があります。

GitHub REST APIの仕様として指定した検索条件の先頭1000件までしか取得できないようになっているようで、例えば1ページ当たり100件の条件でAPIを叩いても10件目までしか取得できません。
(公式ドキュメントではSearch APIにしかそういった記述が見当たりませんでしたが、他のAPIも同様なのではないかと思われます)

そのため、

curl 'https://api.github.com/repos/muno92/gha-usage/actions/runs?created=2022-11-01..2022-11-30' | jq '.total_count'

のように実行数を確認頂いた上で適切な集計期間を指定して頂く必要があります。

動機

これまで、自作ツールのテストでmatrixビルドを使用したり、Nature RemoのAPIから部屋の気温・湿度を取得するプログラムを定期的にGitHub Actions上で実行したりとGitHub Actionsをプライベートでも日常的に使ってきました。 ただ、パブリックリポジトリでは合計の使用量を確認出来ません。

GitHub Actionsではパブリックリポジトリに対して支払が発生しないので実行時間が分からなくとも困りはしないのですが、自分がどれだけGitHub Actionsを利用しているのか確認してみたかったので、今回のツールを作りました。

仕組み

gha-usageはGET /repos/{owner}/{repo}/actions/runsで指定したリポジトリ・期間に実行されたワークフローの結果を取得した後、各ワークフロー実行結果が保持しているjobs_urlにリクエストを送って実行時間を集計しています。

つまり、必然的にN+1のHTTPリクエストが発生することになります。

出来ればN+1は避けたかったのでまずGitHub GraphQL APIを使って集計できないか調べてみたのですが、2022年11月時点ではジョブの実行時間を取得できません。
(Workflow RunのcreatedAtとupdatedAtの差分を取れば近しい値が取れるかもしれませんが、ランナーのOS毎に実行時間を取得するためにはジョブ単位で実行時間を取得する必要があります。)

GitHub REST APIの場合GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/timingというそれらしいAPIはあるのですが、ドキュメントに記載されている通りこのAPIは支払対象となる実行時間しか返しません。

そのため、最初に記載した2種類のAPIを組み合わせて実行時間を集計することにしました。

GET /repos/{owner}/{repo}/actions/runsはレスポンスボディの形式が以下のようになります。

{
  // 指定された条件に合致する(ページ分割される前の)実行結果総数
  "total_count": 65,
  "workflow_runs": [
    {
      "id": 3553437581,
      "name": "Test",
      "node_id": "WFR_kwLOIYWxB87TzSeN",
      "head_branch": "master",
      "head_sha": "6803c43ec8b27c4d8d8a878ee6a18789e623a039",
      "path": ".github/workflows/test.yml",
      省略
      "jobs_url": "https://api.github.com/repos/muno92/gha-usage/actions/runs/3553437581/jobs",
      省略
    },
    2件目以降
  ]
}

そして、workflow_runs1件1件のjobs_urlにリクエストした際のレスポンスボディは以下のようになります。

{
  "total_count": 2,
  "jobs": [
    {
      "id": 9719953258,
      省略
      "status": "completed",
      "conclusion": "success",
      "started_at": "2022-11-26T11:06:03Z",
      "completed_at": "2022-11-26T11:06:46Z",
      "name": "e2e_test",
      "steps": [
        {各ステップのステータスや開始・終了時刻},
      ],
      "check_run_url": "https://api.github.com/repos/muno92/gha-usage/check-runs/9719953258",
      "labels": [
        "ubuntu-latest"
      ],
      "runner_id": 1,
      "runner_name": "Hosted Agent",
      "runner_group_id": 2,
      "runner_group_name": "GitHub Actions"
    },
    2件目以降
  ]
}

各jobのcompleted_atとstarted_atの差分を取ればジョブ毎の実行時間が分かります。

そして、「ubuntu-latest」と出力されているlabelsはドキュメントに

Labels for the workflow job. Specified by the "runs_on" attribute in the action's workflow file.

と説明があるため、(セルフホストランナーを使用していないという前提の上で)この値を見ればどのOSのランナーで実行されたか判別できます。

これらの情報から、

  1. 1ページ目のworkflow_runsを取得
    • 1ページ当たりの件数は最大値の100を指定
  2. total_countが100以上だった場合、2ページ目以降を取得
  3. 各workflowのjobs_urlにリクエストを送り、実行時間を計算
    • 1ページ当たりの件数はworkflow取得時と同じ100を指定
    • ワークフロー毎のジョブ数が100を超えることはほとんど無いだろうと、ここでは2ページ目以降の存在を考慮しない

という流れで集計を行っています。
(2番と3番で投げるHTTPリクエストは非同期)

実装

実装にあたっては

  • HTTPリクエストを大量に送る事になるため、並行処理が強いらしいGoに期待
  • A Tour of GoをやったきりだったGoを手に馴染ませたかった

といった理由でGoを使うことにしました。

実用 Go言語Go言語による並行処理を都度都度参照しながら実装しましたが、並行処理以外ではほとんど詰まることなく進められました。
(パッケージをどう構成したら良いのか悩んだりジェネリクスを使ってみようとしたらメソッドで使えず断念したりといったことはありましたが、どん詰まりする程では無かったかなと思います)

非同期処理を実装する際はいきなり非同期でロジックを組むのではなく、一度同期処理を組んだ後に非同期処理に書き換える形で進めました。
これは一度に多くのことをやろうとするのではなく一歩ずつ進めていくほうが頭が混乱しにくいだろうと考えてのことだったのですが、「同期処理実装時に書いたテストが通る状態を維持しながら速度が上がればOK」とゴールが分かりやすくなったので結果的に良い形で進められたと思います。

そして、ある程度出来上がった段階になって使い方に記載したGitHub REST APIの取得上限が1000件という問題にぶち当たりどうしようかと悩んだのですが、これについてはプログラム側で頑張って対処するのではなく使用者側で適切に期間を指定して貰おうと割り切りました。

まとめ

最終的に、400近いHTTPリクエストが走る場合でも7秒ほどで処理を終えられる実装になったので、Goの並行処理性能にはとても満足しています。
Goだと愚直に実装していくだけだと分かってしまえば実装に迷う事も少なく、言語仕様が少ないシンプルさが好まれるのも分かるような気がしました。
(あまり複雑な事をやっておらずまだGoの辛みに行き当たっていないだけなのかもしれませんが)

また、折角Goで実装したのだからとクロスコンパイルしてコマンドラインでも使えるようにしたのですが、バイナリサイズが5MB前後と小さく収まったのは驚きでした。

他の言語で実装したらどうなるのか気になっているので、(モチベーションが尽きなければ)他言語で書き直して速度やシングルバイナリのサイズ、書き心地を比較してみたいと思います。