Open10

GitHub ActionsのCustom Deployment Protection Ruleを理解する

Kesin11Kesin11

GitHub Actions Meetup Tokyo #4で発表があった以下のスライドと、そこで紹介されていたブログより
https://www.docswell.com/s/yaegashi/KN1R1G-gamt4#p19
https://qiita.com/kazu_yasu/items/a22c35f54fb665642c00

発表の中ではGitHub Actions -> Azureに非同期処理を投げて、その完了をGitHub Actions側で今までポーリングしていたのをCustom Deployment Protection Ruleを使ってポーリング無しにできたので費用を圧縮できましたという話が面白かった。
https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/creating-custom-deployment-protection-rules

業務でGitHub Actionsで別のワークフローが終わるまでポーリングしているコードを見たことがあるので、そういうのを置き換える方法として知っておきたいので試してみる。

そもそもなぜポーリング処理をなくしたいかというと、シンプルにCIにかかるお金を減らしたいため。
GitHub Actionsをヘビーに使って無料枠の範囲を超えてくると意外に馬鹿にならないお値段になってくる。そうなった場合にちまちまとCIのチューニングをしている横でただのポーリング処理のために10分-20分の料金を支払っていると悲しい気持ちになる。

Kesin11Kesin11

Custom Deployment Protection Ruleは全てのリポジトリで使えるわけではなく、PublicかEnterpriseプランじゃないと使えない。
けど自分のPublicリポジトリでenvironmentの設定画面を見てもこのようになっていて、有効にするためのUIが出てきていなかった。

ドキュメントによるとDatadogやHoneycombなどが出しているGitHub Appか、自分でその機能を持ったGitHub Appを作ってリポジトリにインストールする必要があると書いてあるので、そもそもインストールしないとUIが変わらないのかもしれない
https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/creating-custom-deployment-protection-rules#about-custom-deployment-protection-rules

Kesin11Kesin11

適当なGitHub Appを作成。Appに付けるパーミッションはドキュメントの通り
https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/creating-custom-deployment-protection-rules#creating-a-custom-deployment-protection-rule-with-github-apps

注意点として、"Subscribe to events"の項目はWebhookをActiveにしてWebhook URLを入れないとそもそも出てこないので設定ができない。このwebhookが必要か不要かはあまり分かっていないが、webhookの中身も見てみたいのでとりあえずactiveにしておく。

webhookの送り先は人様に迷惑をかけないようにこの後実験で使う https://github.com/Kesin11/actions-newfeature-playground にしておく(githubには申し訳ないけど)

Kesin11Kesin11

作ったGitHub Appを実験リポジトリにインストールしてから再度Environmentの設定画面を見てみる。
たしかにさっき作ったGitHub Appが選択可能になっている。

Kesin11Kesin11

とりあえずapprove待ち状態になるのを見てみたいので最小限の機能だけのワークフローを作ってみる

name: Custom Deployment Protection Rule

on:
  push: 
    branches:
      - custom_deploy_protection
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Wait for approve from GitHub App
    environment: custom-protection
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploy to custom-protection"

これをトリガーすると、このように kesin11-custom-deployment-protection のGitHub Appによるレビュー待ち状態となりジョブが始まる前の状態で待機させられている。

ちなみにここで見えている Start all waiting jobs ボタンを押すと、Admin権限でGitHub Appに成り代わってapproveを出すことが可能っぽい

Kesin11Kesin11

このCustom Deployment Protection Ruleによるレビュー待ちを実際にGitHub Appから承認させてみる。
まずはGitHub Actions内から承認のAPIを叩けるのかだけを確かめたいので、超シンプルに単一のワークフロー内にレビュー待ちと承認の2つの機能を押し込める。

  • pushトリガー:承認待ち状態を作る
  • workflow_dispatchトリガー:inputにrun_idを渡して実行し、GitHub Appに成り代わってトークンを生成して /repos/OWNER/REPO/actions/runs/RUN_ID/deployment_protection_rule のAPIを叩いて承認する

承認のためのAPIのドキュメントはこのあたり
https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/creating-custom-deployment-protection-rules#approving-or-rejecting-deployments
https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#review-custom-deployment-protection-rules-for-a-workflow-run

これをワークフローのコードに落とし込むとこんな感じ

name: Custom Deployment Protection Rule

on:
  push: 
    branches:
      - custom_deploy_protection
  workflow_dispatch:
    inputs:
      approve_run_id:
        description: 'run_id to approve'
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Wait for approve from GitHub App when trigger by push
    environment: custom-protection
    if: github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploy to custom-protection"

  approve:
    runs-on: ubuntu-latest
    # Approve to awaiting run_id when trigger by workflow_dispatch
    if: github.event_name == 'workflow_dispatch' 
    steps:
      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ secrets.DEPLOYMENT_APP_ID }}
          private-key: ${{ secrets.DEPLOYMENT_APP_PRIVATE_KEY }}
      - name: Approve Deployment
        run: |
          gh api -X POST /repos/${{ github.repository }}/actions/runs/${{ github.event.inputs.approve_run_id }}/deployment_protection_rule \
            -f "environment_name=custom-protection" \
            -f "state=approved" \
            -f "comment=Approved by workflow_dispatch"
        env:
          GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

pushすると deploy ジョブの方が承認待ち状態となるので、そのときのrun_idをURLからコピーする。次にworkflow_dispatchで起動するときにそのrun_idを渡して起動する。

pushトリガーで承認待ちとなったジョブ:https://github.com/Kesin11/actions-newfeature-playground/actions/runs/10575173774
workflow_dispatchトリガーで承認したジョブ:https://github.com/Kesin11/actions-newfeature-playground/actions/runs/10575184008

無事にpushトリガーのジョブの方が承認されて動き出した様子。

Kesin11Kesin11

ここまでで確認できたこと

  • 自作のGitHub AppでCustom Deployment Protection Ruleの機能はちゃんと動作する
  • GitHub Appのwebhookは必須ではない(ここまでの使い方であれば)
  • acitons/create-github-app-tokenでGitHub Appのトークンを得れば承認のAPIをGitHub Actions内から叩くことは可能
    • つまり、異なるワークフロー同士で承認待ち状態と承認動作をGitHub Actions内で完結可能
Kesin11Kesin11

もう少し実践的なパターンを考えてみる。
一般的なビルド->デプロイの流れは、基本的に単一のワークフロー内で別々のジョブとしておき needs で依存関係を作る方法が簡単で一般的。
しかし、ビルドとデプロイのそれぞれの処理が複雑になってくると単一のワークフロー内で書くのが大変になってきたり、ビルド処理は使いまわしたいなどの理由で2つのワークフローに分離されるケースもある。

ありがちな例としてはこういうケースだが、要は同じコミットハッシュでトリガーされた2つのワークフローの間にA->Bの順番の依存関係を作りたいという場合がある。

順を追って流れを書くと、

  • pushされたときにAとBを同時に起動
  • BはAより先に承認待ち状態になる
  • Aは時間がかかる処理を行い、その後に承認待ちのBを見つけたらApproveする
  • BはAからのApproveを受けて起動する
Kesin11Kesin11

2つのワークフローでこれを実現するコード

デプロイジョブB(サンプルとして承認待ちするだけなので、先程のコードをほぼ再掲)

name: Custom Deployment Protection Rule

on:
  push: 
    branches:
      - custom_deploy_protection
  workflow_dispatch:
    inputs:
      approve_run_id:
        description: 'run_id to approve'
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Wait for approve from GitHub App when trigger by push
    environment: custom-protection
    if: github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploy to custom-protection"

ビルドジョブA(Bより時間がかかる処理を行い、その後Bをapproveする)

name: Auto Approve Custom Deployment

on:
  push: 
    branches:
      - custom_deploy_protection

jobs:
  build_dummy:
    runs-on: ubuntu-latest
    # 時間がかかるジョブを再現するためにsleepするだけ
    steps:
      - uses: actions/checkout@v4
      - run: sleep 10

  auto_approve:
    runs-on: ubuntu-latest
    # 同じコミットハッシュからトリガーされ、承認待ち状態の別のワークフローのrun_idを特定して保存する
    needs: build_dummy
    steps:
      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ secrets.DEPLOYMENT_APP_ID }}
          private-key: ${{ secrets.DEPLOYMENT_APP_PRIVATE_KEY }}
      - name: Find awaiting approval run_id
        id: awaiting-run
        run: |
            RUN_ID=$(gh api /repos/${{ github.repository }}/actions/workflows/custom_deploy_protection.yml/runs \
              -X GET -f head_sha=${{ github.sha }} |\
              jq -e 'first(.workflow_runs[].id)')
            echo "run_id=${RUN_ID}" >> $GITHUB_OUTPUT
        env:
          GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

      # 本当は gh api /repos/${{ github.repository }}/actions/runs/${RUN_ID}/pending_deployments を見て承認待ち状態かどうか判別するべきだが、
      # 今回は簡略化のために必ず承認待ち状態であると仮定してapproveする

      # 先ほど保存したrun_idに対してapproveを行う
      - name: Approve Deployment
        run: |
            gh api -X POST /repos/${{ github.repository }}/actions/runs/${{ steps.awaiting-run.outputs.run_id }}/deployment_protection_rule \
            -f "environment_name=custom-protection" \
            -f "state=approved" \
            -f "comment=Approved by workflow_dispatch"
        env:
          GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

ポイントは同じコミットハッシュから起動された特定のワークフローのrun_idを調べるために実行しているこのAPI。
今回のサンプルはBのワークフローだけにapproveを出すのでBのyamlを指定している。もしもさらに複数のワークフローがAの処理待ちを待つような複雑なケースだともう少し工夫が必要になりそう。

gh api /repos/${{ github.repository }}/actions/workflows/custom_deploy_protection.yml/runs \
  -X GET -f head_sha=${{ github.sha }} |\
  jq -e 'first(.workflow_runs[].id)`
Kesin11Kesin11

まとめ

Custom Deployment Protection Ruleは元記事で紹介されていたように、GitHub Actions -> 別のクラウドの非同期処理 -> GitHub Actionsのような複雑な呼び出し順を実現するためのポーリング処理を置き換えることが可能だが、GitHub Actions内のワークフロー間の複雑な依存関係にも応用できる。

必要になるのはこの用途のための自作Github Appと、Privateリポジトリの場合はGitHub Enterpriseのライセンス。

今回はサンプルのための最低限のコードを意識したのでほぼghコマンドだけで完結できるようにしましたが、実際はもっと複雑になると思われるのでjsやgoなど別言語でGitHubのAPIを叩くスクリプトや、カスタムactionを作った方がよさそう。