Skip to content

ci(merge-train): dispatch the post-merge workflows after a successful FF (#1423) - #1499

Merged
github-actions[bot] merged 10 commits into
mainfrom
ci/issue-1423-dispatch-after-ff
Aug 11, 2026
Merged

ci(merge-train): dispatch the post-merge workflows after a successful FF (#1423)#1499
github-actions[bot] merged 10 commits into
mainfrom
ci/issue-1423-dispatch-after-ff

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Closes #1423.

What was wrong

merge-train.yml lands every merge as git push origin "${HEAD_SHA}:refs/heads/main" using secrets.GITHUB_TOKEN. GitHub raises no workflow runs from events made with that token — the documented recursion guard, whose only exceptions are workflow_dispatch and repository_dispatch. So the day the merge train became the merge path, every on: push: branches: [main] workflow in this repository stopped, and nothing said so, because "no run" is an absence and every signal in the system is an absence test.

Seven workflows carry that trigger. Six share the same last push-event run — 3421cd73, 2026-07-21T01:30:43Z — and git rev-list --count 3421cd73..github/main is 942 today, against the 666 in the AC1 comment and the 541 in the issue body.

workflow last push run on main other triggers what was actually lost
codeql.yml 2026-07-21 3421cd73 pull_request, schedule post-merge re-run only
deadcode.yml 2026-07-21 3421cd73 pull_request post-merge re-run only
e2e.yml 2026-07-21 3421cd73 pull_request (since #1421) post-merge re-run, and #370's failure surfacing with it
eval-calibration.yml 2026-07-21 3421cd73 pull_request the "re-asserts post-merge" half of its stated surface
release-drafter.yml 2026-07-21 3421cd73 workflow_dispatch everything — release notes written by hand
flag-stale-open-prs.yml 2026-07-21 3421cd73 workflow_dispatch everything — nothing has labelled a behind-main PR in 942 commits
zizmor.yml 2026-05-11 b2657537 pull_request, schedule not this defect — see below

zizmor is not evidence for the token guard and is not counted as such: both its arms carry a paths: filter restricted to .github/, so a date ten weeks older is what a path filter produces, not what a recursion guard produces. publish.yml is unaffected — it triggers on v[0-9]+.[0-9]+.[0-9]+ tag pushes, which the train never creates.

What changed

The train dispatches what its own push cannot trigger. After the FF push succeeds, a [6b/6] step runs gh workflow run "$wf" --ref main --repo "$REPO" over the enumerated set. That costs actions: write and no new secret. The two rejected alternatives are recorded in the workflow itself: a PAT or GitHub App token on the checkout re-arms on: push for everything at once, but it puts a long-lived credential with write access to main in a repository whose entire merge model — FF-only, no bot rebase — exists because the bot deliberately holds no signing key; a schedule decouples the run from the commit that caused it, which is the one property push: [main] had and the only reason to want it.

The list is derived, never written down. scripts/push_trigger_workflows.py parses branches: under on: push: across .github/workflows/*.yml and prints one filename per line. Both consumers — the train and the heartbeat — read it. A literal list is the original defect wearing a different hat: it silently omits the next push:main workflow anyone adds. Pure stdlib and parsed as text, because yaml is not importable in this repo's CI (the trap tests/test_ci_manual_dispatch.py records).

--ref main, no inputs, after the push, non-fatal. A dispatched run's check-runs attach to the head of the ref it was dispatched on, which is what makes a post-merge run structurally unable to report against a commit it did not test (#1436/#1451). Dispatching before the push would target main at its pre-merge SHA and produce a green post-merge run for a commit that never merged. And main has already advanced by the time the loop runs, so a failed dispatch warns rather than reddening a landed merge — the enumeration is captured into a variable rather than inlined into the for word list, because a failing command substitution in a for-list is not a set -e failure and would have iterated zero workflows in silence.

Five workflows gained a bare workflow_dispatch:. gh workflow run returns 422 without it, and the loop is non-fatal, so five of seven would have warned and done nothing. paths: filters do not apply to a dispatch, so e2e and zizmor now run in full post-merge; that is intended, since the point is a run of main. Concurrency was deliberately left alone: every group here keys the PR arm on github.event.pull_request.number and falls through to github.ref, so a dispatch of main can never collide with — or cancel — a PR's own in-flight run.

e2e's failure surfacing had to move with it. The #370 issue-opening step was if: github.event_name == 'push'. With the post-merge run arriving as a dispatch, that guard skipped the step on exactly the runs it exists for: a red e2e on main, no issue, no label, nobody told. It is now != 'pull_request', and the title and body report github.ref_name and github.event_name rather than asserting a push to main.

A heartbeat asserts presence. .github/workflows/push-trigger-heartbeat.yml runs weekly, takes for each enumerated workflow the newest run on main by push or workflow_dispatch, and compares it against main's head commit; a lag over 14 days opens or updates one collated issue. Both events, so a future fix that re-arms on: push properly does not make it cry. Neither schedule nor pull_request, because those are the masking — codeql and zizmor both have a weekly cron that kept their newest run on main looking recent through the whole outage. Against the head commit rather than wall-clock, so a genuinely quiet fortnight is not a defect. An empty enumeration exits 1 rather than reporting everything healthy.

Mutation check

20 mutations, each applied to the tree, run, and restored from a byte copy (never git checkout), with __pycache__ cleared first. All 20 turn tests/test_merge_train_dispatch.py red.

# mutation red tests
M1 drop workflow_dispatch: from e2e.yml 1 — test_every_enumerated_workflow_accepts_a_dispatch[e2e.yml]
M2 drop actions: write from merge-train 1 — test_merge_train_may_dispatch_at_all
M3 hoist the dispatch above the FF push 1 — test_the_dispatch_happens_after_the_ff_push
M4 --ref "${HEAD_REF}" instead of --ref main 1 — test_the_dispatch_targets_main_and_carries_no_inputs
M5 add -f sha=x to the dispatch 1 — same
M6 hard-code the list in merge-train 1 — test_neither_consumer_writes_the_list_down[merge-train.yml]
M7 drop the if/::warning:: guard so a dispatch failure fails the train 1 — test_a_failed_dispatch_cannot_fail_a_landed_merge
M8 move a dispatch inside fail_and_unlabel 3 — incl. test_the_dispatch_is_not_inside_the_refusal_path
M9 revert e2e's guard to == 'push' 1 — test_a_dispatched_e2e_failure_still_opens_an_issue
M10 drop the heartbeat's schedule trigger 1 — test_the_heartbeat_runs_on_a_schedule
M11 heartbeat counts schedule runs too 1 — test_the_heartbeat_ignores_schedule_and_pull_request_runs
M12 heartbeat measures lag against wall-clock now 1 — test_the_heartbeat_measures_lag_against_main_not_wall_clock
M13 heartbeat tolerates an empty enumeration 1 — test_the_heartbeat_refuses_an_empty_enumeration
M14 hard-code the list in the heartbeat 1 — test_neither_consumer_writes_the_list_down[push-trigger-heartbeat.yml]
M15 parser reads tags: as branches: 1 — test_parser_ignores_tag_only_and_branch_ignore_pushes
M16 parser matches branches-ignore: 1 — same
M17 parser looks for push: outside the on: block 1 — test_parser_does_not_match_a_push_key_outside_the_on_block
M18 parser matches the branch as a substring 1 — test_parser_matches_the_branch_literally
M19 parser always returns nothing 5 — incl. test_the_enumeration_is_not_empty_and_excludes_tag_only_pushes
M20 parser reads pull_request: branches as push branches 9 — incl. six push:main false positives

Three of these survived the first pass and the assertions were tightened until they failed, which is why they are worth naming. M11 was matched by containment — for ev in push workflow_dispatch schedule contains the two-event substring — and is now matched exactly. M15 and M18 were behaviour-preserving against realistic fixtures: publish.yml's tags are v[0-9]+…, which no branch match reaches, and release/* does not contain main. The fixtures are now the discriminating cases — a tag literally named main, and a branch named maintenance — not the realistic ones.

The push:main set is compared against a second, deliberately different in-test parser rather than against the script's own output, so agreement means something; comparing the script to itself is the tautological guard of #1161.

Tests: 27 in the new file; 165 across the related selection (test_ci_manual_dispatch, test_merge_train_gate, test_ci_path_filter, test_eval_calibration_gate, test_subprocess_timeout_budget_1307, the three changelog files). Full suite 8258 passed / 7 failed; all seven pre-existing and environmental — six are --archive requires the 'archive' extra (cryptography is not in this venv; CI syncs --extra archive) and five of those reproduce on a detached github/main checkout, and test_doctor_cli_exit_1_when_broken is a 5s pytest-timeout flake that passes in isolation.

Not done, and it needs saying

AC3 and AC5 are not met by this PR and cannot be. Both are observations that only exist after this lands: AC3 wants the run ID of a dispatch fired by a real merge, and AC5 wants the colour of the first e2e run on main in 942 commits. Whoever merges this owes both to the issue. The baseline to compare against: this repository has recorded exactly one workflow_dispatch run ever — link-check, 2026-05-19T18:30:35Z, success, human-initiated — so the documented GITHUB_TOKEN exception is documented but not yet demonstrated here. If the step logs dispatched. and no run appears, the guard applies more broadly than documented and this should be reverted in favour of the token substitution.

Blast radius of a red first e2e is bounded: e2e is not a required context, it already runs per-PR since #1421, and merge-train evaluates check-runs on the PR head SHA, so a red post-merge run on main does not block a labelled PR. It will now open an attn:e2e-failure issue naming the ref and SHA, which is the point.

Out of scope

Summary by Sourcery

Dispatch post-merge workflows explicitly from the merge train using a derived list of push-triggered workflows, add monitoring to detect regressions, and adjust workflows and tests to support and validate this behavior.

New Features:

  • Add a script to derive workflows triggered by pushes to a branch and reuse it from automation

Bug Fixes:

  • Restore post-merge CI workflows on main by dispatching them from the merge train after fast-forward pushes

Enhancements:

  • Add a scheduled heartbeat workflow to detect when post-merge push-triggered workflows stop running
  • Update e2e and eval-calibration workflows to support manual dispatch and ensure failures on main still surface issues appropriately
  • Document merge-train post-merge dispatch behavior and protections in CONTRIBUTING and changelog entries

Tests:

  • Add an extensive test suite validating workflow enumeration, merge-train dispatch behaviour, and heartbeat correctness

@robotrocketscience robotrocketscience added the author-Setr PR coordination mutex label Aug 11, 2026

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

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5463ef7e-01cf-471a-925f-5d93a1154b36

📥 Commits

Reviewing files that changed from the base of the PR and between f116a92 and eb7bf67.

📒 Files selected for processing (11)
  • .github/workflows/codeql.yml
  • .github/workflows/deadcode.yml
  • .github/workflows/e2e.yml
  • .github/workflows/eval-calibration.yml
  • .github/workflows/merge-train.yml
  • .github/workflows/push-trigger-heartbeat.yml
  • .github/workflows/zizmor.yml
  • CHANGELOG/unreleased/1423-post-merge-dispatch.md
  • CONTRIBUTING.md
  • scripts/push_trigger_workflows.py
  • tests/test_merge_train_dispatch.py

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.

@github-actions

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 968 changed lines (limit: 200)
  • 11 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@sourcery-ai

sourcery-ai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements a derived, script-driven mechanism for dispatching and monitoring post-merge workflows that previously relied on push-to-main events (which are blocked when using GITHUB_TOKEN), adds a heartbeat workflow to detect regressions, and updates CI workflows, merge-train, and tests accordingly.

Sequence diagram for post-merge workflow dispatch from merge-train

sequenceDiagram
    actor Developer
    participant MergeTrain as merge-train.yml
    participant GitHub as GitHub
    participant Script as push_trigger_workflows.py
    participant Workflow as push_main_workflow

    Developer->>MergeTrain: Label PR to trigger merge-train
    MergeTrain->>GitHub: git push origin "${HEAD_SHA}:refs/heads/main"
    Note right of GitHub: push with secrets.GITHUB_TOKEN
    GitHub-->>MergeTrain: main fast-forwarded (no push workflows started)

    MergeTrain->>Script: python3 scripts/push_trigger_workflows.py --branch main
    Script-->>MergeTrain: e2e.yml, codeql.yml, deadcode.yml, eval-calibration.yml, release-drafter.yml, flag-stale-open-prs.yml, zizmor.yml

    loop for each workflow in dispatch_list
        MergeTrain->>GitHub: gh workflow run <workflow> --ref main --repo "$REPO"
        GitHub-->>Workflow: Create workflow_dispatch run on main
    end

    MergeTrain-->>Developer: Merge completed (dispatch failures only warn)
Loading

File-Level Changes

Change Details Files
Add a script-based enumeration of push:main workflows and use it from merge-train and a new heartbeat workflow to dispatch and monitor post-merge runs.
  • Introduce scripts/push_trigger_workflows.py to parse workflow files for on: push: branches: [main] without external YAML dependencies.
  • Update .github/workflows/merge-train.yml to gain actions:write, run the script after a successful FF push, and dispatch each enumerated workflow via gh workflow run --ref main, treating failures as non-fatal warnings.
  • Add .github/workflows/push-trigger-heartbeat.yml to run on a schedule, compare the latest push/workflow_dispatch runs on main against main’s head commit, and open/update a tracking issue if lag exceeds a threshold.
scripts/push_trigger_workflows.py
.github/workflows/merge-train.yml
.github/workflows/push-trigger-heartbeat.yml
Ensure all push:main workflows are dispatchable and adjust e2e failure surfacing semantics for dispatched runs.
  • Add workflow_dispatch triggers to e2e, eval-calibration, zizmor, codeql, and deadcode workflows so they can be invoked by gh workflow run.
  • Update e2e.yml comments and triggers to clarify post-merge behavior, and change the failure-issue-opening step condition from github.event_name == 'push' to github.event_name != 'pull_request', with titles/bodies reporting branch and event.
  • Document the new dispatch behavior and requirements in CONTRIBUTING.md and record the fix in a changelog entry.
.github/workflows/e2e.yml
.github/workflows/eval-calibration.yml
.github/workflows/zizmor.yml
.github/workflows/codeql.yml
.github/workflows/deadcode.yml
CONTRIBUTING.md
CHANGELOG/unreleased/1423-post-merge-dispatch.md
Add a comprehensive test suite to lock in dispatch behavior, parser correctness, and heartbeat semantics.
  • Create tests/test_merge_train_dispatch.py to assert the derived workflow list, require workflow_dispatch on every push:main workflow, enforce merge-train dispatch ordering, ref targeting, non-fatal behavior, and ensure heartbeat filtering and lag calculations behave as intended.
  • Include parser-focused tests that exercise tag-only workflows, branches-ignore, pull_request branches, keys outside the on block, quoted on keys, and literal vs glob branch matching.
  • Use subprocess-based invocation of the enumeration script with timeouts and mutation checks to guard against regressions in both the script and workflow wiring.
tests/test_merge_train_dispatch.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1423 Enumerate all workflows with on: push: branches: [main] and capture which ones have stopped firing since the merge train began, using a derived list rather than a hard-coded list, and document that scope.
#1423 Ensure all workflows that rely on on: push: branches: [main] (including e2e, codeql, deadcode, eval-calibration, release-drafter, flag-stale-open-prs, and zizmor) actually run again after merge-train fast-forwards main, via a safe mechanism that does not introduce new long-lived credentials, and make e2e’s failure-surfacing work for the new trigger.
#1423 Add an automated guard that detects when push-main post-merge workflows have silently stopped running (e.g., last successful push/workflow_dispatch run on main older than a threshold) and raises an issue instead of allowing silent regression.

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

…files

The merge train FF-pushes with `secrets.GITHUB_TOKEN`, and GitHub raises no
workflow runs from events made with that token, so every `on: push: branches:
[main]` workflow stopped the day the train became the merge path. Re-arming
them needs a list of what to dispatch, and a hand-written list reproduces the
defect on the next workflow someone adds. This derives it: pure-stdlib text
parse (PyYAML is not importable in CI), `branches:` under `on: push:` only, so
publish.yml's tag trigger is excluded.
`gh workflow run` returns 422 for a workflow that does not declare the
trigger, so without this the merge train's post-merge fan-out would warn and do
nothing for five of the seven. release-drafter and flag-stale-open-prs already
carry it. paths: filters do not apply to a dispatch, which for e2e and zizmor
means a dispatched run always executes — intended, since the point is a
post-merge run of main.
GitHub does not start workflow runs from events raised by `secrets.GITHUB_TOKEN`
— the documented recursion guard — and `workflow_dispatch` is one of its two
exceptions. So the train dispatches what its own push cannot trigger, from the
derived enumeration rather than a literal list, after the FF and never from the
refusal path. `--ref main` and no inputs: a dispatched run's check-runs attach
to the head of the ref it was dispatched on, which is what keeps a post-merge
run from reporting against a commit it did not test. Non-fatal by construction
— main has already advanced, so a failed dispatch warns rather than reddening a
landed merge. Costs `actions: write`; rejected a PAT/App token (a long-lived
credential with write access to main, in a repo whose merge model exists
because the bot has no key) and a schedule (decouples the run from the commit
that caused it).
The #370 issue-opening step was gated on `github.event_name == 'push'`. The
post-merge run is now a dispatch, so that guard skipped the step on exactly the
runs it exists for — a red e2e on main with no issue and no label. Widened to
`!= 'pull_request'`, and the title and body report the ref and event rather
than asserting a push to main.
The stop was invisible for 666 commits because 'no run' is an absence and every
signal in the system was an absence test. This asserts presence: weekly, for
each workflow in the derived enumeration, take the newest run on main by push
or workflow_dispatch and compare it against main's head commit. Schedule and
pull_request runs are excluded — they are the masking that kept codeql and
zizmor looking healthy throughout. Lag is measured against the head commit, not
wall-clock, so a quiet fortnight is not a defect. An empty enumeration fails
rather than reporting everything healthy.
Twenty mutations, each red: dropping a hatch, dropping actions: write, hoisting
the dispatch above the FF push or into fail_and_unlabel, retargeting --ref,
passing an input, un-guarding the failure, hard-coding either consumer's list,
reverting e2e's surfacing guard, and seven parser mutations. The push:main set
is re-derived by a second, deliberately different in-test parse rather than
compared against the script's own output.
…in section

Says why the FF push triggers nothing, that the list is derived, that a new
push:main workflow needs its own workflow_dispatch trigger to be picked up, and
where the regression detector lives.
The script is exercised through its CLI, and the two repo-wide gates on test
termination caught what the first cut left open: `subprocess.run` with no
`timeout=` (#1307's unbounded-blocking-call check) and ten spawning tests on
the suite's 5s default, where contention reports as a hang rather than as
slowness. Both bounded at 30s, and the enumeration is now memoised so the
parametrised cases spawn once rather than once per case.
One entry file under the new per-entry convention (#1475), so it does not
touch the shared [Unreleased] block.
`git rev-list --count 3421cd7..github/main` is 942 as of 2026-08-11, not the
666 the issue's AC1 comment recorded on 2026-08-09. Same figure everywhere and
dated, so the next reader knows when it was taken.
@robotrocketscience
robotrocketscience force-pushed the ci/issue-1423-dispatch-after-ff branch from 77905b7 to eb7bf67 Compare August 11, 2026 22:44
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 11, 2026
@github-actions
github-actions Bot merged commit eb7bf67 into main Aug 11, 2026
31 checks passed
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 11, 2026
@github-actions

Copy link
Copy Markdown

merge-train: merged eb7bf67main via FF push.

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

Labels

author-Setr PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: no on-push workflow has fired since the merge train took over — e2e, codeql and deadcode are dead on main (541 commits)

1 participant