@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.
@@ -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
- if [ -n "$run_sha" ] && [ "$run_sha" != "$current_sha" ]; then
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: ubuntu-latest
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: Merge approved pull request
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
- set -euo pipefail
621
- head_sha=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid')
622
- gh pr merge "$PR" --repo "$REPO" --squash --match-head-commit "$head_sha"
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: ubuntu-latest
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
- gh pr merge "$PR" --repo "$REPO" --squash --match-head-commit "$head_sha"
1016
- echo "merged=true" >> "$GITHUB_OUTPUT"
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.6.47",
3
+ "version": "1.7.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",