Skip to content

Post-CI dispatcher

TL;DR

Item Value
File .github/workflows/post-ci-dispatcher.yml
Trigger workflow_run: CI completed on main
Children auto-release, auto-heal, bernstein-ci-fix, bisect-on-red, telegram-notify
Boots paid 1 per CI completion (was 5)

Single workflow_run: CI completed listener that resolves the upstream metadata once and routes to five reusable workflows via workflow_call. Replaces five sibling listeners that each paid an independent runner cold start plus a GHA token mint per CI completion.

What changed

The five post-CI workflows kept their file paths so existing branch protection rules, runtime tooling, and operator runbooks continue to resolve them by name. Their internals now declare on: workflow_call: instead of on: workflow_run:; the dispatcher owns the upstream event.

Workflow file Trigger before Trigger now
auto-release.yml workflow_run: CI completed workflow_call
auto-heal.yml workflow_run: CI completed workflow_call
bernstein-ci-fix.yml workflow_run: CI completed workflow_call
bisect-on-red.yml workflow_run: CI completed workflow_call
telegram-notify.yml workflow_run: CI completed workflow_call
post-ci-dispatcher.yml n/a (new) workflow_run: CI completed

Sequence

CI run completes on main
        |
        v
+---------------------------+
| post-ci-dispatcher.yml    |  (1 boot, reads workflow_run metadata)
|   meta:                   |
|     head_sha, conclusion, |
|     head_branch, run_id,  |
|     display_title, ...    |
+-------------+-------------+
              |
   +----------+----------+----------+----------+-----------+
   |          |          |          |          |           |
   v          v          v          v          v           v
telegram-  auto-      auto-heal  bernstein-  bisect-     (dispatcher
notify     release               ci-fix     on-red       outputs)
(non-      (main      (failure,  (failure,  (failure,
 success)   branch)    canonical  canonical  main)
                       repo)      repo,
                                  and auto-heal
                                  did NOT open
                                  a PR)

Race resolution

Before: auto-heal and bernstein-ci-fix both listened to workflow_run: CI completed and ran in parallel. On a real failure both tried to open a heal PR on the same SHA, occasionally producing competing patches.

After: the dispatcher serialises them via needs:. bernstein-ci-fix runs only when auto-heal either skipped or did not open a PR. The serialisation point is the reusable workflow output auto-heal.heal_outcome (one of applied, skipped_no_jobs, failed_validation).

Inputs forwarded by the dispatcher

The dispatcher resolves the upstream workflow_run event into typed inputs once and passes them to each child:

Input Used by
conclusion telegram-notify, auto-release
head_branch telegram-notify, auto-release, bernstein-ci-fix
head_sha every child
run_id every child
html_url telegram-notify, auto-release, bisect-on-red
display_title auto-heal, bernstein-ci-fix (recursion guards)
actor_login bernstein-ci-fix (bot filtering)

Security model

The workflow_run trigger is intentional and zizmor-annotated. The dispatcher only reads metadata fields into if: conditions and forwards them as typed inputs. It never executes attacker-controlled values in a run: script.

Each child reusable workflow re-checks its own preconditions (canonical-repo gate, recursion guards, bot allowlist) for defence-in-depth. The dispatcher gates are routing decisions, not security boundaries.

Secret passthrough

The dispatcher does not use secrets: inherit (zizmor flags that as secrets-inherit: every repository secret would be exposed to every child). Each child reusable workflow declares the exact secrets it needs in its on: workflow_call.secrets: block, and the dispatcher forwards only those:

Child Secrets forwarded
telegram-notify TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
auto-release TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
auto-heal TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID (both optional)
bernstein-ci-fix GEMINI_API_KEY (optional)
bisect-on-red none

GITHUB_TOKEN is provided automatically to every reusable workflow invocation and does not need to be listed.

Operator runbook

Question Answer
Which workflow do I look at for a failed heal on a given SHA? The dispatcher run lists each reusable child as a sub-run. Open post-ci-dispatcher.yml for the SHA, drill into the failing child.
Where does gh run list --workflow "auto-release.yml" go? Still works: reusable workflow runs are listed under their workflow file even when triggered via workflow_call.
How do I disable a single child? Set its if: guard to false in the dispatcher (preferred), or open the reusable workflow and gate its first job.
How do I rerun only the post-CI fanout after CI succeeded? Rerun the dispatcher workflow; CI itself is unaffected.

Testing the dispatcher

A real workflow_run event can be observed by pushing a commit to main and watching the dispatcher boot, or simulated locally by invoking each reusable workflow with gh workflow run and synthetic inputs. The unit test tests/unit/test_post_ci_dispatcher_yaml.py asserts the dispatcher's structural invariants (trigger, children, race serialisation).