シンプルな GitHub App (Bot) を Go 言語で作ってみます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は「まとめ」に記載されているものをご参照ください。
作るもの
下図のように Issue を作成すると hello, ${username}
と反応してくれる Bot を作ります。
準備
GitHub に動作確認用のリポジトリを作っておきしょう。
また、GitHub から Webhook イベントを受け取る必要があるので、パブリックアクセス可能な URL が必要です。
今回は開発しながら適宜動作確認が行えるように ngrok というプロキシサービスを利用してローカル開発マシンのポートをインターネットに公開することにします。
こちら から ngrok へのユーザ登録を行います。
登録後、セットアップ方法の案内が表示されるのでそれに従ってセットアップしていきます。
セットアップが完了すると次のようなコマンドで開発マシンのポートを外部公開することができます。
$ nrgok http 8080
便利なサービスですがうっかり外部公開できないものを公開してしまわないように注意が必要です。
Step1: GitHub Apps 認証で API を叩く
まずはシンプルに GitHub API を叩く部分だけを作ってみます。
実行すると Issue に hello
とコメントするプログラムを作成します。
GitHub App を登録する
アカウントの Settings
から Developer settings
を開くと GitHub Apps の画面になるので New GitHub App
から登録を行います。
登録フォームに入力する際は以下を参考にしてください。
-
Github App name
- ユニークな名前をつけてあげる必要があります
-
Homepage URL
- 必須項目なので動作確認用のリポジトリの URL でも入力しておきます
-
Identifying and authorizing users
- App がインストールされた後、アプリ側がインストールしたユーザを識別したり承認したりする必要がある場合に設定します
- 今回は不要なので空欄にしておきます
-
Post installation
- App がインストールされた後、ユーザに追加の設定を行わせたい場合に設定します
- 今回は不要なので空欄にしておきます
-
Webhook
- GitHub から Event を受け取る際に必要ですが、
Active
にチェックを入れてしまうとWebhook URL
の入力を求められてしまうので一旦チェックを外しておきます
- GitHub から Event を受け取る際に必要ですが、
-
Repository permissions
- Issue に読み書きしたいので
Issues
のAccess
をRead & write
に設定します- この際、自動的に
Metadata
のAccess
がRead-only
に設定されます
- この際、自動的に
- Issue に読み書きしたいので
-
Organization permissions
,User permissions
- 組織やユーザに対する権限設定も行えますが今回は不要なので何も設定しません
-
Subscribe to events
- GitHub から受け取る Event を設定できますが、いずれかにチェックを入れると
Webhook URL
の入力を求められてしまうので一旦何もチェックしないでおきます
- GitHub から受け取る Event を設定できますが、いずれかにチェックを入れると
-
Where can this GitHub App be installed?
-
Only on this account
を選択して自分しかインストールできないようにしておきます
-
設定が完了したら Create GitHub App
ボタンを押して登録します。
登録後、次のように App の詳細画面に遷移するので App ID
を控えておきましょう。
秘密鍵を発行する
作成後の画面を下にスクロールしていくと Generate a private key
ボタンがあるのでクリックして秘密鍵を発行しましょう。
pem
ファイルがローカルにダウンロードされます。
リポジトリにインストールする
左側のメニューから Install App
を選択し、自分のアカウントにインストールします。
インストール先は動作確認用に作ったリポジトリのみにしておきます。
インストール後の画面の URL 末尾の ID を控えておきましょう。認証時に Installation ID
として使用します。
実装
ようやく Go での実装を行っていきます。
GitHub API を叩くためのクライアントライブラリとして google/go-github というパッケージを使用します。
ただし、このパッケージは README の Authentication にも記載されているように認証処理は受け持ってくれないので自分で何とかする必要があります。
ここで、GitHub Apps の認証方法を確認しておきましょう。
Authenticating as a GitHub App
に記載されているように、発行した秘密鍵を使用して RS256
アルゴリズムで署名した JWT を Authorization
ヘッダに Bearer トークンとして入れることで GitHub App として認証されます。ただしこの JWT で可能なのは一時トークンの発行などに限定されており、インストール先のリポジトリを直接操作するような API を叩くことはできません。実際に API を叩くためにはインストールされた組織・ユーザ単位で一時トークンを発行し、そちらの方を使う必要があります。
GitHub Apps は開発者以外のユーザや組織にインストールされて使われることが想定されているためこのような仕組みになっているのですが、この部分の実装を自分でやるのはちょっと面倒です。
そこで、認証部分に関しては bradleyfalzon/ghinstallation パッケージを使用することにします。上記の認証処理を自動で行ってくれる http.Transport
を提供してくれるパッケージで、google/go-github で生成される GitHub API Client に埋め込んで使用することができます。google/go-github の README でも GitHub Apps の認証用のパッケージとして紹介されているので安心して使うことができます。
前置きが長くなりましたがこれらのパッケージを使って実装すると次のようなコードになります。
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/bradleyfalzon/ghinstallation"
"github.com/google/go-github/v29/github"
)
const InstallationID = <your-installation-id>
const RepoOwner = "<your-repo-owner>"
const Repo = "<your-repo>"
const IssueNumber = 1
func main() {
appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
if err != nil {
log.Fatal(err)
}
tr := http.DefaultTransport
itr, err := ghinstallation.NewKeyFromFile(tr, appID, InstallationID, "private-key.pem")
if err != nil {
log.Fatal(err)
}
client := github.NewClient(&http.Client{
Transport: itr,
Timeout: 5 * time.Second,
})
ctx := context.Background()
body := "hello"
comment := &github.IssueComment{
Body: &body,
}
if _, _, err := client.Issues.CreateComment(ctx, RepoOwner, Repo, IssueNumber, comment); err != nil {
log.Fatal(err)
}
}
控えておいた App ID
は環境変数から取得するようにしています。Installation ID
は本来 Webhook 経由で受け取るのが正しいので一旦ハードコードしています。
コメントを投稿する Issue のリポジトリ情報や対象の Issue 番号も Webhook イベントから取得したいのでここでは一旦ハードコードしておきます(動作確認用に適当な Issue を作成しておきましょう)。
また、秘密鍵は同じディレクトリの private-key.pem
というファイルに保存されていることを想定しています。
実行してみましょう。
ダウンロードした pem
ファイルを private-key.pem
にリネームして main.go
と同じディレクトリに配置し、環境変数 GITHUB_APP_ID
を export
してから実行します。
$ mv frozenbonito-test-bot.2020-03-14.private-key.pem private-key.pem
$ export GITHUB_APP_ID=<your-app-id>
$ go run main.go
hello
とコメントしてくれました。
上記のコードでは ghinstallation.NewKeyFromFile()
を使ってファイルから秘密鍵を読み込んでいますが、 ghinstallation.New()
の方を使えば変数から参照することも可能です。
以下は環境変数から秘密鍵を参照する例です。コンテナ環境で動かしたい場合はこちらの方が扱いやすいかもしれません。
tr := http.DefaultTransport
key := os.Getenv("GITHUB_APP_PRIVATE_KEY")
itr, err := ghinstallation.New(tr, appID, InstallationID, []byte(key))
if err != nil {
log.Fatal(err)
}
秘密鍵を環境変数にセットしてから実行します。
$ export GITHUB_APP_PRIVATE_KEY=$(cat private-key.pem)
$ go run main.go
AWS Lambda など、環境変数に改行を入れるのが難しい環境では base64 エンコードしたものを入れておきコード中でデコードするのが良さそうです。
Step2: GitHub から Event を受け取って処理する (Webhook)
API を叩けることが確認できたので次は GitHub の Webhook を利用して Issue 作成に反応するようにします。
実装
先ほどのコードを以下のように修正します。
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/bradleyfalzon/ghinstallation"
"github.com/google/go-github/v29/github"
)
func main() {
http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := github.ValidatePayload(r, nil)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadRequest)
return
}
webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadRequest)
return
}
switch event := webhookEvent.(type) {
case *github.IssuesEvent:
if err := processIssuesEvent(ctx, event); err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
})
log.Println("[INFO] Server listening")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error {
if event.GetAction() != "opened" {
return nil
}
installationID := event.GetInstallation().GetID()
client, err := newGithubClient(installationID)
if err != nil {
return err
}
repoOwner := event.Repo.GetOwner().GetLogin()
repo := event.Repo.GetName()
issue := event.GetIssue()
issueNumber := issue.GetNumber()
user := issue.GetUser().GetLogin()
body := "hello, @" + user
comment := &github.IssueComment{
Body: &body,
}
if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil {
return err
}
return nil
}
func newGithubClient(installationID int64) (*github.Client, error) {
appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
if err != nil {
return nil, err
}
tr := http.DefaultTransport
itr, err := ghinstallation.NewKeyFromFile(tr, appID, installationID, "private-key.pem")
if err != nil {
return nil, err
}
return github.NewClient(&http.Client{
Transport: itr,
Timeout: 5 * time.Second,
}), nil
}
コードの解説
func main() {
http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
// ...
})
log.Println("[INFO] Server listening")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
Webhook イベントを受け取るため、main()
はサーバを起動するためのコードに書き換えています。8080 ポートの /github/events
で Webhook イベントを待ち受けます。
payload, err := github.ValidatePayload(r, nil)
github.ValidatePayload()
を使って Webhook イベントの payload をチェックしています。
第二引数に Secret を与えることで署名を検証することができますが一旦ここでは nil
を与えて署名検証を skip しています。
webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload)
payload を parse してイベントを取得しています。ここで戻り値として得られるのは interface{}
ですが、Type switches によってどのイベントかを判定することが可能です。
ちなみに GitHub Webhook イベントのタイプはリクエストの X-GitHub-Event
ヘッダに詰められて送られてきます。第一引数に与えている github.WebHookType()
はこの X-GitHub-Event
ヘッダからイベントタイプを読み取る関数です。
switch event := webhookEvent.(type) {
case *github.IssuesEvent:
if err := processIssuesEvent(ctx, event); err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
Type switches を利用してイベントタイプを判定し、処理を行っています。今回は Issue が作成された場合に処理を行うので *github.IssuesEvent
ケース内に処理を書いています。
func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error {
if event.GetAction() != "opened" {
return nil
}
installationID := event.GetInstallation().GetID()
client, err := newGithubClient(installationID)
if err != nil {
return err
}
repoOwner := event.Repo.GetOwner().GetLogin()
repo := event.Repo.GetName()
issue := event.GetIssue()
issueNumber := issue.GetNumber()
user := issue.GetUser().GetLogin()
body := "hello, @" + user
comment := &github.IssueComment{
Body: &body,
}
if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil {
return err
}
return nil
}
Issues イベントを処理する関数です。
Issues イベントは Issue に関する様々な操作が行われた際に発生しますが、1行目で Action が opened
であることを判定しているので Issue の作成時にのみ反応するようになっています。
残りの処理はほぼ Step1 で main()
に書いていたものと同等ですが、必要な値が Webhook イベントから取得されるようになっています。
リポジトリのオーナーや Issue の作成者を取得する際、github.User
から取得することになりますが GetID()
や GetName()
ではなく GetLogin()
を使う必要があることに注意が必要です。
func newGithubClient(installationID int64) (*github.Client, error) {
// ...
}
先ほどは main()
に書いていた GitHub Client の生成処理を別関数にしました。処理はほぼ変わっていませんが、引数に Webhook イベントから取得した Installation ID
が渡されることを想定しています。
今回はシンプルな実装にするため関数が呼ばれるたびに新しい GitHub Client を生成してしまっていますが、実際には Installation ID
と GitHub Client の対応を map などで持たせておき同一の Installation ID
に対しては Client を使いまわすようにすると良さそうです。
Issues イベントが App に配信されるように設定する
Step1 で Webhook に関する設定を後回しにしたのでここで行います。
Webhook 用の URL が必要なので作成したプログラムを実行し、ngrok
でポートを公開しておきます。
$ go run main.go
2020/03/15 22:42:48 [INFO] Server listening
$ ngrok http 8080
Session Status online
Account frozenbonito (Plan: Free)
Version 2.3.35
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://xxxxxxxx.ngrok.io -> http://localhost:8080
Forwarding https://xxxxxxxx.ngrok.io -> http://localhost:8080
GitHub App の設定ページを開きます(閉じてしまった場合は Settings
> Developer settings
> GitHub Apps
で作成した App を Edit
)。
下の方にある Webhook の Active
にチェックを入れ、Webhook URL には ngrok
で得られた https の URL に Webhook イベントを待ち受けているパスである /github/events
を加えたものを入力します。
Webhook secret
の設定は後で行うので空欄のままにしておきます。
設定できたら Save changes
で保存します。
次に Webhook で配信するイベントの設定を行います。
左側のメニューの Permissions & events
を開きます。
下の方にある Subscribe to events
の Issues
にチェックを入れます。
ここで Change privileges to able to select events
とエラーが出た場合は Repository permissions
の Issues
の Access
を No access
にした後 Read & write
に戻すなどの操作を行ってみてください。不具合なのかもしれませんが、Permission
の方をいじらないとチェックができない仕様になっているようです。
Save changes
で保存したら設定は完了です。動作確認用のリポジトリで新しい Issue を作成してみましょう。
作成した Issue に対して Bot がコメントで反応してくれました。
Step3: GitHub からのリクエストであることを検証する
GitHub App はリポジトリへの参照権限や書き込み権限などを持っているのでセキュリティには十分注意が必要です。
Step2 までで表面的な機能は実装が完了しましたが、安全のためには届いたリクエストが GitHub からのものであることを検証しなくてはいけません。
最後にこのリクエスト検証処理を実装していきます。
Webhook の Secret を設定する
GitHub からのリクエストを検証するためにはまず Webhook の Secret を設定する必要があります。
再度 GitHub App の設定ページを開きます(閉じてしまった場合は Settings
> Developer settings
> GitHub Apps
で作成した App を Edit
)。
下の方にある Webhook 設定のうち、先ほどは入力しなかった Webhook secret
に Secret 文字列を入力します。
入力する文字列は暗号学的に安全な乱数から生成したランダム文字列が良いでしょう。公式ドキュメント では Ruby を使って puts SecureRandom.hex(20)
で生成しています。安全なランダム文字列が得られれば方法は何でもよいのですが、あえて Go でやるなら以下のような感じでしょうか。
package main
import (
"crypto/rand"
"fmt"
"log"
)
func main() {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
log.Fatal(err)
}
fmt.Printf("%x\n", b)
}
crypto/rand を使う必要があることに注意です。
Webhook secret
を入力したら Save changes
をクリックして保存しておきます。
実装
Webhook secret
が設定されていると、GitHub は Secret を使用して payload を署名したものを X-Hub-Signature
ヘッダで送信するようになります。
この検証は github.ValidateSignature()
または github.ValidatePayload()
で行うことが可能です。
先ほどのコードを以下のように修正します。
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/bradleyfalzon/ghinstallation"
"github.com/google/go-github/v29/github"
)
func main() {
http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 修正
secret := os.Getenv("GITHUB_APP_SECRET")
payload, err := github.ValidatePayload(r, []byte(secret))
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadRequest)
return
}
// 省略
// ...
})
// 省略
// ...
}
// 省略
// ...
環境変数から Secret を取得するようにし、github.ValidatePayload()
の第二引数に与えるだけです。github.ValidatePayload()
は第二引数に Secret を与えると内部で github.ValidateSignature()
を呼び出して署名の検証を行ってくれます。
実行してみましょう。
先ほど設定した Secret を環境変数に入れてから実行します。
$ export GITHUB_APP_SECRET=<your-webhook-secret>
$ go run main.go
GitHub で Issue を作成した場合は正常に動作するはずです。
実際に外部からのリクエストを弾けるかも試してみましょう。
と言っても github.ValidatePayload()
は payload の形式自体もチェックしてしまうので、適当なリクエストを偽装するのも面倒です。
そこで手元の環境変数だけ変更した上で再度 GitHub からイベントを飛ばすことで「間違った Secret で署名された偽装リクエストが届いた」状態を疑似的に再現することにします。
環境変数を一時的に変更して再度実行します。
$ GITHUB_APP_SECRET=1234567890abcdef go run main.go
ここでは Issue を新しく作るのではなく GitHub のイベント再送機能を使ってみましょう。
GitHub App の設定ページの左側のメニューから Advanced
を開きます。
今までに GitHub から送信されたイベントの記録が残っています。
最新の成功したものを選択します。
Redeliver
ボタンで全く同じヘッダーと payload でイベントを再送することができます。
実行すると再送は 400 で失敗し、手元で実行している App は以下のようにログを吐くはずです。
$ GITHUB_APP_SECRET=123456789abcdef go run main.go
2020/03/17 01:41:56 [INFO] Server listening
2020/03/17 01:42:04 payload signature check failed
Secret が間違っている偽装リクエストをちゃんと弾けることが確認できました。
まとめ
Go 言語による GitHub App の作り方について解説しました。
実は今回のように Webhook イベントをトリガーに処理を行うケースでは GitHub Actions を使った方がサーバー不要な分お手軽に実現できます。しかし GitHub 外部で発生するイベントをトリガーにいろいろと処理をしたい場合などには GitHub App が活躍してくれそうです。
最後に完成したコードの全体を載せておきます。
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/bradleyfalzon/ghinstallation"
"github.com/google/go-github/v29/github"
)
func main() {
http.HandleFunc("/github/events", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
secret := os.Getenv("GITHUB_APP_SECRET")
payload, err := github.ValidatePayload(r, []byte(secret))
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadRequest)
return
}
webhookEvent, err := github.ParseWebHook(github.WebHookType(r), payload)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadRequest)
return
}
switch event := webhookEvent.(type) {
case *github.IssuesEvent:
if err := processIssuesEvent(ctx, event); err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
})
log.Println("[INFO] Server listening")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
func processIssuesEvent(ctx context.Context, event *github.IssuesEvent) error {
if event.GetAction() != "opened" {
return nil
}
installationID := event.GetInstallation().GetID()
client, err := newGithubClient(installationID)
if err != nil {
return err
}
repoOwner := event.Repo.GetOwner().GetLogin()
repo := event.Repo.GetName()
issue := event.GetIssue()
issueNumber := issue.GetNumber()
user := issue.GetUser().GetLogin()
body := "hello, @" + user
comment := &github.IssueComment{
Body: &body,
}
if _, _, err := client.Issues.CreateComment(ctx, repoOwner, repo, issueNumber, comment); err != nil {
return err
}
return nil
}
func newGithubClient(installationID int64) (*github.Client, error) {
appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
if err != nil {
return nil, err
}
tr := http.DefaultTransport
itr, err := ghinstallation.NewKeyFromFile(tr, appID, installationID, "private-key.pem")
if err != nil {
return nil, err
}
return github.NewClient(&http.Client{
Transport: itr,
Timeout: 5 * time.Second,
}), nil
}