@plainconceptsplatform/workflows 1.6.48 → 1.7.1
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 +4 -0
- package/loops/actions/land-pull-request/action.yml +43 -0
- package/loops/actions/land-pull-request/land-pull-request.sh +96 -0
- package/loops/actions/merge-train/action.yml +5 -0
- package/loops/actions/merge-train/merge-train.sh +43 -1
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +11 -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-merge-gate.md +13 -5
- package/loops/workflows/release-tag.yml +1 -1
- package/loops/workflows/work-router.yml +5 -3
- package/package.json +1 -1
|
@@ -148,6 +148,10 @@ runs:
|
|
|
148
148
|
# the carried verdict and resolves the conflict. Pliny-Bot #862 was handed to the gate and
|
|
149
149
|
# declined every five minutes for an hour waiting for a run that could not start.
|
|
150
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"
|
|
151
155
|
if [ -n "$run_sha" ] && [ "$run_sha" != "$current_sha" ] && [ "$conflicting" != CONFLICTING ]; then
|
|
152
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."
|
|
153
157
|
ci_conclusion=""
|
|
@@ -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,96 @@
|
|
|
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
|
+
# A stack enters the queue only through merge-async, which refuses a GitHub App there. The App lands
|
|
67
|
+
# it directly instead, through the ruleset's bypass list (it must be on it): every layer has passed its
|
|
68
|
+
# own checks and the gate, and the whole stack lands as one operation.
|
|
69
|
+
answer=$(gh api -X PUT "repos/$REPO/pulls/$PR/merge-async" -f merge_action=direct_merge -f merge_method=squash \
|
|
70
|
+
-F bypass_rules=true -f sha="$HEAD_SHA")
|
|
71
|
+
status=$(jq -r .status <<<"$answer")
|
|
72
|
+
uuid=$(jq -r '.details.uuid // ""' <<<"$answer")
|
|
73
|
+
for _ in $(seq 1 12); do
|
|
74
|
+
case "$status" in enqueued | merged) break ;; failed) break ;; esac
|
|
75
|
+
[ -n "$uuid" ] || break
|
|
76
|
+
sleep 5
|
|
77
|
+
answer=$(gh api "repos/$REPO/pulls/$PR/merge-async/$uuid" 2>/dev/null || echo '{}')
|
|
78
|
+
status=$(jq -r '.status // "pending"' <<<"$answer")
|
|
79
|
+
done
|
|
80
|
+
|
|
81
|
+
case "$status" in
|
|
82
|
+
enqueued)
|
|
83
|
+
echo "#$PR is in the $base merge queue; GitHub merges it once its group passes."
|
|
84
|
+
out enqueued
|
|
85
|
+
;;
|
|
86
|
+
merged)
|
|
87
|
+
echo "#$PR merged into $base."
|
|
88
|
+
out merged
|
|
89
|
+
;;
|
|
90
|
+
*)
|
|
91
|
+
message=$(jq -r '.details.message // ""' <<<"$answer")
|
|
92
|
+
echo "::error::Stack #$PR was not merged (status '${status}'${message:+: $message}). The App lands a stack by bypassing the base branch's ruleset, so it must be on that ruleset's bypass list."
|
|
93
|
+
out ""
|
|
94
|
+
exit 1
|
|
95
|
+
;;
|
|
96
|
+
esac
|
|
@@ -50,6 +50,10 @@ 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'
|
|
53
57
|
ci-depth-tiered:
|
|
54
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)."
|
|
55
59
|
required: false
|
|
@@ -82,6 +86,7 @@ runs:
|
|
|
82
86
|
MAIN_RED_WAIT_HOURS: ${{ inputs.main-red-wait-hours }}
|
|
83
87
|
MAX_REPAIRS: ${{ inputs.max-repairs }}
|
|
84
88
|
DRY_RUN: ${{ inputs.dry-run }}
|
|
89
|
+
MERGE_QUEUE: ${{ inputs.merge-queue }}
|
|
85
90
|
CI_DEPTH_TIERED: ${{ inputs.ci-depth-tiered }}
|
|
86
91
|
HINT_RUN_ID: ${{ inputs.ci-run-id }}
|
|
87
92
|
HINT_CONCLUSION: ${{ inputs.ci-conclusion }}
|
|
@@ -227,6 +227,28 @@ deepen_ci() {
|
|
|
227
227
|
gh run rerun "$run" --repo "$REPO" >/dev/null 2>&1
|
|
228
228
|
}
|
|
229
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
|
+
|
|
230
252
|
is_trusted_bot() {
|
|
231
253
|
case " $TRUSTED_BOTS " in *" $1 "*) return 0 ;; esac
|
|
232
254
|
return 1
|
|
@@ -379,6 +401,9 @@ fi
|
|
|
379
401
|
|
|
380
402
|
for line in "${candidates[@]}"; do
|
|
381
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
|
|
382
407
|
|
|
383
408
|
if ! can_ride "$author"; then
|
|
384
409
|
echo "#$pr: opened by $author, who has no write access; not on the train."
|
|
@@ -473,6 +498,23 @@ for line in "${candidates[@]}"; do
|
|
|
473
498
|
--jq '[.jobs[] | select((.name | test("(^|/ )Full CI$")) and .conclusion == "success")] | length' 2>/dev/null || echo "")
|
|
474
499
|
[ "$full_jobs" = 0 ] && ci_shallow=true
|
|
475
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
|
|
476
518
|
ci_stuck=false
|
|
477
519
|
if [ "$ci_status" != none ] && [ "$ci_status" != completed ] && [ -n "${ci_created:-}" ]; then
|
|
478
520
|
[ $((now_epoch - $(iso_epoch "$ci_created"))) -lt $((STUCK_HOURS * 3600)) ] || ci_stuck=true
|
|
@@ -482,7 +524,7 @@ for line in "${candidates[@]}"; do
|
|
|
482
524
|
behind_by=0
|
|
483
525
|
if [ "$park" = none ] && [ "$hold" != true ] && [ "$review_decision" != CHANGES_REQUESTED ]; then
|
|
484
526
|
mergeable=$(mergeable_state "$pr")
|
|
485
|
-
if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ]; then
|
|
527
|
+
if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ] && [ "${MERGE_QUEUE:-false}" != true ]; then
|
|
486
528
|
behind_by=$(gh api "repos/$REPO/compare/$base_ref...$head_sha" --jq '.behind_by' 2>/dev/null || echo 0)
|
|
487
529
|
fi
|
|
488
530
|
fi
|
|
@@ -2886,6 +2886,17 @@ else
|
|
|
2886
2886
|
FAIL=$((FAIL + 1))
|
|
2887
2887
|
echo "FAIL: the gate waits for CI on a conflicting pull request, which never gets a run" >&2
|
|
2888
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
|
|
2889
2900
|
# An empty AGENT_TIER_MODEL must remove an override a reused runner kept from an earlier job.
|
|
2890
2901
|
if grep -qF 'rm -f .opencode/harness.user.json' "${WORKFLOWS_DIR}/shared/opencode-ci.md"; then
|
|
2891
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
|
|
@@ -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
|
|
@@ -940,6 +940,7 @@ jobs:
|
|
|
940
940
|
ci-conclusion: ${{ needs.classify.outputs.ci-conclusion }}
|
|
941
941
|
# Repository variable, read by the repository's CI as well: one switch for both sides.
|
|
942
942
|
ci-depth-tiered: ${{ vars.CI_DEPTH_TIERED || 'false' }}
|
|
943
|
+
merge-queue: ${{ vars.MERGE_QUEUE || 'false' }}
|
|
943
944
|
|
|
944
945
|
# The decision the gate parked for: a person with write access approves a pull request the
|
|
945
946
|
# gate handed to them (human-review or owner-review), and the bot merges the commit they
|
|
@@ -1014,8 +1015,9 @@ jobs:
|
|
|
1014
1015
|
--jq '[.workflow_runs[] | select(.name == env.CI_WORKFLOW_NAME)] | sort_by(.created_at) | last | .conclusion // ""')
|
|
1015
1016
|
[ "$ci" = success ] || skip "$CI_WORKFLOW_NAME on ${head_sha:0:7} is '${ci:-not run}'"
|
|
1016
1017
|
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
|
1019
1021
|
- name: Clear the issue's labels
|
|
1020
1022
|
if: steps.merge.outputs.merged == 'true' && needs.classify.outputs.linked-issue != ''
|
|
1021
1023
|
env:
|