Skip to content

Commit f08f1ab

Browse files
authored
feat(queue): cut the queue branch on the first worker PR (#222)
1 parent 0b4e900 commit f08f1ab

2 files changed

Lines changed: 385 additions & 36 deletions

File tree

.github/workflows/fast-forward-queue.yml

Lines changed: 222 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
2526
on:
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

3960
env:
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

4868
jobs:
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

Comments
 (0)