はじめに
おはようございます、IWSです。
Slack やローカル環境から GitHub Actions のワークフローを起動したい、あるいは API 経由で GitHub を操作したい。そんなシーンで避けて通れないのが Personal Access Token(PAT) の存在です。
しかし、PAT には以下のような悩みがつきまといます。
- 発行した人に紐づくため、退職や異動で使えなくなる
- 有効期限の更新・再発行など、運用コストが地味に高い
- 権限スコープが広くなりがちで、セキュリティ的に扱いづらい
そこで本記事では、GitHub App を使って短期有効の一時トークンを払い出し、PAT 管理から解放される方法 を紹介します。あわせて、GitHub の GraphQL API を使ってブランチ名をあいまい検索する小ネタも載せています。
そもそも GitHub App とは
GitHub App は、GitHub の機能を操作して拡張するために構築できる統合の一種です。ユーザーのサインインやサービス アカウントの作成を必要とせずに、柔軟性を提供し、プロセスの摩擦を軽減できます。
引用:GitHub DocsGitHub アプリの作成について – GitHubドキュメント
ざっくり言うと、個人アカウントではなく「アプリ」として GitHub に対して権限を持てる仕組み です。インストールしたリポジトリに対して、必要最小限の権限で、短命な(最大1時間の)アクセストークンを発行できます。
これにより、
- 個人に紐づかない
- 権限スコープをリポジトリ単位・操作単位で絞れる
- トークンは都度発行・短寿命なので漏洩リスクが低い
といった嬉しさが得られます。
ゴール
本記事のゴールは次のとおりです。
- GitHub App を作成し、対象リポジトリにインストールする
- Lambda から JWT を生成し、一時アクセストークンを取得する
- 取得したトークンで
repository_dispatchイベントを発火し、GitHub Actions を起動する - (おまけ)GraphQL API でブランチ名をあいまい検索する
1. GitHub App を準備する
まずは GitHub App を作成します。個人アカウントの Settings → Developer settings → GitHub Apps → New GitHub App から作成します。
主な設定項目は次のとおりです。
- GitHub App name: 好きな名前を設定
- Homepage URL: 任意の URL(例:
https://example.com) - Webhook: 今回は利用しないのでチェックを外す
- Permissions: 用途に合わせて設定。本記事では
- Actions : Read and write
- Contents : Read and write
- Where can this GitHub App be installed?: Organization のリポジトリへインストールしたいので Any account を選択
作成が完了すると、App の設定画面に遷移します。ここで Client ID を控えておき、ページ下部の Private keys から Generate a private key で.pemファイルを発行してダウンロードしておきます。この秘密鍵は後ほど JWT の署名に使用します。

続いて Install App のページから、対象の Organization にインストールします。

権限を絞るために Only select repositories を選択し、対象リポジトリを指定してインストールしましょう。

インストール先リポジトリの Settings → GitHub Apps に作成した App が表示されれば準備完了です。

2. JWT を生成する
ここから先は AWS Lambda 上での実装例を紹介します(Zapier も検討しましたが、環境変数が扱えず断念しました)。
まずは秘密鍵を使って JWT を生成します。iss には GitHub App の Client ID を、exp は最大10分以内に設定します。
|
1 2 3 4 5 6 7 8 9 10 11 |
import jwt import time def generate_github_jwt(private_key: str) -> str: """GitHub App 用の JWT を生成する""" payload = { 'iat': int(time.time()), 'exp': int(time.time()) + 600, # 最大10分 'iss': '<GitHub App の Client ID>', } return jwt.encode(payload, private_key, algorithm='RS256') |
3. インストールアクセストークンを取得する
生成した JWT を使って、インストール単位のアクセストークンを取得します。トークンの有効期限は最大1時間です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import requests def get_access_token(jwt_token: str) -> str: """GitHub App のインストールアクセストークンを取得する""" install_id = '<GitHub App の Installation ID>' url = f'https://api.github.com/app/installations/{install_id}/access_tokens' headers = { 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/vnd.github+json', } data = { 'repository': '<対象リポジトリ名>', } response = requests.post(url, json=data, headers=headers) return response.json()['token'] |
dataにさらに細かい権限やリポジトリ指定を加えることで、インストール時の権限からもう一段絞ったトークンを発行できます。
|
1 2 3 4 5 6 |
data = { 'repository': '<対象リポジトリ名>', 'permissions': { 'actions': 'write', }, } |
最小権限の原則を徹底したい場合は、呼び出しごとに必要な権限だけを付与したトークンを発行するのがおすすめです。
4. GitHub Actions を repository_dispatch で発火する
取得したトークンを使って、repository_dispatchエンドポイントを叩きます。これで GitHub Actions のワークフローを外部から起動できます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def workflow_trigger(access_token: str, env: str, branch: str) -> None: """GitHub Actions のワークフローを起動する""" url = 'https://api.github.com/repos/<owner>/<repo>/dispatches' headers = { 'Authorization': f'Bearer {access_token}', 'Accept': 'application/vnd.github+json', } data = { 'event_type': f'deploy-{env}', 'client_payload': { 'branch': branch, }, } response = requests.post(url, json=data, headers=headers) if response.status_code != 204: raise Exception(f'Dispatch failed: {response.status_code} {response.text}') |
呼び出される側のワークフローでは、on: repository_dispatchのtypesに上で送ったevent_typeを指定しておきます。
|
1 2 3 |
on: repository_dispatch: types: [deploy-development] |
のようにすることで、event_type で送った値と repository_dispatch の types で設定した値が一致したときに発火させることができます。
- event_type が deploy-development
- 発火する
- event_type が deploy-check
- 発火しない
あとは Slack の ChatBot から Lambda を起動するようにすれば、PAT を使わず Slack から GitHub Actions を叩く 仕組みが完成します。

GitHub 側にも「この App から実行された」という履歴が残るので、監査の観点でも安心です。

おまけ: GraphQL API でブランチ名をあいまい検索する
Slack コマンドから発火する場合、ユーザーが打ち込むブランチ名はタイポしがちです。そこで、発行したトークンを使って GraphQL API からブランチ一覧を取得し、あいまいマッチで最も近いブランチを選ぶ 処理を入れておくと便利です。
ブランチ一覧を取得する
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
def fetch_branches(access_token: str, query: str) -> list: """GraphQL API でリポジトリのブランチ一覧を取得する""" QUERY = """ query($owner: String!, $repo: String!, $query: String!) { repository(owner: $owner, name: $repo) { refs(refPrefix: "refs/heads/", first: 100, query: $query) { edges { node { name } } } } } """ headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', } variables = { 'owner': '<owner>', 'repo': '<repo>', 'query': query, } response = requests.post( 'https://api.github.com/graphql', json={'query': QUERY, 'variables': variables}, headers=headers, ) edges = response.json()['data']['repository']['refs']['edges'] return [edge['node']['name'] for edge in edges] |
類似度が最も高いブランチを選ぶ
|
1 2 3 4 5 6 7 8 9 10 11 12 |
import difflib def search_branch(branches: list, query: str) -> str: """difflib で一番近いブランチ名を選ぶ""" best_match = None highest_ratio = 0.0 for branch in branches: ratio = difflib.SequenceMatcher(None, branch, query).ratio() if ratio > highest_ratio: best_match = branch highest_ratio = ratio return best_match |
Python 標準ライブラリのdifflib.SequenceMatcherで文字列の類似度を計算し、最も近いブランチ名を採用する、という単純なアプローチです。これだけでも「develp」を「develop」に寄せるくらいの手助けはしてくれます。
まとめ
- PAT 管理はしんどい。GitHub App に置き換えると運用負荷もセキュリティリスクも下げられる。
- JWT → インストールアクセストークン →
repository_dispatchの流れで、外部から安全に GitHub Actions を起動できる。 - GraphQL API と
difflibを組み合わせれば、ブランチ名のタイポにも耐える Slack コマンドが作れる。
GitHub Actions 運用で PAT に消耗している方は、ぜひ GitHub App 化を検討してみてください。
Lambda 実装コード全体
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
import json, requests, os import time import jwt import difflib def lambda_handler(event, context): env = event.get('env', 'development') target_branch = event.get('branch', 'develop') private_key = aws_parameters('PRIVATE_KEY', decryption=True) jwt_token = generate_github_jwt(private_key) access_token = get_access_token(jwt_token) if target_branch != 'develop': branches = fetch_branches(access_token, target_branch) target_branch = search_branch(branches, target_branch) workflow_trigger(access_token, env, target_branch) return { 'statusCode': 200, 'body': json.dumps('command success!!'), } def aws_parameters(parameter_name: str, decryption: bool = False) -> str: aws_session_token = os.environ['AWS_SESSION_TOKEN'] headers = {'X-Aws-Parameters-Secrets-Token': aws_session_token} response = requests.get( f'http://localhost:2773/systemsmanager/parameters/get?name={parameter_name}&withDecryption={decryption}', headers=headers, ) return json.loads(response.text)['Parameter']['Value'] def generate_github_jwt(private_key: str) -> str: payload = { 'iat': int(time.time()), 'exp': int(time.time()) + 600, 'iss': aws_parameters('GITHUB_CLIENT_ID'), } return jwt.encode(payload, private_key, algorithm='RS256') def get_access_token(jwt_token: str) -> str: install_id = aws_parameters('GITHUB_INSTALL_ID') url = f'https://api.github.com/app/installations/{install_id}/access_tokens' headers = { 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/vnd.github+json', } data = {'repository': '<対象リポジトリ名>'} response = requests.post(url, json=data, headers=headers) return response.json()['token'] def fetch_branches(access_token: str, query: str) -> list: QUERY = """ query($owner: String!, $repo: String!, $query: String!) { repository(owner: $owner, name: $repo) { refs(refPrefix: "refs/heads/", first: 100, query: $query) { edges { node { name } } } } } """ headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', } variables = { 'owner': '<owner>', 'repo': '<repo>', 'query': query, } response = requests.post( 'https://api.github.com/graphql', json={'query': QUERY, 'variables': variables}, headers=headers, ) edges = response.json()['data']['repository']['refs']['edges'] return [edge['node']['name'] for edge in edges] def search_branch(branches: list, query: str) -> str: best_match = None highest_ratio = 0.0 for branch in branches: ratio = difflib.SequenceMatcher(None, branch, query).ratio() if ratio > highest_ratio: best_match = branch highest_ratio = ratio return best_match def workflow_trigger(access_token: str, env: str, branch: str) -> None: url = 'https://api.github.com/repos/<owner>/<repo>/dispatches' headers = { 'Authorization': f'Bearer {access_token}', 'Accept': 'application/vnd.github+json', } data = { 'event_type': f'deploy-{env}', 'client_payload': {'branch': branch}, } response = requests.post(url, json=data, headers=headers) if response.status_code != 204: raise Exception(f'Dispatch failed: {response.status_code} {response.text}') |


