|
| 1 | +id: concurrency-timing-094 |
| 2 | +title: 'Matrix Strategy Concurrent Jobs All Push to Same Branch — Non-Fast-Forward Rejection' |
| 3 | +category: concurrency-timing |
| 4 | +severity: error |
| 5 | +tags: |
| 6 | + - matrix |
| 7 | + - git-push |
| 8 | + - concurrent |
| 9 | + - non-fast-forward |
| 10 | + - race-condition |
| 11 | + - commit |
| 12 | +patterns: |
| 13 | + - regex: 'non-fast-forward' |
| 14 | + flags: 'i' |
| 15 | + - regex: 'Updates were rejected because the tip of your current branch is behind' |
| 16 | + flags: 'i' |
| 17 | + - regex: 'error: failed to push some refs.+hint: Updates were rejected' |
| 18 | + flags: 'is' |
| 19 | +error_messages: |
| 20 | + - "! [rejected] main -> main (non-fast-forward)" |
| 21 | + - "error: failed to push some refs to 'https://github.com/org/repo'" |
| 22 | + - "hint: Updates were rejected because the tip of your current branch is behind" |
| 23 | + - "hint: its remote counterpart. If you want to integrate the remote changes," |
| 24 | + - "hint: use 'git pull' before pushing again." |
| 25 | +root_cause: | |
| 26 | + When a `strategy.matrix` job contains a `git commit && git push` step, ALL matrix |
| 27 | + instances run concurrently and each one independently checks out the repository at the |
| 28 | + SAME commit SHA at job startup. |
| 29 | +
|
| 30 | + If two or more instances try to push to the same branch: |
| 31 | + - Instance A checks out at commit X, makes changes, pushes → creates commit Y |
| 32 | + - Instance B checked out at commit X (before A pushed), makes its own changes, pushes |
| 33 | + → rejected because the branch tip is now Y, not X. Instance B's push is |
| 34 | + behind the remote and non-fast-forward. |
| 35 | +
|
| 36 | + This fails for ANY workflow that uses a matrix to generate commits — for example: |
| 37 | + - Updating generated documentation files per each matrix target |
| 38 | + - Writing per-target outputs back to the repository |
| 39 | + - Version file updates or artifact manifests shared across matrix entries |
| 40 | +
|
| 41 | + The problem is structurally inherent to matrix parallelism: git push requires a |
| 42 | + linear history, but multiple concurrent runners checking out the same base commit |
| 43 | + and each pushing will create divergent histories. Only the first runner to push wins; |
| 44 | + all subsequent runners are rejected. |
| 45 | +
|
| 46 | + **Even with `concurrency:` at the workflow level**, this race condition occurs at the |
| 47 | + JOB level — concurrent matrix instances within the SAME workflow run share no |
| 48 | + synchronisation mechanism around git operations. |
| 49 | +
|
| 50 | + Source: Stack Overflow Q#78277579 (score 1, 520 views — Apr 2024) and |
| 51 | + Q#79047635 (score 3, 191 views — Oct 2024). |
| 52 | +fix: | |
| 53 | + **The canonical fix: separate the "matrix work" job from the "commit" job.** |
| 54 | +
|
| 55 | + Matrix jobs do their work and write outputs or upload artifacts. A separate downstream |
| 56 | + job (`needs: matrix-job`) runs ONCE after all matrix instances complete and performs |
| 57 | + a single `git commit && git push`. |
| 58 | +
|
| 59 | + This eliminates the race condition entirely — only one job ever touches the repository. |
| 60 | +
|
| 61 | + If matrix instances need to write files back, use `actions/upload-artifact` from each |
| 62 | + matrix instance, then download all artifacts in the commit job. |
| 63 | +
|
| 64 | + **Alternative: apply a `concurrency:` group at the job level** to serialise pushes |
| 65 | + (only one matrix instance pushes at a time). Each instance must also `git pull` before |
| 66 | + its push to incorporate the previous instance's commit. |
| 67 | +fix_code: |
| 68 | + - language: yaml |
| 69 | + label: 'Broken: matrix instances all push to main concurrently' |
| 70 | + code: | |
| 71 | + jobs: |
| 72 | + build: |
| 73 | + runs-on: ubuntu-latest |
| 74 | + strategy: |
| 75 | + matrix: |
| 76 | + target: [a, b, c] |
| 77 | + steps: |
| 78 | + - uses: actions/checkout@v4 |
| 79 | + - name: Generate file for ${{ matrix.target }} |
| 80 | + run: echo "${{ matrix.target }}" > output/${{ matrix.target }}.txt |
| 81 | + - name: Commit and push |
| 82 | + run: | |
| 83 | + git config user.name github-actions |
| 84 | + git config user.email github-actions@github.com |
| 85 | + git add . |
| 86 | + git commit -m "Add ${{ matrix.target }}" |
| 87 | + git push # ❌ Instances B and C fail — A pushed first |
| 88 | + - language: yaml |
| 89 | + label: 'Fixed: matrix uploads artifacts, a single downstream job commits once' |
| 90 | + code: | |
| 91 | + jobs: |
| 92 | + build: |
| 93 | + runs-on: ubuntu-latest |
| 94 | + strategy: |
| 95 | + matrix: |
| 96 | + target: [a, b, c] |
| 97 | + steps: |
| 98 | + - uses: actions/checkout@v4 |
| 99 | + - name: Generate file for ${{ matrix.target }} |
| 100 | + run: | |
| 101 | + mkdir -p output |
| 102 | + echo "${{ matrix.target }}" > output/${{ matrix.target }}.txt |
| 103 | + - name: Upload artifact |
| 104 | + uses: actions/upload-artifact@v4 |
| 105 | + with: |
| 106 | + name: output-${{ matrix.target }} |
| 107 | + path: output/${{ matrix.target }}.txt |
| 108 | +
|
| 109 | + commit: |
| 110 | + runs-on: ubuntu-latest |
| 111 | + needs: build # runs AFTER all matrix instances complete |
| 112 | + steps: |
| 113 | + - uses: actions/checkout@v4 |
| 114 | + - name: Download all artifacts |
| 115 | + uses: actions/download-artifact@v4 |
| 116 | + with: |
| 117 | + path: output/ |
| 118 | + merge-multiple: true |
| 119 | + - name: Commit and push once |
| 120 | + run: | |
| 121 | + git config user.name github-actions |
| 122 | + git config user.email github-actions@github.com |
| 123 | + git add output/ |
| 124 | + git diff --staged --quiet || git commit -m "Add generated outputs" |
| 125 | + git push # ✅ Only one runner, no race condition |
| 126 | + - language: yaml |
| 127 | + label: 'Alternative: serialise pushes with concurrency + pull-before-push' |
| 128 | + code: | |
| 129 | + jobs: |
| 130 | + build: |
| 131 | + runs-on: ubuntu-latest |
| 132 | + concurrency: |
| 133 | + group: git-push-serialised |
| 134 | + cancel-in-progress: false # queue, don't cancel |
| 135 | + strategy: |
| 136 | + matrix: |
| 137 | + target: [a, b, c] |
| 138 | + steps: |
| 139 | + - uses: actions/checkout@v4 |
| 140 | + - name: Generate file |
| 141 | + run: echo "${{ matrix.target }}" > output/${{ matrix.target }}.txt |
| 142 | + - name: Pull, commit, push serialised |
| 143 | + run: | |
| 144 | + git config user.name github-actions |
| 145 | + git config user.email github-actions@github.com |
| 146 | + git fetch origin main |
| 147 | + git reset --hard origin/main # sync with latest main |
| 148 | + git add output/ |
| 149 | + git commit -m "Add ${{ matrix.target }}" |
| 150 | + git push # ✅ Serialised by concurrency group |
| 151 | +prevention: |
| 152 | + - 'Never include `git push` inside a matrix job — use a separate downstream job that runs once after all matrix instances complete.' |
| 153 | + - 'Use `actions/upload-artifact` / `actions/download-artifact` to collect matrix outputs; perform the single commit in a `needs:` downstream job.' |
| 154 | + - 'If matrix instances MUST commit independently, serialise them with a `concurrency:` group and always `git fetch` + `git reset --hard origin/main` immediately before each push.' |
| 155 | + - 'For simple file updates, consider passing the data via job outputs (`$GITHUB_OUTPUT`) to the downstream job instead of writing to the repository mid-matrix.' |
| 156 | +docs: |
| 157 | + - url: 'https://stackoverflow.com/questions/78277579/how-can-i-commit-and-push-changes-to-a-repository-in-concurrent-github-actions-workflow-runs' |
| 158 | + label: 'Stack Overflow — Commit/push in concurrent matrix runs: non-fast-forward rejection (Apr 2024)' |
| 159 | + - url: 'https://stackoverflow.com/questions/79047635/running-a-step-after-a-matrix-step-is-done-executing-in-github-actions' |
| 160 | + label: 'Stack Overflow — Running a step after matrix is done: commit race with git push (Oct 2024)' |
| 161 | + - url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-a-matrix-for-your-jobs' |
| 162 | + label: 'GitHub Docs — Using a matrix for your jobs' |
| 163 | + - url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/storing-and-sharing-data-from-a-workflow' |
| 164 | + label: 'GitHub Docs — Storing and sharing data from a workflow (artifacts)' |
0 commit comments