Pull requestがマージされたとき、異なるリポジトリにPull requestを作成する

こんにちはKenzです。
今回は、あるリポジトリのPull requestがマージされたときに、異なるリポジトリにその変更を反映するPull requestを作るGithub Actionsのワークフローを作る方法を紹介します。

# 複数リポジトリの連携を自動化したい
GitHubで他のリポジトリに依存したリポジトリを作ることがありませんか？
例えば複数プロジェクトで共通利用しているライブラリのリポジトリとそのライブラリを使うアプリというような場合です。

そのような場合、ライブラリ側のリポジトリに変更があった時、それを使うアプリのリポジトリでライブラリの変更を取り込み、ビルドをやり直したPull requestを作る必要があります。
この作業は定型的になりがちなので自動化したいところです。

今回はリポジトリの変更を検知して、異なるリポジトリへPull request作成を自動化するGitHub Actionsのワークフローを作る方法を紹介します。

説明を簡単にするため、依存される側のリポジトリをライブラリのリポジトリ、依存するリポジトリをアプリのリポジトリと呼ぶことにします。
もちろん、アプリとライブラリに限らずVMのイメージやMLのモデルとそれを使うKubernetesなど、様々な場面で使用することができます。

# ライブラリリポジトリの実装
## Pull requestを作る条件
最も簡単にPull requestを作成することを考えるなら、ライブラリリポジトリのPull requestがマージされたタイミングで無条件にアプリリポジトリのPull requestを作成するだけです。
ところがそれだとライブラリの `README.md` を更新するだけでアプリのリポジトリにPull requestが作成されてしまいノイズとなってしまいます。
そこで、ライブラリリポジトリで特定のファイルに変更が加わったときのみ、Pull requestを作成するようにします。

### アプリリポジトリのPull requestはアプリリポジトリで作る
アプリリポジトリのPull requestをライブラリリポジトリから直接作成することもできますが、そうするとライブラリのワークフローにアプリのリポジトリでPull requestのWrite権限を与える必要が出てしまいます。
ライブラリが複数のアプリで使用されている場合、ライブラリのPull requestが使用するリポジトリに対して過剰な権限を持つことになりあまり望ましくないです。
そこで、ライブラリのリポジトリからはアプリのワークフローを呼ぶだけとし、アプリリポジトリのPull requestはアプリリポジトリ自体のワークフローが作成するようにします。

### ライブラリリポジトリの差分を取る
アプリのリポジトリでPull requetを作る必要があるかどうかを判断するために、ライブラリのリポジトリで行われたPull requestでの変更点を洗い出します。
Create a merge commitあるいはSquasy and mergeを使用した場合、
```bash
git diff HEAD^ --name-only
```
を行うことでマージコミットを見て変更内容の一覧を簡単に得ることができます。

特定フォルダ内の変更のみに限定する場合は
```bash
files=(`git diff HEAD^ --name-only --relative=<フォルダ名>|tr '\n' ' ' `)`
```
のようにrelativeスイッチを使用します。

```yaml
name: library_update

on:
  pull_request:
    branches:
      - "main"
    types: [closed]

jobs:

  check_after_merge:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        name: checkout
        with:
          fetch-depth: 2
      - name: check_diff
        id: check_diff
        shell: bash
        run: |
          files=(`git diff HEAD^ --name-only --relative=src|tr '\n' ' ' `)
          echo "changed_files=${files%,*}" >> $GITHUB_OUTPUT
```

## アプリリポジトリのワークフローを呼び出す
該当のファイルに変更があった場合、アプリリポジトリのPull requestを作成します。
上記で説明した通り、ライブラリリポジトリのワークフローで直接アプリリポジトリのPull requestを作成するのではなく、ライブラリリポジトリのワークフローはアプリリポジトリのAPIを呼び、Pull requestの作成はアプリリポジトリのワークフローが行います。

### アプリリポジトリへアクセス可能なトークンの作成
アプリリポジトリAPIを呼び出せるように、まずはトークンを作成します。
アクセストークンには Fine-grained personal access tokenとPersonal access tokens (classic)がありますが、今回はよりセキュリティレベルの高いFine-grained personal access tokenを使用します
https://github.com/settings/tokens を開いて、Fine-grained personal access tokensを選び、`Generate new token` を選びます。
Token nameに識別可能な名前を設定
Repository accessにOnly select repositoriesを選択しアプリポジトリを選びます。
PermissionsのContentsにRead and Writeの権限を付与してGenerate tokenをクリックするとトークンが表示されるのでコピーします。
次にライブラリリポジトリの`Settings`を開き、`Secrets and variables`の`Actions`を選びます
`New repository secrets`を選び、Nameに `CALL_DISPACHER` Secretに先ほど表示されたトークンを貼り付けます。
このトークンを使用してライブラリのGithub ActionsからアプリのGithub Actionsを実行します。

### アプリリポジトリのAPIをPOSTする
 アプリのGithub Actionsを呼ぶには先ほど作成したトークンを使用して、 `https://api.github.com/repos/<アプリリポジトリのオーナー>/<アプリリポジトリ>/dispatches` をPostします。
 
アプリリポジトリが `https://github.com/kenz/sample_app_repository` の場合は次のようになります。
```bash
curl \
-X POST \
-H "Authorization: token ${{ secrets.CALL_DISPACHER }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/kenz/sample_app_repository/dispatches \
-d '{"event_type":"update-library","client_payload":{"date": ”today" }}'
```

`event_type`にはこれからアプリリポジトリに作成するワークフローの名前を設定します。
`client_payload`には追加で送りたい値を設定します。
例えば、srcフォルダ内のファイルが変更された場合に、変更されたファイルの一覧を送りたい場合、ワークフローは次のようになります。

```yaml
name: library_update

on:
  pull_request:
    branches:
      - "main"
    types: [closed]

jobs:

  check_after_merge:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        name: checkout
        with:
          fetch-depth: 2
      - name: check_diff
        id: check_diff
        shell: bash
        run: |
          files=(`git diff HEAD^ --name-only --relative=src|tr '\n' ' ' `)
          echo "changed_files=${files%,*}" >> $GITHUB_OUTPUT
      - name: call_app
        shell: bash
        if: ${{ steps.check_diff.outputs.changed_files != '' }}
        run: |
          curl \
            -X POST \
            -H "Authorization: token ${{ secrets.CALL_DISPACHER }}" \
            -H "Accept: application/vnd.github.v3+json" \
            https://api.github.com/repos/kenz/sample_app_repository/dispatches \
            -d '{"event_type":"update-library","client_payload":{"change_files": ”${{ steps.check_diff.outputs.changed_files }}", "pull_request_url": "${{ github.event.pull_request.html_url }}" }}'
```

このyamlファイルをライブラリのリポジトリ内 `.github/workflows`ディレクトリ配下に保存します。
これで送信側のワークフローは完成です。

# アプリリポジトリ側のワークフローを作る。
つぎに、送られたリクエストを元にPull reuqestを作成するワークフローをアプリのリポジトリに作成します。  
## アプリリポジトリで使用するトークンを追加
まずはライブラリリポジトリ同様にPull requestを作るトークンを作成します。
https://github.com/settings/tokens を開いて`Fine-grained personal access tokens`を選び、`Generate new token` を選びます。
Token nameに識別可能な名前を設定
Repository accessにOnly select repositoriesを選択しアプリポジトリを選びます。
PermissionsのContentsと **Pull requests** にRead and Writeの権限を付与してGenerate tokenをクリックするとトークンが表示されるのでコピーします。
アプリリポジトリの`Settings`を開き、`Secrets and variables`の`Actions`を選びます
`New repository secrets`を選び、Nameに `CREATE_PULL_REQUEST_TOKEN` Secretに先ほど表示されたトークンを貼り付けます。
このトークンを使用してアプリリポジトリにPull requestを作成します。
## Pull requestを作るワークフロー
次にPull requestを作成するワークフローをアプリリポジトリの.github/workflows内に作ります。

### 実行条件と入力値
このワークフローはライブラリリポジトリからdisplach APIが呼ばれたときに実行するため、実行条件は `repository_displach` とし `types` にはevent_typeで指定したのと同じ名前を指定します。
Pull requestを作成するために`permission`として`contents`と`pull-requests`、`id-token`のwrite権限を指定します。
ライブラリリポジトリから送られてくるchange_filesやpull_request_urlなどの追加情報は
` ${{ github.event.client_payload.change_files}}` のように指定することで取得することができます。
ここでは移行性をよくするためにenvへ移し替えています。

```yaml
name: Create PR of Update library

on:
  repository_dispatch:
    types: [update-library]

permissions:
  contents: 'write'
  pull-requests: 'write'
  id-token: 'write'

env:
  CHANGE_FILE: ${{ github.event.client_payload.change_files}}
  PULL_REQUEST_URL : ${{ github.event.client_payload.pull_request_url }}
  GIT_PR_BASE_BRANCH: main
  GIT_PR_BRANCH: feature/library-update
```
### ブランチの作成
ブランチを作ります
ブランチはgitコマンドでローカルで実行するときと同じように作成します。
git configでユーザー名やメールアドレスを指定します。

```yaml
jobs:
  create-branch:
    runs-on: ubuntu-24.04
    steps:

      - uses: actions/checkout@v4
        with:
          ref: main

      - name: create-branch
        run: |
          git remote set-url origin https://github-actions:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}
          git config --global user.name "auto-pull-request-creater"
          git config --global user.email "foo@example.com"  
          git pull origin ${{ env.GIT_PR_BRANCH }} 2>/dev/null || git checkout -b ${{ env.GIT_PR_BRANCH }}
          git push -f origin HEAD:${{ env.GIT_PR_BRANCH }};
```
今回ブランチ名は説明を省くために`feature/library-update`と固定にしていますが 、複数のライブラリなどから汎用的に使えるようにするにはブランチ名も`client_payload`で渡すなどして切り分けたほうが良いかと思います。
### コミットを切る
ライブラリの変更を反映するコミットを切ります。
更新されたライブラリをどのように取り扱うかはそれぞれのプロジェクトによって変わるためここでは触れずに、単に渡されたファイル名をlibrary.txtに追記していくだけとしています。
各自のプロジェクトに適合するように書き換えてください。
```yaml
      - name: create-commit
        run: |
          printf "${{ env.CHANGE_FILE }}" >> library.txt
          git add library.txt
          git commit -m "Update library"
          git push origin ${{ env.GIT_PR_BRANCH }}
```
### Pull requestの作成
最後にPull requestを作成します。
環境変数`GH_TOKEN`にSecretに格納したトークンを渡すのを忘れないようにしてください。
タイトルは決め打ち、本文はライブラリリポジトリから渡されたPullRequestのURLを表示するようにしています。
この部分についても必要に応じて書き換えてください。
オプションでレビューアーを自動で付与するなどの設定も可能です。
```yaml
      - name: Create a pull request
        env:
          GH_TOKEN: ${{ secrets.CREATE_PULL_REQUEST_TOKEN}}
        run: |
          already_pr=(\
            $( gh pr list --base ${{ env.GIT_PR_BASE_BRANCH }} \
              --head ${{ env.GIT_PR_BRANCH }} \
              --state open \
              --base main \
              --json number | jq '. | length'))
          if [ $already_pr = 0 ] ; then 
            gh pr create --base ${{ env.GIT_PR_BASE_BRANCH }} \
                --head ${{ env.GIT_PR_BRANCH }} \
                --body "created by ${{ env.PULL_REQUEST_URL }} " \
                --title "Update library"
          else
            printf "already exist pull request"
          fi    
```
## アプリリポジトリのワークフロー
ワークフロー全体は次のようになります。
```yaml
name: Create PR of Update library

on:
  repository_dispatch:
    types: [update-library]

permissions:
  contents: 'write'
  pull-requests: 'write'
  id-token: 'write'

env:
  CHANGE_FILE: ${{ github.event.client_payload.change_files}}
  PULL_REQUEST_URL : ${{ github.event.client_payload.pull_request_url }}
  GIT_PR_BASE_BRANCH: main
  GIT_PR_BRANCH: feature/library-update
  
jobs:
  create-branch:
    runs-on: ubuntu-24.04
    steps:

      - uses: actions/checkout@v4
        with:
          ref: main

      - name: create-branch
        run: |
          git remote set-url origin https://github-actions:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}
          git config --global user.name "auto-pull-request-creater"
          git config --global user.email "foo@example.com"  
          git pull origin ${{ env.GIT_PR_BRANCH }} 2>/dev/null || git checkout -b ${{ env.GIT_PR_BRANCH }}
          git push -f origin HEAD:${{ env.GIT_PR_BRANCH }};

      - name: create-commit
        run: |
          printf "${{ env.CHANGE_FILE }}" >> library.txt
          git add library.txt
          git commit -m "Update library"
          git push origin ${{ env.GIT_PR_BRANCH }}

      - name: Create a pull request
        env:
          GH_TOKEN: ${{ secrets.CREATE_PULL_REQUEST_TOKEN}}
        run: |
          already_pr=(\
            $( gh pr list --base ${{ env.GIT_PR_BASE_BRANCH }} \
              --head ${{ env.GIT_PR_BRANCH }} \
              --state open \
              --base main \
              --json number | jq '. | length'))
          if [ $already_pr = 0 ] ; then 
            gh pr create --base ${{ env.GIT_PR_BASE_BRANCH }} \
                --head ${{ env.GIT_PR_BRANCH }} \
                --body "created by ${{ env.PULL_REQUEST_URL }} " \
                --title "Update library"
          else
            printf "already exist pull request"
          fi
```

ということで、今回はPull requestを送る方法を紹介しました。
地味だけれど、大量に似たようなPull requestを作っている場合などに思い出してください。