エムスリー Advent Calendar 2025 4 日目の記事です。
クラウド型電子カルテのデジカル開発チームで色々なことをやっている井上 (@wtr_in) です。一年を通して伊藤園の天然ミネラル麦茶を愛飲していますが、最近どうも味が変わった気がしています。(しませんか?)
さて、すでに当ブログでも過去に何人か記事を書いていますが、弊社では GitLab Server から GitHub Enterprise Cloud への移行を進めています。
デジカルチームでもリポジトリごとに順次移行を進めていますが、その際に GitHub Actions を使って Pull Request のマージ方式を間違えないための仕組みを作ったので、その内容を紹介します。
先に3行でまとめ
- チーム内で、Pull Request(以下 PR)の種類に応じて Squash マージする・しないのルールを決めているが、人間がそのルールどおり間違えずにマージボタンを押す運用は難しい
- GitHub Actions を使って、PR に特定のラベルをつけることで適切な方法で自動マージされる仕組みを作ってミスを防ぐようにした
- GitHub の Checks API と Ruleset を使うと、マージボタンを直接押す操作をブロックできて、より確実にミスを防ぐことができる
抱えていた課題
前提: PR を Squash マージしたい
Git および、GitHub などのホスティングサービスを利用した開発フローにおいて、PR をマージする際に Squash すべきかどうか、というのはよく議論になります。詳しくは他の記事に譲りますが、Squash というのは、差分に含まれる多数のコミットをすべて 1 つのコミットにまとめる、という操作のことです。
PR マージ時に Squash すべきかどうかについては、論点はいろいろありますが、個人的に思う重要な利点・欠点は以下のようなものです。
| Squash | 利点 | 欠点 |
|---|---|---|
| する | メインブランチの履歴が PR 単位できれいに並ぶ | Squash によって conflict が発生しがち*1, せっかくコミットをきれいに積んでも失われてしまう |
| しない | Squash するかしないか悩まなくて良い*2 | 雑に積んだコミットがメインブランチにそのまま残って履歴が汚れがち |
どちらが良いかはチームの開発スタイルにも依存するので正解はないのですが、私は基本的には「Squash したい」派です。あとで blame などで変更差分を見に行くときに、PR 単位の粒度でコミット並んでいる方が履歴を追いやすいというのが一番の理由です。
コミットをどのぐらいきれいに積むかは個人差が大きいものです。Squash しない場合、雑に積まれた fix みたいなコミットが main や develop ブランチに残ってしまうことも起きがちで、そうなると履歴を追うときのノイズとなってしまいます。
そして生まれる人間には複雑すぎるルール(主語デカ)
そういうわけで、Squash マージは活用したいのですが、かといってデジカルチームの場合、すべての PR を Squash マージしたいわけではありません。
デジカルでは、いわゆる GitHub Flow と Git Flow を混ぜたようなブランチ戦略を採用しています。ざっくりいうとこんな感じです。
- main を本番環境に、develop をテスト環境に継続的にデプロイ
- 基本的に develop から feature ブランチを切ってマージしていく
- hotfix は main に直接マージすることもある

このデプロイ戦略において、一度 Squash merge されて作られたコミットが、再度 Squash されるのは避けたい気持ちがあります。
例えば、feature -> develop と Squash マージされたあと、その変更を develop -> main などと別ブランチに持っていく場合にもう一度 Squash してしまうと、せっかく feature ブランチの PR 単位で粒度が揃ったコミットたちが、main ブランチではさらにまとめられてしまい、そのリリースにどの feature の PR が含まれていたか、というせっかくの情報が消えてしまいます。*3

加えて、規模の大きい開発の場合は、develop から epic ブランチを切った上で、そこからさらに孫ブランチを切り、epic ブランチにすべての変更を集約してから develop にマージする場合もあります。その場合は、epic -> develop も Squash マージは避けたい、ということになります。

それらのルールを条件として書き下すと、以下のようになります。*4
- PR の Source branch が Protected ブランチ (main/master/develop) の場合、Squash マージしない
- PR の Source branch が epic ブランチの場合、Squash マージしない
- 上記のいずれにも当てはまらない PR は、Squash マージする
これを人間が判断して、GitHub の PR 画面にあるマージボタンから適切なオプションを毎回間違えずに選んで押すのはなかなか困難です。
人間の限られた認知力・集中力をこのようなところで浪費するのはまったくもって得策ではなく、自動化でミスを防ぐべきタスクであることに異論はないでしょう。
やったこと
方針の検討
話をさかのぼると、GitHub 移行前に使っていた GitLab では、Merge Request (GitHub における PR) のリソースが Squash するか否かのフラグを状態として持っており、これを API から変更できました。そのため、MR の作成や編集を行った契機で、このチェックボックスを適切なものに変更する CI Job を実行して自動化を行っていました。

一方 GitHub では PR 自体はそのようなフラグは持っておらず、Squash するか否かはマージ実行時のオプションとして渡すような考え方になっています。

または GitHub CLI だと以下のようにオプションを渡します。
# Squash しない場合 (--no-ff merge) $ gh pr merge --merge # Squash する場合 $ gh pr merge --squash
GitLab CI で Squash 要否を判定していた発想からすると、このマージを実行する契機で何らかの GitHub Actions を実行してマージ方式を判定させたい気がしましたが、Web UI にあるマージボタン押下をフックとしてチェックなどの処理を割り込ませるのは難しそうでした。
そこからなるべくシンプルかつ素直な方法をあれこれ考えた結果、マージを実行したい場合、事前に決めた名称のラベルを付与し、その契機で GitHub Actions にマージまで実行させるのがシンプルで良さそうかなと思い至りました。
GitHub Actions によるマージ方式判定の自動化
方針が決まったら、あとは Claude なり Gemini なりに書いてもらいましょう。
今回の場合、Organization 配下の多数のリポジトリで同じ運用にしたかったので、Composite Action として共通化しました。
Composite Action のメインとなる、マージ方式の判定と実際のマージを実行する部分を抜き出すとこんなイメージです。マージ実行の契機となるラベル名は ready to merge としています。
なお、ここでは割愛していますが、事前に GitHub App を作成し、その権限を持ったトークンを使って PR の読み取りやマージを行っています。
name: "PR Merger" inputs: app_id: description: 'マージを実行する権限を持った GitHub App ID' required: true private_key: description: 'GitHub App の秘密鍵' required: true repository: description: 'リポジトリ名(例: owner/repo)' required: true pr_number: description: 'マージする PR の番号' required: true source_branch: description: 'マージする PR のソースブランチ' required: true target_branch: description: 'マージする PR のターゲットブランチ' required: true protected_branches: description: 'カンマ区切りの Protected ブランチのリスト (e.g. "main,master,develop,front-develop")' required: false default: 'main,master,develop,front-develop' merge_label: description: 'マージをトリガするラベル名' required: false default: 'ready to merge' runs: # ...略... - name: Determine merge method id: merge-method env: GH_TOKEN: ${{ ... }} REPO: ${{ inputs.repository }} PR_NUMBER: ${{ inputs.pr_number }} SOURCE_BRANCH: ${{ inputs.source_branch }} TARGET_BRANCH: ${{ inputs.target_branch }} PROTECTED_BRANCHES_INPUT: ${{ inputs.protected_branches }} shell: bash run: | echo "Source: $SOURCE_BRANCH -> Target: $TARGET_BRANCH" # デフォルトは (--no-ff) merge MERGE_METHOD="merge" # 1. Source が protected branch かチェック # カンマ区切りの文字列を配列に変換 IFS=',' read -ra PROTECTED_BRANCHES <<< "$PROTECTED_BRANCHES_INPUT" IS_PROTECTED=false for branch in "${PROTECTED_BRANCHES[@]}"; do # 前後の空白を削除 branch=$(echo "$branch" | xargs) if [[ "$SOURCE_BRANCH" == "$branch" ]]; then IS_PROTECTED=true echo "Source is a protected branch: $SOURCE_BRANCH" break fi done # 2. Source が epic ブランチかチェック(そのブランチを target とするマージ済み PR があるか) IS_EPIC_BRANCH=false # GitHub API で該当ブランチを base とするマージ済み PR を検索 if MERGED_PRS=$(gh pr list \ --repo "$REPO" \ --base "$SOURCE_BRANCH" \ --state merged \ --limit 1 \ --json number \ --jq 'length' 2>&1); then if [[ "$MERGED_PRS" -gt 0 ]]; then IS_EPIC_BRANCH=true echo "Source is an epic branch (has $MERGED_PRS merged PR(s) targeting it)" fi else echo "::warning::Failed to check merged PRs for branch $SOURCE_BRANCH. Assuming not an epic branch." echo "Error: $MERGED_PRS" fi # 判定: すべての条件を満たす場合のみ squash merge if [[ "$IS_PROTECTED" == false ]] && \ [[ "$IS_EPIC_BRANCH" == false ]]; then MERGE_METHOD="squash" echo "✅ All conditions met: Using SQUASH merge" else echo "❌ Conditions not met: Using --no-ff merge" echo " - Protected branch: $IS_PROTECTED" echo " - Epic branch: $IS_EPIC_BRANCH" fi echo "method=$MERGE_METHOD" >> $GITHUB_OUTPUT echo "Final merge method: $MERGE_METHOD" - name: Merge PR id: merge-pr env: GH_TOKEN: ${{ ... }} REPO: ${{ inputs.repository }} PR_NUMBER: ${{ inputs.pr_number }} MERGE_METHOD: ${{ steps.merge-method.outputs.method }} shell: bash run: | echo "Merging PR #$PR_NUMBER with method: $MERGE_METHOD" MERGE_FLAGS="--$MERGE_METHOD --repo $REPO" if ! gh pr merge $PR_NUMBER $MERGE_FLAGS 2>&1; then echo "::error::Failed to merge PR #$PR_NUMBER" exit 1 fi echo "✅ Successfully merged PR #$PR_NUMBER"
なお注意事項として、PR にラベルさえつけられれば GitHub App の権限でマージが実行できてしまうことになるため、例えば「ラベルは追加できるが、(本来)PR マージはできない」という Triage 権限を活用している場合には別の方法を考えましょう。
Checks API と Ruleset による手動マージのブロック
ここまでで、適切なマージ方法を自動判定して PR をマージする仕組みは実装できました。
あとは「手動でマージボタンは押すのは禁止! ラベルつけてマージしてね!」とチーム内で大きな声でアピールして運用を浸透させることもできるのですが、せっかくであればそこも仕組みでミスを防止したいところです。
ここで便利なのが GitHub の Checks (API) です。
Checks を使うと、PR で実行されるテストや Lint、コード解析などの結果(ステータス)を GitHub 側に返し、PR 画面にわかりやすく表示できます。
さらに、今回の場合便利なのが、Checks とリポジトリの Rulesets をセットで使うと、特定の Check が完了しない限り、PR のマージができないという制約を設定できます。

先ほど作成したマージ用 Action の中でマージ実行前に Check を作成するようにし、その Check がない限りマージを禁止する Ruleset を作成すると、PR 画面上のマージボタンは非アクティブとなり、押下できなくなります。
前述の yaml に追記するなら、Merge PR step の前に、このように Check の作成 Step を挿入します。
runs: steps: # ...略... - name: Create check result env: GH_TOKEN: ${{ ... }} REPO: ${{ inputs.repository }} PR_NUMBER: ${{ inputs.pr_number }} shell: bash run: | CHECK_NAME="Merge Approval by Digikar Action" if ! gh api repos/$REPO/check-runs -X POST -F name="$CHECK_NAME" -F head_sha=$(gh pr view $PR_NUMBER --repo "$REPO" --json headRefOid --jq '.headRefOid') -F status="completed" -F conclusion="success" 2>&1; then echo "::error::Failed to create check run for PR approval" exit 1 fi echo "✅ Created check run: $CHECK_NAME" - name: Merge PR
こうすることで、Ruleset を適用したリポジトリの PR においては、常にこのマージ前に必須の Check が完了していない状態になるので、マージボタンの押下はブロックされます。

なおこの方法を使うと、もし他にもマージ前に必須の Checks を設定している場合、PR 画面で実際にマージ可能な状態なのかがちょっと判別しづらくなる(常に未完了の Check があるため)というデメリットはあります。とはいえ、他の必須 Checks がパスしていない限りラベルによる自動マージも失敗するので、安全性の面で大きな懸念はないかなと思います。
We are Hiring!
私が所属するデジカルチームでは、クラウド型電子カルテを開発したい方はもちろん、このような地味な自動化で開発者体験を改善するのが好きな方も絶賛募集中です! 開発チームの紹介資料もありますので是非ご覧ください。
デジカルチーム以外でもエンジニアを絶賛募集中しておりますので、興味を持って頂けた方、カジュアル面談や採用へのご応募をお待ちしています。
*1:よくあるケースだと、依存関係がある複数の feature ブランチがあるときに conflict しがちです。例えば、feature-1 の作業の途中で、その作業(差分)に依存する feature-2 ブランチを切り、そこからさらに両ブランチでコミットを積んでいった状態で、feature-1 を先にメインブランチに Squash マージすると、共通で持っていた commit の hash が変わってしまい conflict の原因となります。
*2:Squash しない場合は、一律で --no-ff のマージコミットにするケースが多いかなと思います。
*3:ここもブランチ戦略などに依存するので、シンプルにすべての PR を Squash する考え方もあるでしょう。単に私のチームではそう判断しているというだけです。
*4:実際にはこれに加えて、"PR が Protected branch に向けた conflict 解消 PR の場合、Squash マージしない" というルールもありますが、説明がややこしくなるので省略しました。例えば develop into main の PR が conflict した場合、ローカルで develop からコンフリクト解消ブランチを切り、そこに main をマージしてコンフリクト解消作業を行った上で develop 向けに PR を作成して反映しますが、その際も main に含まれる Squash 済みコミット(直接 main に向けた hotfix PR など)を潰さないように Squash せずにマージしたい、という感じです。