👋

GitHub Appsを使って組織の全リポジトリ イベントを横断的に検知する仕組みを作る

に公開

組織のリポジトリを跨いてeventを監視しつつ、特定のevent発火時に当該リポジトリに対して処理を行う機能を作りたくて調査・検証しました。
具体的にはPRのopen/closeを検知して処理を挟んだり、releaseの作成を検知して集計したりなどを想定しています。

調査

複数のリポジトリのevent監視

リポジトリのwebhookは使ったことがあったのですが、複数のリポジトリを跨いでwebhookを使おうと思ったことがなかったのでまずは調査から。1つのリポジトリであればwebhookを設定すればいいだけなのですが、組織のリポジトリ全部とかになってくると1つ1つ設定して回るのは大変です。

ということで調べてみたところ、以下の方法が使えそうです。

  1. Organizationレベルのwebhook
  2. GitHub Appsのwebhook

イベント監視自体はどちらの方法でも構わないです。が、そのリポジトリに対して何かしらの処理を行う場合は権限の有無や誰の権限を使うかが問題になるため、GitHub Appsに権限を持たせてしまってGitHub Appsのtokenで処理を行うのが良さそうです。

backendをどうするか?

GitHub Appsでwebhookを使うにあたってwebhookを受け取るサーバーが必要になります。サーバー側をがっつり作り込んでもよいのですが、保守管理が面倒になるため今回は別の手法を取りました。

具体的にはwebhookをGASで受け、GASはGitHhub APIを利用してGitHub Actionsをトリガー・webhook event情報を転送する。GitHub Actions内ではGitHub Appsの権限を利用して処理を行う流れとしました。

GitHub Apps

GitHub Appsの作成

settings > developer settings > GitHub Apps
から作成を行います。

適当にアプリ名等を入力します。
今回はPRのイベントを受け取りたいのでPermissionにPRのRead only権限を付与。
後述しますがイベントを受け取った後の処理をrepository_dispatch経由でGitHub ActionsをtriggerするためにContentnsのread and write権限を付与。

Webhookは使う予定ですがActiveにするとWebhook URLが必須入力になるため一旦disableに変更。

GitHub Appsを作成すると以下のような注意書きが出てきますので、privete keyを作成して保存します。

install GitHub App

settings > developer settings > Install App
から先ほど作成したアプリをinstallします。

GAS

GASでは飛んできたwebhookの情報をrepository_dispatchに流すだけの薄い中継サーバーとして実装します。処理本体はGASでは実装しません。

当初webhookのURLにrepository_dispatchのAPIのpathを指定すればワンチャン動かないかな、と思ったのですが、このAPIを叩くには認証情報が必要なことと、渡すデータの構造が決まっていて({"event_type": "hoge", "client_payload": {"fuga": "piyo"}})webhookのpayloadとは構造が合わないためにサーバーを噛ませる必要があります。

repository_dispatchについて

https://api.github.com/repos/${owner}/${repo}/dispatchesにpostすることでworkflowとtriggerできます。

To subscribe to this event, a GitHub App must have at least read-level access for the "Contents" repository permission.

https://docs.github.com/ja/webhooks/webhook-events-and-payloads#repository_dispatch

粒度の細かいトークンには次のアクセス許可セットが設定されている必要があります:
"Contents" repository permissions (write)

https://docs.github.com/ja/rest/repos/repos?apiVersion=2022-11-28#create-a-repository-dispatch-event

上記のようにeventのsubscribeにはContentsのread権限が、eventをdispatchするにはContentsのwrite権限が必要です。ですのでGitHub Appの作成時にContentsのwrite権限を付与しました。

似たようなAPIにworkflow_dispatchがあるが、今回は割愛。
以下の記事が詳しかった。
https://gkzz.dev/posts/repository-dispatch-vs-workflow-dispatch-in-github-actions/

コードの概要

コードの本体は長いので畳んでおきます。
処理の流れば

  1. webhookを受け取る
  2. GitHub Appsのtokenを取得
  3. tokenで署名してターゲットのレポジトリのrepository_dispatch APIを叩く

です。repository_dispatchのAPIにはwebhookのpayloadをそのまま丸っと渡します。

script

webhookはsecretを設定した上で検証するロジックを入れるべきですが、今回は簡略化のため省略しています。

function doPost(e) {
  try {   
    const appId = PropertiesService.getScriptProperties().getProperty('GITHUB_APP_ID');
    const privateKey = PropertiesService.getScriptProperties().getProperty('GITHUB_PRIVATE_KEY');
    const installationId = PropertiesService.getScriptProperties().getProperty('INSTALLATION_ID');
    
    const installationToken = getInstallationToken(appId, privateKey, installationId);
    const payload = JSON.parse(e.postData.contents);
    sendRepositoryDispatch(installationToken, payload);
    
    return ContentService.createTextOutput('OK');
  } catch (error) {
    console.error('Error:', error);
    return ContentService.createTextOutput('Error');
  }
}

function getInstallationToken(appId, privateKey, installationId) {
  const jwt = generateJWT(appId, privateKey);

  const response = UrlFetchApp.fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
    method: 'POST',
    headers: {
      'Accept': 'application/vnd.github+json',
      'Authorization': `Bearer ${jwt}`,
      'X-GitHub-Api-Version': '2022-11-28'
    }
  });
  
  const data = JSON.parse(response.getContentText());
  return data.token;
}

function generateJWT(appId, privateKey){
  const now = Math.floor(new Date().getTime() / 1000);
  const iat = now - 60;  // Issues 60 seconds in the past
  const exp = now + 600; // Expires 10 minutes in the future

  const headerJSON = {
    typ: 'JWT',
    alg: 'RS256',
  };
  const header = Utilities.base64EncodeWebSafe(JSON.stringify(headerJSON));

  const payloadJSON = {
    iat: iat,
    exp: exp,
    iss: appId,
  };
  const payload = Utilities.base64EncodeWebSafe(JSON.stringify(payloadJSON));

  const headerPayload = `${header}.${payload}`;
  const signature = Utilities.base64EncodeWebSafe(Utilities.computeRsaSha256Signature(headerPayload, privateKey));

  return `${headerPayload}.${signature}`;
};

function sendRepositoryDispatch(token, webhookPayload) {
  const owner = 'owner'; // 書き換えてください
  const repo = 'repo'; // 書き換えてください
  
  const dispatchPayload = {
    event_type: 'webhook-triggered',
    client_payload: webhookPayload
  };
  
  UrlFetchApp.fetch(`https://api.github.com/repos/${owner}/${repo}/dispatches`, {
    method: 'POST',
    headers: {
      'Accept': 'application/vnd.github+json',
      'Authorization': `Bearer ${token}`,
      'X-GitHub-Api-Version': '2022-11-28',
      'Content-Type': 'application/json'
    },
    payload: JSON.stringify(dispatchPayload)
  });
}

Error cases

秘密鍵が使えない

ScriptPropertiesに秘密鍵を設定していても以下のエラーが出る。

Exception: Invalid argument: key

私の場合は原因は以下の2つありました。

  • private keyの形式が違う(GASで使うならPKCS#1ではなくPKCS#8)
  • プロジェクトの設定からScriptPropertyを設定した

1つ目の場合は変換すればOK。

openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in github-private-key.pem -out gas-private-key.pem

2つ目の場合ですが、画像のように左のメニューからプロジェクトの設定に移動してprivate keyを設定した場合に発生するようです。

こちらの記事を参照させていただき、コードからScriptPropetiesを設定することで解消しました。以下の関数を作成して、setKey関数を一度実行すればOK。

const TMP_PRIVATE_KEY = `
GAS.PRIVATE-KEY.pem の中身を貼り付ける
`;

const setKey = () => {
  PropertiesService.getScriptProperties().setProperty('GITHUB_PRIVATE_KEY', TMP_PRIVATE_KEY);
};

確認

GASが完成したらデプロイし、webhookを正しく受け取れるか確認します。

  1. GASをウェブアプリとしてデプロイしてURLをコピーします。
  2. settings > developer settings > GitHub Apps
    から先ほど作ったAppを選択して、Generalタブに移動します。
    webhookを有効にします。
  3. 今回はPR系のeventをsubscribeしているので組織内の適当なリポジトリでPRをopenします。
  4. settings > developer settings > GitHub Apps
    から先ほど作ったAppを選択して、Advancedタブへ移動します。
    この画面でwebhookの成否やeventの中身が確認できます。

GitHub Actions

最後にGitHub Actionsで処理の本体を作っていきます。
今回はPR関連のイベントを受け取っているのでそのPRのdescriptionを取得するだけの簡単な処理とします。

actions.yml

やることはシンプルです。

  1. webhookのpayloadからownerとreposを取得
  2. actions/create-github-app-token@v1を使ってGitHub Appsのtokenを取得
  3. tokenを使って対象のPRの情報を取得
name: Repository Dispatch Handler

on:
  repository_dispatch:
    # すべてのリポジトリディスパッチイベントタイプを受け取る

jobs:
  process-pr:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Extract PR information
        id: extract_pr_info
        run: |
          echo "Event type: ${{ github.event.action }}"
          echo "Client payload: ${{ toJson(github.event.client_payload) }}"

          # client_payloadからリポジトリ情報とPR番号を抽出
          REPO_OWNER="${{ github.event.client_payload.repository.owner.login }}"
          REPO_NAME="${{ github.event.client_payload.repository.name }}"
          PR_NUMBER="${{ github.event.client_payload.pull_request.number }}"

          echo "Repository Owner: $REPO_OWNER"
          echo "Repository Name: $REPO_NAME"
          echo "PR Number: $PR_NUMBER"

          # GitHub Actions環境変数として設定
          echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV
          echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV
          echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV

      - name: Get GitHub App Token
        id: get_token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.REVIEW_ARENA_APP_ID }}
          private-key: ${{ secrets.REVIEW_ARENA_PRIVATE_KEY }}
          owner: ${{ env.REPO_OWNER }}
          repositories: ${{ env.REPO_NAME }}

      - name: Get PR Description
        run: |
          echo "Fetching PR description for PR #$PR_NUMBER in $REPO_OWNER/$REPO_NAME..."

          # GitHub APIを使ってPRの詳細を取得
          RESPONSE=$(curl -s -H "Authorization: Bearer ${{ steps.get_token.outputs.token }}" \
            -H "Accept: application/vnd.github.v3+json" \
            "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/pulls/$PR_NUMBER")

          # PRのdescriptionを抽出
          PR_DESCRIPTION=$(echo "$RESPONSE" | jq -r '.body // "No description provided"')

          echo "=== PR Description ==="
          echo "$PR_DESCRIPTION"
          echo "====================="

Repository Secrets

上記のコードではSecretから値を渡しています。

settings > Secrets and variables > Repository secrets
SECRETにGitHub AppsのAPP_IDとPRIVATE_KEYを設定します。

まとめ

GitHub Appsを活用して組織レベルでリポジトリのイベント監視を行うシステムを構築しました。

構成のポイント

  • GitHub Apps: 組織全体のwebhookを受信し、必要な権限を一元管理
  • GAS: 軽量な中継サーバーとして、webhookペイロードをrepository_dispatchに転送
  • GitHub Actions: 実際の処理ロジックを実装し、GitHub Appsの権限で対象リポジトリを操作

この構成のメリット

  1. スケーラビリティ: 新しいリポジトリが追加されても個別のwebhook設定が不要
  2. 権限管理の簡素化: GitHub Appsで一元的に権限を管理
  3. 保守性: 処理ロジックはGitHub Actions側に集約されているため、修正・追加が容易
  4. コスト効率: GASは無料枠内で十分運用可能

この仕組みを応用すれば、PR自動レビュー、リリース通知、コードメトリクス収集など様々な用途に展開できそうです。組織内の開発効率化に役立つシステムとして活用していきたいと思います。

Terass Tech Blog

Discussion