はじめに おはようございます、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分以内に設定します。 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時間です。 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 にさらに細かい権限やリポジトリ指定を加えることで、インストール時の権限からもう一段絞ったトークンを発行できます。 data = { 'repository': '<対象リポジトリ名>', 'permissions': { 'actions': 'write', }, } 最小権限の原則を徹底したい場合は、呼び出しごとに必要な権限だけを付与したトークンを発行するのがおすすめです。 4. GitHub Actions を repository_dispatch で発火する 取得したトークンを使って、 repository_dispatch エンドポイントを叩きます。これで GitHub Actions のワークフローを外部から起動できます。 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 を指定しておきます。 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 からブランチ一覧を取得し、あいまいマッチで最も近いブランチを選ぶ 処理を入れておくと便利です。 ブランチ一覧を取得する 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] 類似度が最も高いブランチを選ぶ 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 実装コード全体 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}')