🤾

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

2024/05/08に公開

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

Problem

シェルスクリプトは可読性が抜群なので、ちょっとしたタスクランナーとしてヘビーユースしています。とあるMonorepoプロジェクトで開発したシェルスクリプト群が評価され、他のプロジェクトでも利用させて欲しいとの要望が上がってきました。
コピペするのでは芸がないですし、改修もシステマティックに伝搬させたいです。
私が所属する企業でCI/CDに採用しているGitHub Actionsとの統合方法も検討が必要です。

Solution

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

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

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

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

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

$ git clone <$MAIN_REPOSITORY>
$ cd <$MAIN_REPOSITORY>
$ cd checkout -b <$WORKING_BRANCH>

# 開発者好みのプロトコルで操作できるよう相対パスを推奨
$ git submodule add ../<$SUB_REPOSTIORY> <$DIR_NAME_INJECTED>

# 特定のポインターを指定
$ cd <$DIR_NAME_INJECTED>
$ git checkout <$SUB_RESPOSITORY_TAG>

# 通常のコードのようにコミットしてプッシュ
$ cd ../
$ git add .gitmodule <$DIR_NAME_INJECTED>
$ git push origin <$WORKING_BRANCH>

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

# --recursiveオプションが必要
$ git clone --recursive <$MAIN_REPOSITORY>

# 既にクローン済みのリポジトリでの操作
$ git clone <$MAIN_REPOSITORY>
# submoduleの取り込み、更新
$ git submodule update --init

言及されている記事が少なかったので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との統合

詳細は次章で説明しますが、デプロイキー、PAT(Personal Access Token)も検証したものの、独自GitHub Appのインストールトークンがベストな方法と言えそうです。

GitHub Actionsでは、自動生成されるアクセストークンを使いリポジトリのチェクアウト等を実施します。ただしこのトークンの権限はメインリポジトリに限定されています。

追加の権限を付与するに記載の通り、独自のGitHub アプリを作成し、Git Submodule化したサブリポジトリを含む権限を持ったアクセストークンを利用することで、GitHub Actionsとの統合が可能になります。

独自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 Git App installe 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

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

Discussion

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

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

デプロイーキーの管理で説明されていますが、自動化処理を実施するにあたっては以下のパターンがあります。

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

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

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

GitHub Actionでは自動生成されるアクセストークン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/<$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": "<$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 --recursieve https://x-access-token:ghs_*******************@github.com/<$YOUR_ORG_NAME>/<$YOUR_REPOSITORY>.git

create-github-app-tokenアクションのオプションで、ownerを設定するのはこのためです。repositoriesオプションでさらに絞り込みもできますが、ownerにOrganization名を指定し、アプリのインストールの設定でアクセスできるリポジトリを絞るのが最もシンプルな利用パターンかと思われます。

まとめ

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

Discussion