@plainconceptsplatform/workflows 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -203,20 +203,23 @@ assert_route "EVIDENCE_ENABLED=false turns the merge route off" none \
203
203
  EVENT=pull_request_target ACTION=closed PR_MERGED=true EVENT_PR_NUMBER=7 EVIDENCE_ENABLED=false
204
204
 
205
205
  echo "── CI completion ─────────────────────────────────────────────────────────"
206
- assert_route "a failed App CI run on a pull request routes to merge-gate" merge-gate \
207
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99
208
- assert "merge-gate carries the failing CI conclusion" failure \
209
- "$(route_field ci-conclusion EVENT=workflow_run RUN_PR_NUMBER=7 \
210
- RUN_CONCLUSION=failure RUN_ID=99)"
211
- assert "merge-gate carries the failing CI run id" 99 \
212
- "$(route_field ci-run-id EVENT=workflow_run RUN_PR_NUMBER=7 \
213
- RUN_CONCLUSION=failure RUN_ID=99)"
214
- assert_route "a green App CI run does not auto-trigger the gate" none \
215
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
216
- assert_route "a cancelled App CI run does not auto-trigger the gate" none \
217
- EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=cancelled RUN_ID=99
218
- assert_route "a failed CI run with no pull request routes nowhere" none \
219
- EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=failure RUN_ID=99
206
+ # A CI verdict moves the merge train, green or red; the train picks which pull request the gate
207
+ # takes. Dispatching the gate for the run's own pull request is how a night of them raced each
208
+ # other for the default branch.
209
+ assert_route "a failed App CI run on a pull request moves the merge train" merge-train EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99
210
+ assert_route "a green App CI run on a pull request moves the merge train" merge-train EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=success RUN_ID=99
211
+ assert "the train hears the CI conclusion" failure "$(route_field ci-conclusion EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99)"
212
+ assert "the train hears the CI run id" 99 "$(route_field ci-run-id EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=failure RUN_ID=99)"
213
+ assert_route "a cancelled App CI run moves nothing" none EVENT=workflow_run RUN_PR_NUMBER=7 RUN_CONCLUSION=cancelled RUN_ID=99
214
+ assert_route "a failed CI run with no pull request routes nowhere" none EVENT=workflow_run RUN_PR_NUMBER= RUN_CONCLUSION=failure RUN_ID=99
215
+ # A consumer CI hands its verdict over with GITHUB_TOKEN. Old CIs dispatch merge-gate; that is a
216
+ # CI completion, so it moves the train instead of jumping the queue. A person or the train itself
217
+ # dispatching merge-gate still reaches the gate.
218
+ assert_route "a consumer CI's merge-gate handover moves the train" merge-train EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 ACTOR='github-actions[bot]'
219
+ assert "the handover carries its verdict to the train" success "$(route_field ci-conclusion EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 ACTOR='github-actions[bot]' INPUT_CI_CONCLUSION=success INPUT_CI_RUN_ID=5)"
220
+ assert_route "the train's own gate dispatch reaches the gate" merge-gate EVENT=workflow_dispatch OPERATION=merge-gate INPUT_PR_NUMBER=7 ACTOR='platform-devbox[bot]'
221
+ assert_route "merge-train dispatch needs no pull request" merge-train EVENT=workflow_dispatch OPERATION=merge-train
222
+ assert "a merge-train handover carries its run id" 5 "$(route_field ci-run-id EVENT=workflow_dispatch OPERATION=merge-train INPUT_PR_NUMBER=7 INPUT_CI_CONCLUSION=failure INPUT_CI_RUN_ID=5)"
220
223
 
221
224
  echo "── Schedules ─────────────────────────────────────────────────────────────"
222
225
  while read -r cron; do
@@ -694,60 +697,76 @@ if worker_installed merge-gate; then
694
697
  fi
695
698
  fi
696
699
 
697
- # A verdict is the gate marker AND a `**Verdict:**` line together. Comments carrying the
698
- # marker alone were progress notes and failed attempts, and the reconcile belt read every
699
- # one of them as final: a crashed or OOM-killed gate parked its pull request for the rest
700
- # of the night. Attempts are counted separately, capped, and reset by any new CI run.
701
- # The belt lives in the router's plumbing jobs, so this holds in every repository.
702
- BELT_OK=1
703
- if ! grep -q 'agent-merge-gate-attempt' "$ROUTER_YML"; then
704
- BELT_OK=0; echo "FAIL: router never counts gate attempts" >&2
705
- fi
706
- if [ "$(count -cF 'contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))' "$ROUTER_YML")" -lt 4 ]; then
707
- BELT_OK=0; echo "FAIL: verdict detection must pair the gate marker with a Verdict line in both dispatch paths" >&2
708
- fi
709
- if [ "$(count -c 'attempts_so_far' "$ROUTER_YML")" -lt 2 ]; then
710
- BELT_OK=0; echo "FAIL: dispatch sites must forward attempts_so_far" >&2
711
- fi
712
- # A second gate for a pull request whose gate is already queued or running reads the same CI
713
- # verdict, and the belt's queue would run it twice.
714
- if [ "$(count -c 'a merge-gate run is already live' "$ROUTER_YML")" -lt 2 ]; then
715
- BELT_OK=0; echo "FAIL: both dispatch paths must skip a pull request whose gate is already live" >&2
716
- fi
717
- # A conflicting pull request has no refs/pull/N/merge for GitHub to build, so a `pull_request`
718
- # CI workflow can never run on that head. Requiring a fresh verdict before dispatching deadlocks
719
- # the belt: only the gate resolves the conflict, and the gate never runs. Both paths fall back to
720
- # the branch's last verdict when, and only when, the pull request is conflicting.
721
- if [ "$(count -c 'conflicts, so CI cannot run on' "$ROUTER_YML")" -lt 2 ]; then
722
- BELT_OK=0
723
- echo "FAIL: both dispatch paths must gate a conflicting pull request that can never get fresh CI" >&2
724
- fi
725
- # That fallback has to read the computed mergeable state. The REST boolean is null until GitHub
726
- # recomputes it, and stays null for a pull request nobody has opened recently, which is exactly
727
- # the stale conflicting pull request the fallback exists for: it never fired once in production.
728
- # The state has to be polled, not read once. GitHub computes mergeability on demand and the
729
- # first read answers UNKNOWN (or null through REST) while it works it out, so a single read
730
- # reports "not conflicting" for exactly the stale pull requests the fallback is for. Observed
731
- # twice in production: the fallback logged "no completed CI run" for a pull request that
732
- # `gh pr view` reported as CONFLICTING from a warm cache seconds later.
733
- if [ "$(count -c 'mergeable_state()' "$ROUTER_YML")" -lt 2 ] ||
734
- [ "$(count -c 'mergeable_now=$(mergeable_state' "$ROUTER_YML")" -lt 2 ]; then
735
- BELT_OK=0
736
- echo "FAIL: both dispatch paths must poll the mergeable state; a single read answers UNKNOWN" >&2
700
+ # The belt is the merge train now: one gate at a time, oldest pull request first. Its I/O lives in
701
+ # the merge-train action and every rule it learned as the belt is asserted there.
702
+ #
703
+ # A verdict is the gate marker AND a `**Verdict:**` line together. Comments carrying the marker
704
+ # alone were progress notes and failed attempts, and the belt read every one of them as final: a
705
+ # crashed or OOM-killed gate parked its pull request for the rest of the night. Attempts are
706
+ # counted separately and capped.
707
+ TRAIN_SH="${HERE}/../merge-train/merge-train.sh"
708
+ if worker_installed merge-gate; then
709
+ BELT_OK=1
710
+ if [ ! -f "$TRAIN_SH" ]; then
711
+ BELT_OK=0; echo "FAIL: the merge gate is installed without the merge-train action" >&2
712
+ else
713
+ grep -q 'agent-merge-gate-attempt' "$TRAIN_SH" ||
714
+ { BELT_OK=0; echo "FAIL: the merge train never counts gate attempts" >&2; }
715
+ grep -qF 'contains("<!-- agent-merge-gate -->")) and (.body | test("Verdict:' "$TRAIN_SH" ||
716
+ { BELT_OK=0; echo "FAIL: the train's verdict detection must pair the gate marker with a Verdict line" >&2; }
717
+ grep -qF 'inputs[attempts_so_far]=' "$TRAIN_SH" ||
718
+ { BELT_OK=0; echo "FAIL: the train must forward attempts_so_far to the gate" >&2; }
719
+ # One gate at a time, found by the title run-name gives it. The belt looked for a title that
720
+ # run-name had stopped producing, saw no live gate ever, and dispatched #680 five times in
721
+ # an hour onto a belt that runs one gate at a time.
722
+ gate_title="$(sed -n "s/.*format('\(Agent: Merge Gate - PR #\){0}'.*/\1/p" "$ROUTER_YML" | head -1)"
723
+ if [ -z "$gate_title" ] || ! grep -qF "GATE_TITLE_PREFIX=\"${gate_title}\"" "$TRAIN_SH"; then
724
+ BELT_OK=0
725
+ echo "FAIL: the train looks for live gates titled differently from what run-name names them ('${gate_title:-unread}')" >&2
726
+ fi
727
+ # A conflicting pull request has no refs/pull/N/merge, so CI can never run on its head; the
728
+ # train hands it to the gate on the branch's last verdict rather than waiting forever.
729
+ grep -qF 'actions/runs?branch=$head_ref' "$TRAIN_SH" ||
730
+ { BELT_OK=0; echo "FAIL: the train must gate a conflicting pull request on its branch's last verdict" >&2; }
731
+ # GitHub computes mergeability on demand and the first read answers UNKNOWN.
732
+ grep -q 'mergeable=$(mergeable_state' "$TRAIN_SH" ||
733
+ { BELT_OK=0; echo "FAIL: the train must poll the mergeable state; a single read answers UNKNOWN" >&2; }
734
+ # The park is keyed to the head commit, never the CI time: a CI re-run must not reopen a park
735
+ # a person owns.
736
+ grep -qF '[ "$verdict_at" \> "$head_committed" ]' "$TRAIN_SH" ||
737
+ { BELT_OK=0; echo "FAIL: the train must key a verdict's park to the head commit" >&2; }
738
+ fi
739
+ # The App token: a dispatch or a branch update made with GITHUB_TOKEN starts no workflow.
740
+ train_job="$(sed -n '/^ merge-train:$/,/^ [a-z-]*:$/p' "$ROUTER_YML")"
741
+ if ! grep -q 'token: ${{ steps.app-token.outputs.token }}' <<<"$train_job" ||
742
+ ! grep -q 'group: merge-train' <<<"$train_job" ||
743
+ ! grep -q 'needs: \[classify, call-merge-gate\]' <<<"$train_job"; then
744
+ BELT_OK=0
745
+ echo "FAIL: the merge-train job must use the App token, hold the merge-train lock and tick after the gate" >&2
746
+ fi
747
+ if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
737
748
  fi
738
- if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
739
749
 
740
- # GitHub delivers workflow_run only for CI runs whose actor is a human, so a bot pull request's
741
- # CI never reaches the router's CI-completion route. The package ships a dispatch-merge-gate job
742
- # in templates/ci that hands the verdict over from inside CI; a consumer CI workflow, where one
743
- # exists beside the router, must carry it or bot pull requests wait for the hourly belt.
750
+ # GitHub delivers workflow_run only for CI runs whose actor is a human, so a bot-updated pull
751
+ # request's CI never reaches the router's CI-completion route. The package ships a dispatch job in
752
+ # templates/ci that hands the verdict over from inside CI; a consumer CI workflow, where one exists
753
+ # beside the router, must carry it or those pull requests wait for the hourly tick.
744
754
  for ci in "${WORKFLOWS_DIR}/ci.yml" "${WORKFLOWS_DIR}/app-ci.yml"; do
745
755
  [ -f "$ci" ] || continue
746
- if grep -q 'operation=merge-gate' "$ci"; then
756
+ if grep -qE 'operation=merge-(gate|train)' "$ci"; then
747
757
  PASS=$((PASS + 1))
748
758
  else
749
759
  FAIL=$((FAIL + 1))
750
- echo "FAIL: $(basename "$ci") has no dispatch-merge-gate job; bot pull requests would wait for the hourly belt" >&2
760
+ echo "FAIL: $(basename "$ci") hands no CI verdict to the merge train; pull requests would wait for the hourly tick" >&2
761
+ fi
762
+ done
763
+ for template in "${HERE}/../../templates/ci/"*.yml; do
764
+ [ -f "$template" ] || continue
765
+ if grep -q 'operation=merge-train' "$template" && ! grep -q "pull_request.user.type == 'Bot'" "$template"; then
766
+ PASS=$((PASS + 1))
767
+ else
768
+ FAIL=$((FAIL + 1))
769
+ echo "FAIL: $(basename "$template") must hand every same-repository pull request's verdict to the merge train" >&2
751
770
  fi
752
771
  done
753
772
 
@@ -1110,7 +1129,7 @@ done < <(sed -n '/^ operation:/,/^ issue-number:/p' "$ROUTER_YML" |
1110
1129
  # contents and issues and failed nightly on a 403 that named the endpoint and nothing else.
1111
1130
  # Paired job-to-scope rather than parsed out of each action: the jobs that touch pull
1112
1131
  # requests are few and known, and naming them here is what makes the omission visible.
1113
- for pr_job in audit-close reconcile-bot-pr-runs detect-pr-conflicts housekeeping; do
1132
+ for pr_job in audit-close reconcile-bot-pr-runs merge-train land-stack-child housekeeping; do
1114
1133
  if ! grep -q "^ ${pr_job}:$" "$ROUTER_YML"; then
1115
1134
  continue
1116
1135
  fi
@@ -1754,17 +1773,18 @@ fi
1754
1773
 
1755
1774
  echo "── Merge gate park ───────────────────────────────────────────────────────"
1756
1775
 
1757
- # A gate verdict parks the code it was given on. Both dispatch paths -- detect-pr-conflicts and
1758
- # the reconcile belt -- used to compare the standing verdict against the CI finish time, which
1759
- # made the park worthless: any later run on the same commits was newer than the verdict, so the
1760
- # belt re-dispatched a pull request a human already owned and reset its attempt budget at the
1761
- # same time. Lyceum PR #13 sat parked for six days while that happened. Asserted because both
1762
- # comparisons are one line and neither failing produces a red run.
1776
+ # A gate verdict parks the code it was given on. The belt used to compare the standing verdict
1777
+ # against the CI finish time, which made the park worthless: any later run on the same commits was
1778
+ # newer than the verdict, so it re-dispatched a pull request a human already owned. Lyceum PR #13
1779
+ # sat parked for six days while that happened. The opposite failure is what parked Pliny-Bot for
1780
+ # whole weekends: a park that never expired, so a pull request blocked on Friday night was still
1781
+ # blocked on Monday though the default branch had moved under it a dozen times. The train's two
1782
+ # pure functions decide both, and they are exercised here rather than read.
1763
1783
  if worker_installed merge-gate; then
1764
1784
  GATE_OK=1
1765
1785
 
1766
- # Neither path may key the park to CI timing again.
1767
- stale_horizon=$(count -cE 'verdict" \\> "\$ci_finished"|latest_verdict" \\> "\$ci_finished"' "$ROUTER_YML")
1786
+ # Nothing may key the park to CI timing again.
1787
+ stale_horizon=$(count -cE 'verdict" \> "\$ci_finished"|latest_verdict" \> "\$ci_finished"' "$ROUTER_YML")
1768
1788
  if [ "$stale_horizon" -eq 0 ]; then
1769
1789
  PASS=$((PASS + 1))
1770
1790
  else
@@ -1772,13 +1792,37 @@ if worker_installed merge-gate; then
1772
1792
  echo "FAIL: the merge-gate park is keyed to the CI finish time in ${stale_horizon} place(s); a CI re-run would reopen a park a person owns" >&2
1773
1793
  fi
1774
1794
 
1775
- # Both must fall back to the CI time only when the head commit cannot be read.
1776
- horizons=$(count -cE '\$\{head_committed:-\$ci_finished\}' "$ROUTER_YML")
1777
- if [ "$horizons" -eq 2 ]; then
1778
- PASS=$((PASS + 1))
1779
- else
1780
- GATE_OK=0
1781
- echo "FAIL: ${horizons} of the 2 gate dispatch paths key their park to the head commit" >&2
1795
+ if [ -f "$TRAIN_SH" ]; then
1796
+ # Each call in its own shell: the facts are environment variables, and one case's must not
1797
+ # leak into the next.
1798
+ park() { env "$@" bash -c "source '$TRAIN_SH'; train_park"; }
1799
+ move() { env "$@" bash -c "source '$TRAIN_SH'; train_move" | sed -n 's/^move=//p'; }
1800
+
1801
+ assert "no verdict leaves the pull request to the train" none "$(park VERDICT= VERDICT_NEWER=false)"
1802
+ assert "a verdict older than the head is spent" none "$(park VERDICT=owner-review VERDICT_NEWER=false)"
1803
+ assert "owner-review on this head waits for its owner" held "$(park VERDICT=owner-review VERDICT_NEWER=true BASE_MOVED=true)"
1804
+ assert "human-review on this head waits for a person" held "$(park VERDICT=human-review VERDICT_NEWER=true BASE_MOVED=true)"
1805
+ assert "remediated never parks: the gate wants the next CI" none "$(park VERDICT=remediated VERDICT_NEWER=true)"
1806
+ assert "blocked holds while the base has not moved" held "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=false RETRIES=1)"
1807
+ assert "blocked is retried once the base moves" none "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=true RETRIES=1 MAX_RETRIES=3)"
1808
+ assert "blocked holds once its retries are spent" held "$(park VERDICT=blocked VERDICT_NEWER=true BASE_MOVED=true RETRIES=3 MAX_RETRIES=3)"
1809
+ assert "a merge that did not happen is retried once the base moves" none "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=1)"
1810
+ assert "a stack root is never retried into a merge" held "$(park VERDICT=auto-merge VERDICT_NEWER=true BASE_MOVED=true RETRIES=0 STACK_ROOT=true)"
1811
+
1812
+ assert "hold keeps a pull request off the train" skip "$(move HOLD=true CI_STATUS=completed CI_CONCLUSION=success)"
1813
+ assert "requested changes keep it off the train" skip "$(move REVIEW_DECISION=CHANGES_REQUESTED CI_STATUS=completed CI_CONCLUSION=success)"
1814
+ assert "a parked pull request is passed by, not waited on" skip "$(move PARK=held CI_STATUS=completed CI_CONCLUSION=success)"
1815
+ assert "a spent attempt budget is passed by" skip "$(move ATTEMPTS=6 MAX_ATTEMPTS=6 CI_STATUS=completed CI_CONCLUSION=failure)"
1816
+ assert "a conflict goes to the gate even with no CI" gate "$(move MERGEABLE=CONFLICTING CI_STATUS=none)"
1817
+ assert "running CI is waited on" wait "$(move MERGEABLE=MERGEABLE CI_STATUS=in_progress)"
1818
+ assert "CI stuck past the limit is passed by" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=queued CI_STUCK=true)"
1819
+ assert "red CI goes to the gate" gate "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=failure)"
1820
+ assert "green but behind is brought up to date first" update "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=success BEHIND_BY=2)"
1821
+ assert "green and current goes to the gate" gate "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=success BEHIND_BY=0)"
1822
+ assert "no CI run is passed by" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=none)"
1823
+ assert "a trusted bot's CI awaiting approval is approved" approve "$(move CI_STATUS=completed CI_CONCLUSION=action_required TRUSTED_AUTHOR=true)"
1824
+ assert "a person's CI awaiting approval is not approved by the train" skip "$(move CI_STATUS=completed CI_CONCLUSION=action_required TRUSTED_AUTHOR=false)"
1825
+ assert "a cancelled run is no verdict" skip "$(move MERGEABLE=MERGEABLE CI_STATUS=completed CI_CONCLUSION=cancelled)"
1782
1826
  fi
1783
1827
 
1784
1828
  # One cap, not four literals, and it has to match what the worker tells the reader.
@@ -2518,8 +2562,8 @@ for worker_md in "${WORKFLOWS_DIR}"/agent-*.md; do
2518
2562
  done
2519
2563
  if [ "$INPUT_WIRING_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
2520
2564
 
2521
- # Several paths dispatch gates at once (the reconcile tick, CI's dispatch-merge-gate, the
2522
- # conflict rescan). Without `queue: max` GitHub keeps one pending run in the group and cancels the
2565
+ # The train dispatches one gate at a time, but a person, a merge-gate label and an old consumer CI
2566
+ # can still dispatch one beside it. Without `queue: max` GitHub keeps one pending run in the group and cancels the
2523
2567
  # rest with no comment and no recorded attempt; every dispatch still returns 204.
2524
2568
  BELT_QUEUE_OK=1
2525
2569
  belt_caller=$(awk '/^ call-merge-gate:/ { found = 1; print; next } found && /^ [a-z]/ { exit } found { print }' "$ROUTER_YML")
@@ -2573,7 +2617,7 @@ if worker_installed merge-gate; then
2573
2617
  echo "FAIL: the incomplete job must read one computed budget decision, not re-derive it" >&2
2574
2618
  fi
2575
2619
  # The park has to be a verdict or the belt keeps dispatching: a comment carrying the gate
2576
- # marker AND a Verdict line is what detect-pr-conflicts and the reconcile belt both park on.
2620
+ # marker AND a Verdict line is what the merge train parks on.
2577
2621
  if ! grep -A 16 "Record an unusable report as a decision" "$MERGE_GATE_WORKER_MD" |
2578
2622
  grep -q '\${{ env.GATE_MARKER }}' ||
2579
2623
  ! grep -A 16 "Record an unusable report as a decision" "$MERGE_GATE_WORKER_MD" |
@@ -57,7 +57,7 @@ flowchart TD
57
57
  wTriage("agent-triage")
58
58
  classify -.->|"cron or dispatch"| plumbing
59
59
  plumbing["deterministic jobs<br/>no model runs in any of these"] --> pJobs
60
- pJobs("bot-approve · audit-close<br/>cleanup-artifacts · validate<br/>reconcile-bot-pr-runs · detect-pr-conflicts<br/>check-stack-branch · dispatch-implement")
60
+ pJobs("bot-approve · audit-close<br/>cleanup-artifacts · validate<br/>reconcile-bot-pr-runs · merge-train · land-stack-child<br/>check-stack-branch · dispatch-implement")
61
61
  idle(("Idle<br/>run ends, ~10s, every job skipped"))
62
62
 
63
63
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
@@ -87,10 +87,14 @@ flowchart LR
87
87
  refine -->|"8 or more"| split("Split into children") --> refine
88
88
  refine -.->|questions| author
89
89
  implement("implement<br/>branch, PR, closes the issue") --> ci("CI")
90
- ci --> gate{"merge-gate"}
90
+ person("A person opens a PR") --> ci
91
+ ci --> train{"merge train<br/>oldest PR first,<br/>one at a time"}
92
+ train -.->|"green, behind main"| update("Merge main in") --> ci
93
+ train --> gate{"merge-gate"}
91
94
  gate -->|merge| merged(("Merged"))
92
- gate -.->|"CI failed"| fix("Fix, push, CI again") --> gate
93
- gate -.->|"risk or protected"| human(("Human review"))
95
+ merged -->|"next PR"| train
96
+ gate -.->|"CI failed or conflict"| fix("Fix, push, CI again") --> ci
97
+ gate -.->|".github, secrets<br/>or 1000+ lines"| human(("Human review"))
94
98
  review("Someone reviews the PR") --> applyReview("apply-review") --> ci
95
99
  audit("audit, weekly") -->|"files one issue"| refine
96
100
 
@@ -99,9 +103,9 @@ flowchart LR
99
103
  classDef failure fill:#fff0f0,stroke:#ef2929,stroke-width:2px,color:#8b1a1a
100
104
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
101
105
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
102
- class open,write,review,audit start
103
- class refine,split,implement,ci,fix,applyReview,author action
104
- class triage,gate decision
106
+ class open,write,review,audit,person start
107
+ class refine,split,implement,ci,fix,applyReview,author,update action
108
+ class triage,gate,train decision
105
109
  class human,closed failure
106
110
  class merged success
107
111
  ```
@@ -110,6 +114,44 @@ Audit creates work rather than consuming it, and files into `refine` rather than
110
114
  `implement`, so a report of several unrelated findings becomes one properly sized issue per finding
111
115
  instead of one pull request that has to fix them all.
112
116
 
117
+ ### The merge train
118
+
119
+ One pull request moves at a time, the lowest number first, whoever opened it. Everything else waits
120
+ untouched, so a merge never invalidates work in flight. A pull request a person has to decide on is
121
+ passed by, so it never blocks the line. The train ticks on every merge, every CI completion, after
122
+ every gate, and hourly as the fallback.
123
+
124
+ ```mermaid
125
+ flowchart TD
126
+ trainTick("A merge, a CI verdict,<br/>a finished gate, or the hour") --> trainLive
127
+ trainLive{"A gate already<br/>on the belt?"}
128
+ trainLive -.->|yes| trainWait
129
+ trainLive -->|no| trainNext
130
+ trainNext{"Oldest open PR from this repo,<br/>bot or writer, not draft,<br/>no hold label"}
131
+ trainNext -.->|"none left"| trainIdle
132
+ trainNext --> trainPark
133
+ trainPark{"Parked on this head?<br/>owner or human review,<br/>blocked with main unmoved,<br/>attempts spent"}
134
+ trainPark -.->|"yes: pass it by"| trainNext
135
+ trainPark -->|no| trainCi
136
+ trainCi{"Its CI"}
137
+ trainCi -.->|running| trainWait
138
+ trainCi -->|"green, behind main"| trainUpdate
139
+ trainCi -->|"red, conflicting,<br/>or green and current"| trainGate
140
+ trainUpdate(("Merge main in<br/>CI reruns on what it<br/>will land on"))
141
+ trainGate(("Dispatch the gate<br/>repair or squash-merge"))
142
+ trainWait(("Wait<br/>the next tick looks again"))
143
+ trainIdle(("Idle<br/>the train is empty"))
144
+
145
+ classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
146
+ classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
147
+ classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
148
+ classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
149
+ class trainTick start
150
+ class trainLive,trainNext,trainPark,trainCi decision
151
+ class trainWait,trainIdle idle
152
+ class trainUpdate,trainGate success
153
+ ```
154
+
113
155
  ### How a feature stacks
114
156
 
115
157
  When refine splits a story that carries real sequencing — a feature whose children build on each
@@ -319,8 +361,8 @@ overnight pull requests mean every merge moves the default branch under the rest
319
361
 
320
362
  ```mermaid
321
363
  flowchart TD
322
- gateStart("Work Router<br/>merge-gate route<br/>CI reported, or the hourly<br/>reconcile belt dispatched it") --> gateSubject
323
- gateSubject{"subject (rung 4)<br/>Our open PR?<br/>Closes an implement issue?"}
364
+ gateStart("Work Router<br/>merge-gate route<br/>the merge train dispatched it,<br/>oldest PR first") --> gateSubject
365
+ gateSubject{"subject (rung 4)<br/>Open PR from this repo,<br/>by the bot or a writer?"}
324
366
  gateSubject -.->|no| gateIdle
325
367
  gateSubject -->|yes| gateBranch
326
368
  gateBranch("Check out the PR branch<br/>the push is fast-forward only,<br/>so never rebase") --> gateFacts
@@ -332,14 +374,14 @@ flowchart TD
332
374
  gatePush(("Pushed<br/>CI runs again, gate re-enters"))
333
375
  gateFix -.->|"cannot fix"| gateHuman
334
376
  gateProtected{"protected_changes (rung 4b)<br/>blast radius from paths, CODEOWNERS<br/>and the size of the code that ships"}
335
- gateProtected -.->|"protected path"| gateOwner
377
+ gateProtected -.->|".github/ path"| gateOwner
336
378
  gateProtected -->|"measured low, medium or high"| gateAssess
337
379
  gateAssess("Agent (rung 5)<br/>find defects, verify them,<br/>rate recoverability") --> gateDisposition
338
380
  gateDisposition{"Disposition<br/>computed in shell from<br/>the facts and the report"}
339
381
  gateDisposition -->|"no verified blocker,<br/>radius low or recoverable medium"| gateRootCheck
340
382
  gateRootCheck{"PR base is a<br/>stack feature branch<br/>and this PR is the root?"}
341
383
  gateRootCheck -->|"child PR: no"| gateMerge
342
- gateDisposition -.->|"radius high, protected<br/>or owner path"| gateOwner
384
+ gateDisposition -.->|"1000+ lines, .github/<br/>or a secret"| gateOwner
343
385
  gateDisposition -.->|"fragile, unmet criteria<br/>or low confidence"| gateHuman
344
386
  gateDisposition -.->|"CI red or a verified<br/>high finding"| gateBlocked
345
387
  gateMerge(("AUTO-MERGE<br/>squash into the PR's base<br/>(main, or the feature branch:<br/>then the child issue closes,<br/>pr-pending removed)"))
@@ -350,8 +392,8 @@ flowchart TD
350
392
  gateOwner --> gatePreview
351
393
  gatePreview("operation=evidence<br/>screenshots of the head<br/>before the person decides") --> gateApproved
352
394
  gateApproved(("Approved by a writer<br/>merge-approved merges<br/>the approved commit"))
353
- gateBlocked(("BLOCKED<br/>review + blocked,<br/>the belt does not retry"))
354
- gateIdle(("Idle<br/>not ours, or not open"))
395
+ gateBlocked(("BLOCKED<br/>review + blocked, the train<br/>retries it when main moves"))
396
+ gateIdle(("Idle<br/>a fork, a stranger,<br/>or not open"))
355
397
  gateFix -.->|"attempt 6 of 6"| gateBlocked
356
398
 
357
399
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
@@ -280,32 +280,31 @@ jobs:
280
280
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
281
281
  upload-release-assets: false
282
282
 
283
- # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
284
- # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
285
- # never sees them, so this job hands the verdict over from inside CI. workflow_dispatch is
286
- # one of the two events GITHUB_TOKEN may raise, so no App token is needed here, and the
287
- # router treats a dispatch as trusted. Human pull requests are left to the workflow_run
288
- # route, which does fire for them. The reconcile belt remains the hourly fallback.
283
+ # Hands this run's verdict to the router's merge train, which decides whether this pull request
284
+ # is the one in front and what it needs. A pull request opened or updated with a bot token gets
285
+ # no workflow_run event for its CI (the run's actor is the bot), and the train brings people's
286
+ # pull requests up to date with one too, so every pull request on this repository hands over
287
+ # from here. workflow_dispatch is one of the two events GITHUB_TOKEN may raise, so no App token
288
+ # is needed, and the router treats a dispatch as trusted. The hourly schedule is the fallback.
289
289
  #
290
290
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
291
291
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
292
292
  # whole run fails to start with zero jobs and no annotation when it does.
293
- dispatch-merge-gate:
294
- name: Dispatch merge gate
293
+ dispatch-merge-train:
294
+ name: Dispatch merge train
295
295
  needs: [api, mutation-api, web, mutation-web]
296
296
  if: >
297
297
  always() &&
298
298
  github.event_name == 'pull_request' &&
299
299
  github.event.pull_request.head.repo.full_name == github.repository &&
300
- github.event.pull_request.draft == false &&
301
- github.event.pull_request.user.type == 'Bot'
300
+ github.event.pull_request.draft == false
302
301
  runs-on: agents-arc
303
302
  timeout-minutes: 5
304
303
  permissions:
305
304
  contents: read
306
305
  actions: write
307
306
  steps:
308
- - name: Dispatch the merge gate with this run's verdict
307
+ - name: Hand this run's verdict to the merge train
309
308
  env:
310
309
  GH_TOKEN: ${{ github.token }}
311
310
  REPO: ${{ github.repository }}
@@ -324,10 +323,10 @@ jobs:
324
323
  done
325
324
  # A cancelled run was superseded by a newer push, whose own run dispatches.
326
325
  if [ "$conclusion" = cancelled ]; then
327
- echo "CI was cancelled; not dispatching the merge gate."
326
+ echo "CI was cancelled; not moving the merge train."
328
327
  exit 0
329
328
  fi
330
- echo "Dispatching the merge gate for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
329
+ echo "Moving the merge train for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
331
330
  gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
332
- -f operation=merge-gate -f pr-number="$PR_NUMBER" \
331
+ -f operation=merge-train -f pr-number="$PR_NUMBER" \
333
332
  -f ci-conclusion="$conclusion" -f ci-run-id="$RUN_ID"
@@ -210,32 +210,31 @@ jobs:
210
210
  # needs contents: write this workflow does not have, and fails the run that gates deploy.
211
211
  upload-release-assets: false
212
212
 
213
- # A bot pull request is opened with GITHUB_TOKEN, and GitHub delivers no workflow_run event
214
- # for the CI runs that follow (the run's actor is the bot). The router's CI-completion route
215
- # never sees them, so this job hands the verdict over from inside CI. workflow_dispatch is
216
- # one of the two events GITHUB_TOKEN may raise, so no App token is needed here, and the
217
- # router treats a dispatch as trusted. Human pull requests are left to the workflow_run
218
- # route, which does fire for them. The reconcile belt remains the hourly fallback.
213
+ # Hands this run's verdict to the router's merge train, which decides whether this pull request
214
+ # is the one in front and what it needs. A pull request opened or updated with a bot token gets
215
+ # no workflow_run event for its CI (the run's actor is the bot), and the train brings people's
216
+ # pull requests up to date with one too, so every pull request on this repository hands over
217
+ # from here. workflow_dispatch is one of the two events GITHUB_TOKEN may raise, so no App token
218
+ # is needed, and the router treats a dispatch as trusted. The hourly schedule is the fallback.
219
219
  #
220
220
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
221
221
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
222
222
  # whole run fails to start with zero jobs and no annotation when it does.
223
- dispatch-merge-gate:
224
- name: Dispatch merge gate
223
+ dispatch-merge-train:
224
+ name: Dispatch merge train
225
225
  needs: [lint, web, mutation-web, e2e, desktop, mobile]
226
226
  if: >
227
227
  always() &&
228
228
  github.event_name == 'pull_request' &&
229
229
  github.event.pull_request.head.repo.full_name == github.repository &&
230
- github.event.pull_request.draft == false &&
231
- github.event.pull_request.user.type == 'Bot'
230
+ github.event.pull_request.draft == false
232
231
  runs-on: agents-arc
233
232
  timeout-minutes: 5
234
233
  permissions:
235
234
  contents: read
236
235
  actions: write
237
236
  steps:
238
- - name: Dispatch the merge gate with this run's verdict
237
+ - name: Hand this run's verdict to the merge train
239
238
  env:
240
239
  GH_TOKEN: ${{ github.token }}
241
240
  REPO: ${{ github.repository }}
@@ -254,10 +253,10 @@ jobs:
254
253
  done
255
254
  # A cancelled run was superseded by a newer push, whose own run dispatches.
256
255
  if [ "$conclusion" = cancelled ]; then
257
- echo "CI was cancelled; not dispatching the merge gate."
256
+ echo "CI was cancelled; not moving the merge train."
258
257
  exit 0
259
258
  fi
260
- echo "Dispatching the merge gate for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
259
+ echo "Moving the merge train for PR #$PR_NUMBER with CI $conclusion (run $RUN_ID)."
261
260
  gh workflow run work-router.yml --repo "$REPO" --ref "$REF" \
262
- -f operation=merge-gate -f pr-number="$PR_NUMBER" \
261
+ -f operation=merge-train -f pr-number="$PR_NUMBER" \
263
262
  -f ci-conclusion="$conclusion" -f ci-run-id="$RUN_ID"