@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.
@@ -65,8 +65,15 @@ run-name: >-
65
65
  && format('Agent: Implement - #{0}', inputs.issue-number)
66
66
  || github.event_name == 'workflow_dispatch'
67
67
  && inputs.operation == 'merge-gate'
68
+ && github.actor == 'github-actions[bot]'
69
+ && format('Agentics: Merge Train - CI on PR #{0}', inputs.pr-number)
70
+ || github.event_name == 'workflow_dispatch'
71
+ && inputs.operation == 'merge-gate'
68
72
  && format('Agent: Merge Gate - PR #{0}', inputs.pr-number)
69
73
  || github.event_name == 'workflow_dispatch'
74
+ && inputs.operation == 'merge-train'
75
+ && format('Agentics: Merge Train{0}', '')
76
+ || github.event_name == 'workflow_dispatch'
70
77
  && inputs.operation == 'evidence'
71
78
  && format('Agentics: Evidence - PR #{0}', inputs.pr-number)
72
79
  || github.event_name == 'workflow_dispatch'
@@ -144,6 +151,15 @@ env:
144
151
  # merge-gate worker carried its own MAX_ATTEMPTS; the route matrix now asserts the two
145
152
  # agree, because a belt that gives up later than the worker says burns whole runs.
146
153
  MAX_GATE_ATTEMPTS: "6"
154
+ # The merge train takes the oldest open pull request, bot or person, and moves it one step at a
155
+ # time until it merges or a person has to decide. A pull request with this label stays off it.
156
+ MERGE_TRAIN_HOLD_LABEL: "hold"
157
+ # How many times a `blocked` verdict, or a merge that did not happen, is retried once the base
158
+ # branch has moved. After that the pull request waits for a person or a new commit.
159
+ MERGE_TRAIN_MAX_RETRIES: "3"
160
+ # "true" logs the move the train would make and makes none. For trying the train on a
161
+ # repository before letting it act.
162
+ MERGE_TRAIN_DRY_RUN: "false"
147
163
  # Whether a merged pull request's verification plan is replayed and its screenshots archived on
148
164
  # the pull request (evidence.yml). It needs nothing configured: the app starts with the root
149
165
  # `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
@@ -161,25 +177,22 @@ on:
161
177
  types: [submitted]
162
178
 
163
179
  pull_request_target:
164
- # opened / reopened / synchronize feed conflict detection (detect-pr-conflicts) and
165
- # bot-approve. closed feeds detect-pr-conflicts: when a PR merges, the target branch
166
- # moved and previously-mergeable sibling bot PRs may now conflict. A merge also routes to
167
- # evidence, which replays the pull request's verification plan.
180
+ # opened / reopened / synchronize feed bot-approve. closed feeds the merge train: a merge
181
+ # moved the base branch, so the next pull request in line can move. A merge into a feature
182
+ # branch also lands a stack child (land-stack-child), and every merge routes to evidence,
183
+ # which replays the pull request's verification plan.
168
184
  types: [opened, synchronize, reopened, labeled, closed]
169
185
 
170
- # CI completion on a pull request. The classifier admits only conclusion == failure with an
171
- # attached pull request; a green run reaches the gate through the consumer CI's
172
- # dispatch-merge-gate job and the reconcile belt, so routing success here would double-fire
173
- # the gate on every passing PR.
186
+ # CI completion on a pull request moves the merge train, green or red: the train decides
187
+ # whether that pull request is the one in front, and what it needs.
174
188
  #
175
189
  # Delivery (verified 2026-09-06 in Pliny-Bot): GitHub delivers workflow_run only for CI runs
176
- # whose actor is a human. A bot pull request is opened with GITHUB_TOKEN, the CI runs that
177
- # follow have the bot as their actor, and no workflow_run arrives for any of them (four
178
- # human-actor runs produced four router runs; three bot-actor runs produced none). For bot
179
- # pull requests the consumer CI's dispatch-merge-gate job (templates/ci) and the hourly
180
- # reconcile-bot-pr-runs belt are the paths. Security: workflow_run runs from the default
181
- # branch with secrets, but the gate's subject job rejects any PR that is not open and
182
- # bot-authored, so a fork PR's failing CI cannot start privileged work.
190
+ # whose actor is a human. A pull request opened or updated with a bot token has the bot as the
191
+ # actor of every CI run that follows, and no workflow_run arrives for any of them. For those
192
+ # the consumer CI's dispatch job (templates/ci) and the hourly schedule are the paths.
193
+ # Security: workflow_run runs from the default branch with secrets, but the train only ever
194
+ # moves pull requests from this repository opened by a trusted bot or someone with write
195
+ # access, and the gate's subject job checks the same again.
183
196
  workflow_run:
184
197
  # Mirrored from env.CI_WORKFLOW_NAME by the installer. A trigger evaluates no expression.
185
198
  workflows: ["App: CI"]
@@ -205,6 +218,7 @@ on:
205
218
  - triage
206
219
  - apply-review
207
220
  - merge-gate
221
+ - merge-train
208
222
  - evidence
209
223
  - audit
210
224
  - audit-close
@@ -565,8 +579,8 @@ jobs:
565
579
  # blocked by #111, modules) was dispatched alongside #111 and would have built units on a
566
580
  # feat/45 with no modules. The in-flight check below cannot catch that: both routers ran
567
581
  # before either dispatch existed, and serialising runs would still start #112 before #111's
568
- # pull request merged. The landing of the blocker releases the child (the stack block in
569
- # detect-pr-conflicts). Held, it keeps `implement` and loses `bot-working`, so it reads as
582
+ # pull request merged. The landing of the blocker releases the child (land-stack-child).
583
+ # Held, it keeps `implement` and loses `bot-working`, so it reads as
570
584
  # waiting rather than stuck.
571
585
  - name: Hold a child whose blockers have not landed
572
586
  id: blocked
@@ -768,9 +782,9 @@ jobs:
768
782
  # The merge belt is one lock for the whole repository, not one group per pull request.
769
783
  # A batch of six overnight pull requests means every merge moves the default branch
770
784
  # under the other five; gates running at once would each rebase onto a base another
771
- # gate is about to invalidate. Serialising the belt means each gate sees a main that
772
- # only changes between gates, every merge re-arms the siblings through
773
- # detect-pr-conflicts's rescan, and the queue drains one pull request at a time.
785
+ # gate is about to invalidate. The merge train dispatches one gate at a time, oldest pull
786
+ # request first; the lock is what still holds when a person or a consumer CI dispatches one
787
+ # beside it.
774
788
  #
775
789
  # `queue: max` keeps up to 100 gates waiting in order. Without it GitHub keeps one pending run
776
790
  # per group and silently cancels the previous one: in one week Numa cancelled 12 gates and
@@ -798,7 +812,64 @@ jobs:
798
812
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
799
813
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
800
814
 
801
- # The decision the gate parked for: a person with write access approves a bot pull request the
815
+ # The merge train: one step for the oldest open pull request that can move, bot or person.
816
+ # It merges the base branch into a green pull request that fell behind, hands a red,
817
+ # conflicting or green-and-current one to the gate, and skips one a person has to decide on,
818
+ # so a parked pull request never blocks the line. One gate in flight at a time; everything
819
+ # else waits untouched. It ticks on every merge, every CI completion, after every gate (this
820
+ # job waits for call-merge-gate in the same run), and hourly as the fallback. Deterministic:
821
+ # it reads GitHub and dispatches, and never checks out or runs pull request code.
822
+ #
823
+ # One tick at a time. A pending tick replaced by a newer one loses nothing: each reads the
824
+ # whole repository afresh.
825
+ merge-train:
826
+ needs: [classify, call-merge-gate]
827
+ if: >
828
+ always() &&
829
+ needs.classify.result == 'success' &&
830
+ !github.event.repository.fork &&
831
+ (
832
+ needs.classify.outputs.route == 'merge-train' ||
833
+ needs.classify.outputs.route == 'merge-gate' ||
834
+ needs.classify.outputs.route == 'reconcile-bot-pr-runs' ||
835
+ (github.event_name == 'pull_request_target' &&
836
+ github.event.action == 'closed' &&
837
+ github.event.pull_request.merged == true)
838
+ )
839
+ runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
840
+ timeout-minutes: 15
841
+ concurrency:
842
+ group: merge-train
843
+ cancel-in-progress: false
844
+ permissions:
845
+ contents: read
846
+ pull-requests: read
847
+ steps:
848
+ - name: Checkout workflow actions
849
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
850
+ with:
851
+ persist-credentials: false
852
+ # The App token, not GITHUB_TOKEN: a gate dispatch and a branch update made with
853
+ # GITHUB_TOKEN start no workflow, so the gate would never run and CI never rerun.
854
+ - name: Generate App token
855
+ id: app-token
856
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
857
+ with:
858
+ app-id: ${{ secrets.BOT_APP_ID }}
859
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
860
+ - uses: ./.github/actions/merge-train
861
+ with:
862
+ token: ${{ steps.app-token.outputs.token }}
863
+ ci-workflow-name: ${{ env.CI_WORKFLOW_NAME }}
864
+ trusted-bots: ${{ env.TRUSTED_BOTS }}
865
+ hold-label: ${{ env.MERGE_TRAIN_HOLD_LABEL }}
866
+ max-gate-attempts: ${{ env.MAX_GATE_ATTEMPTS }}
867
+ max-retries: ${{ env.MERGE_TRAIN_MAX_RETRIES }}
868
+ dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
869
+ ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
870
+ ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
871
+
872
+ # The decision the gate parked for: a person with write access approves a pull request the
802
873
  # gate handed to them (human-review or owner-review), and the bot merges the commit they
803
874
  # approved. Re-running the gate would only reproduce the same parked verdict.
804
875
  merge-approved:
@@ -841,7 +912,15 @@ jobs:
841
912
 
842
913
  read -r state author head_sha < <(gh api "repos/$REPO/pulls/$PR" --jq '[.state, .user.login, .head.sha] | @tsv')
843
914
  [ "$state" = open ] || skip "it is $state"
844
- case " $TRUSTED_BOTS " in *" $author "*) ;; *) skip "it was opened by $author, not the bot" ;; esac
915
+ # The gate parks pull requests by people too. Its author needs write access, like
916
+ # everyone the merge train carries; a bot needs to be one this router trusts.
917
+ case " $TRUSTED_BOTS " in
918
+ *" $author "*) ;;
919
+ *)
920
+ author_permission=$(gh api "repos/$REPO/collaborators/$author/permission" --jq '.permission' 2>/dev/null || echo none)
921
+ case "$author_permission" in admin|maintain|write) ;; *) skip "it was opened by $author, who has $author_permission access" ;; esac
922
+ ;;
923
+ esac
845
924
  [ "$head_sha" = "$REVIEW_COMMIT" ] || skip "$REVIEWER approved ${REVIEW_COMMIT:0:7}, the head is now ${head_sha:0:7}"
846
925
 
847
926
  # Another reviewer's change request still stands.
@@ -969,41 +1048,23 @@ jobs:
969
1048
  gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
970
1049
  done
971
1050
 
972
- # Conflict watcher for bot pull requests. Merge-conflict state is only available from the
973
- # mergeability API, which the pure classifier cannot read, so this is an event-gated
974
- # deterministic dispatcher rather than a classify route (it runs alongside bot-approve on
975
- # the same pull_request_target event). It NEVER checks out or runs pull request code - it
976
- # reads mergeability through the API and dispatches the Merge Gate - so it is safe on
977
- # pull_request_target. Two entry points:
978
- # opened / reopened / synchronize → check the pull request that changed. Gated to this
979
- # repository's own branches so a fork PR never starts this App-token job.
980
- # closed & merged → the target branch moved, so re-check every open
981
- # pull request that may have just become conflicted (the "main changed after another PR
982
- # merged" case). A merge into the default branch is an inherently trusted internal event.
983
- # It dispatches only for open, non-draft pull requests whose head is this
984
- # repository AND whose CI has already concluded (dispatching while CI is still pending would
985
- # make the gate read a 'pending' verdict and prematurely ask for human review; that PR is
986
- # covered by the CI-completion route instead). The gate's subject job re-validates ownership
987
- # before any agent runs.
988
- detect-pr-conflicts:
1051
+ # A stack child landing. A pull request merged into a feature branch rather than the default
1052
+ # branch is one child of a stack: it may unblock siblings that waited on it, and it may be the
1053
+ # last one, which makes the root pull request ready for a person. Event-gated rather than a
1054
+ # route because a merge already routes to evidence. It NEVER checks out or runs pull request
1055
+ # code; it reads issues and pull requests through the API and writes labels and one comment.
1056
+ land-stack-child:
989
1057
  needs: classify
990
1058
  if: >
991
1059
  github.event_name == 'pull_request_target' &&
992
- (
993
- (github.event.action == 'closed' && github.event.pull_request.merged == true) ||
994
- (
995
- (github.event.action == 'opened' ||
996
- github.event.action == 'reopened' ||
997
- github.event.action == 'synchronize') &&
998
- github.event.pull_request.head.repo.full_name == github.repository
999
- )
1000
- )
1060
+ github.event.action == 'closed' &&
1061
+ github.event.pull_request.merged == true &&
1062
+ github.event.pull_request.base.ref != github.event.repository.default_branch
1001
1063
  runs-on: [agents-aw, "affinity-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}"]
1002
1064
  timeout-minutes: 10
1003
1065
  permissions:
1004
1066
  contents: read
1005
1067
  pull-requests: read
1006
- actions: write
1007
1068
  steps:
1008
1069
  - name: Checkout for App token action
1009
1070
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1015,309 +1076,146 @@ jobs:
1015
1076
  with:
1016
1077
  app-id: ${{ secrets.BOT_APP_ID }}
1017
1078
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1018
- - name: Dispatch Merge Gate for conflicting bot pull requests
1079
+ - name: Release unblocked children and announce a complete stack
1019
1080
  env:
1020
1081
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
1021
1082
  REPO: ${{ github.repository }}
1022
1083
  DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1023
- ACTION: ${{ github.event.action }}
1024
1084
  EVENT_PR: ${{ github.event.pull_request.number }}
1025
1085
  run: |
1026
1086
  set -euo pipefail
1027
1087
 
1028
- # Dispatch the Merge Gate for one pull request when it is a red-CI open, non-draft
1029
- # pull request on this repository whose CI has concluded - the
1030
- # conflict case is one arm of that, because a conflicting PR never gets a fresh
1031
- # CI run of its own; a merge conflict with red or concluded CI is the same
1032
- # "needs the gate" signal. A green non-conflicting PR is left for the merge belt
1033
- # to pick up when its checks finish (bot-approve / reconcile).
1034
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
1035
- # opened recently answers UNKNOWN through the API and null through REST, while it works
1036
- # the answer out in the background. Reading once therefore reports "not conflicting"
1037
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
1038
- # settles.
1039
- mergeable_state() {
1040
- local pr="$1" state=UNKNOWN
1041
- for _ in 1 2 3 4 5 6; do
1042
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1043
- [ "$state" = "UNKNOWN" ] || break
1044
- sleep 5
1045
- done
1046
- echo "$state"
1047
- }
1048
-
1049
- # A gate already queued or running for this pull request will read the same CI verdict;
1050
- # a second dispatch would wait in the belt's queue and run the same gate twice. Same
1051
- # live-run test as the reconcile belt's stale-reservation sweep, keyed on the exact
1052
- # title run-name gives a gate dispatch.
1053
- gate_is_live() {
1054
- local pr="$1" status live
1055
- for status in queued in_progress waiting pending; do
1056
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
1057
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
1058
- [ "${live:-0}" -eq 0 ] || return 0
1059
- done
1060
- return 1
1061
- }
1062
-
1063
- maybe_dispatch() {
1064
- pr="$1"
1065
- [ -n "$pr" ] || return 0
1066
-
1067
- meta=$(gh api "repos/$REPO/pulls/$pr" \
1068
- --jq '{state:.state, draft:.draft, bot:.user.type, head:(.head.repo.full_name // "")}' \
1069
- 2>/dev/null || echo '')
1070
- [ -n "$meta" ] || return 0
1071
- if [ "$(jq -r '.state' <<<"$meta")" != "open" ] \
1072
- || [ "$(jq -r '.draft' <<<"$meta")" != "false" ] \
1073
- || [ "$(jq -r '.head' <<<"$meta")" != "$REPO" ]; then
1074
- return 0
1075
- fi
1076
-
1077
- # Only dispatch when CI has already concluded. A conflict while CI is still
1078
- # running is handled by the CI-completion route; dispatching now would make the
1079
- # gate read a 'pending' conclusion and prematurely ask for human review.
1080
- head_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
1081
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
1082
- --jq '[.workflow_runs[]
1083
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1084
- and (.conclusion == "success" or .conclusion == "failure"))]
1085
- | sort_by(.created_at) | last // empty')
1086
- if [ -z "$ci" ]; then
1087
- # A conflicting pull request has no merge ref for GitHub to build, so a
1088
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
1089
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
1090
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
1091
- # back to the branch's last verdict so the gate runs; it stays on the remediation
1092
- # path and cannot merge on a stale conclusion.
1093
- head_ref=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.ref')
1094
- mergeable_now=$(mergeable_state "$pr")
1095
- if [ "$mergeable_now" != "CONFLICTING" ]; then
1096
- echo "PR #$pr has no concluded CI run; deferring to the CI-completion route."
1097
- return 0
1098
- fi
1099
- ci=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
1100
- --jq '[.workflow_runs[]
1101
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1102
- and (.conclusion == "success" or .conclusion == "failure"))]
1103
- | sort_by(.created_at) | last // empty')
1104
- if [ -z "$ci" ]; then
1105
- echo "PR #$pr conflicts and has no CI verdict on any head; nothing to gate on yet."
1106
- return 0
1107
- fi
1108
- echo "PR #$pr conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
1109
- fi
1110
- ci_conclusion=$(jq -r '.conclusion' <<<"$ci")
1111
- ci_finished=$(jq -r '.updated_at' <<<"$ci")
1112
- ci_run_id=$(jq -r '.id' <<<"$ci")
1113
-
1114
- # GitHub computes mergeability asynchronously; poll until it is known (not null).
1115
- mergeable=""
1116
- for _ in 1 2 3 4 5 6; do
1117
- mergeable=$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable // "null"' 2>/dev/null || echo null)
1118
- [ "$mergeable" = "null" ] || break
1119
- sleep 5
1120
- done
1121
-
1122
- if [ "$mergeable" != "false" ] && [ "$ci_conclusion" != "failure" ]; then
1123
- # Mergeable and green: the reconcile belt and the PR's own checks finish it.
1124
- return 0
1125
- fi
1126
-
1127
- # A terminal verdict newer than this CI run means a gate already decided;
1128
- # failed attempts (the attempt marker) deliberately do not count.
1129
- verdict=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
1130
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1131
- linked_issue=$(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1132
- if [ -n "$linked_issue" ]; then
1133
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1134
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1135
- if [ -n "$issue_verdict" ] && { [ -z "$verdict" ] || [ "$issue_verdict" \> "$verdict" ]; }; then
1136
- verdict="$issue_verdict"
1137
- fi
1138
- fi
1139
- # A verdict parks the CODE it was given on, not the CI run that prompted it.
1140
- # Comparing against the CI finish time meant any later run on the same commits
1141
- # reopened a park a human already owned. The newest commit on the branch is the
1142
- # right key: push, and the gate decides again; re-run CI, and the park holds.
1143
- head_committed=$(gh pr view "$pr" --repo "$REPO" \
1144
- --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
1145
- horizon="${head_committed:-$ci_finished}"
1146
- if [ -n "$verdict" ] && [ "$verdict" \> "$horizon" ]; then
1147
- echo "PR #$pr has a gate verdict newer than its head commit; the park holds, skipping."
1148
- return 0
1149
- fi
1150
-
1151
- attempts=$(gh api "repos/$REPO/issues/$pr/comments?per_page=100" \
1152
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
1153
- if [ -n "$linked_issue" ]; then
1154
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1155
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$horizon"'") ] | length')
1156
- attempts=$((attempts + issue_attempts))
1157
- fi
1158
- if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
1159
- echo "PR #$pr already consumed $attempts gate attempts on this head, parking for review."
1160
- return 0
1161
- fi
1162
- if gate_is_live "$pr"; then
1163
- echo "PR #$pr: a merge-gate run is already live; not dispatching another."
1164
- return 0
1165
- fi
1166
-
1167
- echo "PR #$pr is $([ "$mergeable" = "false" ] && echo conflicting || echo red-CI) (CI $ci_conclusion); dispatching Merge Gate (attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)."
1168
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
1169
- --method POST \
1170
- -f ref="$DEFAULT_BRANCH" \
1171
- -f "inputs[operation]=merge-gate" \
1172
- -f "inputs[pr-number]=$pr" \
1173
- -f "inputs[ci-conclusion]=$ci_conclusion" \
1174
- -f "inputs[ci-run-id]=$ci_run_id" \
1175
- -f "inputs[attempts_so_far]=$attempts"
1176
- }
1177
-
1178
- if [ "$ACTION" = "closed" ]; then
1179
- # The target branch just moved. Any previously-mergeable bot PR may now conflict.
1180
- # The just-merged PR is now closed and is skipped by maybe_dispatch's state check.
1181
- gh api "repos/$REPO/pulls?state=open&per_page=100" \
1182
- --jq '.[] | select(.user.type == "Bot" and .draft == false) | .number' |
1183
- while IFS= read -r pr; do
1184
- maybe_dispatch "$pr"
1185
- done
1186
-
1187
- # Stacked delivery: the merged pull request merged into a feature branch, not the
1188
- # default branch, so this is a child landing. If the feature branch has no open
1189
- # children left and its root pull request is green, the stack is complete and a
1190
- # comment on the root pull request tells the human it is ready for the root merge.
1191
- # Repos without stack markers never reach the interesting part: the event PR's base
1192
- # was the default branch, so the guard below fails immediately. Fully idempotent:
1193
- # the completion comment carries its own marker, and a run that finds it already
1194
- # present exits without writing anything.
1195
- event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
1196
- if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
1197
- echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
1198
- # The implement worker's pull request closes exactly its own child issue, so
1199
- # the closed issue comes from the linked closing references (the resolved form
1200
- # GitHub computes whatever keyword the agent wrote), with the body text as
1201
- # fallback for a PR created before that guarantee.
1202
- closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1203
- --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1204
- [ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
1205
- | grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
1206
- if [ -z "$closed_issue" ]; then
1207
- echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
1088
+ # Stacked delivery: the merged pull request merged into a feature branch, not the
1089
+ # default branch, so this is a child landing. If the feature branch has no open
1090
+ # children left and its root pull request is green, the stack is complete and a
1091
+ # comment on the root pull request tells the human it is ready for the root merge.
1092
+ # Repos without stack markers never reach the interesting part: the event PR's base
1093
+ # was the default branch, so the guard below fails immediately. Fully idempotent:
1094
+ # the completion comment carries its own marker, and a run that finds it already
1095
+ # present exits without writing anything.
1096
+ event_base=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.base.ref // empty' 2>/dev/null || echo '')
1097
+ if [ -n "$event_base" ] && [ "$event_base" != "$DEFAULT_BRANCH" ]; then
1098
+ echo "PR #$EVENT_PR merged into a feature branch; checking the stack."
1099
+ # The implement worker's pull request closes exactly its own child issue, so
1100
+ # the closed issue comes from the linked closing references (the resolved form
1101
+ # GitHub computes whatever keyword the agent wrote), with the body text as
1102
+ # fallback for a PR created before that guarantee.
1103
+ closed_issue=$(gh pr view "$EVENT_PR" --repo "$REPO" --json closingIssuesReferences \
1104
+ --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1105
+ [ -n "$closed_issue" ] || closed_issue=$(gh api "repos/$REPO/pulls/$EVENT_PR" --jq '.body // ""' \
1106
+ | grep -oiE '(closes?|fixes?|resolves?) #[0-9]+' | head -1 | grep -oE '[0-9]+' || true)
1107
+ if [ -z "$closed_issue" ]; then
1108
+ echo "PR #$EVENT_PR closes no issue; not a stack child. Nothing to do."
1109
+ else
1110
+ # Refine stamps the split-parent marker on every child body it hands over, so
1111
+ # the child's own body names the feature issue. The reverse sub-issues
1112
+ # listing has no equivalent per-child lookup: the sub-issues API only walks
1113
+ # downward from the parent.
1114
+ parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1115
+ | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1116
+ if [ -z "$parent" ]; then
1117
+ echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
1208
1118
  else
1209
- # Refine stamps the split-parent marker on every child body it hands over, so
1210
- # the child's own body names the feature issue. The reverse sub-issues
1211
- # listing has no equivalent per-child lookup: the sub-issues API only walks
1212
- # downward from the parent.
1213
- parent=$(printf '%s' "$(gh issue view "$closed_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1214
- | grep -oE '<!-- split-parent: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1215
- if [ -z "$parent" ]; then
1216
- echo "#$closed_issue is not a stack child issue (no parent found); nothing stack-related to do."
1119
+ root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1120
+ | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1121
+ # Case-insensitive on purpose, here and in the child count below: `gh pr view` and
1122
+ # `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
1123
+ # exact `open` never matched, so no root was ever open and no child was ever counted
1124
+ # open, and the stack-complete announcement could not fire (Lyceum #44, router run
1125
+ # 38752856518: "Stack #44 has no open root pull request" with #70 open).
1126
+ root_open=false
1127
+ [ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
1128
+ if [ "$root_open" != true ]; then
1129
+ echo "Stack #$parent has no open root pull request; nothing to announce."
1217
1130
  else
1218
- root_pr=$(printf '%s' "$(gh issue view "$parent" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null || echo '')" \
1219
- | grep -oE '<!-- stack-root-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
1220
- # Case-insensitive on purpose, here and in the child count below: `gh pr view` and
1221
- # `gh issue view` read GraphQL and answer OPEN, where the REST API answers open. An
1222
- # exact `open` never matched, so no root was ever open and no child was ever counted
1223
- # open, and the stack-complete announcement could not fire (Lyceum #44, router run
1224
- # 38752856518: "Stack #44 has no open root pull request" with #70 open).
1225
- root_open=false
1226
- [ -n "$root_pr" ] && root_open=$(gh pr view "$root_pr" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo true || echo false)
1227
- if [ "$root_open" != true ]; then
1228
- echo "Stack #$parent has no open root pull request; nothing to announce."
1131
+ # The same child listing refine uses to hand work out: the sub-issues
1132
+ # link safe_outputs writes, with the body-marker search as fallback. A
1133
+ # stack parent always has children, so a listing that finds none means
1134
+ # the lookups failed, and announcing completion of a stack we cannot
1135
+ # see would be wrong -- stay silent and let a rerun decide.
1136
+ children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
1137
+ if [ -z "$children" ]; then
1138
+ children=$(gh issue list --repo "$REPO" --state open --limit 50 \
1139
+ --search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
1140
+ fi
1141
+ if [ -z "$children" ]; then
1142
+ echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
1229
1143
  else
1230
- # The same child listing refine uses to hand work out: the sub-issues
1231
- # link safe_outputs writes, with the body-marker search as fallback. A
1232
- # stack parent always has children, so a listing that finds none means
1233
- # the lookups failed, and announcing completion of a stack we cannot
1234
- # see would be wrong -- stay silent and let a rerun decide.
1235
- children=$(gh api "repos/$REPO/issues/$parent/sub_issues" --jq '.[].number' 2>/dev/null || true)
1236
- if [ -z "$children" ]; then
1237
- children=$(gh issue list --repo "$REPO" --state open --limit 50 \
1238
- --search "\"<!-- split-parent: ${parent} -->\" in:body" --json number --jq '.[].number' || true)
1239
- fi
1240
- if [ -z "$children" ]; then
1241
- echo "Cannot list any child of stack #$parent; leaving completion undetected for a rerun."
1242
- else
1243
- # Release the children this landing unblocks. check-stack-branch holds a child
1244
- # whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
1245
- # so the landing does: re-adding `implement` with the App token is a bot label
1246
- # event, which the router routes straight to implement. The child that just
1247
- # landed counts as done even before the gate closes its issue a moment later.
1248
- for child in $children; do
1249
- [ "$child" = "$closed_issue" ] && continue
1250
- meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
1251
- printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
1252
- printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
1253
- blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1254
- [ -n "$blockers" ] || continue
1255
- waiting=""
1256
- for b in $blockers; do
1257
- [ "$b" = "$closed_issue" ] && continue
1258
- if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1259
- waiting="$waiting #$b"
1260
- fi
1261
- done
1262
- if [ -n "$waiting" ]; then
1263
- echo "#$child still waits on$waiting."
1264
- continue
1144
+ # Release the children this landing unblocks. check-stack-branch holds a child
1145
+ # whose `Blocked by #N` sibling is still open, and nothing else re-dispatches it,
1146
+ # so the landing does: re-adding `implement` with the App token is a bot label
1147
+ # event, which the router routes straight to implement. The child that just
1148
+ # landed counts as done even before the gate closes its issue a moment later.
1149
+ for child in $children; do
1150
+ [ "$child" = "$closed_issue" ] && continue
1151
+ meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
1152
+ printf '%s' "$meta" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
1153
+ printf '%s' "$meta" | jq -e '[.labels[]?.name] | (index("implement") != null) and (index("bot-working") == null)' >/dev/null || continue
1154
+ blockers=$(printf '%s' "$meta" | jq -r '.body // ""' | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
1155
+ [ -n "$blockers" ] || continue
1156
+ waiting=""
1157
+ for b in $blockers; do
1158
+ [ "$b" = "$closed_issue" ] && continue
1159
+ if gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1160
+ waiting="$waiting #$b"
1265
1161
  fi
1266
- gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1267
- gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1268
- echo "Released #$child: everything it was blocked by has landed."
1269
1162
  done
1270
- open_children=0
1271
- for child in $children; do
1272
- # Landed, though the gate may not have closed its issue yet.
1273
- [ "$child" = "$closed_issue" ] && continue
1274
- if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1275
- open_children=$((open_children + 1))
1276
- fi
1163
+ if [ -n "$waiting" ]; then
1164
+ echo "#$child still waits on$waiting."
1165
+ continue
1166
+ fi
1167
+ gh issue edit "$child" --repo "$REPO" --remove-label implement >/dev/null 2>&1 || true
1168
+ gh issue edit "$child" --repo "$REPO" --add-label implement >/dev/null
1169
+ echo "Released #$child: everything it was blocked by has landed."
1170
+ done
1171
+ open_children=0
1172
+ for child in $children; do
1173
+ # Landed, though the gate may not have closed its issue yet.
1174
+ [ "$child" = "$closed_issue" ] && continue
1175
+ if gh issue view "$child" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open; then
1176
+ open_children=$((open_children + 1))
1177
+ fi
1178
+ done
1179
+ if [ "$open_children" -gt 0 ]; then
1180
+ echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
1181
+ else
1182
+ # The root pull request must be green and mergeable. Green: a successful
1183
+ # CI run on its head SHA. Mergeable: the same on-demand computation
1184
+ # every gate dispatch already relies on; UNKNOWN settles in the
1185
+ # background, so ask until it does, like mergeable_state() does.
1186
+ root_state=UNKNOWN
1187
+ for _ in 1 2 3 4 5 6; do
1188
+ root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1189
+ [ "$root_state" = "UNKNOWN" ] || break
1190
+ sleep 5
1277
1191
  done
1278
- if [ "$open_children" -gt 0 ]; then
1279
- echo "$open_children child issue(s) still open on stack #$parent; the stack continues."
1192
+ root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
1193
+ root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
1194
+ --jq '[.workflow_runs[]
1195
+ | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1196
+ and .conclusion == "success")]
1197
+ | sort_by(.created_at) | last // empty')
1198
+ if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
1199
+ echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
1280
1200
  else
1281
- # The root pull request must be green and mergeable. Green: a successful
1282
- # CI run on its head SHA. Mergeable: the same on-demand computation
1283
- # every gate dispatch already relies on; UNKNOWN settles in the
1284
- # background, so ask until it does, like mergeable_state() does.
1285
- root_state=UNKNOWN
1286
- for _ in 1 2 3 4 5 6; do
1287
- root_state=$(gh pr view "$root_pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1288
- [ "$root_state" = "UNKNOWN" ] || break
1289
- sleep 5
1290
- done
1291
- root_sha=$(gh api "repos/$REPO/pulls/$root_pr" --jq '.head.sha')
1292
- root_ci=$(gh api "repos/$REPO/actions/runs?head_sha=$root_sha&per_page=100" \
1293
- --jq '[.workflow_runs[]
1294
- | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
1295
- and .conclusion == "success")]
1296
- | sort_by(.created_at) | last // empty')
1297
- if [ -z "$root_ci" ] || [ "$root_state" != "MERGEABLE" ]; then
1298
- echo "Root PR #$root_pr is not green/mergeable yet; the human will merge when it is."
1201
+ completion_marker='<!-- stack-complete -->'
1202
+ # The announcement is a pull request comment, and pull request
1203
+ # comments live at the issue endpoint for the pull request number,
1204
+ # so the marker is looked for where the comment was posted.
1205
+ already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
1206
+ --jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
1207
+ if [ "${already:-0}" -gt 0 ]; then
1208
+ echo "Stack #$parent already has a completion announcement; staying silent."
1299
1209
  else
1300
- completion_marker='<!-- stack-complete -->'
1301
- # The announcement is a pull request comment, and pull request
1302
- # comments live at the issue endpoint for the pull request number,
1303
- # so the marker is looked for where the comment was posted.
1304
- already=$(gh api "repos/$REPO/issues/$root_pr/comments?per_page=100" \
1305
- --jq "[.[] | select(.body | contains(\"$completion_marker\"))] | length" 2>/dev/null || echo 0)
1306
- if [ "${already:-0}" -gt 0 ]; then
1307
- echo "Stack #$parent already has a completion announcement; staying silent."
1308
- else
1309
- gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
1310
- echo "Stack #$parent complete: announced root PR #$root_pr."
1311
- fi
1210
+ gh pr comment "$root_pr" --repo "$REPO" --body "Stack complete: all children have merged into the feature branch and CI is green. This root pull request is ready for a human to review and merge into $DEFAULT_BRANCH. $completion_marker"
1211
+ echo "Stack #$parent complete: announced root PR #$root_pr."
1312
1212
  fi
1313
1213
  fi
1314
- fi
1214
+ fi
1315
1215
  fi
1316
1216
  fi
1317
1217
  fi
1318
1218
  fi
1319
- else
1320
- maybe_dispatch "$EVENT_PR"
1321
1219
  fi
1322
1220
 
1323
1221
  reconcile-bot-pr-runs:
@@ -1347,41 +1245,14 @@ jobs:
1347
1245
  private-key: ${{ secrets.BOT_PRIVATE_KEY }}
1348
1246
  # Runs only the default-branch workflow and never checks out pull-request code.
1349
1247
  # External users cannot impersonate github-actions[bot].
1350
- - name: Approve checks and dispatch Merge Gate for trusted bot pull requests
1248
+ # Moving pull requests towards a merge is the merge train's, which this schedule also ticks.
1249
+ - name: Approve trusted bot CI and clear stale reservations
1351
1250
  env:
1352
1251
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
1353
1252
  REPO: ${{ github.repository }}
1354
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
1355
1253
  run: |
1356
1254
  set -euo pipefail
1357
1255
 
1358
- # GitHub computes mergeability on demand. The first read of a pull request nobody has
1359
- # opened recently answers UNKNOWN through the API and null through REST, while it works
1360
- # the answer out in the background. Reading once therefore reports "not conflicting"
1361
- # for precisely the stale conflicting pull requests that need the gate. Ask until it
1362
- # settles.
1363
- mergeable_state() {
1364
- local pr="$1" state=UNKNOWN
1365
- for _ in 1 2 3 4 5 6; do
1366
- state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
1367
- [ "$state" = "UNKNOWN" ] || break
1368
- sleep 5
1369
- done
1370
- echo "$state"
1371
- }
1372
-
1373
- # A gate already queued or running for this pull request will read the same CI verdict;
1374
- # a second dispatch would wait in the belt's queue and run the same gate twice.
1375
- gate_is_live() {
1376
- local pr="$1" status live
1377
- for status in queued in_progress waiting pending; do
1378
- live=$(gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" \
1379
- --jq "[.workflow_runs[] | select(.display_title == \"Working (Merge Gate): PR #${pr}\")] | length" 2>/dev/null || echo 0)
1380
- [ "${live:-0}" -eq 0 ] || return 0
1381
- done
1382
- return 1
1383
- }
1384
-
1385
1256
  # Stale reservation sweep. Labelling ten issues at once labels all ten immediately,
1386
1257
  # because authorize-bot-work is keyed per issue, while implement-global lets exactly
1387
1258
  # one run at a time. A queued run that is cancelled therefore leaves its issue
@@ -1419,7 +1290,7 @@ jobs:
1419
1290
  )
1420
1291
  | [.number, .head.ref, .head.sha] | @tsv
1421
1292
  ' |
1422
- while IFS=$'\t' read -r pr_number branch head_sha; do
1293
+ while IFS=$'\t' read -r _ branch _; do
1423
1294
  gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" \
1424
1295
  --jq '.workflow_runs[]
1425
1296
  | select(
@@ -1431,110 +1302,6 @@ jobs:
1431
1302
  echo "Approving trusted bot run $run_id"
1432
1303
  gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
1433
1304
  done
1434
-
1435
- ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
1436
- --jq '[.workflow_runs[]
1437
- | select(
1438
- .name == env.CI_WORKFLOW_NAME
1439
- and .event == "pull_request"
1440
- and .status == "completed"
1441
- and (.conclusion == "success" or .conclusion == "failure")
1442
- )
1443
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
1444
- if [ -z "$ci" ]; then
1445
- # A conflicting pull request has no merge ref for GitHub to build, so a
1446
- # `pull_request` CI workflow can never run on that head: refs/pull/N/merge is a
1447
- # 404 for as long as the conflict lasts. Waiting for a verdict that cannot arrive
1448
- # deadlocks the belt, because resolving the conflict is the gate's own job. Fall
1449
- # back to the branch's last verdict so the gate runs; it stays on the remediation
1450
- # path and cannot merge on a stale conclusion.
1451
- mergeable_now=$(mergeable_state "$pr_number")
1452
- if [ "$mergeable_now" = "CONFLICTING" ]; then
1453
- ci=$(gh api "repos/$REPO/actions/runs?branch=$branch&per_page=100" \
1454
- --jq '[.workflow_runs[]
1455
- | select(
1456
- .name == env.CI_WORKFLOW_NAME
1457
- and .event == "pull_request"
1458
- and .status == "completed"
1459
- and (.conclusion == "success" or .conclusion == "failure")
1460
- )
1461
- ] | sort_by(.created_at) | last // empty | [.id, .conclusion, .updated_at] | @tsv')
1462
- fi
1463
- if [ -z "$ci" ]; then
1464
- echo "::warning::PR #$pr_number head $head_sha has no completed $CI_WORKFLOW_NAME run; the belt cannot gate it until CI runs."
1465
- continue
1466
- fi
1467
- echo "PR #$pr_number conflicts, so CI cannot run on $head_sha at all; gating on the branch's last verdict."
1468
- fi
1469
-
1470
- IFS=$'\t' read -r ci_run_id ci_conclusion ci_finished <<<"$ci"
1471
-
1472
- # A verdict is a comment carrying BOTH the gate marker and a `**Verdict:**`
1473
- # line; progress comments and failed attempts deliberately carry neither
1474
- # matching pair, so a crashed or OOM-killed gate never masks a PR from
1475
- # later sweeps. A recorded verdict only counts when it is NEWER than the
1476
- # latest CI run; the old existence check made the gate one-shot per PR.
1477
- # Attempts are failed gates (the attempt marker) since this CI verdict.
1478
- latest_verdict=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1479
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1480
- linked_issue=$(gh pr view "$pr_number" --repo "$REPO" --json closingIssuesReferences --jq '.closingIssuesReferences[0].number // empty' 2>/dev/null || true)
1481
- if [ -n "$linked_issue" ]; then
1482
- issue_verdict=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1483
- --jq '[.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | contains("**Verdict:**"))) | .created_at] | max // empty')
1484
- if [ -n "$issue_verdict" ] && { [ -z "$latest_verdict" ] || [ "$issue_verdict" \> "$latest_verdict" ]; }; then
1485
- latest_verdict="$issue_verdict"
1486
- fi
1487
- fi
1488
- # A verdict parks the CODE it was given on, not the CI run that prompted it. This
1489
- # used to compare against the CI finish time, which made the park worthless: any
1490
- # later CI run on the same commits -- a re-run, a nightly, a rebuild triggered by
1491
- # main moving -- was newer than the verdict, so the belt re-dispatched a pull
1492
- # request a human had already been handed, and the attempt counter below reset to
1493
- # zero at the same time and handed it a fresh budget of six full gate runs.
1494
- # Lyceum PR #13 sat parked by a `review` verdict for six days while this happened.
1495
- #
1496
- # The newest commit on the branch is the right key. A gate verdict newer than it
1497
- # applies to the current head, so the park holds however often CI runs. Push a
1498
- # commit -- which is what apply-review and a `remediated` verdict both do -- and
1499
- # the head moves past the verdict and the gate decides again, which is correct:
1500
- # that is new code nobody has ruled on.
1501
- head_committed=$(gh pr view "$pr_number" --repo "$REPO" \
1502
- --json commits --jq '[.commits[].committedDate] | max // empty' 2>/dev/null || true)
1503
- verdict_horizon="${head_committed:-$ci_finished}"
1504
- if [ -n "$latest_verdict" ] && [ "$latest_verdict" \> "$verdict_horizon" ]; then
1505
- echo "PR #$pr_number: gate verdict ($latest_verdict) is newer than its head commit ($verdict_horizon); the park holds, skipping"
1506
- continue
1507
- fi
1508
-
1509
- attempts=$(gh api "repos/$REPO/issues/$pr_number/comments?per_page=100" \
1510
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1511
- if [ -n "$linked_issue" ]; then
1512
- issue_attempts=$(gh api "repos/$REPO/issues/$linked_issue/comments?per_page=100" \
1513
- --jq '[.[] | select(.body | contains("<!-- agent-merge-gate-attempt -->")) | select(.created_at > "'"$verdict_horizon"'") ] | length')
1514
- attempts=$((attempts + issue_attempts))
1515
- fi
1516
- if [ "$attempts" -ge "$MAX_GATE_ATTEMPTS" ]; then
1517
- echo "PR #$pr_number already consumed $attempts gate attempts on this head, parking for review"
1518
- continue
1519
- fi
1520
- if gate_is_live "$pr_number"; then
1521
- echo "PR #$pr_number: a merge-gate run is already live; not dispatching another."
1522
- continue
1523
- fi
1524
-
1525
- echo "Dispatching Merge Gate for PR #$pr_number ($ci_conclusion, run $ci_run_id, attempt $((attempts + 1)) of $MAX_GATE_ATTEMPTS)"
1526
- gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" \
1527
- --method POST \
1528
- -f ref="$DEFAULT_BRANCH" \
1529
- -f "inputs[operation]=merge-gate" \
1530
- -f "inputs[pr-number]=$pr_number" \
1531
- -f "inputs[ci-conclusion]=$ci_conclusion" \
1532
- -f "inputs[ci-run-id]=$ci_run_id" \
1533
- -f "inputs[attempts_so_far]=$attempts"
1534
-
1535
- # Every eligible pull request in one tick. The belt queues them first in, first out
1536
- # (`queue: max` on call-merge-gate); before it did, each dispatch cancelled the one
1537
- # pending ahead of it, so this loop stopped after one and the rest waited an hour.
1538
1305
  done
1539
1306
 
1540
1307
  audit-close: