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