@plainconceptsplatform/workflows 1.6.47 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/loops/actions/identify-gate-subject/action.yml +11 -1
- package/loops/actions/land-pull-request/action.yml +43 -0
- package/loops/actions/land-pull-request/land-pull-request.sh +93 -0
- package/loops/actions/merge-train/action.yml +10 -0
- package/loops/actions/merge-train/merge-train.sh +101 -2
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +34 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +21 -2
- package/loops/templates/ci/app-ci-node-monorepo.yml +23 -4
- package/loops/templates/release/github-release.yml +30 -30
- package/loops/workflows/agent-implement.md +10 -0
- package/loops/workflows/agent-merge-gate.md +13 -5
- package/loops/workflows/release-tag.yml +1 -1
- package/loops/workflows/work-router.yml +7 -3
- package/package.json +1 -1
|
@@ -144,7 +144,15 @@ runs:
|
|
|
144
144
|
if [ -n "$ci_run_id" ]; then
|
|
145
145
|
run_sha="$(gh api "repos/$REPO/actions/runs/$ci_run_id" --jq '.head_sha' 2>/dev/null || true)"
|
|
146
146
|
current_sha="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')"
|
|
147
|
-
|
|
147
|
+
# A conflicting pull request has no merge ref, so CI never runs on its head: the gate keeps
|
|
148
|
+
# the carried verdict and resolves the conflict. Pliny-Bot #862 was handed to the gate and
|
|
149
|
+
# declined every five minutes for an hour waiting for a run that could not start.
|
|
150
|
+
conflicting="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json mergeable --jq '.mergeable' 2>/dev/null || true)"
|
|
151
|
+
# A merge-queue run is built on the queue's own branch, so its commit is never the head;
|
|
152
|
+
# it is the verdict when the train hands over a pull request the queue ejected.
|
|
153
|
+
run_event="$(gh api "repos/$REPO/actions/runs/$ci_run_id" --jq '.event' 2>/dev/null || true)"
|
|
154
|
+
[ "$run_event" != merge_group ] || run_sha="$current_sha"
|
|
155
|
+
if [ -n "$run_sha" ] && [ "$run_sha" != "$current_sha" ] && [ "$conflicting" != CONFLICTING ]; then
|
|
148
156
|
echo "::notice::CI run $ci_run_id judged ${run_sha:0:7}, but the head is ${current_sha:0:7}; reading the verdict again from the head."
|
|
149
157
|
ci_conclusion=""
|
|
150
158
|
ci_run_id=""
|
|
@@ -154,6 +162,8 @@ runs:
|
|
|
154
162
|
fi
|
|
155
163
|
ci_conclusion="$(jq -r '.conclusion // "pending"' <<<"$fresh")"
|
|
156
164
|
ci_run_id="$(jq -r '.id' <<<"$fresh")"
|
|
165
|
+
elif [ "$conflicting" = CONFLICTING ]; then
|
|
166
|
+
echo "::notice::PR #$PR_NUMBER conflicts with its base, so CI cannot run on its head; the gate resolves the conflict."
|
|
157
167
|
fi
|
|
158
168
|
fi
|
|
159
169
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/land-pull-request/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Land pull request
|
|
3
|
+
description: >-
|
|
4
|
+
Land one pull request the gate or a person approved: into the base branch's merge queue when it
|
|
5
|
+
has one, otherwise by merging it. The head must still be the commit that was judged.
|
|
6
|
+
|
|
7
|
+
inputs:
|
|
8
|
+
token:
|
|
9
|
+
description: App token with pull-requests and contents write, plus merge queues write where a queue is on.
|
|
10
|
+
required: true
|
|
11
|
+
pr:
|
|
12
|
+
description: The pull request number.
|
|
13
|
+
required: true
|
|
14
|
+
head-sha:
|
|
15
|
+
description: The head commit that was judged. Landing is refused once the branch has moved.
|
|
16
|
+
required: true
|
|
17
|
+
approve:
|
|
18
|
+
description: "'true' to approve the pull request first, so a ruleset's required approval is met by the gate."
|
|
19
|
+
required: false
|
|
20
|
+
default: 'false'
|
|
21
|
+
approval-body:
|
|
22
|
+
description: The review body that goes with the approval.
|
|
23
|
+
required: false
|
|
24
|
+
default: Approved by the merge gate.
|
|
25
|
+
|
|
26
|
+
outputs:
|
|
27
|
+
landed:
|
|
28
|
+
description: "'merged', 'enqueued', or empty when nothing happened."
|
|
29
|
+
value: ${{ steps.land.outputs.landed }}
|
|
30
|
+
|
|
31
|
+
runs:
|
|
32
|
+
using: composite
|
|
33
|
+
steps:
|
|
34
|
+
- id: land
|
|
35
|
+
shell: bash
|
|
36
|
+
env:
|
|
37
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
38
|
+
REPO: ${{ github.repository }}
|
|
39
|
+
PR: ${{ inputs.pr }}
|
|
40
|
+
HEAD_SHA: ${{ inputs.head-sha }}
|
|
41
|
+
APPROVE: ${{ inputs.approve }}
|
|
42
|
+
APPROVAL_BODY: ${{ inputs.approval-body }}
|
|
43
|
+
run: bash "${GITHUB_ACTION_PATH}/land-pull-request.sh"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/land-pull-request/land-pull-request.sh. Update with workflows update --force; consumer edits may be overwritten.
|
|
3
|
+
#
|
|
4
|
+
# Lands one approved pull request. A base branch with a merge queue gets the pull request in the
|
|
5
|
+
# queue, where GitHub builds it on top of what is ahead and merges it; any other base gets a squash
|
|
6
|
+
# merge, as before. The asynchronous merge endpoint is the one that enqueues: the older merge
|
|
7
|
+
# endpoint and auto-merge do not take stacked pull requests.
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
: "${REPO:?}" "${PR:?}" "${HEAD_SHA:?}"
|
|
11
|
+
[[ "$PR" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid pull request number: $PR"; exit 1; }
|
|
12
|
+
[[ "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || { echo "::error::Invalid head commit for #$PR: $HEAD_SHA"; exit 1; }
|
|
13
|
+
|
|
14
|
+
out() { echo "landed=$1" >> "${GITHUB_OUTPUT:-/dev/null}"; }
|
|
15
|
+
|
|
16
|
+
pr_json=$(gh pr view "$PR" --repo "$REPO" --json headRefOid,baseRefName,headRefName,state)
|
|
17
|
+
state=$(jq -r .state <<<"$pr_json")
|
|
18
|
+
[ "$state" = OPEN ] || { echo "#$PR is $state; nothing to land."; out ""; exit 0; }
|
|
19
|
+
current=$(jq -r .headRefOid <<<"$pr_json")
|
|
20
|
+
if [ "$current" != "$HEAD_SHA" ]; then
|
|
21
|
+
echo "::warning::#$PR moved from ${HEAD_SHA:0:7} to ${current:0:7} after it was judged; not landing it."
|
|
22
|
+
out ""
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
base=$(jq -r .baseRefName <<<"$pr_json")
|
|
26
|
+
head_ref=$(jq -r .headRefName <<<"$pr_json")
|
|
27
|
+
|
|
28
|
+
owner=${REPO%%/*}
|
|
29
|
+
name=${REPO#*/}
|
|
30
|
+
queue=$(gh api graphql -f query='query($o:String!,$n:String!,$b:String!){repository(owner:$o,name:$n){mergeQueue(branch:$b){id}}}' \
|
|
31
|
+
-f o="$owner" -f n="$name" -f b="$base" --jq '.data.repository.mergeQueue.id // ""' 2>/dev/null || true)
|
|
32
|
+
|
|
33
|
+
if [ "${APPROVE:-false}" = true ]; then
|
|
34
|
+
# A ruleset that requires an approval counts the App's. Without one this is a record of the verdict.
|
|
35
|
+
gh api -X POST "repos/$REPO/pulls/$PR/reviews" -f commit_id="$HEAD_SHA" -f event=APPROVE \
|
|
36
|
+
-f body="${APPROVAL_BODY:-Approved by the merge gate.}" >/dev/null ||
|
|
37
|
+
echo "::warning::Could not approve #$PR; a required approval will hold it."
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
if [ -z "$queue" ]; then
|
|
41
|
+
gh pr merge "$PR" --repo "$REPO" --squash --match-head-commit "$HEAD_SHA"
|
|
42
|
+
# The branch goes as well: nothing else deletes it where `delete_branch_on_merge` is off.
|
|
43
|
+
gh api -X DELETE "repos/$REPO/git/refs/heads/$head_ref" >/dev/null 2>&1 || true
|
|
44
|
+
echo "Merged #$PR into $base."
|
|
45
|
+
out merged
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
pr_id=$(gh pr view "$PR" --repo "$REPO" --json id --jq .id)
|
|
50
|
+
# GraphQL first: the asynchronous merge endpoint refuses a GitHub App with "Enqueuer is not
|
|
51
|
+
# authorized to merge" (Agentic-Workflow-Dogfood, 2026-10-20), while enqueuePullRequest takes the
|
|
52
|
+
# same token. A stacked pull request refuses GraphQL and only the asynchronous endpoint takes it.
|
|
53
|
+
enqueued=$(gh api graphql -f query='mutation($id:ID!,$sha:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$sha}){mergeQueueEntry{state}}}' \
|
|
54
|
+
-f id="$pr_id" -f sha="$HEAD_SHA" 2>&1 || true)
|
|
55
|
+
if grep -q '"mergeQueueEntry":{' <<<"$enqueued"; then
|
|
56
|
+
echo "#$PR is in the $base merge queue; GitHub merges it once its group passes."
|
|
57
|
+
out enqueued
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
if ! grep -q 'part of a stack' <<<"$enqueued"; then
|
|
61
|
+
echo "::error::#$PR could not be added to the $base merge queue: $(tr '\n' ' ' <<<"$enqueued" | cut -c1-400)"
|
|
62
|
+
out ""
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
answer=$(gh api -X PUT "repos/$REPO/pulls/$PR/merge-async" -f merge_action=merge_queue -f sha="$HEAD_SHA")
|
|
67
|
+
status=$(jq -r .status <<<"$answer")
|
|
68
|
+
uuid=$(jq -r '.details.uuid // ""' <<<"$answer")
|
|
69
|
+
for _ in $(seq 1 12); do
|
|
70
|
+
case "$status" in enqueued | merged) break ;; failed) break ;; esac
|
|
71
|
+
[ -n "$uuid" ] || break
|
|
72
|
+
sleep 5
|
|
73
|
+
answer=$(gh api "repos/$REPO/pulls/$PR/merge-async/$uuid" 2>/dev/null || echo '{}')
|
|
74
|
+
status=$(jq -r '.status // "pending"' <<<"$answer")
|
|
75
|
+
done
|
|
76
|
+
|
|
77
|
+
case "$status" in
|
|
78
|
+
enqueued)
|
|
79
|
+
echo "#$PR is in the $base merge queue; GitHub merges it once its group passes."
|
|
80
|
+
out enqueued
|
|
81
|
+
;;
|
|
82
|
+
merged)
|
|
83
|
+
echo "#$PR merged into $base."
|
|
84
|
+
out merged
|
|
85
|
+
;;
|
|
86
|
+
*)
|
|
87
|
+
message=$(jq -r '.details.message // ""' <<<"$answer")
|
|
88
|
+
# A stack has no other way in, and GitHub refuses an App on this endpoint: a person enqueues it.
|
|
89
|
+
echo "::error::Stack #$PR did not reach the $base merge queue (status '${status}'${message:+: $message}). GitHub lets a GitHub App enqueue a stack only through this endpoint, which refuses Apps today; a person adds the stack to the queue from the pull request."
|
|
90
|
+
out ""
|
|
91
|
+
exit 1
|
|
92
|
+
;;
|
|
93
|
+
esac
|
|
@@ -50,6 +50,14 @@ inputs:
|
|
|
50
50
|
description: Decide and report the move without making it.
|
|
51
51
|
required: false
|
|
52
52
|
default: 'false'
|
|
53
|
+
merge-queue:
|
|
54
|
+
description: "true when the base branch has GitHub's merge queue (repository variable MERGE_QUEUE): the train stops ordering and updating, and repairs what the queue ejects."
|
|
55
|
+
required: false
|
|
56
|
+
default: 'false'
|
|
57
|
+
ci-depth-tiered:
|
|
58
|
+
description: "true when the repository's CI runs only light checks on a bot's push and the full CI when the train asks (repository variable CI_DEPTH_TIERED)."
|
|
59
|
+
required: false
|
|
60
|
+
default: 'false'
|
|
53
61
|
ci-run-id:
|
|
54
62
|
description: A CI run that handed its verdict over from inside itself, and so still reads as in progress.
|
|
55
63
|
required: false
|
|
@@ -78,6 +86,8 @@ runs:
|
|
|
78
86
|
MAIN_RED_WAIT_HOURS: ${{ inputs.main-red-wait-hours }}
|
|
79
87
|
MAX_REPAIRS: ${{ inputs.max-repairs }}
|
|
80
88
|
DRY_RUN: ${{ inputs.dry-run }}
|
|
89
|
+
MERGE_QUEUE: ${{ inputs.merge-queue }}
|
|
90
|
+
CI_DEPTH_TIERED: ${{ inputs.ci-depth-tiered }}
|
|
81
91
|
HINT_RUN_ID: ${{ inputs.ci-run-id }}
|
|
82
92
|
HINT_CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
83
93
|
run: bash "${GITHUB_ACTION_PATH}/merge-train.sh"
|
|
@@ -70,6 +70,8 @@ train_move() {
|
|
|
70
70
|
success)
|
|
71
71
|
if [ "${BEHIND_BY:-0}" -gt 0 ]; then
|
|
72
72
|
move=update; reason="green but ${BEHIND_BY} commit(s) behind its base branch"
|
|
73
|
+
elif [ "${CI_SHALLOW:-false}" = true ]; then
|
|
74
|
+
move=deepen; reason="green on the light checks only; the full CI has not run on this head"
|
|
73
75
|
else
|
|
74
76
|
move=gate; reason="green and up to date"
|
|
75
77
|
fi
|
|
@@ -199,6 +201,54 @@ act() {
|
|
|
199
201
|
"$@"
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
# Tiered CI: the label that asks a pull request's CI for the full depth. The CI's depth job reads the
|
|
205
|
+
# pull request's labels at run time, so a re-run of the same pull_request run turns full: the event and
|
|
206
|
+
# its context stay the pull request's, which the scope, mutation and coverage jobs rely on (a separate
|
|
207
|
+
# workflow_dispatch run would lose it, and mutation runs on pull_request events only).
|
|
208
|
+
CI_FULL_LABEL="ci-full"
|
|
209
|
+
|
|
210
|
+
mark_full_ci() {
|
|
211
|
+
local pr="$1"
|
|
212
|
+
gh label create "$CI_FULL_LABEL" --repo "$REPO" --color 0E8A16 \
|
|
213
|
+
--description "The CI runs at full depth on this pull request (set by the merge train)" >/dev/null 2>&1 || true
|
|
214
|
+
gh api -X POST "repos/$REPO/issues/$pr/labels" -f "labels[]=$CI_FULL_LABEL" >/dev/null 2>&1
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
deepen_ci() {
|
|
218
|
+
local pr="$1" run="$2"
|
|
219
|
+
[ -n "$run" ] || return 1
|
|
220
|
+
mark_full_ci "$pr" || return 1
|
|
221
|
+
# A CI handover reaches the train from inside the run, and a run still going cannot be re-run.
|
|
222
|
+
local i
|
|
223
|
+
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
224
|
+
[ "$(gh api "repos/$REPO/actions/runs/$run" --jq .status 2>/dev/null)" = completed ] && break
|
|
225
|
+
sleep 10
|
|
226
|
+
done
|
|
227
|
+
gh run rerun "$run" --repo "$REPO" >/dev/null 2>&1
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Where a pull request stands in its base branch's merge queue: QUEUED, AWAITING_CHECKS, LOCKED,
|
|
231
|
+
# UNMERGEABLE or MERGEABLE, or empty when it is not in one.
|
|
232
|
+
queue_state() {
|
|
233
|
+
local pr="$1"
|
|
234
|
+
gh api graphql -f query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){mergeQueueEntry{state}}}}' \
|
|
235
|
+
-f o="${REPO%%/*}" -f n="${REPO#*/}" -F p="$pr" \
|
|
236
|
+
--jq '.data.repository.pullRequest.mergeQueueEntry.state // ""' 2>/dev/null || true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# The failed merge-group CI run behind the queue's last ejection of a pull request since its head
|
|
240
|
+
# was pushed, or nothing. The queue names its branches gh-readonly-queue/<base>/pr-<number>-<sha>.
|
|
241
|
+
queue_ejection_run() {
|
|
242
|
+
local pr="$1" since="$2" removed
|
|
243
|
+
removed=$(gh api --paginate "repos/$REPO/issues/$pr/timeline?per_page=100" \
|
|
244
|
+
--jq '.[] | select(.event == "removed_from_merge_queue") | .created_at' 2>/dev/null | sort | tail -1)
|
|
245
|
+
[ -n "$removed" ] || return 0
|
|
246
|
+
[ -z "$since" ] || [ "$removed" \> "$since" ] || return 0
|
|
247
|
+
gh api "repos/$REPO/actions/runs?event=merge_group&per_page=100" \
|
|
248
|
+
--jq "[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME and .conclusion == \"failure\"
|
|
249
|
+
and (.head_branch | test(\"/pr-$pr-\")))] | sort_by(.created_at) | last | .id // empty" 2>/dev/null || true
|
|
250
|
+
}
|
|
251
|
+
|
|
202
252
|
is_trusted_bot() {
|
|
203
253
|
case " $TRUSTED_BOTS " in *" $1 "*) return 0 ;; esac
|
|
204
254
|
return 1
|
|
@@ -351,6 +401,9 @@ fi
|
|
|
351
401
|
|
|
352
402
|
for line in "${candidates[@]}"; do
|
|
353
403
|
IFS=$'\t' read -r pr author head_sha head_ref base_ref hold <<<"$line"
|
|
404
|
+
# With GitHub's merge queue (MERGE_QUEUE) the queue orders, builds and merges: nothing here waits
|
|
405
|
+
# for the pull request ahead, brings a branch up to date or asks for a deeper CI.
|
|
406
|
+
[ "${MERGE_QUEUE:-false}" = true ] && merge_blocked=false
|
|
354
407
|
|
|
355
408
|
if ! can_ride "$author"; then
|
|
356
409
|
echo "#$pr: opened by $author, who has no write access; not on the train."
|
|
@@ -435,6 +488,33 @@ for line in "${candidates[@]}"; do
|
|
|
435
488
|
success | failure) ci_status=completed; ci_conclusion="$HINT_CONCLUSION" ;;
|
|
436
489
|
esac
|
|
437
490
|
fi
|
|
491
|
+
# Tiered CI (CI_DEPTH_TIERED): a bot's push runs the light checks and the full CI runs once, when the
|
|
492
|
+
# pull request is next in line. Pliny-Bot spent two thirds of its pull request CI on heads that were
|
|
493
|
+
# replaced before they merged. A green run without a successful "Full CI" job was light and says
|
|
494
|
+
# nothing about the tests. When the jobs cannot be read the run is taken as full, as before.
|
|
495
|
+
ci_shallow=false
|
|
496
|
+
if [ "${CI_DEPTH_TIERED:-false}" = true ] && [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ] && [ -n "${ci_run_id:-}" ]; then
|
|
497
|
+
full_jobs=$(gh api "repos/$REPO/actions/runs/$ci_run_id/jobs?per_page=100" \
|
|
498
|
+
--jq '[.jobs[] | select((.name | test("(^|/ )Full CI$")) and .conclusion == "success")] | length' 2>/dev/null || echo "")
|
|
499
|
+
[ "$full_jobs" = 0 ] && ci_shallow=true
|
|
500
|
+
fi
|
|
501
|
+
if [ "${MERGE_QUEUE:-false}" = true ]; then
|
|
502
|
+
# The pull request's own CI is the light one; the full CI runs on the queue's merge group.
|
|
503
|
+
ci_shallow=false
|
|
504
|
+
if [ "$(queue_state "$pr")" != "" ]; then
|
|
505
|
+
echo "#$pr ($author): skip, it is in the merge queue."
|
|
506
|
+
summary "| #$pr | $author | skip | in the merge queue |"
|
|
507
|
+
continue
|
|
508
|
+
fi
|
|
509
|
+
# The queue ejected it after this head was pushed: the merge group's CI failed on it, or it
|
|
510
|
+
# conflicted with what was ahead. That run is the verdict the gate repairs from; the pull
|
|
511
|
+
# request's own green light CI says nothing about it.
|
|
512
|
+
if ejected_run=$(queue_ejection_run "$pr" "${head_committed:-}"); [ -n "$ejected_run" ]; then
|
|
513
|
+
ci_run_id="$ejected_run"
|
|
514
|
+
ci_status=completed
|
|
515
|
+
ci_conclusion=failure
|
|
516
|
+
fi
|
|
517
|
+
fi
|
|
438
518
|
ci_stuck=false
|
|
439
519
|
if [ "$ci_status" != none ] && [ "$ci_status" != completed ] && [ -n "${ci_created:-}" ]; then
|
|
440
520
|
[ $((now_epoch - $(iso_epoch "$ci_created"))) -lt $((STUCK_HOURS * 3600)) ] || ci_stuck=true
|
|
@@ -444,7 +524,7 @@ for line in "${candidates[@]}"; do
|
|
|
444
524
|
behind_by=0
|
|
445
525
|
if [ "$park" = none ] && [ "$hold" != true ] && [ "$review_decision" != CHANGES_REQUESTED ]; then
|
|
446
526
|
mergeable=$(mergeable_state "$pr")
|
|
447
|
-
if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ]; then
|
|
527
|
+
if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ] && [ "${MERGE_QUEUE:-false}" != true ]; then
|
|
448
528
|
behind_by=$(gh api "repos/$REPO/compare/$base_ref...$head_sha" --jq '.behind_by' 2>/dev/null || echo 0)
|
|
449
529
|
fi
|
|
450
530
|
fi
|
|
@@ -503,7 +583,7 @@ for line in "${candidates[@]}"; do
|
|
|
503
583
|
decision=$(HOLD="$hold" REVIEW_DECISION="$review_decision" PARK="$park" \
|
|
504
584
|
ATTEMPTS="$attempts" MAX_ATTEMPTS="$MAX_ATTEMPTS" SILENT_GATE="$silent_gate" MERGEABLE="$mergeable" \
|
|
505
585
|
CI_STATUS="$ci_status" CI_CONCLUSION="$ci_conclusion" CI_STUCK="$ci_stuck" SAME_AS_MAIN="$same_as_main" \
|
|
506
|
-
BEHIND_BY="${behind_by:-0}" TRUSTED_AUTHOR="$trusted_author" train_move)
|
|
586
|
+
BEHIND_BY="${behind_by:-0}" TRUSTED_AUTHOR="$trusted_author" CI_SHALLOW="$ci_shallow" train_move)
|
|
507
587
|
move=$(sed -n 's/^move=//p' <<<"$decision")
|
|
508
588
|
reason=$(sed -n 's/^reason=//p' <<<"$decision")
|
|
509
589
|
|
|
@@ -544,11 +624,30 @@ for line in "${candidates[@]}"; do
|
|
|
544
624
|
merge_blocked=true
|
|
545
625
|
continue
|
|
546
626
|
;;
|
|
627
|
+
deepen)
|
|
628
|
+
if [ "$merge_blocked" = true ]; then
|
|
629
|
+
echo " #$pr waits for the pull request ahead of it before its full CI runs."
|
|
630
|
+
continue
|
|
631
|
+
fi
|
|
632
|
+
if act "Running the full CI on #$pr, which is next in line." deepen_ci "$pr" "${ci_run_id:-}"; then
|
|
633
|
+
merge_blocked=true
|
|
634
|
+
continue
|
|
635
|
+
fi
|
|
636
|
+
# Never a gate on the light run: it could merge code no test has run on. The pull request is
|
|
637
|
+
# skipped this tick and the line moves on, so one that cannot be deepened does not stop the rest.
|
|
638
|
+
echo "::warning::Could not start the full CI on #$pr; it is skipped this tick."
|
|
639
|
+
continue
|
|
640
|
+
;;
|
|
547
641
|
update)
|
|
548
642
|
if [ "$merge_blocked" = true ]; then
|
|
549
643
|
echo " #$pr waits for the pull request ahead of it before it is brought up to date."
|
|
550
644
|
continue
|
|
551
645
|
fi
|
|
646
|
+
# Next in line: with tiered CI the run this update starts is the one that decides, so it runs full.
|
|
647
|
+
if [ "${CI_DEPTH_TIERED:-false}" = true ]; then
|
|
648
|
+
act "Asking for the full CI on #$pr before it is brought up to date." mark_full_ci "$pr" ||
|
|
649
|
+
echo "::warning::Could not label #$pr for the full CI; its update runs the light checks."
|
|
650
|
+
fi
|
|
552
651
|
# Merge, never rebase: the gate's pushes are fast-forward only, and so are a person's.
|
|
553
652
|
if act "Merging $base_ref into #$pr so CI judges it against what it will land on." \
|
|
554
653
|
update_branch "$pr" "$head_sha"; then
|
|
@@ -2863,6 +2863,40 @@ else
|
|
|
2863
2863
|
FAIL=$((FAIL + 1))
|
|
2864
2864
|
echo "FAIL: the gate measures a stack child's comments and tests against the default branch" >&2
|
|
2865
2865
|
fi
|
|
2866
|
+
# Tiered CI: the train deepens a light green run instead of gating it, and never gates a light run.
|
|
2867
|
+
if grep -qF 'move=deepen' "${HERE}/../merge-train/merge-train.sh" &&
|
|
2868
|
+
grep -qF 'deepen_ci "$pr" "${ci_run_id:-}"' "${HERE}/../merge-train/merge-train.sh" &&
|
|
2869
|
+
grep -qF "ci-depth-tiered: \${{ vars.CI_DEPTH_TIERED || 'false' }}" "${WORKFLOWS_DIR}/work-router.yml"; then
|
|
2870
|
+
PASS=$((PASS + 1))
|
|
2871
|
+
else
|
|
2872
|
+
FAIL=$((FAIL + 1))
|
|
2873
|
+
echo "FAIL: the merge train does not ask for the full CI before gating a light run" >&2
|
|
2874
|
+
fi
|
|
2875
|
+
# The implement agent tests only what it changed; the repository's CI runs the full suite.
|
|
2876
|
+
if grep -qF 'Test only what you changed; CI tests the rest.' "${WORKFLOWS_DIR}/agent-implement.md"; then
|
|
2877
|
+
PASS=$((PASS + 1))
|
|
2878
|
+
else
|
|
2879
|
+
FAIL=$((FAIL + 1))
|
|
2880
|
+
echo "FAIL: implement tests only what it changed is missing from the prompt" >&2
|
|
2881
|
+
fi
|
|
2882
|
+
# A conflicting pull request is gated on its carried verdict: CI cannot run on a head with no merge ref.
|
|
2883
|
+
if grep -qF '[ "$conflicting" != CONFLICTING ]' "${HERE}/../identify-gate-subject/action.yml"; then
|
|
2884
|
+
PASS=$((PASS + 1))
|
|
2885
|
+
else
|
|
2886
|
+
FAIL=$((FAIL + 1))
|
|
2887
|
+
echo "FAIL: the gate waits for CI on a conflicting pull request, which never gets a run" >&2
|
|
2888
|
+
fi
|
|
2889
|
+
# Merge queue: the train hands the queue what it can, repairs what the queue ejects, and the gate lands
|
|
2890
|
+
# through the queue when the base branch has one.
|
|
2891
|
+
if grep -qF 'queue_ejection_run' "${HERE}/../merge-train/merge-train.sh" &&
|
|
2892
|
+
grep -qF "merge-queue: \${{ vars.MERGE_QUEUE || 'false' }}" "${WORKFLOWS_DIR}/work-router.yml" &&
|
|
2893
|
+
grep -qF 'enqueuePullRequest' "${HERE}/../land-pull-request/land-pull-request.sh" &&
|
|
2894
|
+
grep -qF 'uses: ./.github/actions/land-pull-request' "${WORKFLOWS_DIR}/agent-merge-gate.md"; then
|
|
2895
|
+
PASS=$((PASS + 1))
|
|
2896
|
+
else
|
|
2897
|
+
FAIL=$((FAIL + 1))
|
|
2898
|
+
echo "FAIL: the merge queue path is incomplete" >&2
|
|
2899
|
+
fi
|
|
2866
2900
|
# An empty AGENT_TIER_MODEL must remove an override a reused runner kept from an earlier job.
|
|
2867
2901
|
if grep -qF 'rm -f .opencode/harness.user.json' "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
|
|
2868
2902
|
PASS=$((PASS + 1))
|
|
@@ -3,6 +3,8 @@ name: "App: CI"
|
|
|
3
3
|
|
|
4
4
|
on:
|
|
5
5
|
pull_request:
|
|
6
|
+
# GitHub's merge queue builds the base plus the pull requests ahead; the full CI runs there.
|
|
7
|
+
merge_group:
|
|
6
8
|
schedule:
|
|
7
9
|
- cron: "0 6 * * 1"
|
|
8
10
|
workflow_dispatch:
|
|
@@ -61,7 +63,7 @@ concurrency:
|
|
|
61
63
|
jobs:
|
|
62
64
|
api:
|
|
63
65
|
name: API (.NET)
|
|
64
|
-
if: github.event_name != 'schedule'
|
|
66
|
+
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
|
|
65
67
|
runs-on: agents-arc
|
|
66
68
|
timeout-minutes: 30
|
|
67
69
|
services:
|
|
@@ -168,7 +170,7 @@ jobs:
|
|
|
168
170
|
|
|
169
171
|
web:
|
|
170
172
|
name: Web (Next.js)
|
|
171
|
-
if: github.event_name != 'schedule'
|
|
173
|
+
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
|
|
172
174
|
runs-on: agents-arc
|
|
173
175
|
timeout-minutes: 15
|
|
174
176
|
steps:
|
|
@@ -280,6 +282,23 @@ jobs:
|
|
|
280
282
|
# needs contents: write this workflow does not have, and fails the run that gates deploy.
|
|
281
283
|
upload-release-assets: false
|
|
282
284
|
|
|
285
|
+
# The merge queue requires this check. On a pull request with MERGE_QUEUE on, the heavy jobs are
|
|
286
|
+
# skipped and the full CI runs on the merge group instead; skipped counts as passed.
|
|
287
|
+
full-ci:
|
|
288
|
+
name: Full CI
|
|
289
|
+
needs: [api, web]
|
|
290
|
+
if: always()
|
|
291
|
+
runs-on: agents-arc
|
|
292
|
+
timeout-minutes: 5
|
|
293
|
+
steps:
|
|
294
|
+
- name: Every heavy job passed
|
|
295
|
+
env:
|
|
296
|
+
RESULTS: ${{ join(needs.*.result, ' ') }}
|
|
297
|
+
run: |
|
|
298
|
+
for result in $RESULTS; do
|
|
299
|
+
case "$result" in failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;; esac
|
|
300
|
+
done
|
|
301
|
+
|
|
283
302
|
# Hands this run's verdict to the router's merge train, which decides whether this pull request
|
|
284
303
|
# is the one in front and what it needs. A pull request opened or updated with a bot token gets
|
|
285
304
|
# no workflow_run event for its CI (the run's actor is the bot), and the train brings people's
|
|
@@ -3,6 +3,8 @@ name: "App: CI (Node monorepo)"
|
|
|
3
3
|
|
|
4
4
|
on:
|
|
5
5
|
pull_request:
|
|
6
|
+
# GitHub's merge queue builds the base plus the pull requests ahead; the full CI runs there.
|
|
7
|
+
merge_group:
|
|
6
8
|
schedule:
|
|
7
9
|
- cron: "0 6 * * 1"
|
|
8
10
|
workflow_dispatch:
|
|
@@ -50,7 +52,7 @@ jobs:
|
|
|
50
52
|
|
|
51
53
|
web:
|
|
52
54
|
name: Web (Next.js static export)
|
|
53
|
-
if: github.event_name != 'schedule'
|
|
55
|
+
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
|
|
54
56
|
runs-on: agents-arc
|
|
55
57
|
steps:
|
|
56
58
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -101,7 +103,7 @@ jobs:
|
|
|
101
103
|
|
|
102
104
|
e2e:
|
|
103
105
|
name: E2E (Playwright)
|
|
104
|
-
if: github.event_name != 'schedule'
|
|
106
|
+
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
|
|
105
107
|
needs: web
|
|
106
108
|
runs-on: agents-arc
|
|
107
109
|
steps:
|
|
@@ -126,7 +128,7 @@ jobs:
|
|
|
126
128
|
|
|
127
129
|
desktop:
|
|
128
130
|
name: Desktop (Electron)
|
|
129
|
-
if: github.event_name != 'schedule'
|
|
131
|
+
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
|
|
130
132
|
needs: web
|
|
131
133
|
runs-on: agents-arc
|
|
132
134
|
steps:
|
|
@@ -151,7 +153,7 @@ jobs:
|
|
|
151
153
|
|
|
152
154
|
mobile:
|
|
153
155
|
name: Mobile (Capacitor)
|
|
154
|
-
if: github.event_name != 'schedule'
|
|
156
|
+
if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || vars.MERGE_QUEUE != 'true')
|
|
155
157
|
runs-on: agents-arc
|
|
156
158
|
steps:
|
|
157
159
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -210,6 +212,23 @@ jobs:
|
|
|
210
212
|
# needs contents: write this workflow does not have, and fails the run that gates deploy.
|
|
211
213
|
upload-release-assets: false
|
|
212
214
|
|
|
215
|
+
# The merge queue requires this check. On a pull request with MERGE_QUEUE on, the heavy jobs are
|
|
216
|
+
# skipped and the full CI runs on the merge group instead; skipped counts as passed.
|
|
217
|
+
full-ci:
|
|
218
|
+
name: Full CI
|
|
219
|
+
needs: [web, e2e, desktop, mobile]
|
|
220
|
+
if: always()
|
|
221
|
+
runs-on: agents-arc
|
|
222
|
+
timeout-minutes: 5
|
|
223
|
+
steps:
|
|
224
|
+
- name: Every heavy job passed
|
|
225
|
+
env:
|
|
226
|
+
RESULTS: ${{ join(needs.*.result, ' ') }}
|
|
227
|
+
run: |
|
|
228
|
+
for result in $RESULTS; do
|
|
229
|
+
case "$result" in failure | cancelled | timed_out) echo "A heavy job ended $result."; exit 1 ;; esac
|
|
230
|
+
done
|
|
231
|
+
|
|
213
232
|
# Hands this run's verdict to the router's merge train, which decides whether this pull request
|
|
214
233
|
# is the one in front and what it needs. A pull request opened or updated with a bot token gets
|
|
215
234
|
# no workflow_run event for its CI (the run's actor is the bot), and the train brings people's
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/templates/release/github-release.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
name: Publish GitHub release
|
|
3
|
-
|
|
4
|
-
on:
|
|
5
|
-
push:
|
|
6
|
-
tags:
|
|
7
|
-
- "v*"
|
|
8
|
-
|
|
9
|
-
permissions:
|
|
10
|
-
contents: write
|
|
11
|
-
|
|
12
|
-
jobs:
|
|
13
|
-
publish:
|
|
14
|
-
name: Publish release
|
|
15
|
-
runs-on:
|
|
16
|
-
timeout-minutes: 10
|
|
17
|
-
steps:
|
|
18
|
-
- name: Create or update GitHub release
|
|
19
|
-
env:
|
|
20
|
-
GH_TOKEN: ${{ github.token }}
|
|
21
|
-
TAG: ${{ github.ref_name }}
|
|
22
|
-
shell: bash
|
|
23
|
-
run: |
|
|
24
|
-
set -euo pipefail
|
|
25
|
-
|
|
26
|
-
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
27
|
-
gh release edit "$TAG" --title "$TAG" --generate-notes
|
|
28
|
-
else
|
|
29
|
-
gh release create "$TAG" --title "$TAG" --generate-notes
|
|
30
|
-
fi
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/templates/release/github-release.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
name: Publish GitHub release
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
name: Publish release
|
|
15
|
+
runs-on: agents-arc
|
|
16
|
+
timeout-minutes: 10
|
|
17
|
+
steps:
|
|
18
|
+
- name: Create or update GitHub release
|
|
19
|
+
env:
|
|
20
|
+
GH_TOKEN: ${{ github.token }}
|
|
21
|
+
TAG: ${{ github.ref_name }}
|
|
22
|
+
shell: bash
|
|
23
|
+
run: |
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
27
|
+
gh release edit "$TAG" --title "$TAG" --generate-notes
|
|
28
|
+
else
|
|
29
|
+
gh release create "$TAG" --title "$TAG" --generate-notes
|
|
30
|
+
fi
|
|
@@ -839,6 +839,16 @@ timeout-minutes: 180
|
|
|
839
839
|
|
|
840
840
|
Never open a pull request that does not pass them.
|
|
841
841
|
|
|
842
|
+
**Test only what you changed; CI tests the rest.** In this run, run the tests that cover the
|
|
843
|
+
code you touched and nothing wider: the test files you added or edited, and the tests next to
|
|
844
|
+
the code you changed (web: `pnpm exec vitest related --run <changed files>`; .NET:
|
|
845
|
+
`dotnet test <the test project> --filter <the test classes>`). Do not run integration,
|
|
846
|
+
end-to-end, mutation or coverage suites here, and do not run the whole test suite to be safe:
|
|
847
|
+
the repository's CI runs all of it on your pull request before anything merges, on a runner
|
|
848
|
+
that has the database and the browsers this sandbox lacks. The verification commands above are
|
|
849
|
+
the repository's quick check for that reason. A full suite in the agent is the slowest part of
|
|
850
|
+
a run and repeats what CI does anyway.
|
|
851
|
+
|
|
842
852
|
**Run them once, on the final tree.** They are slow: Pliny-Bot's `pnpm verify` is about
|
|
843
853
|
twenty minutes of work on this runner. `pc-plan-apply` runs them in its verify step, and
|
|
844
854
|
that run is this one: do not run them again when nothing changed since. Order the end of
|
|
@@ -610,16 +610,24 @@ jobs:
|
|
|
610
610
|
|
|
611
611
|
${{ needs.subject.outputs.issue != '' && format('Findings and verification on the linked issue: #{0}.', needs.subject.outputs.issue) || 'Findings and verification are in the review comment above.' }} [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
612
612
|
${{ env.EVIDENCE_BEFORE_REVIEW == 'true' && contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome) && 'Screenshots of this head follow in their own comment if it carries a verification plan. Approving the pull request merges it.' || '' }}
|
|
613
|
-
- name:
|
|
613
|
+
- name: Read the head to land
|
|
614
|
+
id: landing_head
|
|
614
615
|
if: needs.validate_output.outputs.outcome == 'auto-merge' && (needs.subject.outputs.stack_root != 'true' || needs.subject.outputs.root_complete == 'true')
|
|
615
616
|
env:
|
|
616
617
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
617
618
|
REPO: ${{ github.repository }}
|
|
618
619
|
PR: ${{ needs.subject.outputs.pr }}
|
|
619
|
-
run:
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
620
|
+
run: echo "sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid')" >> "$GITHUB_OUTPUT"
|
|
621
|
+
# Into the base branch's merge queue when it has one (approved first, so a ruleset's required
|
|
622
|
+
# approval is the gate's), otherwise merged as before.
|
|
623
|
+
- name: Merge approved pull request
|
|
624
|
+
if: needs.validate_output.outputs.outcome == 'auto-merge' && (needs.subject.outputs.stack_root != 'true' || needs.subject.outputs.root_complete == 'true')
|
|
625
|
+
uses: ./.github/actions/land-pull-request
|
|
626
|
+
with:
|
|
627
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
628
|
+
pr: ${{ needs.subject.outputs.pr }}
|
|
629
|
+
head-sha: ${{ steps.landing_head.outputs.sha }}
|
|
630
|
+
approve: 'true'
|
|
623
631
|
# The root's own gate run stops here: children may still be landing on the feature
|
|
624
632
|
# branch, and the merge decision for main belongs to a person. The comment says only
|
|
625
633
|
# what a reader needs: the gate held the root, children merge into the feature branch,
|
|
@@ -29,7 +29,7 @@ jobs:
|
|
|
29
29
|
name: Tag and publish the release
|
|
30
30
|
if: >-
|
|
31
31
|
startsWith(github.event.head_commit.message, 'chore(release): v')
|
|
32
|
-
runs-on:
|
|
32
|
+
runs-on: agents-arc
|
|
33
33
|
timeout-minutes: 10
|
|
34
34
|
steps:
|
|
35
35
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -862,7 +862,7 @@ jobs:
|
|
|
862
862
|
# gate. Those run in a group of their own per pull request, in parallel, while everything that
|
|
863
863
|
# may merge still waits its turn on the one belt.
|
|
864
864
|
concurrency:
|
|
865
|
-
group: ${{ needs.classify.outputs.ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number) || 'merge-belt' }}
|
|
865
|
+
group: ${{ needs.classify.outputs.ci-conclusion == 'failure' && format('merge-repair-{0}', needs.classify.outputs.pr-number) || (vars.MERGE_QUEUE == 'true' && format('merge-gate-{0}', needs.classify.outputs.pr-number) || 'merge-belt') }}
|
|
866
866
|
cancel-in-progress: false
|
|
867
867
|
queue: max
|
|
868
868
|
permissions: write-all
|
|
@@ -938,6 +938,9 @@ jobs:
|
|
|
938
938
|
dry-run: ${{ env.MERGE_TRAIN_DRY_RUN }}
|
|
939
939
|
ci-run-id: ${{ needs.classify.outputs.ci-run-id }}
|
|
940
940
|
ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
|
|
941
|
+
# Repository variable, read by the repository's CI as well: one switch for both sides.
|
|
942
|
+
ci-depth-tiered: ${{ vars.CI_DEPTH_TIERED || 'false' }}
|
|
943
|
+
merge-queue: ${{ vars.MERGE_QUEUE || 'false' }}
|
|
941
944
|
|
|
942
945
|
# The decision the gate parked for: a person with write access approves a pull request the
|
|
943
946
|
# gate handed to them (human-review or owner-review), and the bot merges the commit they
|
|
@@ -1012,8 +1015,9 @@ jobs:
|
|
|
1012
1015
|
--jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME)] | sort_by(.created_at) | last | .conclusion // ""')
|
|
1013
1016
|
[ "$ci" = success ] || skip "$CI_WORKFLOW_NAME on ${head_sha:0:7} is '${ci:-not run}'"
|
|
1014
1017
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1018
|
+
# Into the merge queue where the base branch has one, merged otherwise.
|
|
1019
|
+
landed=$(HEAD_SHA="$head_sha" GITHUB_OUTPUT=/dev/stdout bash ./.github/actions/land-pull-request/land-pull-request.sh | sed -n 's/^landed=//p' | tail -1)
|
|
1020
|
+
if [ -n "$landed" ]; then echo "merged=true" >> "$GITHUB_OUTPUT"; fi
|
|
1017
1021
|
- name: Clear the issue's labels
|
|
1018
1022
|
if: steps.merge.outputs.merged == 'true' && needs.classify.outputs.linked-issue != ''
|
|
1019
1023
|
env:
|