カタツムリワークスのリポジトリは、親リポジトリが api / blog / web / infra の 4 つを Git submodule で束ねる構成にしています。便利なのですが、運用していると地味に面倒なことが一つありました。子リポジトリを更新するたびに、親の submodule pointer を手で追従させる必要があることです。

今回はこれを GitHub Actions で自動化したので、その記録を残しておきます。同じように submodule で monorepo を組んでいる方の参考になれば嬉しいです。

何が面倒だったか

submodule は「親が、子の特定コミットを指している」状態を持ちます。なので子リポジトリの main を進めても、親はそれを自動では追いかけてくれません。毎回こういう作業が要ります。

git submodule update --remote
git add api blog web infra
git commit -m "submodule pointer を最新 main に追従"

そして親の main は保護していて直 push できないので、この追従だけのためにいちいち PR を作ることになります。1 日に何度も子を更新すると、この「追従 PR」がどんどん積み上がって、本筋と関係ないノイズになっていきます。

参考: Git のさまざまなツール - サブモジュール

やりたかったこと

  • 子リポジトリの main が進んだら、親の submodule pointer を自動で追従させたい
  • main は保護したまま(直 push はしない)にしたいので、PR を作って即マージする形にしたい

子側からイベントを飛ばす案(repository_dispatch)も考えましたが、子 4 つに同じ設定を配るのは DRY じゃないなと思い、親の cron 一箇所に集約することにしました。30 分ごとに全 submodule をまとめて確認し、変化があれば PR を作って squash merge する、という素朴な作りです。

ハマったところ:GITHUB_TOKEN だと PR が作れない

最初は何も考えず、ワークフロー標準の GITHUB_TOKENgh pr create しようとしました。すると、こう言って弾かれます。

GitHub Actions is not permitted to create or approve pull requests

これは organization / リポジトリの設定で、「GitHub Actions に PR の作成・承認を許可するか」 がデフォルトで無効になっているためでした。セキュリティ的にはまっとうな挙動です(Actions が勝手に PR を量産・自己承認できると怖いので)。

参考: GitHub Actions が pull request を作成・承認できないようにする

設定を緩める手もありましたが、組織のポリシーを下げるのは避けたかったので、PR の作成・マージ用に PAT(Personal Access Token)を別途用意して、そちらを使うことにしました。この記事では SYNC_PAT という名前の secret にしています(値そのものは当然どこにも書きません)。

ついでに submodule の fetch も、private な infra を含むので PAT 経由にしておきます。.gitmodules は SSH URL なので、insteadOf で PAT 付きの https に差し替えてあげると通ります。

git config --global \
  url."https://x-access-token:${SYNC_PAT}@github.com/".insteadOf "git@github.com:"

参考: git config - url.<base>.insteadOf

実際のワークフロー

要点だけ抜き出すとこんな感じです。

on:
  schedule:
    - cron: '*/30 * * * *' # 30 分ごと(多少遅延することあり)
  workflow_dispatch: # 子をマージ直後にすぐ反映したいとき用の手動実行

permissions:
  contents: write
  pull-requests: write

concurrency:
  group: bump-submodule-pointers
  cancel-in-progress: false

jobs:
  bump:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: false
          fetch-depth: 0

      - name: Configure git auth (SYNC_PAT)
        run: |
          git config --global \
            url."https://x-access-token:${SYNC_PAT}@github.com/".insteadOf "git@github.com:"
        env:
          SYNC_PAT: ${{ secrets.SYNC_PAT }}

      - name: Update submodules to remote main
        run: |
          git submodule sync --recursive
          git submodule update --init --remote --recursive

      - name: Open PR and squash merge if changed
        env:
          GH_TOKEN: ${{ secrets.SYNC_PAT }} # PR 作成/マージは PAT
        run: |
          set -euo pipefail
          if [[ -z "$(git status --porcelain)" ]]; then
            echo "変化なし。スキップ。"; exit 0
          fi
          STAMP="$(date -u +%Y%m%d-%H%M%S)"
          BRANCH="chore/auto-bump-submodule-pointers-${STAMP}"
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git checkout -b "$BRANCH"
          git add api blog web infra
          git commit -m "chore: submodule pointer を最新 main に自動追従 (${STAMP})"
          git push -u origin "$BRANCH"
          PR_URL=$(gh pr create --base main --head "$BRANCH" \
            --title "chore: submodule pointer を最新 main に自動追従 (${STAMP})" \
            --body "各子 repo の最新 main に追従する自動 PR です。")
          gh pr merge "$PR_URL" --squash --delete-branch

ポイントをいくつか。

  • workflow_dispatch も付けておくと安心です。cron は最大で 30 分待ちますが、子をマージした直後に手動実行すればすぐ反映できます。 参考: workflow_dispatch
  • concurrency で多重起動を防ぐ。cron と手動実行が重なっても、追従 PR が二重に走らないようにしています。 参考: concurrency の使用
  • 変化がなければ即スキップgit status --porcelain が空なら何もしないので、空っぽの PR が乱立しません。
  • PR 作成と submodule fetch だけ PAT、それ以外は標準のまま。権限は必要なところに最小限で渡すのが安心かなと思います。

結果

これで、子リポジトリの PR をマージしたあとは(最大 30 分後、急ぐときは手動実行で即座に)親の pointer が勝手に追従するようになりました。手で追従 PR を作る作業がまるごと消えて、本筋の変更だけに集中できるようになっています。

submodule での monorepo はこの「pointer 追従」が地味な摩擦になりがちなので、CI に肩代わりさせると一気に楽になりました。GITHUB_TOKEN の PR 作成制限にだけ気をつければ、仕組み自体はかなりシンプルです。