@@ -7,27 +7,48 @@ name: Fast-forward Queue
77# those messages to build one CHANGELOG entry per change, and a squash would
88# collapse a whole drain into one. See ADR-0008 in kirchDev/infrastructure.
99#
10- # MANUAL DISPATCH IS THE HUMAN GATE, and that is a fallback, not the design.
11- # ADR-0008 gates the landing on a `fast-forward-queue` environment with a
12- # required reviewer. No repo in this estate has one: GitHub sells required
13- # reviewers only for PUBLIC repos on Free, Pro and Team, so on a private repo
14- # the resource cannot be created at all (the apply of 2026-08-03 failed with
15- # `422 … ensure the billing plan supports the required reviewers protection
16- # rule`), and rather than gate half the estate and leave the other half open,
17- # the module now creates none anywhere. So the trigger IS the approval: whoever
18- # dispatches this workflow has decided the queue branch may land.
10+ # THE HUMAN GATE IS AN APPROVAL ON THE QUEUE PR, and the App is what makes that
11+ # possible. GitHub disables the approve button for a PR's own AUTHOR, so a queue
12+ # PR opened by the sole maintainer could never be approved by them — which is
13+ # why ADR-0008 reached for a `fast-forward-queue` environment instead, and why
14+ # its amendment records that GitHub does not sell required reviewers for a
15+ # private repo on Team. Here the App opens the PR, so the maintainer is not its
16+ # author and can approve it; `pick` then refuses to land anything without that
17+ # approval. See ADR-0010 in kirchDev/infrastructure.
1918#
20- # check_suite and pull_request:ready_for_review were the automatic triggers and
21- # are deliberately gone. With no environment to wait on they would land a green
22- # queue branch with no human involved at all — the one thing ADR-0008 exists to
23- # prevent. Getting an enforced gate back is INF-60: the App opens the queue PR
24- # itself, which makes a real PR approval possible.
19+ # THE APPROVAL IS CHECKED HERE, NOT BY A RULESET. Two reasons, both structural:
20+ # the App bypasses the merge-gate ruleset, so a `required_approving_review_count`
21+ # there would be skipped along with everything else in it; and the ruleset sets
22+ # that count to 0 anyway, which makes GitHub's own `reviewDecision` null rather
23+ # than APPROVED however many approvals a PR carries. The rules the platform
24+ # would have applied — an approver with write access, an approval that is not
25+ # stale — are therefore spelled out in `pick` below.
2526on :
27+ # A queue branch was pushed. The App opens its PR if none is open yet; nothing
28+ # lands on this trigger, because a fresh push has no CI behind it and no
29+ # approval that could still be current. `push` runs the workflow file from the
30+ # pushed branch, which is cut from the integration branch and so carries it.
31+ push :
32+ branches :
33+ - ' ai/queue-**'
34+ # An approval arrived. This is the ONE automatic landing trigger, and it fires
35+ # only from the DEFAULT branch's copy of this file, so it stays inert until the
36+ # rollup lands this on `main`; until then, `workflow_dispatch` is the way.
37+ #
38+ # THE APPROVAL MUST COME LAST — that is a condition, not a style preference.
39+ # An approval is only counted while its `commit_id` is still the head, so any
40+ # push after it un-approves the PR; and nothing fires when CI goes green on an
41+ # already-approved PR. `check_suite: completed` was meant to cover exactly that
42+ # case and cannot: GitHub does not fire it "if the check suite was created by
43+ # GitHub Actions", which is every suite this repo produces. Approve after CI is
44+ # green, or land it with `workflow_dispatch`. See the ADR-0010 amendment in kirchDev/infrastructure.
45+ pull_request_review :
46+ types : [submitted]
2647 workflow_dispatch :
2748 inputs :
2849 pr :
29- description : ' Queue PR number to fast-forward the integration branch onto '
30- required : true
50+ description : ' Queue PR number to land; empty considers every open queue PR '
51+ required : false
3152 type : string
3253
3354# One fast-forward at a time. Two concurrent runs would race on the same ref and
@@ -37,20 +58,110 @@ concurrency:
3758 cancel-in-progress : false
3859
3960env :
40- # The repo's integration branch — first entry of its branch_flow in the SSOT
41- # (tofu/data/github-{orgs,users}/*.yml). Change it here if that flow changes.
61+ # The repo's integration branch — first entry of its branch_flow.
4262 INTEGRATION_BRANCH : dev
4363 # Only a head branch with this prefix may be fast-forwarded. A ruleset can
4464 # condition on the TARGET ref only, so this check is the head-branch half of
45- # the App bypass and lives here on purpose (ADR-0008).
65+ # the App bypass and lives here on purpose (ADR-0008 in kirchDev/infrastructure ).
4666 QUEUE_BRANCH_PREFIX : ai/queue-
4767
4868jobs :
49- # Kept as its own job even though only a dispatch reaches it now: it is what
50- # re-checks the dispatched number against every condition below, so a wrong PR
51- # number ends the run green instead of landing something.
69+ # The App authors the queue PR. That is the whole point of this job: an
70+ # approval is only possible on a PR the approver did not open, and the drain
71+ # runs under the maintainer's identity.
72+ #
73+ # `github.event.deleted` guards the push that DELETES a queue branch — the ref
74+ # is gone by then and there is nothing to open a PR from.
75+ open :
76+ name : Open the queue PR
77+ if : github.event_name == 'push' && github.event.deleted == false
78+ runs-on : ubuntu-latest
79+ permissions :
80+ contents : read
81+ pull-requests : read
82+ steps :
83+ # Checked with the workflow token, so the App token is minted only when a
84+ # PR actually has to be created — every later push to the same queue
85+ # branch already has one and stops here.
86+ - name : Is a queue PR already open?
87+ id : existing
88+ env :
89+ GH_TOKEN : ${{ github.token }}
90+ GH_REPO : ${{ github.repository }}
91+ BRANCH : ${{ github.ref_name }}
92+ run : |
93+ set -euo pipefail
94+ existing=$(gh pr list --state open --base "$INTEGRATION_BRANCH" --head "$BRANCH" \
95+ --json number --jq '.[0].number // empty')
96+ if [ -n "$existing" ]; then
97+ echo "PR #$existing is already open for $BRANCH — the push updated it."
98+ echo "create=false" >> "$GITHUB_OUTPUT"
99+ else
100+ echo "No open PR for $BRANCH -> $INTEGRATION_BRANCH."
101+ echo "create=true" >> "$GITHUB_OUTPUT"
102+ fi
103+
104+ # Same runtime-from-Bitwarden pattern as the landing job below; the PEM is
105+ # never a GitHub secret.
106+ - name : Fetch Queue App PEM from Bitwarden
107+ if : steps.existing.outputs.create == 'true'
108+ uses : bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793 # v3.0.1
109+ with :
110+ access_token : ${{ secrets.BWS_ACCESS_TOKEN }}
111+ cloud_region : eu
112+ secrets : |
113+ 7e77b6cb-0661-40f6-a10e-b49b014f6985 > KIRCHDEV_QUEUE_APP_PEM
114+
115+ - name : Mint Queue App token
116+ id : app-token
117+ if : steps.existing.outputs.create == 'true'
118+ uses : actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
119+ with :
120+ app-id : ' 4473889'
121+ private-key : ${{ env.KIRCHDEV_QUEUE_APP_PEM }}
122+
123+ # Opened READY, not draft: ci.yml skips draft PRs, and `pick` skips them
124+ # too, so a draft queue PR would be a PR nothing ever runs on and nothing
125+ # could ever land. The title is cosmetic — a fast-forward never turns it
126+ # into a commit message — but it follows the repo's convention anyway.
127+ - name : Open the queue PR as the App
128+ if : steps.existing.outputs.create == 'true'
129+ env :
130+ GH_TOKEN : ${{ steps.app-token.outputs.token }}
131+ GH_REPO : ${{ github.repository }}
132+ BRANCH : ${{ github.ref_name }}
133+ run : |
134+ set -euo pipefail
135+
136+ # A quoted heredoc: the body is copied through verbatim, so its
137+ # backticks and asterisks need no shell escaping.
138+ body=$(cat <<'EOF'
139+ Queue branch for an AI drain. Every worker PR targets this branch, so CI
140+ runs once here instead of once per issue.
141+
142+ **Approving this PR is what lands it.** Once every check is green and this
143+ PR carries an approval on its **current head**, `fast-forward-queue.yml`
144+ fast-forwards the integration branch onto it — each commit arriving
145+ individually, with its message intact, so release-please still sees one
146+ entry per change. Nothing lands without that approval, green CI included.
147+
148+ A push after the approval invalidates it: the approval is checked against
149+ the head commit it was given on, because nothing else dismisses it here.
150+ EOF
151+ )
152+
153+ gh pr create \
154+ --base "$INTEGRATION_BRANCH" \
155+ --head "$BRANCH" \
156+ --title "chore(queue): land $BRANCH on $INTEGRATION_BRANCH" \
157+ --body "$body"
158+
159+ # Re-checks every candidate against every condition below, so a wrong dispatch
160+ # input, an unapproved PR or a red one ends the run green instead of landing
161+ # something.
52162 pick :
53163 name : Pick the queue PR
164+ if : github.event_name != 'push'
54165 runs-on : ubuntu-latest
55166 permissions :
56167 contents : read
@@ -64,9 +175,10 @@ jobs:
64175 pr : ${{ steps.pick.outputs.pr }}
65176 sha : ${{ steps.pick.outputs.sha }}
66177 steps :
67- # The dispatch names exactly one PR. Anything that is not an open, ready,
68- # green queue PR into the integration branch leaves `pr` empty and the job
69- # ends green — a mistyped number must not land anything.
178+ # A dispatch may name exactly one PR; every other trigger (and a dispatch
179+ # with an empty input) considers every open queue PR. Anything that is not
180+ # an open, ready, green, APPROVED queue PR into the integration branch
181+ # leaves `pr` empty and the job ends green.
70182 - name : Pick the queue PR
71183 id : pick
72184 env :
@@ -76,7 +188,20 @@ jobs:
76188 run : |
77189 set -euo pipefail
78190
79- for n in $INPUT_PR; do
191+ if [ -n "${INPUT_PR:-}" ]; then
192+ candidates=$INPUT_PR
193+ else
194+ candidates=$(gh pr list --state open --base "$INTEGRATION_BRANCH" \
195+ --json number,headRefName \
196+ --jq '.[] | select(.headRefName | startswith(env.QUEUE_BRANCH_PREFIX)) | .number')
197+ fi
198+
199+ if [ -z "$candidates" ]; then
200+ echo "No open queue PR into $INTEGRATION_BRANCH — nothing to do."
201+ exit 0
202+ fi
203+
204+ for n in $candidates; do
80205 pr=$(gh pr view "$n" --json number,state,isDraft,baseRefName,headRefName,headRefOid,isCrossRepository)
81206 state=$(printf '%s' "$pr" | jq -r '.state')
82207 draft=$(printf '%s' "$pr" | jq -r '.isDraft')
@@ -117,14 +242,76 @@ jobs:
117242 continue
118243 fi
119244
245+ # THE HUMAN GATE. `reviewDecision` is deliberately not used: the
246+ # merge-gate ruleset sets required_approving_review_count to 0, and
247+ # GitHub reports reviewDecision as null wherever no review is
248+ # required — however many approvals the PR actually carries. So the
249+ # three things the platform would have checked are checked here:
250+ # the latest review per author, the approval's freshness, and the
251+ # approver's write access.
252+ #
253+ # --paginate emits one array per page, so `.[] | …` streams the
254+ # reviews and `jq -s` collects them back into a single array.
255+ reviews=$(gh api --paginate "repos/$GH_REPO/pulls/$n/reviews" \
256+ --jq '.[] | {login: .user.login, state: .state, commit: .commit_id}' | jq -s '.')
257+
258+ # COMMENTED and DISMISSED reviews are dropped BEFORE the per-author
259+ # reduction: a later comment must not clear an approval, and a
260+ # dismissed review must not count as one. group_by is stable, so
261+ # `last` is each author's most recent verdict.
262+ latest=$(printf '%s' "$reviews" \
263+ | jq -c '[.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")]
264+ | group_by(.login) | map(last)')
265+
266+ if [ "$(printf '%s' "$latest" | jq -r '[.[] | select(.state == "CHANGES_REQUESTED")] | length')" != "0" ]; then
267+ echo "PR #$n: changes are requested — skipping."
268+ continue
269+ fi
270+
271+ # An approval names the commit it was given on. Nothing dismisses a
272+ # stale one here — dismiss_stale_reviews_on_push is false, and the
273+ # App bypasses the ruleset that carries it — so the workflow does it
274+ # itself: approve, then push, and the PR is unapproved again.
275+ approvers=$(printf '%s' "$latest" \
276+ | jq -r --arg sha "$sha" '.[] | select(.state == "APPROVED" and .commit == $sha) | .login')
277+
278+ if [ -z "$approvers" ]; then
279+ echo "PR #$n: no approval on the current head $sha — skipping."
280+ continue
281+ fi
282+
283+ # An approval only gates anything if the approver could have landed
284+ # this themselves. `authorAssociation` on the review is too coarse
285+ # to lean on — COLLABORATOR covers read- and triage-only access.
286+ approved=false
287+ for login in $approvers; do
288+ perm=$(gh api "repos/$GH_REPO/collaborators/$login/permission" --jq '.permission') || perm=
289+ case "$perm" in
290+ admin | maintain | write)
291+ echo "PR #$n: approved on $sha by $login ($perm)."
292+ approved=true
293+ break
294+ ;;
295+ *)
296+ echo "PR #$n: $login approved but has permission '${perm:-unreadable}' — not counted."
297+ ;;
298+ esac
299+ done
300+
301+ if [ "$approved" != "true" ]; then
302+ echo "PR #$n: no approval from anyone with write access — skipping."
303+ continue
304+ fi
305+
120306 echo "pr=$n" >> "$GITHUB_OUTPUT"
121307 echo "sha=$sha" >> "$GITHUB_OUTPUT"
122- echo "PR #$n: green queue PR $head -> $base at $sha."
308+ echo "PR #$n: green, approved queue PR $head -> $base at $sha."
123309 break
124310 done
125311
126- # No `environment:` — see the header. The gate is the dispatch that started
127- # this run, so this job is reachable only by someone who can dispatch it.
312+ # No `environment:` — see the header. The gate is the approval `pick` verified,
313+ # and it is pinned to a SHA: this job fast-forwards onto the exact commit that
314+ # was approved, so a push landing between the two jobs cannot ride along.
128315 fast-forward :
129316 name : Fast-forward the integration branch
130317 needs : pick
@@ -135,14 +322,13 @@ jobs:
135322 steps :
136323 # The PEM is never a GitHub secret — same runtime-from-Bitwarden pattern as
137324 # release-please.yml. Unlike that one this uses the purpose-built "kirchDev
138- # Queue" App, which carries `contents: write` and nothing else and is the
139- # merge-gate bypass actor on every integration branch of both owners
140- # (`queue_merge_app_id` in the owner YAML).
325+ # Queue" App, which carries `contents: write` plus `pull_requests: write`
326+ # (ADR-0010 in kirchDev/infrastructure, for the `open` job above) and nothing else, and is the
327+ # merge-gate bypass actor on the integration branch
328+ # (the SSOT in kirchDev/infrastructure).
141329 #
142- # BWS_ACCESS_TOKEN is published per owner — org-level on kirchDev, per-repo
143- # on the TitusKirch user account. With no environment in play it is
144- # `workflow_dispatch` permission, and nothing else, that keeps the App
145- # token out of reach.
330+ # BWS_ACCESS_TOKEN is shared with this repo's other App-token workflows —
331+ # org-level on kirchDev, a repo secret on the TitusKirch account.
146332 - name : Fetch Queue App PEM from Bitwarden
147333 uses : bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793 # v3.0.1
148334 with :
0 commit comments