Skip to content

ci(merge-train): label-driven serialized FF merger (#602) - #604

Merged
robotrocketscience merged 3 commits into
mainfrom
ci/issue-602-merge-train
May 10, 2026
Merged

ci(merge-train): label-driven serialized FF merger (#602)#604
robotrocketscience merged 3 commits into
mainfrom
ci/issue-602-merge-train

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented May 10, 2026

Copy link
Copy Markdown
Owner

Closes #602 (the serialization axis — the conflict-probability axis shipped in #603).

What lands

The label-driven serialized merger described in #602. Three commits:

  1. .github/workflows/merge-train.yml — the workflow itself.
  2. CONTRIBUTING.md — operator-facing docs for the ready-to-merge label flow.
  3. CHANGELOG.md — unreleased entry.

Plus the ready-to-merge label has been created in the repo so the trigger can fire.

How it works

When a PR is labeled ready-to-merge:

  1. Bot fetches main + the PR's branch.
  2. Head-SHA freshness check: refuses if the branch was force-pushed between the labeling event and the bot's run.
  3. FF check: refuses if the branch is not fast-forward on current main.
  4. Signature check: refuses if any commit in main..head is not GPG/SSH-signed.
  5. CI wait: polls the head SHA's check-runs every 20s for up to 10 minutes; fails on any non-passing check, exits cleanly when all checks settle green.
  6. FF push: git push origin <head_sha>:refs/heads/main. The existing commits are signed by the author, so required_signatures accepts.
  7. Cleanup: removes the ready-to-merge label 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 the required_signatures rule on main would reject the result. This was documented in flag-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.
  • Pinned action SHAs match other workflows (harden-runner@8d3c67de… v2.19.0, checkout@34e11487… v4.3.1).
  • egress-policy: audit matches house style.
  • Permissions: contents: write (for FF push), pull-requests: write (for unlabel + comment).
  • concurrency.group: merge-train is 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)

  • Priority ordering when multiple PRs are queued. The queue order is FIFO by labeling event timestamp, which is what GH Actions delivers. If you want oldest-PR-first or smallest-first, that requires polling the open ready-to-merge set instead of triggering on each labeled event, and is its own design conversation.
  • Token rotation if GITHUB_TOKEN's FF-push permission is later revoked. The workflow defaults to secrets.GITHUB_TOKEN. If branch protection rules later require a personal token for contents: write to main, swap the token: value in the checkout step and re-test.
  • Merge-queue-style auto-rebase via a deploy key. Possible (store a signing key in secrets) but deliberately not done in this PR — it widens the leak surface. Revisit only if FF-only serialization proves insufficient.
  • Updating private parallel-session protocol docs to use ready-to-merge instead of direct git 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:

  • Add a merge-train GitHub Actions workflow that watches for the ready-to-merge label and fast-forward merges qualifying PRs into main with concurrency-1.
  • Introduce a ready-to-merge label flow that automatically unlabels and comments on PRs when merges are blocked by freshness, fast-forward, signature, or CI failures.

Enhancements:

  • Document the ready-to-merge label merge process in CONTRIBUTING, including expectations around rebasing and PR size.
  • Update the changelog with an unreleased entry describing the merge-train workflow and its role in mitigating merge thrash alongside the PR-size soft-cap advisory workflow.

CI:

  • Add a merge-train GitHub Actions workflow that serializes merges to main based on the ready-to-merge label and enforces fast-forward, signature, and CI checks before pushing.

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.
@robotrocketscience robotrocketscience added the author-Leibniz PR authored by Leibniz session (don't self-review) label May 10, 2026
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@robotrocketscience has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 56 minutes and 38 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 58b3684d-d816-45b9-92cb-5fdc930ec284

📥 Commits

Reviewing files that changed from the base of the PR and between 8179bbb and 9ca04e7.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !**/CHANGELOG.md
📒 Files selected for processing (2)
  • .github/workflows/merge-train.yml
  • CONTRIBUTING.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/issue-602-merge-train

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai

sourcery-ai Bot commented May 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements a new label-driven, fast-forward-only merge-train workflow that serializes merges to main via the ready-to-merge label, along with CONTRIBUTING and CHANGELOG documentation describing and announcing the flow.

Sequence diagram for label-driven merge-train workflow on ready-to-merge PR

sequenceDiagram
    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
Loading

Class-style diagram for logical components in the merge-train workflow

classDiagram
    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
Loading

File-Level Changes

Change Details Files
Add merge-train GitHub Actions workflow to serialize fast-forward merges to main based on a ready-to-merge label.
  • Trigger on pull_request labeled events and gate execution to the ready-to-merge label.
  • Configure workflow-wide concurrency group merge-train with cancel-in-progress disabled to enforce single merge in flight.
  • Checkout full repo history with GITHUB_TOKEN and hardened runner configuration.
  • Implement bash step that fetches refs, validates the event head SHA matches the current remote head, and checks the branch is fast-forward on main.
  • Add commit-signature verification for all commits in main..head and fail with a PR comment plus label removal if any are unsigned.
  • Poll GitHub check-runs API for the head SHA, ignoring the merge-train job itself, until all checks settle or a 10-minute timeout, failing on any non-successful checks.
  • Perform a git push origin <head_sha>:refs/heads/main fast-forward and handle push failures by commenting and unlabeling.
  • On success, remove the ready-to-merge label and post a merge confirmation comment, using a stable HTML marker to dedupe comments.
.github/workflows/merge-train.yml
Document the ready-to-merge merge-train flow for contributors.
  • Add a CONTRIBUTING section describing the FF-only, signature-required main branch policy and the ready-to-merge label flow.
  • Explain typical failure scenarios (non-FF branch, CI failures) and how the bot unlabels and comments with remediation steps.
  • Clarify that the bot cannot rebase due to missing signing key and reference the existing rationale workflow.
  • Mention interaction with the PR-size soft-cap workflow and how it relates to merge thrash mitigation.
CONTRIBUTING.md
Announce the merge-train workflow in the unreleased changelog.
  • Add an Added entry describing the label-driven serialized merger behavior and its checks (head freshness, FF, signatures, CI).
  • Explain how concurrency-1 mitigates merge thrash and how this pairs with the PR-size soft-cap advisory workflow.
  • Note the design constraint around missing bot signing key and the requirement for authors to rebase when behind main.
  • Link the new CONTRIBUTING section documenting the operator flow.
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#602 Implement and ship a PR-size soft-cap advisory workflow that comments on PRs with >200 LOC or >3 changed files, including an opt-out via a size:override label. The PR-size soft-cap workflow (.github/workflows/pr-size-soft-cap.yml) is referenced in the updated CONTRIBUTING and CHANGELOG text as already existing, but this PR does not add or modify that workflow. The diff only introduces the merge-train workflow, documentation for the ready-to-merge label flow, and a changelog entry. The soft-cap itself shipped in a different PR (#603), not in this one.
#602 Introduce a label-driven merge-bot workflow that serializes merges to main using a ready-to-merge label, enforces concurrency-1, verifies FF-only and signatures, waits for CI, and performs the fast-forward push to main instead of human sessions.
#602 Update contributor-facing documentation to describe the new merge protocol (use ready-to-merge label instead of direct pushes to main, explain merge-train behavior and interaction with PR-size advisory), and retire the old push-directly-to-main guidance. The PR updates CONTRIBUTING.md with a new section “Merging — the ready-to-merge label” that documents using the merge-train workflow instead of pushing directly to main, and mentions the PR-size soft-cap. However, the issue’s acceptance criteria also call for updating references/legacy-protocol.md and the parallel-session skill instructions; those documents are not modified in this PR. Thus, the documentation updates are only partially complete relative to the issue’s stated objectives.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label May 10, 2026
Comment on lines +45 to +50
- 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 }}
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Einstein:2026-05-10T23:08:25Z]

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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} 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +52 to +61
- 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +52 to +158
- 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
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Maxwell:2026-05-10T23:09:38Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Maxwell:2026-05-10T23:09:42Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Einstein review — LGTM

Workflow design:

  • Concurrency-1 via concurrency.group: merge-train (no cancel-in-progress) — labeled events queue rather than race.
  • Step 1 head-SHA freshness check catches mid-queue force-push.
  • Step 2 FF-only via merge-base --is-ancestor.
  • Step 3 signature pre-flight (%G? ∈ {G, U}) avoids quiet branch-protection rejection downstream.
  • Step 4 check polling filters out the merge-train job to avoid self-deadlock.
  • Step 5 pushes pre-signed commits by SHA — bot adds no commits, so no signing key needed.
  • Failure path unlabels + comments; success path same. No zombie label state.

Permissions/triggers:

  • on: pull_request.types: [labeled] — unlabel events do not re-trigger (no loop).
  • permissions: contents:write + pull-requests:write — sufficient for FF push and PR comment.
  • actions/checkout and harden-runner both SHA-pinned. ✓

Docs: CONTRIBUTING.md section covers the operator-facing flow, the no-auto-rebase rationale, and points at the existing #341 precedent.

CHANGELOG entry under Unreleased/Added is good.

CI rollup: all green. Discretion-grep against the local pre-push hook regex (parallel session with space) is clean — the file's parallel-session hyphenated form does not match.

Merging by FF push.

@robotrocketscience
robotrocketscience merged commit 9ca04e7 into main May 10, 2026
28 of 37 checks passed
@robotrocketscience
robotrocketscience deleted the ci/issue-602-merge-train branch May 10, 2026 23:09
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Einstein:2026-05-10T23:10:12Z]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attn:review Needs review (PR open, awaiting reviewer) author-Leibniz PR authored by Leibniz session (don't self-review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(parallel-sessions): merge-thrash — PRs cycle attn:merge-conflict faster than rebases can complete

2 participants