GitHub Appsを使って組織の全リポジトリ イベントを横断的に検知する仕組みを作る
組織のリポジトリを跨いてeventを監視しつつ、特定のevent発火時に当該リポジトリに対して処理を行う機能を作りたくて調査・検証しました。
具体的にはPRのopen/closeを検知して処理を挟んだり、releaseの作成を検知して集計したりなどを想定しています。
調査
複数のリポジトリのevent監視
リポジトリのwebhookは使ったことがあったのですが、複数のリポジトリを跨いでwebhookを使おうと思ったことがなかったのでまずは調査から。1つのリポジトリであればwebhookを設定すればいいだけなのですが、組織のリポジトリ全部とかになってくると1つ1つ設定して回るのは大変です。
ということで調べてみたところ、以下の方法が使えそうです。
イベント監視自体はどちらの方法でも構わないです。が、そのリポジトリに対して何かしらの処理を行う場合は権限の有無や誰の権限を使うかが問題になるため、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.
粒度の細かいトークンには次のアクセス許可セットが設定されている必要があります:
"Contents" repository permissions (write)
上記のようにeventのsubscribeにはContentsのread権限が、eventをdispatchするにはContentsのwrite権限が必要です。ですのでGitHub Appの作成時にContentsのwrite権限を付与しました。
似たようなAPIにworkflow_dispatchがあるが、今回は割愛。
以下の記事が詳しかった。
コードの概要
コードの本体は長いので畳んでおきます。
処理の流れば
- webhookを受け取る
- GitHub Appsのtokenを取得
- 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を正しく受け取れるか確認します。
- GASをウェブアプリとしてデプロイしてURLをコピーします。
- settings > developer settings > GitHub Apps
から先ほど作ったAppを選択して、Generalタブに移動します。
webhookを有効にします。 - 今回はPR系のeventをsubscribeしているので組織内の適当なリポジトリでPRをopenします。
- settings > developer settings > GitHub Apps
から先ほど作ったAppを選択して、Advancedタブへ移動します。
この画面でwebhookの成否やeventの中身が確認できます。
GitHub Actions
最後にGitHub Actionsで処理の本体を作っていきます。
今回はPR関連のイベントを受け取っているのでそのPRのdescriptionを取得するだけの簡単な処理とします。
actions.yml
やることはシンプルです。
- webhookのpayloadからownerとreposを取得
- actions/create-github-app-token@v1を使ってGitHub Appsのtokenを取得
- 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の権限で対象リポジトリを操作
この構成のメリット
- スケーラビリティ: 新しいリポジトリが追加されても個別のwebhook設定が不要
- 権限管理の簡素化: GitHub Appsで一元的に権限を管理
- 保守性: 処理ロジックはGitHub Actions側に集約されているため、修正・追加が容易
- コスト効率: GASは無料枠内で十分運用可能
この仕組みを応用すれば、PR自動レビュー、リリース通知、コードメトリクス収集など様々な用途に展開できそうです。組織内の開発効率化に役立つシステムとして活用していきたいと思います。
Discussion