📝

PRがmergeされるまでの時間をGitHubActionsで取得してGoogle Spread Sheetに追記していく

2022/12/27に公開

こんにちは、@okuxxbos20です。普段はRailsなどを業務で触りつつ、レガシーシステムの改善やDevOpsっぽいこともやります。自分が関わった改善プロジェクトでチームの開発パフォーマンスが上がっていくとテンションも上がりますよね。

最近よく FourKeys という言葉を聞くようになりました。これはGoogleのDevOps Research&Assessmentチームが提唱している、ソフトウェア開発のパフォーマンスを測定するための以下の4つの指標のことです。

  1. リードタイム
  2. デプロイ頻度
  3. 変更失敗率
  4. 復元時間

https://cloud.google.com/blog/ja/products/gcp/using-the-four-keys-to-measure-your-devops-performance?hl=ja

この中の「リードタイム」についてですが、Googleでは 「commitから本番環境稼働までの所要時間」 と定義されています。つまり、あるリリースアイテムがfirst commitされた時間から本番稼働するまでの時間ですね。リードタイムが短いチームはユーザに早く価値を届けることができ、パフォーマンスの高い開発プロセスを実行できていることになります。このリードタイムを計測することで、チームの開発パフォーマンスを測定し、改善プロセスをスタートさせることができそうです。

しかしfour keysに関してのリポジトリを見るとBigQueryのテーブルを作成したり、dashboardにデータを連携するパイプラインを作成したりとちょっと大変そうです。
https://github.com/GoogleCloudPlatform/fourkeys#key-metrics-definitions
ですがリードタイムを取るだけであれば、GitHubActionsとSpreadsheetという馴染みがあるツールで代替することで改善プロセスをスモールスタートすることができそうです。

本番稼働するまでの時間...?🤔

改めて「本番稼働するまでの時間」とはなんでしょうか。例えば一台のEC2インスタンスで稼働しているサービスであればそのインスタンスへのデプロイが完了したタイミングですし、CodeDeployなどでBlue/Greenデプロイを採用しているサービスであれば、Taskセットを終了した時間でしょうか。本番稼働するまでの時間を正確に取得するためにはそのサービスのリリース完了タイミングを知る必要があり、なかなか難しいです。

このように本番稼働するまでの時間を含める場合、リードタイムとして出てくる値はそのリリース方法に依存します。仮に実装からmergeまでが早い場合でもリリース時間が伸びた場合、リードタイムは長くなってしまいます。

そこからどうやって早く本番稼働まで持っていくかという視点で議論もできそうですが、今回の場合はチームの実装スピードやレビュー体制などの変化を可視化したかったため、Googleの定義からは少し変える必要がありました。

代替案

ということで、今回は本番稼働するまでの時間ではなく、「PRがmergeされるまでの時間」を記録することにします。なので今回定義するリードタイムは以下で計算することにしました。[1]

リードタイム = PRがmergeされた時刻 - first_commitの時刻

PRがmergeされたらGitHubActionsで定義したworkflowを開始します。そのPRのfirst_commitの時刻とmergeされた時刻を抽出し、Spreadsheetに追記することで可視化していくことをゴールにします。

Actionを定義していく

そのPRがmergeされたらActionを開始します。「PRがmergeされたら」という条件でworkflowを走らせたかったのですが、GitHubActions上では pull_request の中にmergeのhookは見当たりませんでした。なので以下のようにして対応します。

.github/workflows/fourkeys_leadtime.yml
on:
  pull_request:
    types:
      - closed

jobs:
  fetch_pr_data:
    runs-on: ubuntu-latest
    # GitHubActionsのトリガーにmergeは存在しないため, closeをトリガーにworkflowをstartする.
    # closeされた && mergeされたPRに対してworkflowを走らせる.
    if: github.event.pull_request.merged == true

https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-your-workflow-when-a-pull-request-merges

PRのデータの取得方法

PRのデータの取得はGitHubAPIを使います。PRのauthorを取得することで、チームごとのリードタイムの傾向を調査できるようにしておきます。またPRのURLを取得してSpreadsheetに記録しておけばすぐにそのPRへ飛んで原因分析できそうです。いい感じにjqで欲しい値を抽出します。

$ gh api /repos/<OWNER_NAME>/<REPO_NAME>/pulls/<PR_NUMBER> \
    -H "Accept: application/vnd.github+json" \
    | jq -r "{
        merged_at: .merged_at, \
        author: .user.login, \
        html_url: .html_url
      }"
      
# =>
# {
#   "merged_at": "2022-12-23T02:48:02Z",
#   "author": "<AUTHOR_NAME>",
#   "html_url": # "https://github.com/<OWNER_NAME>/<REPO_NAME>/pull/<PR_NUMBER>"
# }

https://docs.github.com/ja/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request

またfirst_commitは上記のAPIのレスポンスには含まれていなかったため、こちらのAPIでcommitの配列を取得し、先頭のcommitの配列の時刻を記録します。

$ gh api /repos/<OWNER_NAME>/<REPO_NAME>/pulls/<PR_NUMBER>/commits \
    -H "Accept: application/vnd.github+json" \
    | jq -r ".[0]".commit.author.date
    
# => 2022-11-21T02:48:52Z

https://docs.github.com/ja/rest/pulls/pulls?apiVersion=2022-11-28#list-commits-on-a-pull-request

以上をGitHubActionsのstepにするとこんな感じでしょうか[2]。ここで取得した値を後のstepで使えるように $GITHUB_OUTPUT に値を格納します。

.github/workflows/fourkeys_leadtime.yml
- id: pr_data
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    PR_NUMBER: ${{ github.event.number }}
  run: |
    JSON_RESPONSE=$(gh api /repos/<OWNER_NAME>/${{ github.event.repository.name }}/pulls/${{ env.PR_NUMBER }} \
      -H "Accept: application/vnd.github+json" \
      | jq -r "{
          merged_at: .merged_at, \
          author: .user.login, \
          html_url: .html_url
        }")

    echo "merged_at=$(echo $JSON_RESPONSE | jq ".merged_at")" >> $GITHUB_OUTPUT
    echo "author=$(echo $JSON_RESPONSE    | jq ".author")"    >> $GITHUB_OUTPUT
    echo "html_url=$(echo $JSON_RESPONSE  | jq ".html_url")"  >> $GITHUB_OUTPUT

- id: first_commit_at
  name: Fetch first commit at
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    PR_NUMBER: ${{ github.event.number }}
  run: |
    FIRST_COMMIT_AT=$(gh api /repos/<OWNER>/${{ github.event.repository.name }}/pulls/${{ env.PR_NUMBER }}/commits \
      --header "Accept: application/vnd.github+json" \
      | jq -r '.[0]'.commit.author.date)

    echo "first_commit_at=$(echo $FIRST_COMMIT_AT)" >> $GITHUB_OUTPUT

Google Platformに認証

データが取得できたらSpreadsheetにデータを書き込んでいくのですが、Sheet APIを使うためにはGoogle Cloud上で認証が必要です。GitHubActionではOIDCトークンが使えるため、Google CloudのWorkloadIdentity連携を使ってGoogle Cloudに対して認証できます[3]。わざわざAPIキーを管理し、定期的にローテートするなどの対応が必要ないのは運用面でもとても楽ですね。

https://cloud.google.com/blog/ja/products/identity-security/enabling-keyless-authentication-from-github-actions

そのためのGCP純正のgoogle-github-actions/authというGitHubActionsを使っていきます。その前にGoogle Cloudで

  • Projectの作成
  • Service Accountの作成
  • Workload Identity Poolの作成
  • OIDCプロバイダ設定

などの事前セットアップが必要です。詳しくはSetting up Workload Identity Federationを参考にしてください。リソースが作成できたらauthに情報を渡して準備完了です。

- id: auth
  uses: google-github-actions/auth@v1
  with:
    workload_identity_provider: <WORKLOAD_IDENTITY_PROVIDER>
    service_account: <SERVICE_ACCOUNT>
    token_format: access_token
    access_token_scopes: https://www.googleapis.com/auth/spreadsheets

この認証フローによってworking directoryのtopにcredential情報のjsonファイルを保存し、認証を行います。workflow終了後にこのjsonファイルは自動で削除されますが、誤ってcommitしてしまわないように .gitignore 設定をしておきます。

.gitignore
# Ignore generated credentials from google-github-actions/auth
gha-creds-*.json

https://github.com/google-github-actions/auth#prerequisites

Spreadsheetに書き込み

いよいよ取得したデータをSpreadsheetに書き込んでいきます。Google Sheets APIのドキュメントを見るとNode.jsやRubyなど様々な言語をサポートしています。
https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append

しかしGitHubAction上でこれらを使う場合、例えばNode.jsやRubyなどを使うときは npm ibundle install などを実行して環境をセットアップする必要があります。今回はシンプルにGitHubActionでPOSTリクエストを送信したいだけなので、curlを使ってリクエストしてみます。ここでHeaderにBearerトークンとして auth で取得したtokenを指定することでリクエストが可能です。

- name: Append PR data to spreadsheet
  env:
    SHEET_NAME: sample
    FIRST_COMMIT_AT: ${{ steps.first_commit_at.outputs.first_commit_at }}
    MERGED_AT: ${{ steps.pr_data.outputs.merged_at }}
    AUTHOR: ${{ steps.pr_data.outputs.author }}
    PR_URL: ${{ steps.pr_data.outputs.html_url }}
  run: |
    curl -X POST https://sheets.googleapis.com/v4/spreadsheets/${{ secrets.SPREADSHEET_ID }}/values/${{ env.SHEET_NAME }}:append?valueInputOption=USER_ENTERED \
      -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \
      -H "Content-Type:application/json" \
      -d \
        '
          {
            "majorDimension": "ROWS",
            "values": [
              [${{ env.FIRST_COMMIT_AT }}, ${{ env.MERGED_AT }}, ${{ env.AUTHOR }}, ${{ env.PR_URL }}]
            ]
          }
        '

書き込みが成功すると以下のような感じになります(GitHubAPIで取得できる時刻はISO8601形式なので、lead_timeを計算するときはSpreadsheet側で頑張って整形しました)。

GitHubActions全体図

以下が全体図です。何回かworkflowを走らせてみた結果、10数秒程度でSpreadsheetの追記まで完了していました。Timeout設定やbranch設定は適宜変更してください。

.github/workflows/fourkeys_leadtime.yml
name: four-keys lead time
on:
  pull_request:
    types:
      - closed
    branches:
      - main

jobs:
  four_keys_lead_time:
    runs-on: ubuntu-latest
    if: github.event.pull_request.merged == true
    permissions:
      contents: read
      pull-requests: read
      id-token: write
    steps:
      - name: Check out code
        uses: actions/checkout@v3

      - id: pr_data
        name: Fetch PR data
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.number }}
        run: |
          JSON_RESPONSE=$(gh api /repos/<OWNER_NAME>/${{ github.event.repository.name }}/pulls/${{ env.PR_NUMBER }} \
            -H "Accept: application/vnd.github+json" \
            | jq -r "{
                merged_at: .merged_at, \
                author: .user.login, \
                html_url: .html_url
              }")

          echo "merged_at=$(echo $JSON_RESPONSE | jq ".merged_at")" >> $GITHUB_OUTPUT
          echo "author=$(echo $JSON_RESPONSE    | jq ".author")"    >> $GITHUB_OUTPUT
          echo "html_url=$(echo $JSON_RESPONSE  | jq ".html_url")"  >> $GITHUB_OUTPUT

      - id: first_commit_at
        name: Fetch first commit at
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.number }}
        run: |
          FIRST_COMMIT_AT=$(gh api /repos/<OWNER_NAME>/${{ github.event.repository.name }}/pulls/${{ env.PR_NUMBER }}/commits \
            -H "Accept: application/vnd.github+json" \
            | jq -r '.[0]'.commit.author.date)

          echo "first_commit_at=$(echo $FIRST_COMMIT_AT)" >> $GITHUB_OUTPUT

      - id: auth
        name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v1
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}
          token_format: access_token
          access_token_scopes: https://www.googleapis.com/auth/spreadsheets

      - name: Append PR data to spreadsheet
        env:
          SHEET_NAME: sample
          FIRST_COMMIT_AT: ${{ steps.first_commit_at.outputs.first_commit_at }}
          MERGED_AT: ${{ steps.pr_data.outputs.merged_at }}
          AUTHOR: ${{ steps.pr_data.outputs.author }}
          PR_URL: ${{ steps.pr_data.outputs.html_url }}
        run: |
          curl -X POST https://sheets.googleapis.com/v4/spreadsheets/${{ secrets.SPREADSHEET_ID }}/values/${{ env.SHEET_NAME }}:append?valueInputOption=USER_ENTERED \
            -H "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \
            -H "Content-Type:application/json" \
            -d \
              '
                {
                  "majorDimension": "ROWS",
                  "values": [
                    [${{ env.FIRST_COMMIT_AT }}, ${{ env.MERGED_AT }}, ${{ env.AUTHOR }}, ${{ env.PR_URL }}]
                  ]
                }
              '

まとめ

以上four keysのメトリクスの一つである、リードタイムを計測して可視化するまでをやってみました。計測したいリポジトリに対して、ymlファイルを一つ追加するだけで簡単に計測ができ、改善プロセスをスタートすることができました。今後の展開としてはPRが作成された時間やapproveが付いた時間なども計測することで、より詳細な分析ができそうです。

脚注
  1. mercariなどでもリードタイムに関しては「PRの作成時間からmainブランチにmergeされるまでの時間」と独自定義したものを使っているそうです。https://engineering.mercari.com/blog/entry/20221116-souzoh-productivity/ ↩︎

  2. GitHubActionsのrunner-imageで ubuntu-latest を指定すると2022/12月現在はUbuntu 22.04.1 LTSが使われますが、最初からgithub apiやjqが使えるので特にinstallのstepは不要です。便利ですね。https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu2204-Readme.md ↩︎

  3. WorkloadIdentity連携などで使用するリソースは基本的に無料で使えます。https://cloud.google.com/iam/pricing ↩︎

Discussion