tech.guitarrapc.cóm

Technical updates

GitHub Actionsからgit pushをするパターン

GitHub Actionsからgit pushを実行する機会は少なくありません。例えば自動フォーマッタやドキュメント生成、特定ファイルの中身を更新などをした人は多いんじゃないでしょうか。 今回は、GitHub Actionsでgit pushをするときに私がやっている方法の備忘録です。

はじめに

GitHub Actionsのワークフローは基本的に並列動作します。厳密には制御しえますが、GitHub Actionsとしては並列に動作することを前提に設計されており、直列実行をサポートする機能は限定的と言わざるを得ません。git pushは並列動作と相性が悪く、同一リモートrefに対して複数のワークフローやジョブがgit pushを実行すると、競合が発生して失敗します。

そこで、今回は以下の2つのパターンでの対処法を紹介します。

  1. 同一ワークフローで何度もpushしたくなったら
  2. 複数ワークフローでpushしたくなったら

同一ワークフローで何度もpushしたくなったら

同一ワークフローで何度もgit pushをしたくなる例として、Matrixを使うケースがあります。よくあるのがマルチプラットフォーム向けのビルドです。Linux、Windows、macOS向けにビルドして、それぞれの成果物を同一ブランチにpushしたい、みたいなケースが想像しやすいでしょう。

  • ジョブAでOSごとに並列実行してgit pushを実行

素直にワークフローを書くと以下のようになります。1最初のgit pushは成功しますが、2番目以降のジョブ2git pushが失敗します。これは、1つ目と2つ目のジョブでチェックアウトタイミングはほぼ同一なのに、2つ目以降のgit pushはcheckout時点と比べてリモートが更新されているためです。

# 省略....

jobs:
  build:
    permissions:
      contents: write # pushするのでwriteが必要
    strategy:
      matrix:
        build-os: ["linux_x64", "linux_arm64", "windows_x64", "windows_arm64", "macos_arm64"]
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
      - name: Build
        run: |
          echo "do build"
          echo "build artifact ${{ runner.os }}/${{ runner.arch }}" >> .publish/binary_${{ runner.os }}/${{ runner.arch }}
      - name: Configure git for push
        run: |
          git remote set-url origin "https://github-actions:${GITHUB_TOKEN}@github.com/${{ github.repository }}"
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Commit build output
        run: |
          git switch -c "auto/create_pr" || git switch "auto/create_pr"
          git add .
          git commit -m "[automate] add build artifact for ${{ runner.os }}/${{ runner.arch }}"
          git push origin HEAD:${{ github.ref }} # <-- ここでリモート更新済みのためエラーが出る

この場合、それぞれのジョブでgit pushするのをやめて、代わりにgit pushを実行するジョブを分離して1回にしましょう。修正すると次のようになります。

# 省略....

jobs:
  build:
    permissions:
      contents: read # pushしないのでreadだけ
    strategy:
      matrix:
        build-os: ["linux_x64", "linux_arm64", "windows_x64", "windows_arm64", "macos_arm64"]
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    outputs:
      build-artifacts: ${{ steps.set-artifacts.outputs.build-artifacts }}
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
      - name: Build
        run: |
          echo "do build"
          echo "build artifact ${{ runner.os }}/${{ runner.arch }}" >> .publish/binary_${{ runner.os }}/${{ runner.arch }}
      - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
        with:
          name: artifact-${{ runner.os }}/${{ runner.arch }}
          path: .publish/
          retention-days: 1

  # git pushを1回だけ実行するようにジョブをわける
  push:
    needs: [build] # buildジョブの後に実行するように制御
    permissions:
      contents: write # pushするのでwriteが必要
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
      - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
        with:
          path: artifacts # ダウンロード先があれば指定する
      - name: Configure git for push
        run: |
          git remote set-url origin "https://github-actions:${GITHUB_TOKEN}@github.com/${{ github.repository }}"
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Commit build output
        run: |
          git checkout -b "${NEW_BRANCH_NAME}"
          git add .
          git commit -m "[automate] add build artifacts"
          git push -u origin "${NEW_BRANCH_NAME}"
        env:
          NEW_BRANCH_NAME: auto/create_pr

複数ワークフローでpushしたくなったら

同一ブランチに対して異なる処理をする複数のワークフローが存在する場合、この問題が発生します。例えば、ドキュメント生成ワークフローとソースコードフォーマッターワークフローがあって、両方とも同一ブランチにpushしたい、みたいなケースを考えましょう。

  • ワークフローAでDocs/のファイルを更新してpush
  • ワークフローBでSrc/のフォーマッターを実行してpush

このような場合、最初にpushしたワークフローやジョブは成功しますが、後からpushしようとしたワークフローはcheckout時点と比べてリモートが更新されているため、git pushは失敗します。

- name: git push
  shell: bash
  run: |
    git checkout -b "${NEW_BRANCH_NAME}"
    git add .
    git commit -m "[automate] foobar"
    git push -u origin "${NEW_BRANCH_NAME}"

どっちのワークフローが先に実行するか実行順序は保証されていないため、複数ワークフローが同一ブランチにpushする場合はリトライロジックを入れるのがおすすめです。以下の例は、リモートが更新されてgit pushに失敗した場合、git pull --rebaseでリモートの変更を取り込んでから再度pushを試みる例です。これは最大3回までリトライします。

- name: git commit
  run: |
    git checkout -b "${NEW_BRANCH_NAME}"
    git add .
    git commit -m "[automate] foobar"
- name: git push
  shell: bash
  run: |
    max_retries=3
    retry_count=0

    while [ $retry_count -lt $max_retries ]; do
      echo "try 'git push' ($((retry_count + 1))/${max_retries}) ..."
      if git push origin "$NEW_BRANCH_NAME"; then
        echo "Push succeeded."
        exit 0
      fi

      # Push failed, increment retry count
      ((retry_count++))

      # If we haven't exceeded max retries, try to rebase and retry
      if [ $retry_count -lt $max_retries ]; then
        echo "Failed to push, try 'git pull --rebase' to resolve ..."
        if ! git pull origin "$NEW_BRANCH_NAME" --rebase; then
          echo "'git pull --rebase' has problem, you need resolve conflict ..."
          exit 1
        fi
        echo "Rebase succeeded, will retry push."
      fi
    done

    echo "max retry reached, but failed to push."
    exit 1

まとめ

GitHub Actionsからのgit pushはうっかり競合が発生しやすいため、工夫が必要です。今回は2つのパターンでの対処法を紹介しました。 他にもいろいろなやり方がありますが、基本的には以下のポイントを押さえておけば大丈夫でしょう。

  • 同一ワークフロー内で複数ジョブがpushする場合は、pushを1つのジョブにまとめる
  • 複数ワークフローがpushする場合は、リトライロジックを入れる

補足: お約束

ワークフローで使っている、どのパターンでも共通するお約束です。以前にも紹介したGitHub Actions脆弱性を考慮した書き方です。

Permissionsを指定する

Permissionsは原則指定しましょう。git pushをする場合はcontents: writeが必要です。

# NG
# permissionsがない

# OK
permissions:
  contents: write

timeout-minutesを指定する

ジョブのタイムアウトは指定しましょう。デフォルトは6時間ですが、ビルドやgit pushだけなら3-5分もあれば十分なはずです。

# NG
# timeout-minutesがない

# OK
timeout-minutes: 3

SHA指定でactions/checkoutを使う

サードパーティアクションは基本的にSHA指定でバージョンを固定して使いましょう。

# NG
- uses: actions/checkout@v5

# OK
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

actions/checkoutはpersist-credentialsを無効にする

# NG
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

# OK
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
  with:
    persist-credentials: false

この場合、Git push時に認証情報がないため、後述のGITHUB_TOKENを使った認証が必要になります。

GitコンフィグでリモートURLとユーザー情報を設定する

git pushするときに、GitHub Actionsのボットユーザー情報を設定しましょう。お決まりのメアドとユーザー名があります。 また、persist-credentials: falseを指定した場合は、リモートURLに${{ secrets.GITHUB_TOKEN }}を埋め込む必要があります。

# NG
# いきなりgit pushしようとする
# set-urlをしないでpushしようとする

# OK
- name: Configure git for push
  run: |
    git remote set-url origin "https://github-actions:${GITHUB_TOKEN}@github.com/${{ github.repository }}"
    git config user.name  "github-actions[bot]"
    git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

サードパーティのGitアクションの利用は慎重に

git pushを行うサードパーティアクションはいくつかあります。よくあるのはad-m/github-push-actionです。だいたいうまく動くのですが、そのアクションを信頼できるかという問題が常に付きまといます。

ここ最近は、アクション関連の脆弱性がサプライチェインから突かれているケースが多いため、git pushのような万が一が許されない操作に関してはより慎重な判断が必要です。このため、個人的にはgit pushは自前でスクリプトを書くことを意識しています。


  1. ジョブごとにOS(runs-on)を切り替えることが多いでしょうが、Goのようにあるプラットフォームからマルチプラットフォーム向けにビルドできるケースということで。
  2. どのジョブが先に実行されるかは不定です。Matrixでmax-parallel: 1にして直列にしても結局、後続のジョブは失敗します。