@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.
- package/dist/index.js +0 -0
- package/dist/route-processing.js +9 -2
- package/dist/workflow-catalog.js +1 -1
- package/loops/actions/apply-agent-output/action.yml +5 -0
- package/loops/actions/classify-route/classify-route.sh +34 -13
- package/loops/actions/identify-gate-subject/action.yml +50 -7
- package/loops/actions/merge-train/action.yml +66 -0
- package/loops/actions/merge-train/merge-train.sh +424 -0
- package/loops/actions/push-agent-branch/action.yml +60 -45
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +124 -80
- package/loops/docs/diagrams.md +55 -13
- package/loops/templates/ci/app-ci-dotnet-next.yml +13 -14
- package/loops/templates/ci/app-ci-node-monorepo.yml +13 -14
- package/loops/workflows/agent-merge-gate.md +75 -57
- package/loops/workflows/work-router.yml +231 -464
- package/package.json +8 -9
|
@@ -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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
assert "
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
assert_route "a
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
assert_route "a
|
|
219
|
-
|
|
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
|
-
#
|
|
698
|
-
#
|
|
699
|
-
#
|
|
700
|
-
#
|
|
701
|
-
#
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
#
|
|
718
|
-
#
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
#
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
#
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
#
|
|
732
|
-
#
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
741
|
-
# CI never reaches the router's CI-completion route. The package ships a dispatch
|
|
742
|
-
#
|
|
743
|
-
#
|
|
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 -
|
|
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")
|
|
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
|
|
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.
|
|
1758
|
-
# the
|
|
1759
|
-
#
|
|
1760
|
-
#
|
|
1761
|
-
#
|
|
1762
|
-
#
|
|
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
|
-
#
|
|
1767
|
-
stale_horizon=$(count -cE 'verdict"
|
|
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
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
-
#
|
|
2522
|
-
#
|
|
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
|
|
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" |
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -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 ·
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
gate -.->|"
|
|
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/>
|
|
323
|
-
gateSubject{"subject (rung 4)<br/>
|
|
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 -.->|"
|
|
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 -.->|"
|
|
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
|
|
354
|
-
gateIdle(("Idle<br/>
|
|
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
|
-
#
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
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-
|
|
294
|
-
name: Dispatch merge
|
|
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:
|
|
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
|
|
326
|
+
echo "CI was cancelled; not moving the merge train."
|
|
328
327
|
exit 0
|
|
329
328
|
fi
|
|
330
|
-
echo "
|
|
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-
|
|
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
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
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-
|
|
224
|
-
name: Dispatch merge
|
|
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:
|
|
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
|
|
256
|
+
echo "CI was cancelled; not moving the merge train."
|
|
258
257
|
exit 0
|
|
259
258
|
fi
|
|
260
|
-
echo "
|
|
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-
|
|
261
|
+
-f operation=merge-train -f pr-number="$PR_NUMBER" \
|
|
263
262
|
-f ci-conclusion="$conclusion" -f ci-run-id="$RUN_ID"
|