@plainconceptsplatform/workflows 1.5.0 → 1.6.10

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.
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env bash
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/merge-train/merge-train.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ # Move the merge train: look at every open pull request, oldest first, and do for each the one
4
+ # thing it needs, with merges one at a time and repairs in parallel.
5
+ #
6
+ # The belt this replaces dispatched the gate for every eligible pull request at once. Each gate
7
+ # then resolved its branch against a default branch the gate ahead of it was about to move, so a
8
+ # night of ten pull requests became ten repairs, ten CI runs and ten more gates after every merge,
9
+ # and a verdict that parked a pull request held it however stale it went.
10
+ #
11
+ # What races is a merge, because it moves the default branch under everyone else. A repair (a gate
12
+ # handed red CI) pushes to its own branch and can never merge, so it races nothing. So:
13
+ # merge one at a time, the oldest ready pull request first; nothing behind a pull request that
14
+ # is waiting on its CI, or behind a gate that may merge, merges or is brought up to date
15
+ # repair a red or conflicting pull request goes to the gate at once, up to MAX_REPAIRS together
16
+ # approve its CI is waiting for approval and a trusted bot opened it
17
+ # update it is green but behind the default branch: merge the default branch in, CI reruns
18
+ # A pull request a person has to decide on, or that carries the hold label, is skipped, so it never
19
+ # blocks the line. Ticks come from every merge, every CI completion, every finished gate, and the
20
+ # hourly schedule as the fallback.
21
+ #
22
+ # `train_move` is pure, so the route matrix sources this file and exercises the same decision the
23
+ # train runs. Everything below it reads GitHub and acts.
24
+
25
+ set -euo pipefail
26
+
27
+ # Facts in, one move out. Every input is a plain string the I/O layer measured:
28
+ # HOLD true when the pull request carries the hold label
29
+ # REVIEW_DECISION GitHub's review decision (CHANGES_REQUESTED holds it for its author)
30
+ # PARK none, or held when a standing gate verdict covers this head
31
+ # ATTEMPTS failed gate attempts on this head; MAX_ATTEMPTS is the budget. Counts the
32
+ # larger of the attempt comments and the gate runs, so a gate that fails
33
+ # without a comment still spends it
34
+ # SILENT_GATE true when a gate already ran on this head and left no verdict and no attempt
35
+ # comment: it declined, or died before it could say so. Handing it the same
36
+ # pull request again reproduces that, a hot loop with every tick
37
+ # MERGEABLE MERGEABLE, CONFLICTING or UNKNOWN
38
+ # CI_STATUS none, or the latest CI run's status (queued, in_progress, completed, ...)
39
+ # CI_CONCLUSION that run's conclusion when it completed
40
+ # CI_STUCK true when that run has been pending longer than the train waits
41
+ # BEHIND_BY commits on the base branch the head does not have
42
+ # TRUSTED_AUTHOR true when a trusted bot opened it (only those runs are approved)
43
+ train_move() {
44
+ local move="" reason=""
45
+
46
+ if [ "${HOLD:-false}" = true ]; then
47
+ move=skip; reason="it carries the hold label"
48
+ elif [ "${REVIEW_DECISION:-}" = CHANGES_REQUESTED ]; then
49
+ move=skip; reason="a reviewer requested changes"
50
+ elif [ "${PARK:-none}" = held ]; then
51
+ move=skip; reason="a gate verdict on this head is waiting for a person"
52
+ elif [ "${ATTEMPTS:-0}" -ge "${MAX_ATTEMPTS:-6}" ]; then
53
+ move=skip; reason="its gate attempt budget on this head is spent"
54
+ elif [ "${SILENT_GATE:-false}" = true ]; then
55
+ move=skip; reason="its gate already ran on this head and left no verdict or attempt"
56
+ elif [ "${MERGEABLE:-UNKNOWN}" = CONFLICTING ]; then
57
+ # A conflicting pull request has no merge ref, so CI can never run on it: waiting for a
58
+ # verdict would wait forever. Resolving the conflict is the gate's job.
59
+ move=gate; reason="it conflicts with its base branch"
60
+ else
61
+ case "${CI_STATUS:-none}" in
62
+ none)
63
+ move=skip; reason="no CI run on its head, nothing to gate on" ;;
64
+ completed)
65
+ case "${CI_CONCLUSION:-}" in
66
+ success)
67
+ if [ "${BEHIND_BY:-0}" -gt 0 ]; then
68
+ move=update; reason="green but ${BEHIND_BY} commit(s) behind its base branch"
69
+ else
70
+ move=gate; reason="green and up to date"
71
+ fi
72
+ ;;
73
+ failure) move=gate; reason="CI failed" ;;
74
+ action_required)
75
+ if [ "${TRUSTED_AUTHOR:-false}" = true ]; then
76
+ move=approve; reason="CI is waiting for approval"
77
+ else
78
+ move=skip; reason="CI is waiting for a person to approve it"
79
+ fi
80
+ ;;
81
+ *) move=skip; reason="CI concluded '${CI_CONCLUSION:-none}', which is no verdict" ;;
82
+ esac
83
+ ;;
84
+ *)
85
+ if [ "${CI_STUCK:-false}" = true ]; then
86
+ move=skip; reason="CI has been '${CI_STATUS}' for longer than the train waits"
87
+ else
88
+ move="wait"; reason="CI is ${CI_STATUS}"
89
+ fi
90
+ ;;
91
+ esac
92
+ fi
93
+
94
+ printf 'move=%s\nreason=%s\n' "$move" "$reason"
95
+ }
96
+
97
+ # A standing verdict holds the pull request when it is newer than the head commit. Two verdicts
98
+ # never hold: `remediated` is the gate saying it pushed and wants the next CI, and its comment is
99
+ # always newer than the commit it pushed. `blocked` and a merge that did not happen expire once
100
+ # the base branch moves, a bounded number of times: the failure may have been the base's, and a
101
+ # repair attempt on a newer base is cheap next to a weekend of waiting.
102
+ # VERDICT the latest gate verdict word, or empty
103
+ # VERDICT_NEWER true when that verdict is newer than the head commit
104
+ # BASE_MOVED true when the base branch has a commit newer than the verdict
105
+ # RETRIES blocked or failed-merge verdicts already given on this head
106
+ # MAX_RETRIES how many of those the train retries before leaving it to a person
107
+ # STACK_ROOT true for a stack's root pull request, which a person always merges
108
+ train_park() {
109
+ local park=none
110
+ if [ "${VERDICT_NEWER:-false}" = true ]; then
111
+ case "${VERDICT:-}" in
112
+ remediated | "") park=none ;;
113
+ blocked | auto-merge)
114
+ if [ "${STACK_ROOT:-false}" = true ]; then
115
+ park=held
116
+ elif [ "${BASE_MOVED:-false}" = true ] && [ "${RETRIES:-0}" -lt "${MAX_RETRIES:-3}" ]; then
117
+ park=none
118
+ else
119
+ park=held
120
+ fi
121
+ ;;
122
+ *) park=held ;;
123
+ esac
124
+ fi
125
+ echo "$park"
126
+ }
127
+
128
+ if [ "${BASH_SOURCE[0]}" != "$0" ]; then
129
+ return 0
130
+ fi
131
+
132
+ : "${REPO:?}" "${DEFAULT_BRANCH:?}" "${CI_WORKFLOW_NAME:?}"
133
+ TRUSTED_BOTS="${TRUSTED_BOTS:-}"
134
+ HOLD_LABEL="${HOLD_LABEL:-hold}"
135
+ MAX_ATTEMPTS="${MAX_ATTEMPTS:-6}"
136
+ MAX_RETRIES="${MAX_RETRIES:-3}"
137
+ MAX_REPAIRS="${MAX_REPAIRS:-1}"
138
+ case "$MAX_REPAIRS" in ''|*[!0-9]*|0) MAX_REPAIRS=1 ;; esac
139
+ STUCK_HOURS="${STUCK_HOURS:-3}"
140
+ DRY_RUN="${DRY_RUN:-false}"
141
+ SELF_RUN_ID="${GITHUB_RUN_ID:-0}"
142
+ GATE_TITLE_PREFIX="Agent: Merge Gate - PR #"
143
+ SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
144
+
145
+ now_epoch=$(date -u +%s)
146
+ iso_epoch() { date -u -d "$1" +%s 2>/dev/null || echo 0; }
147
+
148
+ summary() { printf '%s\n' "$*" >>"$SUMMARY"; }
149
+
150
+ # Every write goes through this, so a dry run is honest: there is no second path that forgets.
151
+ act() {
152
+ local what="$1"
153
+ shift
154
+ if [ "$DRY_RUN" = true ]; then
155
+ echo "[dry-run] $what"
156
+ summary "**Next move (dry run):** $what"
157
+ return 0
158
+ fi
159
+ echo "$what"
160
+ summary "**Next move:** $what"
161
+ "$@"
162
+ }
163
+
164
+ is_trusted_bot() {
165
+ case " $TRUSTED_BOTS " in *" $1 "*) return 0 ;; esac
166
+ return 1
167
+ }
168
+
169
+ # Who may ride the train: a trusted bot, or a person with write access. Asked once per login.
170
+ declare -A WRITE_ACCESS=()
171
+ can_ride() {
172
+ local login="$1" permission
173
+ is_trusted_bot "$login" && return 0
174
+ if [ -z "${WRITE_ACCESS[$login]+set}" ]; then
175
+ permission=$(gh api "repos/$REPO/collaborators/$login/permission" --jq '.permission' 2>/dev/null || echo none)
176
+ case "$permission" in admin | maintain | write) WRITE_ACCESS[$login]=true ;; *) WRITE_ACCESS[$login]=false ;; esac
177
+ fi
178
+ [ "${WRITE_ACCESS[$login]}" = true ]
179
+ }
180
+
181
+ # The gates on the belt, one title per line. A gate handed red CI repairs and pushes and cannot
182
+ # merge, so run-name gives it a " (repair)" suffix and the router queues it per pull request;
183
+ # every other gate may merge, and there is one of those at a time across the repository. The run
184
+ # this tick belongs to is excluded: the tick after a gate runs inside the gate's own router run.
185
+ live_gates() {
186
+ local status
187
+ for status in queued in_progress waiting pending requested; do
188
+ gh api "repos/$REPO/actions/workflows/work-router.yml/runs?status=$status&per_page=100" --jq ".workflow_runs[]
189
+ | select(.id != $SELF_RUN_ID)
190
+ | select(.display_title | startswith(\"$GATE_TITLE_PREFIX\"))
191
+ | .display_title" 2>/dev/null
192
+ done | sort -u
193
+ }
194
+
195
+ # GitHub works mergeability out on demand: the first read of a pull request nobody has opened
196
+ # recently answers UNKNOWN while it computes. Ask until it settles.
197
+ mergeable_state() {
198
+ local pr="$1" state=UNKNOWN
199
+ for _ in 1 2 3 4 5 6; do
200
+ state=$(gh pr view "$pr" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || echo UNKNOWN)
201
+ [ "$state" = UNKNOWN ] || break
202
+ sleep 5
203
+ done
204
+ echo "$state"
205
+ }
206
+
207
+ # Gate comments carry the marker and a Verdict line, on the pull request or on its issue. The
208
+ # agent's own report says `assessed` and is always followed by the disposition the workflow
209
+ # computed from it, so it is not a verdict of its own.
210
+ verdict_comments() {
211
+ local number="$1"
212
+ gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
213
+ --jq '.[] | select((.body | contains("<!-- agent-merge-gate -->")) and (.body | test("Verdict:[*]+ [a-z-]+")))
214
+ | [.created_at, (.body | capture("Verdict:[*]+ (?<v>[a-z-]+)").v)]
215
+ | select(.[1] != "assessed") | @tsv' 2>/dev/null || true
216
+ }
217
+
218
+ # shellcheck disable=SC2317 # called through act
219
+ approve_runs() {
220
+ local branch="$1" id
221
+ gh api "repos/$REPO/actions/runs?status=action_required&branch=$branch" --jq '.workflow_runs[].id' |
222
+ while IFS= read -r id; do
223
+ gh api "repos/$REPO/actions/runs/$id/approve" --method POST >/dev/null
224
+ done
225
+ }
226
+
227
+ # shellcheck disable=SC2317 # called through act
228
+ update_branch() {
229
+ local pr="$1" head_sha="$2"
230
+ gh api "repos/$REPO/pulls/$pr/update-branch" --method PUT -f expected_head_sha="$head_sha" >/dev/null
231
+ }
232
+
233
+ # How many gate runs were started for a pull request since a moment, whatever became of them. A
234
+ # run is titled by the router's run-name; this is the one count that does not depend on the gate
235
+ # having written anything.
236
+ gate_run_count() {
237
+ local pr="$1" since="$2"
238
+ gh api --paginate "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&per_page=100" --jq "[.workflow_runs[] | select(.display_title == \"$GATE_TITLE_PREFIX$pr\") | select(.created_at > \"$since\")] | length" 2>/dev/null | awk '{ n += $1 } END { print n + 0 }'
239
+ }
240
+
241
+ attempt_count() {
242
+ local number="$1" since="$2"
243
+ gh api --paginate "repos/$REPO/issues/$number/comments?per_page=100" \
244
+ --jq "[.[] | select(.body | contains(\"<!-- agent-merge-gate-attempt -->\")) | select(.created_at > \"$since\")] | length" \
245
+ 2>/dev/null | awk '{ n += $1 } END { print n + 0 }'
246
+ }
247
+
248
+ # shellcheck disable=SC2317 # called through act
249
+ dispatch_gate() {
250
+ local pr="$1" conclusion="$2" run_id="$3" attempts="$4" since
251
+ since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
252
+ gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" --method POST \
253
+ -f ref="$DEFAULT_BRANCH" \
254
+ -f "inputs[operation]=merge-gate" \
255
+ -f "inputs[pr-number]=$pr" \
256
+ -f "inputs[ci-conclusion]=$conclusion" \
257
+ -f "inputs[ci-run-id]=$run_id" \
258
+ -f "inputs[attempts_so_far]=$attempts" >/dev/null
259
+ # The next tick waits behind this one and must see the gate it started, or it starts a second.
260
+ for _ in $(seq 1 12); do
261
+ if gh api "repos/$REPO/actions/workflows/work-router.yml/runs?event=workflow_dispatch&created=>=$since&per_page=20" \
262
+ --jq ".workflow_runs[] | select(.display_title | startswith(\"$GATE_TITLE_PREFIX$pr\")) | .id" 2>/dev/null | grep -q .; then
263
+ return 0
264
+ fi
265
+ sleep 5
266
+ done
267
+ echo "::warning::Dispatched the gate for #$pr but its run did not appear within a minute."
268
+ }
269
+
270
+ summary "### Merge train"
271
+ summary ""
272
+
273
+ live_titles="$(live_gates)"
274
+ live_repairs=$(printf '%s
275
+ ' "$live_titles" | grep -c ' (repair)$' || true)
276
+ live_merge=$(printf '%s
277
+ ' "$live_titles" | grep -v ' (repair)$' | grep -c . || true)
278
+ live_for_pr() { printf '%s
279
+ ' "$live_titles" | grep -qE "PR #$1( \(repair\))?$"; }
280
+ echo "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s). Up to ${MAX_REPAIRS} repair(s) at once."
281
+ summary "On the belt: ${live_merge} merging gate(s), ${live_repairs} repair(s); up to ${MAX_REPAIRS} repair(s) at once."
282
+ summary ""
283
+
284
+ # Once a pull request ahead is waiting on its CI, or a gate that may merge is on the belt, nothing
285
+ # behind it may merge or be brought up to date: the oldest merges first, and one at a time.
286
+ merge_blocked=false
287
+ [ "$live_merge" -eq 0 ] || merge_blocked=true
288
+
289
+ base_head_date=""
290
+ base_head_date_for() {
291
+ local base="$1"
292
+ gh api "repos/$REPO/commits/$base" --jq '.commit.committer.date' 2>/dev/null || echo ""
293
+ }
294
+
295
+ summary "| PR | Author | Move | Why |"
296
+ summary "|---|---|---|---|"
297
+
298
+ mapfile -t candidates < <(
299
+ gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" |
300
+ jq -r --arg repo "$REPO" --arg hold "$HOLD_LABEL" '
301
+ .[]
302
+ | select(.draft == false and (.head.repo.full_name // "") == $repo)
303
+ | [.number, .user.login, .head.sha, .head.ref, .base.ref,
304
+ ([.labels[].name] | index($hold) != null)] | @tsv' |
305
+ sort -n
306
+ )
307
+
308
+ if [ "${#candidates[@]}" -eq 0 ]; then
309
+ echo "No open pull request on this repository; the train is empty."
310
+ summary "| - | - | empty | no open pull request |"
311
+ exit 0
312
+ fi
313
+
314
+ for line in "${candidates[@]}"; do
315
+ IFS=$'\t' read -r pr author head_sha head_ref base_ref hold <<<"$line"
316
+
317
+ if ! can_ride "$author"; then
318
+ echo "#$pr: opened by $author, who has no write access; not on the train."
319
+ continue
320
+ fi
321
+
322
+ meta=$(gh pr view "$pr" --repo "$REPO" --json reviewDecision,commits,closingIssuesReferences 2>/dev/null || echo '{}')
323
+ review_decision=$(jq -r '.reviewDecision // ""' <<<"$meta")
324
+ head_committed=$(jq -r '[.commits[]?.committedDate] | max // ""' <<<"$meta")
325
+ linked_issue=$(jq -r '.closingIssuesReferences[0].number // ""' <<<"$meta")
326
+
327
+ # The latest verdict on the pull request or its issue, and how many retries it has had.
328
+ verdicts=$(
329
+ {
330
+ verdict_comments "$pr"
331
+ [ -z "$linked_issue" ] || verdict_comments "$linked_issue"
332
+ } | sort
333
+ )
334
+ verdict_line=$(tail -1 <<<"$verdicts")
335
+ verdict_at=$(cut -f1 <<<"$verdict_line")
336
+ verdict=$(cut -f2 <<<"$verdict_line")
337
+ verdict_newer=false
338
+ if [ -n "$verdict_at" ] && { [ -z "$head_committed" ] || [ "$verdict_at" \> "$head_committed" ]; }; then
339
+ verdict_newer=true
340
+ fi
341
+ retries=$(awk -F'\t' -v since="$head_committed" '$1 > since && ($2 == "blocked" || $2 == "auto-merge")' <<<"$verdicts" | grep -c . || true)
342
+
343
+ base_moved=false
344
+ if [ "$verdict_newer" = true ]; then
345
+ base_head_date=$(base_head_date_for "$base_ref")
346
+ if [ -n "$base_head_date" ] && [ "$base_head_date" \> "$verdict_at" ]; then
347
+ base_moved=true
348
+ fi
349
+ fi
350
+
351
+ stack_root=false
352
+ if [ "$verdict_newer" = true ] && [ -n "$linked_issue" ]; then
353
+ if gh issue view "$linked_issue" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null |
354
+ grep -q "<!-- stack-root-pr: $pr -->"; then
355
+ stack_root=true
356
+ fi
357
+ fi
358
+
359
+ park=$(VERDICT="$verdict" VERDICT_NEWER="$verdict_newer" BASE_MOVED="$base_moved" \
360
+ RETRIES="$retries" MAX_RETRIES="$MAX_RETRIES" STACK_ROOT="$stack_root" train_park)
361
+
362
+ attempts=0
363
+ silent_gate=false
364
+ if [ "$park" = none ]; then
365
+ horizon="${head_committed:-1970-01-01T00:00:00Z}"
366
+ # A retried verdict starts a fresh budget from the verdict it retries, not from the head.
367
+ [ "$verdict_newer" = true ] && horizon="$verdict_at"
368
+ attempts=$(attempt_count "$pr" "$horizon")
369
+ if [ -n "$linked_issue" ]; then
370
+ attempts=$((attempts + $(attempt_count "$linked_issue" "$horizon")))
371
+ fi
372
+ gate_runs=$(gate_run_count "$pr" "$horizon")
373
+ # Comments say what the gate decided. Runs say it was asked. A run on this head that left no
374
+ # attempt comment and no verdict is a gate that did nothing, and the next tick must not ask
375
+ # again: that is the loop.
376
+ if [ "$gate_runs" -gt 0 ] && [ "$attempts" -eq 0 ]; then silent_gate=true; fi
377
+ [ "$gate_runs" -le "$attempts" ] || attempts="$gate_runs"
378
+ fi
379
+
380
+ ci=$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
381
+ --jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME)] | sort_by(.created_at) | last // empty
382
+ | [.id, .status, (.conclusion // ""), .created_at] | @tsv' 2>/dev/null || true)
383
+ IFS=$'\t' read -r ci_run_id ci_status ci_conclusion ci_created <<<"${ci:-}"
384
+ ci_status="${ci_status:-none}"
385
+ # A CI run hands its verdict over from its last job, so the run itself is still in progress
386
+ # when this tick reads it. Its own word stands in for the conclusion GitHub has not written yet.
387
+ if [ -n "${HINT_RUN_ID:-}" ] && [ "${ci_run_id:-}" = "$HINT_RUN_ID" ] && [ "$ci_status" != completed ]; then
388
+ case "${HINT_CONCLUSION:-}" in
389
+ success | failure) ci_status=completed; ci_conclusion="$HINT_CONCLUSION" ;;
390
+ esac
391
+ fi
392
+ ci_stuck=false
393
+ if [ "$ci_status" != none ] && [ "$ci_status" != completed ] && [ -n "${ci_created:-}" ]; then
394
+ [ $((now_epoch - $(iso_epoch "$ci_created"))) -lt $((STUCK_HOURS * 3600)) ] || ci_stuck=true
395
+ fi
396
+
397
+ mergeable=UNKNOWN
398
+ behind_by=0
399
+ if [ "$park" = none ] && [ "$hold" != true ] && [ "$review_decision" != CHANGES_REQUESTED ]; then
400
+ mergeable=$(mergeable_state "$pr")
401
+ if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ]; then
402
+ behind_by=$(gh api "repos/$REPO/compare/$base_ref...$head_sha" --jq '.behind_by' 2>/dev/null || echo 0)
403
+ fi
404
+ fi
405
+
406
+ trusted_author=false
407
+ is_trusted_bot "$author" && trusted_author=true
408
+
409
+ decision=$(HOLD="$hold" REVIEW_DECISION="$review_decision" PARK="$park" \
410
+ ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" SILENT_GATE="$silent_gate" MERGEABLE="$mergeable" \
411
+ CI_STATUS="$ci_status" CI_CONCLUSION="$ci_conclusion" CI_STUCK="$ci_stuck" \
412
+ BEHIND_BY="${behind_by:-0}" TRUSTED_AUTHOR="$trusted_author" train_move)
413
+ move=$(sed -n 's/^move=//p' <<<"$decision")
414
+ reason=$(sed -n 's/^reason=//p' <<<"$decision")
415
+
416
+ # A gate is already on this pull request, whatever kind: offering it another would run the same
417
+ # work twice. It does not hold the line up for the repair kind; a merging gate already does.
418
+ if [ "$move" != skip ] && live_for_pr "$pr"; then
419
+ move=skip
420
+ reason="a gate is already on it"
421
+ fi
422
+
423
+ echo "#$pr ($author): $move, $reason."
424
+ summary "| #$pr | $author | $move | $reason |"
425
+
426
+ case "$move" in
427
+ skip) continue ;;
428
+ wait)
429
+ # Its CI is running: nothing behind it may merge or be brought up to date first, but the
430
+ # pull requests behind it that need repair do not wait for anyone.
431
+ merge_blocked=true
432
+ continue
433
+ ;;
434
+ approve)
435
+ act "Approving the CI runs waiting on #$pr." approve_runs "$head_ref"
436
+ merge_blocked=true
437
+ continue
438
+ ;;
439
+ update)
440
+ if [ "$merge_blocked" = true ]; then
441
+ echo " #$pr waits for the pull request ahead of it before it is brought up to date."
442
+ continue
443
+ fi
444
+ # Merge, never rebase: the gate's pushes are fast-forward only, and so are a person's.
445
+ if act "Merging $base_ref into #$pr so CI judges it against what it will land on." \
446
+ update_branch "$pr" "$head_sha"; then
447
+ merge_blocked=true
448
+ continue
449
+ fi
450
+ echo "::warning::GitHub could not update #$pr from $base_ref; handing it to the gate."
451
+ act "Dispatching the merge gate for #$pr to bring it up to date." \
452
+ dispatch_gate "$pr" success "${ci_run_id:-}" "$attempts"
453
+ merge_blocked=true
454
+ continue
455
+ ;;
456
+ gate)
457
+ conclusion="${ci_conclusion:-}"
458
+ if [ "$ci_status" != completed ] || { [ "$conclusion" != success ] && [ "$conclusion" != failure ]; }; then
459
+ # Only a conflict gets here without a usable verdict on this head. The branch's last
460
+ # verdict tells the gate which path it is on; with none, the repair path resolves it.
461
+ last=$(gh api "repos/$REPO/actions/runs?branch=$head_ref&per_page=100" \
462
+ --jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME and .status == "completed"
463
+ and (.conclusion == "success" or .conclusion == "failure"))]
464
+ | sort_by(.created_at) | last // empty | [.id, .conclusion] | @tsv' 2>/dev/null || true)
465
+ ci_run_id=$(cut -f1 <<<"$last")
466
+ conclusion=$(cut -f2 <<<"$last")
467
+ [ -n "$conclusion" ] || conclusion=failure
468
+ fi
469
+ if [ "$conclusion" = failure ]; then
470
+ # A repair: it pushes and can never merge, so it races nothing. Up to MAX_REPAIRS at once.
471
+ if [ "$live_repairs" -ge "$MAX_REPAIRS" ]; then
472
+ echo " #$pr needs a repair; ${live_repairs} already in progress, the limit is ${MAX_REPAIRS}."
473
+ continue
474
+ fi
475
+ act "Dispatching a repair gate for #$pr (CI failed, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
476
+ dispatch_gate "$pr" failure "${ci_run_id:-}" "$attempts"
477
+ live_repairs=$((live_repairs + 1))
478
+ continue
479
+ fi
480
+ # CI is green on the verdict: this gate may merge, so it goes only when nothing is ahead of it.
481
+ if [ "$merge_blocked" = true ]; then
482
+ echo " #$pr is ready for the gate; it waits for the pull request ahead of it."
483
+ continue
484
+ fi
485
+ act "Dispatching the merge gate for #$pr (CI $conclusion, attempt $((attempts + 1)) of $MAX_ATTEMPTS)." \
486
+ dispatch_gate "$pr" "$conclusion" "${ci_run_id:-}" "$attempts"
487
+ merge_blocked=true
488
+ continue
489
+ ;;
490
+ esac
491
+ done
492
+
493
+ echo "That is every open pull request this tick could move."
494
+ summary ""
495
+ summary "Every open pull request was looked at; those that could move have."
@@ -1,45 +1,73 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/push-agent-branch/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Push agent branch
3
- description: Read push_to_pull_request_branch from agent_output.json and fast-forward from its git bundle.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- bundle-file:
9
- description: Path to the git bundle from the agent artifact.
10
- required: false
11
- default: ''
12
- token:
13
- description: GitHub token with contents:write.
14
- required: true
15
- runs:
16
- using: composite
17
- steps:
18
- - name: Configure git credentials
19
- shell: bash
20
- env:
21
- GH_TOKEN: ${{ inputs.token }}
22
- run: |
23
- git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
24
- - name: Read pull request branch
25
- id: request
26
- shell: bash
27
- env:
28
- GH_TOKEN: ${{ inputs.token }}
29
- OUTPUT_FILE: ${{ inputs.output-file }}
30
- BUNDLE_FILE: ${{ inputs.bundle-file }}
31
- run: |
32
- set -euo pipefail
33
- [ -f "$OUTPUT_FILE" ] || exit 0
34
-
35
- ITEM=$(jq -c '.items[] | select(.type == "push_to_pull_request_branch")' "$OUTPUT_FILE")
36
- [ -n "$ITEM" ] || exit 0
37
-
38
- BRANCH=$(echo "$ITEM" | jq -r '.branch')
39
- echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
40
- - name: Apply bundle to pull request branch
41
- if: steps.request.outputs.branch != '' && inputs.bundle-file != ''
42
- uses: ./.github/actions/apply-agent-bundle
43
- with:
44
- bundle-file: ${{ inputs.bundle-file }}
45
- target-branch: ${{ steps.request.outputs.branch }}
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/push-agent-branch/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Push agent branch
3
+ description: Read push_to_pull_request_branch from agent_output.json and fast-forward from its git bundle.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ bundle-file:
9
+ description: Path to the git bundle from the agent artifact.
10
+ required: false
11
+ default: ''
12
+ token:
13
+ description: GitHub token with contents:write.
14
+ required: true
15
+ expected-branch:
16
+ description: The only branch the push may target. Empty accepts the branch the item names.
17
+ required: false
18
+ default: ''
19
+ runs:
20
+ using: composite
21
+ steps:
22
+ - name: Configure git credentials
23
+ shell: bash
24
+ env:
25
+ GH_TOKEN: ${{ inputs.token }}
26
+ run: |
27
+ set -euo pipefail
28
+ # The runners here outlive their jobs, and `git config --global` outlives the job with
29
+ # them. This step used to add one `url.<token>.insteadOf` rule per run and never remove it,
30
+ # so a runner that had pushed before held several rules for the same prefix, each carrying
31
+ # a token revoked since, and git picks among equal-length matches arbitrarily. The same
32
+ # gate pushed for one pull request and was refused for the next: "Invalid username or
33
+ # token", then "Target branch does not exist" because ls-remote exited 128 (Pliny-Bot #716
34
+ # failed twice that way while #717 pushed in between). Drop every such rule wherever it
35
+ # lives, and set this run's rule in the checkout only.
36
+ for scope in --global --local; do
37
+ git config "$scope" --get-regexp '^url\..*x-access-token.*\.insteadof$' 2>/dev/null |
38
+ while read -r key _; do git config "$scope" --unset-all "$key" || true; done || true
39
+ done
40
+ git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
41
+ - name: Read pull request branch
42
+ id: request
43
+ shell: bash
44
+ env:
45
+ GH_TOKEN: ${{ inputs.token }}
46
+ OUTPUT_FILE: ${{ inputs.output-file }}
47
+ BUNDLE_FILE: ${{ inputs.bundle-file }}
48
+ EXPECTED_BRANCH: ${{ inputs.expected-branch }}
49
+ run: |
50
+ set -euo pipefail
51
+ [ -f "$OUTPUT_FILE" ] || exit 0
52
+
53
+ ITEM=$(jq -c '.items[] | select(.type == "push_to_pull_request_branch")' "$OUTPUT_FILE")
54
+ [ -n "$ITEM" ] || exit 0
55
+
56
+ BRANCH=$(echo "$ITEM" | jq -r '.branch')
57
+ # The agent names the branch; the caller knows it. A push anywhere else is refused, not
58
+ # redirected: a model that named the wrong branch may have built on the wrong one too.
59
+ # An item that names none was built on the checkout, which is the expected branch.
60
+ if [ -n "$EXPECTED_BRANCH" ] && { [ -z "$BRANCH" ] || [ "$BRANCH" = null ]; }; then
61
+ BRANCH="$EXPECTED_BRANCH"
62
+ fi
63
+ if [ -n "$EXPECTED_BRANCH" ] && [ "$BRANCH" != "$EXPECTED_BRANCH" ]; then
64
+ echo "::error::The agent asked to push to '$BRANCH'; this run may only push to '$EXPECTED_BRANCH'."
65
+ exit 1
66
+ fi
67
+ echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
68
+ - name: Apply bundle to pull request branch
69
+ if: steps.request.outputs.branch != '' && inputs.bundle-file != ''
70
+ uses: ./.github/actions/apply-agent-bundle
71
+ with:
72
+ bundle-file: ${{ inputs.bundle-file }}
73
+ target-branch: ${{ steps.request.outputs.branch }}