🤾

Git SubmoduleをGitHub Actionsで利用するにはGitHub Appsが正解でした

2024/05/08に公開

複数リポジトリで共通利用したいシェルスクリプトをGit Submodule化し、GitHub Actionsで利用する方法を確立したので紹介します。シェルスクリプトでなくとも、複数リポジトリで共有利用したいプライベートコードの管理方法をお探しの方にお役に立てば幸いです。

Problem

OSSでも度々見かけますが、シェルスクリプトは利便性が高いため、ちょっとしたタスクの実行にヘビーユースしています(Nuxt.jsでのシェルスクリプト利用)。管理するリポジトリが増えてくると、大概そのシェルスクリプト再利用の要望が発生してきます。全てのリポジトリにスクリプトをコピペするのも芸がないですし、改修が発生した場合には効率よく反映させたいです。私が所属する企業でCI/CDに採用しているGitHub Actionsとの統合方法も検討する必要があります。

Solution

詳細は次章で説明しますが、それぞれ以下の技術を採用することにしました。

シェルスクリプトの共通化

Git Submoduleを利用することで、別のリポジトリの任意のポインタ(ブランチ、タグ、コミットID)を自身のリポジトリのサブディレクトリとして取り込み、まるで自身のコードベースの一部かの様に管理できます。指定するポインタの更新タイミングは取り込み側のリポジトリに委ねられるので、バージョン管理も柔軟に可能です。

Git Submoduleの操作については色々な記事がありますので詳細は割愛しますが、最低限の操作のイメージを記載しておきます。

Git Submoduleを追加する時の操作例。

# メインのリポジトリをチェックアウト
$ git clone main-repository
$ cd main-repository

# 別のリポジトリを、Submoduleとしてメインリポジトリに登録
$ git submodule add ../sub-module.git sub-module

# Submoduleの特定のポインタを指定
$ cd sub-module
$ git checkout v1.1.0

# 通常の変更と同じ様に、コミットしてプッシュする。メインリポジトリにはSubmoduleの情報と、ポインタのコミットIDが記録される
$ cd ../
$ git add .gitmodule sub-module
$ git push origin main

別の開発者がチェックアウトする操作の例。

# --recursiveオプションを指定することで、Submoduleのコードも一緒にチェックアウトする
$ git clone --recursive main-repository

# Submodule内のコードを、まるで自身のコードかの様に扱える
$ bash sub-module/build.sh

言及されている記事が少なかったので1点補足します。Git Submoduleとしてサブリポジトリを取り込む際のパスは、相対パス(メインリポジトリからの)がお勧めです。開発者がメインリポジトリとやりとりしているプロトコル(https、ssh等)がサブリポジトリにも引き継がれます。

This may be either an absolute URL, or (if it begins with ./ or ../), the location relative to the superproject’s default remote repository (Please note that to specify a repository foo.git which is located right next to a superproject bar.git, you’ll have to use ../foo.git instead of ./foo.git - as one might expect when following the rules for relative URLs - because the evaluation of relative URLs in Git is identical to that of relative directories).

-- https://git-scm.com/docs/git-submodule?utm_source=pocket_saves

代替案

今回は採用しませんでしたが、シェルスクリプトのpackage managerを謳っているbpkgも紹介しておきます。npmにインスパイアされた、JSONフォーマットでシェルスクリプトの依存関係を管理できるツールです。Spotifyがスポンサーになっている点も魅了的でしたが、Git Submoduleであればシェルスクリプト以外にも転用できるため、本件での採用は見送りました。しかしながら、npmと同様グローバルインストールの機能も実装されており、シェルスクリプトをインストールしてくれて、パスも通してくれる使い勝手のいいツールです。特定リポジトリに依存しない便利スクリプトの社内展開で今後利用予定です。

GitHub Actionsとの統合

GitHub Actionsでコードのチェックアウトは、自動生成されるアクセストークン(GITHUB_TOKEN)の権限で実行されます。ただしこのトークンの権限はメインリポジトリに限定され、他のリポジトリへのアクセス権限を追加することができません。

このトークンの権限の拡張方法は公式ドキュメント、自動トークン認証 > 追加の権限を付与するで言及されています。

GITHUB_TOKEN で利用できないアクセス許可を要求するトークンが必要な場合、GitHub App を作成し、ワークフロー内でインストール アクセス トークンを生成できます

技術検証の詳細は次章で説明しますが、結論は独自のGitHub アプリを作成し、Git Submodule化したサブリポジトリを含む権限を持ったアクセストークンを利用することで権限問題を解決できます。

独自GitHub Appの作成

本記事でのユースケースでは、メイン、サブリポジトリとも特定のOrganization配下にあるプライベートリポジトリが対象です。そのため独自GitHub アプリはOrganizationが所有するアプリとして登録します。個人開発であれば個人所有のアプリを登録することで実現できるはずです。

公式の手順に従って登録します。所属する企業によってはアプリ作成の権限が付与されていない可能性があるので、その際は管理部門に問い合わせください。

  1. https://github.com/organizations/<$YOUR_ORGANIZATION_NAME>/settings/apps/newにアクセスします。

  2. アプリを登録します。入力内容は以下を参考ください。

  3. アプリの登録が完了したら、https://github.com/organizations/<$YOUR_ORGANIZATION_NAME>/settings/apps/<$REGISTERD_APP_NAME>にアクセスします。

    1. App IDを控えます。
    2. Private keysセクションに移動しGenerate a private keyを押下します。秘密鍵がダウンロードされるので保管しておきます。

  4. https://github.com/organizations/<$YOUR_ORGANIZATION_NAME>/settings/apps/<$REGISTERD_APP_NAME>/installationsにアクセスし、アプリをインストールするOrganizationを選択します。

    1. Only select repositoriesにチェックし、メインリポジトリ、サブリポジトリを選択します。(GitHub Actionsでチェックアウトが必要な全てのリポジトリを選択します)
  5. (オプショナル)登録したGitHub アプリに追加管理者を登録します。https://github.com/organizations/<$YOUR_ORGANIZATION_NAME>/settings/apps/<$REGISTERD_APP_NAME>/managersにアクセスし、追加のアプリ管理者を登録します。

GitHub Actions Workflowでの利用方法

独自GitHub アプリの登録と、アプリのOrganizationへのインストールが完了したので、GitHub Actionsでの利用方法を説明します。

  1. メインリポジトリに、GitHub Appの作成手順で取得したApp IDをVariablesに、秘密鍵をSecretsに登録します。(手順)。
  2. create-githb-app-tokenアクションを使い、GitHub Appのインストール アクセス トークンを取得します。
  3. checkoutアクションで、2で生成したアクセストークンを指定し、Git Submoduleもチェックアウトするオプションを指定します。

以下の様なYAMLファイルを、メインリポジトリの.github/workflows/with-git-submodule.yamlディレクトリに配置します。

name: With Git Submodule

on:
  pull_request:
    types: [opened, reopened, synchronize]

jobs:
  runs-on: ubuntu-latest
  steps:
    - name: Generate a GitHub App install access token
      id: generate-token
      uses: actions/create-github-app-token@v1.9.3
      with:
        app-id: ${{ vars.GH_APP_ID }}
        private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
        # ownerをOrganization名にすることがポイントです。(詳細は次章)
        owner: "${YOUR_ORGANIZATION_NAME}"

    - name: checkout codebase
      uses: actions/checkout@v4
      with:
        token: ${{ steps.generate-token.outputs.token }}
        # この設定により、 git clone --recursiveライクな動きになります
        submodules: recursive
        # Make sure the value of GITHUB_TOKEN will not be persisted in repo's config
        persist-credentials: false

    # any steps

これで、メイン、サブリポジトリを一緒にチェックアウトでき、以降のステップで利用することが可能です。

以上がプライペートなGit SubmoduleをGitHub Actionsで利用する方法です。次章で各技術要素の詳細を説明します。

Discussion

Git Submodule、GitHub Actionsについてはインターネット上に良質な記事が存在しているので詳細は割愛しますが、GitHub Appのインストール アクセス トークンがイマイチ理解できなかったので、検証内容とあわせて私の理解内容を共有します。

GitHubに自動化処理(GitHub Actions含む)を実装する際の認証認可のパターンを比較する

まず、GitHubとやりとりする際の認証認可のパターンを調べました。デプロイーキーの管理で説明されていますが、GitHubとの認証認可は以下のパターンがあります。

認証認可パターン 説明 マルチリポジトリにおけるKO(Knock Out)要素
デプロイキー デプロイキーは、単一のリポジトリへのアクセス権を付与する SSHキーで、リポジトリに公開鍵を登録することで、ペアの秘密鍵があれば認証認可できる。2つ目以降のリポジトリに同じ公開鍵を登録しようとするとエラーで弾かれる。 単一リポジトリのみなのでKO。
マシンユーザーにSSHキーをアタッチ マシンユーザーを発行し、同ユーザーにアタッチしたSSHを利用する。 特定ユーザーに紐づくSSHキーの特性上(アクセスリポジトリの制限の観点)、自動化の単位でユーザーを作成する必要がありライセンス、運用コストの観点からKO。
PAT(Personal Acceess Token) 特定ユーザに紐づけるトークンで、アクセスできるリポジトリを制限できるfine-grainedと、リポジトリを制限できないclassicがある。前者は有効期限必須で、後者は有効期限無しも設定可能。ただしclassicは非推奨。 まず特定の個人ユーザーのPATの利用は一般論でKO。単一マシンユーザーを発行し、自動化単位で複数のPATを発行する方法もあるが、同マシンユーザーに複数ユーザーのアクセスを許可する必要があり、誤操作による事故のリスクが高いのでKO。
GitHub App インストール アクセス トークン アプリが独自で操作を実行する際に利用する。OAuthでいう”server-to-sever"のパターン。 KO要素なし。ただしレート制限がある様なので大量のリクエストを実行する場合は注意。

消去法でGitHub App インストール アクセス トークン一択になりました。GitHub Actionsのドキュメントでも、権限の拡張が必要な場合GitHub App インストール アクセス トークンの利用が推奨されています。

GitHub App インストール アクセス トークンの動作検証

”インストール アクセス トークン”という名詞に馴染みが無かったのでちゃんと調べてみました。GitHub Actionsでは自動生成されるアクセストークンGITHUB_TOKEN環境変数に設定されるトークンで、このトークンを使いリポジトリのチェックアウトが行われます。このトークンの正体は、GitHub Actionsを有効化した際にリポジトリにインストールされる内部 GitHub アプリ経由で生成されるインストール アクセス トークンで、OAuthの"server-to-server"認証フローで生成されます。GitHubアプリでは、"インストール"する際にアプリがアクセスできるリポジトリを指定しますが、それがそのままインストール アクセス トークンでアクセスできるリポジトリ群になります。ただし、自動生成されるアクセストークンを発行するアプリは、内部アプリのためアクセスできるリポジトリ設定を変更することができません。したがって、アクセスできるリポジトリをカスタマイズ可能な独自アプリをOrganizationに登録、インストールし、同独自アプリで生成したインストール アクセス トークンを利用する用にする必要があります。

独自アプリのインストール アクセス トークンは、create-github-app-tokenアクションで簡単に発行はできますが、裏で何が行われるか、実際のトークン発行処理をベースにブレイクダウンしていきます。

まず独自GitHubアプリのApp IDと秘密鍵を使ってJWT(JSON Web Token)を生成します。これは公式の手順で説明されています。

#!/usr/bin/env bash

set -o pipefail

app_id=$1 # App ID as first argument
pem=$( cat $2 ) # file path of the private key as second argument

now=$(date +%s)
iat=$((${now} - 60)) # Issues 60 seconds in the past
exp=$((${now} + 600)) # Expires 10 minutes in the future

b64enc() { openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n'; }

header_json='{
    "typ":"JWT",
    "alg":"RS256"
}'
# Header encode
header=$( echo -n "${header_json}" | b64enc )

payload_json='{
    "iat":'"${iat}"',
    "exp":'"${exp}"',
    "iss":'"${app_id}"'
}'

# Payload encode
payload=$( echo -n "${payload_json}" | b64enc )

# Signature
header_payload="${header}"."${payload}"
signature=$(
    openssl dgst -sha256 -sign <(echo -n "${pem}") \
    <(echo -n "${header_payload}") | b64enc
)

# Create JWT
JWT="${header_payload}"."${signature}"
printf '%s\n' "JWT: $JWT"

上記スクリプトで取得できたJWTトークンを使い、インストール アクセス トークンを取得します。

#!/usr/bin/env bash

set -o pipefail

jwt_token=$1 # JWT Token pre generated

## 独自アプリのインストールIDを取得。Organizationが所有するアプリが対象になるので、APIのエンドポイントは /orgs/<$YOUR_ORG_NAME>
installation_id=$(curl --request GET \
  --header "Accept: application/vnd.github+json" \
  --header "Authorization: Bearer $jwt_token" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --url "https://api.github.com/orgs/<$YOUR_ORG_NAME>/installation" | jq -r .id)

# インストール アクセス トークンを取得
curl --request POST \
  --header "Accept: application/vnd.github+json" \
  --header "Authorization: Bearer $jwt_token" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --url "https://api.github.com/app/installations/$installation_id/access_tokens" \
  -d '{"owner": "<$YOUR_ORG_NAME>"}'

上記スクリプトを実行すると、以下の様なインストールアクセストークンが取得できます。POSTのリクエストに{"owner": "<$YOUR_ORG_NAME>"}を含める事で、独自アプリのインストール時に設定した対象リポジトリ("repository_selection": "selected")への権限を持ったアクセストークンを取得できます。

{
  "token": "ghs_*******************",
  "expires_at": "2024-05-06T09:37:34Z",
  "permissions": {
    "contents": "read",
    "metadata": "read"
  },
  "repository_selection": "selected"
}

このアクセストークンを使い、例えばgit cloneは以下の様に実行できます。

git clone --recursive https://x-access-token:ghs_*******************@github.com/<$YOUR_ORG_NAME>/<$YOUR_REPOSITORY>.git

create-github-app-tokenアクションのオプションで、owner${YOUR_ORG_NAME}を設定するのはこのためです。repositoriesオプションでさらなる絞り込みもできますが、ownerにOrganization名を指定することでアプリのインストール時の設定が反映されるため、アクセスが必要なリポジトリは、アプリインストール設定で集中管理する。が最もシンプルに利用できるパターンかと考えます。

まとめ

以上プライベートなGit SubmoduleをGitHub Actionsで利用する方法について紹介しました。この記事では、SSOT(single source of truth)プラクティスを原則として、共通コードのコピペでの管理を悪としていますが、GitHub Copilotに代表されるLLMが隆盛してきている現在、むしろコピペしてしまい、コードベースのコンテキストを理解しやすいリポジトリ構成の方がAIフレンドリーかも。と思い悩も部分もあります。とはいえ、LLMはSubmoduleについても理解してくれる様になる事を期待し、人間ができるベストを尽くしていくつもりです。

Discussion