ci(merge-train): label-driven serialized FF merger (#602) - #604
Conversation
Concurrency-1 workflow: when a PR is labeled `ready-to-merge`, the bot fetches the branch, verifies it is FF on current main and all commits are signed, polls required checks until they settle, and FF-pushes the existing head to main. Removes label and posts a diagnostic comment on any failure. The bot is intentionally FF-only — it has no signing key and the `required_signatures` rule on main would reject any commit it generates (per #341). Authors rebase locally; the bot only serializes. Eliminates the merge-thrash documented in #602: under concurrency-1 main only moves when the bot is merging, so the loop where overlapping rebases keep invalidating each other does not arise. PR #591 cycled attn:merge-conflict four times in 30 minutes on 2026-05-10 (and PR #540 eight times in 9 minutes) before this shipped — both would have queued cleanly under serialization.
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (2)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Reviewer's GuideImplements a new label-driven, fast-forward-only merge-train workflow that serializes merges to main via the Sequence diagram for label-driven merge-train workflow on ready-to-merge PRsequenceDiagram
actor Author
participant GitHubUI
participant GitHubEvents as GitHub_Events
participant Actions as GitHub_Actions
participant MergeTrain as MergeTrain_Job
participant Repo as Repo_and_Main_Branch
participant CI as CI_Check_Runs
Author->>GitHubUI: Open PR and push commits
Author->>CI: Trigger CI checks on PR head
CI-->>GitHubUI: Report check statuses
Author->>GitHubUI: Add ready-to-merge label to PR
GitHubUI-->>GitHubEvents: Emit pull_request labeled event
GitHubEvents-->>Actions: Trigger merge-train workflow
Actions->>MergeTrain: Start job (concurrency group merge-train)
MergeTrain->>Repo: git fetch origin main and PR head ref
MergeTrain->>Repo: Compare HEAD_SHA from event vs remote head
alt Head SHA changed
MergeTrain->>GitHubAPI: Post merge-train blocked comment (head moved)
MergeTrain->>GitHubAPI: Remove ready-to-merge label
MergeTrain-->>Actions: Exit job
else Head SHA unchanged
MergeTrain->>Repo: Check PR branch is fast-forward on main
alt Not fast-forward
MergeTrain->>GitHubAPI: Post blocked comment (needs rebase)
MergeTrain->>GitHubAPI: Remove ready-to-merge label
MergeTrain-->>Actions: Exit job
else Fast-forward
MergeTrain->>Repo: Inspect commits main..head for signatures
alt Unsigned commits found
MergeTrain->>GitHubAPI: Post blocked comment (unsigned commits)
MergeTrain->>GitHubAPI: Remove ready-to-merge label
MergeTrain-->>Actions: Exit job
else All commits signed
loop Until checks settled or timeout
MergeTrain->>CI: Query check-runs for head SHA (excluding merge-train)
alt Any check failure or cancelled
MergeTrain->>GitHubAPI: Post blocked comment (CI failed)
MergeTrain->>GitHubAPI: Remove ready-to-merge label
MergeTrain-->>Actions: Exit job
else All completed and passing
MergeTrain-->>MergeTrain: Break polling loop
end
end
alt CI timeout reached
MergeTrain->>GitHubAPI: Post blocked comment (CI timeout)
MergeTrain->>GitHubAPI: Remove ready-to-merge label
MergeTrain-->>Actions: Exit job
else Checks green within timeout
MergeTrain->>Repo: git push head SHA to refs/heads/main (FF)
alt Push rejected
MergeTrain->>GitHubAPI: Post blocked comment (push failed)
MergeTrain->>GitHubAPI: Remove ready-to-merge label
MergeTrain-->>Actions: Exit job
else Push accepted
Repo-->>MainBranch: Advance main to PR head
MergeTrain->>GitHubAPI: Remove ready-to-merge label (best effort)
MergeTrain->>GitHubAPI: Post merge-train merged confirmation comment
MergeTrain-->>Actions: Job success
end
end
end
end
end
GitHubUI-->>Author: Show PR merged or comment explaining block
Class-style diagram for logical components in the merge-train workflowclassDiagram
class MergeTrainWorkflow {
+string name
+string trigger_event
+string concurrency_group
+run()
}
class MergeJob {
+int pr_number
+string head_ref
+string head_sha
+int check_timeout_seconds
+run()
-refresh_refs()
-check_head_freshness()
-check_fast_forward()
-check_signatures()
-wait_for_ci_checks()
-push_fast_forward()
-post_success_comment()
}
class FailureHandler {
+fail_and_unlabel(reason)
-post_block_comment(reason)
-remove_ready_to_merge_label()
}
class GitRepositoryAdapter {
+fetch_main_and_head(head_ref)
+get_remote_head(head_ref)
+is_fast_forward_on_main(head_ref)
+list_unsigned_commits(range)
+push_fast_forward(head_sha)
}
class GitHubApiAdapter {
+post_issue_comment(pr_number, body)
+remove_label(pr_number, label)
+get_check_runs(head_sha)
}
MergeTrainWorkflow --> MergeJob : creates
MergeJob --> FailureHandler : uses
MergeJob --> GitRepositoryAdapter : uses
MergeJob --> GitHubApiAdapter : uses
FailureHandler --> GitHubApiAdapter : uses
class ReadyToMergeLabelFlow {
+request_merge()
+respond_to_block(reason)
}
ReadyToMergeLabelFlow ..> MergeTrainWorkflow : triggers via label
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | ||
| with: | ||
| fetch-depth: 0 | ||
| # Default token is fine for FF pushes — we're advancing main | ||
| # to existing already-signed commits, not adding new ones. | ||
| token: ${{ secrets.GITHUB_TOKEN }} |
|
[claim:review:Einstein:2026-05-10T23:08:25Z] |
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The workflow assumes PR heads are in the same repository (e.g.,
git fetch origin ${HEAD_REF}andorigin/${HEAD_REF}), so if you ever enable or rely on fork-based contributions this merge-train will break; consider explicitly documenting or enforcing the same-repo-only constraint or adjusting refs to userefs/pull/...instead. - The CI check-rollup filters out the current job by hard-coded names (
"Attempt merge-train FF"and"merge"), which is brittle; using something derived fromgithub.jobor the app name instead would avoid deadlocks if step/job names change. - The helper
fail_and_unlabelexits with status 0, so the workflow always reports success even on merge-train failures; if you want failed attempts to be visible in the Actions UI and metrics, consider exiting non-zero after posting the comment and removing the label.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The workflow assumes PR heads are in the same repository (e.g., `git fetch origin ${HEAD_REF}` and `origin/${HEAD_REF}`), so if you ever enable or rely on fork-based contributions this merge-train will break; consider explicitly documenting or enforcing the same-repo-only constraint or adjusting refs to use `refs/pull/...` instead.
- The CI check-rollup filters out the current job by hard-coded names (`"Attempt merge-train FF"` and `"merge"`), which is brittle; using something derived from `github.job` or the app name instead would avoid deadlocks if step/job names change.
- The helper `fail_and_unlabel` exits with status 0, so the workflow always reports success even on merge-train failures; if you want failed attempts to be visible in the Actions UI and metrics, consider exiting non-zero after posting the comment and removing the label.
## Individual Comments
### Comment 1
<location path=".github/workflows/merge-train.yml" line_range="52-61" />
<code_context>
+ - name: Attempt merge-train FF
</code_context>
<issue_to_address>
**issue (bug_risk):** The workflow assumes `gh` is available on the runner, but GitHub-hosted runners don’t ship with the GitHub CLI by default.
Because this job invokes `gh` for comments, label updates, and check-run queries, it will fail on the first `gh` call unless you install/configure it explicitly. Please add an early step to set up the GitHub CLI (e.g., via an official setup action or package install), or replace these calls with REST API requests using `curl` and `GITHUB_TOKEN` to avoid the dependency.
</issue_to_address>
### Comment 2
<location path=".github/workflows/merge-train.yml" line_range="83" />
<code_context>
+ }
+
+ echo "[1/5] refreshing refs..."
+ git fetch origin main "${HEAD_REF}" --quiet
+
+ # Sanity-check the PR head matches what the event reported. If
</code_context>
<issue_to_address>
**issue:** Fetching `HEAD_REF` from `origin` will fail or misbehave for fork-based PRs, since the head branch usually lives on a different remote.
On forked PRs, `github.event.pull_request.head.ref` is only the branch name on the fork, so `git fetch origin main "${HEAD_REF}"` won’t find that ref on `origin` (or may hit an unrelated branch with the same name).
If this job should only run for same-repo PRs, you can guard with `github.event.pull_request.head.repo.full_name == github.repository` and skip fork PRs. Otherwise, you’ll need to either fetch from the head repo’s clone URL or rely on the `refs/pull/...` ref that GitHub Actions provides for the PR.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| - name: Attempt merge-train FF | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| REPO: ${{ github.repository }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| HEAD_REF: ${{ github.event.pull_request.head.ref }} | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| CHECK_TIMEOUT_SECONDS: '600' | ||
| run: | | ||
| set -euo pipefail |
There was a problem hiding this comment.
issue (bug_risk): The workflow assumes gh is available on the runner, but GitHub-hosted runners don’t ship with the GitHub CLI by default.
Because this job invokes gh for comments, label updates, and check-run queries, it will fail on the first gh call unless you install/configure it explicitly. Please add an early step to set up the GitHub CLI (e.g., via an official setup action or package install), or replace these calls with REST API requests using curl and GITHUB_TOKEN to avoid the dependency.
| } | ||
|
|
||
| echo "[1/5] refreshing refs..." | ||
| git fetch origin main "${HEAD_REF}" --quiet |
There was a problem hiding this comment.
issue: Fetching HEAD_REF from origin will fail or misbehave for fork-based PRs, since the head branch usually lives on a different remote.
On forked PRs, github.event.pull_request.head.ref is only the branch name on the fork, so git fetch origin main "${HEAD_REF}" won’t find that ref on origin (or may hit an unrelated branch with the same name).
If this job should only run for same-repo PRs, you can guard with github.event.pull_request.head.repo.full_name == github.repository and skip fork PRs. Otherwise, you’ll need to either fetch from the head repo’s clone URL or rely on the refs/pull/... ref that GitHub Actions provides for the PR.
| - name: Attempt merge-train FF | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| REPO: ${{ github.repository }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| HEAD_REF: ${{ github.event.pull_request.head.ref }} | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| CHECK_TIMEOUT_SECONDS: '600' | ||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| fail_and_unlabel() { | ||
| local reason="$1" | ||
| # Use the comment marker so repeat-bumps can be detected | ||
| # / de-duped by anyone reading the PR thread later. | ||
| cat > /tmp/comment.md <<EOF | ||
| <!-- merge-train-v1 --> | ||
| **merge-train: blocked** | ||
|
|
||
| ${reason} | ||
|
|
||
| The \`ready-to-merge\` label has been removed. Address the issue above and re-add the label when you're ready for another attempt. | ||
| EOF | ||
| jq -Rs '{body: .}' < /tmp/comment.md > /tmp/payload.json | ||
| gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" \ | ||
| --input /tmp/payload.json >/dev/null | ||
| gh pr edit "${PR_NUMBER}" --remove-label ready-to-merge | ||
| exit 0 | ||
| } | ||
|
|
||
| echo "[1/5] refreshing refs..." | ||
| git fetch origin main "${HEAD_REF}" --quiet | ||
|
|
||
| # Sanity-check the PR head matches what the event reported. If | ||
| # the author force-pushed between the label-fire and now, the | ||
| # bot would FF to a stale commit. Refuse and re-trigger via | ||
| # re-labeling. | ||
| remote_head=$(git rev-parse "origin/${HEAD_REF}") | ||
| if [ "${remote_head}" != "${HEAD_SHA}" ]; then | ||
| fail_and_unlabel "branch head moved during merge-train queue (event=\`${HEAD_SHA}\`, current=\`${remote_head}\`). Re-add \`ready-to-merge\` to retry against the new head." | ||
| fi | ||
|
|
||
| echo "[2/5] FF check..." | ||
| if ! git merge-base --is-ancestor origin/main "origin/${HEAD_REF}"; then | ||
| base=$(git merge-base origin/main "origin/${HEAD_REF}" || echo "unknown") | ||
| head=$(git rev-parse origin/main) | ||
| fail_and_unlabel "branch is not fast-forward on \`main\` (branch base \`${base}\`, current main \`${head}\`). Rebase locally (\`git rebase github/main\`), force-push, and re-add the label." | ||
| fi | ||
|
|
||
| echo "[3/5] signature check on commits being pushed..." | ||
| # Every commit reachable from PR head but not from main must | ||
| # be signed. The bot can't sign, but it isn't adding commits | ||
| # here — these are the author's. Verify upfront so a quiet | ||
| # branch-protection rejection downstream doesn't confuse. | ||
| unsigned=$(git log --format='%H %G?' "origin/main..origin/${HEAD_REF}" | awk '$2!="G" && $2!="U" {print $1}' || true) | ||
| if [ -n "${unsigned}" ]; then | ||
| fail_and_unlabel "one or more commits between main and \`${HEAD_REF}\` are not GPG/SSH-signed:\n\n\`\`\`\n${unsigned}\n\`\`\`\n\nSign them locally and re-add the label. The bot cannot sign on your behalf." | ||
| fi | ||
|
|
||
| echo "[4/5] waiting for required checks to complete..." | ||
| deadline=$(( $(date +%s) + CHECK_TIMEOUT_SECONDS )) | ||
| while [ "$(date +%s)" -lt "${deadline}" ]; do | ||
| # Pull the merge-rollup state. We filter out the | ||
| # merge-train job itself — checking it would deadlock. | ||
| rollup=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs?per_page=100" \ | ||
| --jq '[.check_runs[] | select(.name != "Attempt merge-train FF" and .name != "merge") | {n:.name, s:.status, c:.conclusion}]') | ||
|
|
||
| fails=$(echo "${rollup}" | jq -r '.[] | select(.c == "failure" or .c == "cancelled" or .c == "timed_out" or .c == "action_required") | .n' | paste -sd, -) | ||
| if [ -n "${fails}" ]; then | ||
| fail_and_unlabel "required check(s) failed: \`${fails}\`. Fix CI and re-add the label." | ||
| fi | ||
|
|
||
| pending=$(echo "${rollup}" | jq -r '.[] | select(.s == "in_progress" or .s == "queued" or .s == "pending") | .n' | wc -l | tr -d ' ') | ||
| if [ "${pending}" = "0" ]; then | ||
| echo "all checks settled." | ||
| break | ||
| fi | ||
| echo "...${pending} check(s) still running, sleeping 20s" | ||
| sleep 20 | ||
| done | ||
|
|
||
| if [ "$(date +%s)" -ge "${deadline}" ]; then | ||
| fail_and_unlabel "timed out waiting for CI checks to complete after $((CHECK_TIMEOUT_SECONDS / 60)) minutes. Retrigger once checks have settled." | ||
| fi | ||
|
|
||
| echo "[5/5] FF push..." | ||
| # Push the existing signed head to main. Branch protection's | ||
| # required-signatures rule sees the commits' signatures and | ||
| # accepts. | ||
| if ! git push origin "${HEAD_SHA}:refs/heads/main" 2>/tmp/push.err; then | ||
| push_err=$(cat /tmp/push.err) | ||
| fail_and_unlabel "FF push to \`main\` failed:\n\n\`\`\`\n${push_err}\n\`\`\`\n\nCommon causes: branch protection rule changed, force-push detected by another writer, or token permission insufficient. Re-add the label after investigating." | ||
| fi | ||
|
|
||
| # Success: unlabel and post a confirmation. Don't error if | ||
| # the unlabel step fails — main already advanced; the worst | ||
| # case is a stale label that a sister session sees and | ||
| # ignores because the PR will be CLOSED/MERGED already. | ||
| gh pr edit "${PR_NUMBER}" --remove-label ready-to-merge || true | ||
|
|
||
| cat > /tmp/comment.md <<EOF | ||
| <!-- merge-train-v1 --> | ||
| **merge-train: merged** ${HEAD_SHA} → \`main\` via FF push. | ||
| EOF | ||
| jq -Rs '{body: .}' < /tmp/comment.md > /tmp/payload.json | ||
| gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" \ | ||
| --input /tmp/payload.json >/dev/null |
|
[claim:review:Maxwell:2026-05-10T23:09:38Z] |
|
[release:review:Maxwell:2026-05-10T23:09:42Z] |
|
Einstein review — LGTM Workflow design:
Permissions/triggers:
Docs: CONTRIBUTING.md section covers the operator-facing flow, the no-auto-rebase rationale, and points at the existing CHANGELOG entry under Unreleased/Added is good. CI rollup: all green. Discretion-grep against the local pre-push hook regex ( Merging by FF push. |
|
[release:review:Einstein:2026-05-10T23:10:12Z] |
Closes #602 (the serialization axis — the conflict-probability axis shipped in #603).
What lands
The label-driven serialized merger described in #602. Three commits:
.github/workflows/merge-train.yml— the workflow itself.CONTRIBUTING.md— operator-facing docs for theready-to-mergelabel flow.CHANGELOG.md— unreleased entry.Plus the
ready-to-mergelabel has been created in the repo so the trigger can fire.How it works
When a PR is labeled
ready-to-merge:main.main..headis not GPG/SSH-signed.git push origin <head_sha>:refs/heads/main. The existing commits are signed by the author, sorequired_signaturesaccepts.ready-to-mergelabel and posts a confirmation comment.Any failure path posts a comment with the specific reason (FF blocked, signatures missing, CI failed, push rejected) and removes the label so the operator can fix and re-add.
Why FF-only, not auto-rebase
github-actions[bot]has no signing key. If it rebased an author's branch it would strip the SSH signatures, and therequired_signaturesrule onmainwould reject the result. This was documented inflag-stale-open-prs.yml(#341) — every prior auto-rebase attempt produced a worse outcome than not auto-rebasing.So the bot is intentionally FF-only. Authors rebase locally, then label. Under concurrency-1 this converges: main only moves when the bot is merging, so a freshly-rebased author who labels promptly is FF when their slot is processed. Sister sessions that label while another PR is mid-merge will queue (GH Actions
concurrency: group: merge-train, cancel-in-progress: false), and the queued run will hit FF on the post-merge main if the queued PR was rebased recently.If a queued PR is no longer FF when the bot picks it up, the bot unlabels and asks for a rebase. That's the documented operator action.
Verification
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/merge-train.yml'))"→ loads cleanly.harden-runner@8d3c67de…v2.19.0,checkout@34e11487…v4.3.1).egress-policy: auditmatches house style.contents: write(for FF push),pull-requests: write(for unlabel + comment).concurrency.group: merge-trainis workflow-wide (not per-PR), enforcing single-merge-at-a-time across all labeled PRs.if: github.event.label.name == 'ready-to-merge'— workflow is silent on every other label event.Out of scope (follow-ups, if needed)
GITHUB_TOKEN's FF-push permission is later revoked. The workflow defaults tosecrets.GITHUB_TOKEN. If branch protection rules later require a personal token forcontents: writetomain, swap thetoken:value in the checkout step and re-test.ready-to-mergeinstead of directgit push. That lives in~/.claude/skills/and ships as a separate local edit per the discretion rule.Discretion
Workflow YAML and CHANGELOG / CONTRIBUTING entries reviewed for private terminology — clean. The diagnosis numbers (#591 cycled 4×, #540 cycled 8×) are observable from public
gh api .../events, not from any private source.Summary by Sourcery
Introduce a label-driven, serialized fast-forward merge workflow for PRs and document its usage.
New Features:
Enhancements:
CI: