Workflow-YAML Hand-off — Runbook
Status: active — 2026-08-12. Founder-approved.
Owner: devops-engineer.
Related: core#2503, core#2498, core#2504.
TL;DR
FIVE agent Apps cannot push a branch that touches .github/workflows/**.
Only backend-sme and devops-engineer can (re-measured 2026-09-04T06:46Z
— the 2026-08-13 revocation has since been executed against frontend-sme and
project-manager). Check before assuming — run
sg upsquad-devs -c 'python3 scripts/show-app-permissions.py workflows'.
The merge queue (#2965) does not change any of this. GitHub refuses the
push, long before any merge mechanism is involved. The hand-off procedure
below is unchanged; see landing-a-pr.md for what the queue does change.
If you are one of the three: commit the YAML yourself with your own identity,
then ask devops-engineer to push the branch. Your authorship survives the
hand-off. devops-engineer posts a hand-off comment on the PR so the record
explains itself.
For qa-engineer this is a founder decision (2026-08-12), not an oversight. The
basis is least privilege
— and that argument is generic, so it applies to the four Apps that do hold the
permission just as much. There is no principled basis on record for the current
distribution. That is the open question, tracked at
#2592 — not "is QA
special?" but "who, if anyone, needs push access to .github/workflows/**?".
The permission is worth more than it looks. Branch protection gates merge;
it does not gate execution. A workflow on a pushed branch runs from its own
definition, on pull_request, before any review, with same-repo secrets in
scope — and board-sync.yml puts a GitHub App private key (secrets.PJM)
in that scope, with no environment: gating anywhere in the tree. See
WRONG 3
before pricing any grant.
The failure you will hit
You will see something like this from git push:
! [remote rejected] <branch> -> <branch> (refusing to allow a GitHub App to
create or update workflow `.github/workflows/<file>.yml` without `workflows`
permission)
It is GitHub, not branch protection, not a CI check, and not something a retry
or a rebase will clear. GitHub refuses any App-token push whose tree touches
.github/workflows/** unless that App installation holds workflows: write.
Note the wording: the tree, not the diff. A branch that contains a workflow edit anywhere in its history is refused even if your latest commit does not touch one.
Which apps hold workflows: write
Measured 2026-09-04T06:46Z. Do not trust this table — reproduce it. It was wrong in the first draft of this document (two apps listed as lacking the permission hold it), which is exactly the failure mode a permission table in a runbook has: people reason from it instead of checking.
The 2026-08-13 revocation HAS NOW BEEN EXECUTED. frontend-sme and
project-manager no longer hold workflows: write (re-measured
2026-09-04T06:46Z, table below). Two holders remain: backend-sme and
devops-engineer.
This does not close the board-sync.yml exposure. secrets.PJM is the
project-manager App's private key and it is still fed to
actions/create-github-app-token on a pull_request trigger with no
environment: gate. Revoking the App's own workflows permission narrows what
a token minted from that key can do; it does nothing about the other permissions
that key carries, and nothing about pre-merge execution reaching it at all. See
WRONG 3 below before pricing this as a fix.
sg upsquad-devs -c 'python3 scripts/show-app-permissions.py workflows'
| App | app id | installation | workflows |
|---|---|---|---|
upsquad-product-manager[bot] | 3263684 | 124769206 | (absent) |
upsquad-project-manager[bot] | 3281205 | 124769251 | (absent) — revoked since 2026-08-15 |
upsquad-principal-architect[bot] | 3263720 | 124769296 | (absent) |
upsquad-backend-sme[bot] | 3263728 | 124766786 | write |
upsquad-frontend-sme[bot] | 3263739 | 124769366 | (absent) — revoked since 2026-08-15 |
upsquad-qa-engineer[bot] | 3263959 | 124769426 | (absent) |
upsquad-devops-engineer[bot] | 3263965 | 124769489 | write |
How this was measured. scripts/show-app-permissions.py mints a JWT from
each App's private key in /opt/upsquad-keys/, POSTs
/app/installations/{id}/access_tokens, and reads the permissions object off
the response body. That object is GitHub's own statement of what the minted
token can do. The script discards the token immediately and writes nothing.
Do not infer a permission from "the push worked." A push exercises
contents; it exercises workflows only when the tree touches
.github/workflows/**. A green push proves nothing about a permission it never
needed — which is how the first version of this table came to be wrong.
Why the answer is a hand-off and not a grant
Least privilege. That is the whole argument, and it is not QA-specific.
An App should hold the minimum permission set that lets it do its job. qa-engineer
writes tests, fixtures and coverage gates; authoring .github/workflows/** is not
part of that job — it came up once, on #2498. A standing grant to cover an
occasional need is exactly what least privilege exists to refuse, and the cost of
the alternative is one dispatch.
This argument applies with equal force to every App that holds the permission.
It is not a reason QA is special. Whether project-manager, backend-sme and
frontend-sme need it is open — see
#2592 — and the honest
statement is that no principled basis for the current distribution has been
articulated by anyone, including me.
Three arguments this document used to make, all of which are WRONG
They were wrong in the first three drafts and are recorded here so nobody reconstructs them from first principles and thinks they are new. Note the pattern: each was a mechanism that sounded checked and was not.
WRONG 1: "a QA token that can edit the workflows removes the independent
review of them." It does not. CLAUDE.md's routing table keys on what the
PR touches, not who wrote it:
| PR touches | Primary reviewer (required) | | CI, workflows, IaC, k8s, deploy configs |
devops-engineer|
A QA-authored workflow PR routes to devops-engineer exactly like anyone
else's. No independent check is removed by the grant, so the
separation-of-duties framing does not apply.
WRONG 2: "the write path should match the review path." Still not deployed,
though the gap has narrowed: two of seven Apps hold workflows: write
(2026-09-04) while devops-engineer is the sole required reviewer for those
files. Narrowing is not adopting — it reads like a principle and is a proposal.
Do not cite it as repo policy.
WRONG 3: "workflows: write is only a PUSH permission, and branch protection
gates MERGE, so the risk is low." The first half is true and worth keeping.
The conclusion drawn from it is wrong, and it is wrong in the direction that
matters.
Branch protection gates merge. It does not gate EXECUTION. A workflow on a
pushed branch runs from its own definition, on pull_request, before any
review, with same-repo secrets in scope. So the real capability is not
"can put a file on a branch" — it is arbitrary pre-merge CI execution with
secrets, which sits upstream of every line of branch protection.
The protection settings are still exactly as measured, and they still do what they say:
required_approving_review_count: 1
dismiss_stale_reviews: true
enforce_admins: true
required_status_checks.strict: true (10 contexts, 2026-09-04)
merge queue: enabled, SQUASH (#2965 — and inert, #2967)
They are simply not the relevant control. Nothing in that list runs before a workflow does.
Direct evidence from this repo, no hypothetical required. PR #2585 added
.github/workflows/migration-ceiling.yml — a workflow that did not exist on
main — and it executed on that PR, twice, reporting failure on a
deliberately-bad commit and success on the fix, all before the PR was
reviewed or merged. That is the mechanism, demonstrated by the guard PR itself.
The concrete exposure, measured on main (2026-08-15):
| workflow | trigger | environment: | secrets in scope |
|---|---|---|---|
board-sync.yml | pull_request | no | secrets.PJM — a GitHub App private key |
docs-site-pages.yml | pull_request | no | CF_API_TOKEN, CF_ACCOUNT_ID |
agent-consistency.yml | pull_request | no | GITHUB_TOKEN |
wave2-smoke.yml | pull_request | no | GITHUB_TOKEN |
There is zero environment: gating anywhere in .github/workflows/ — 24
workflows trigger on pull_request, none of them gate a deployment
environment. Reproduce with
grep -rEn '^\s{4,6}environment:' .github/workflows/.
And it compounds. secrets.PJM is not a scoped token, it is the
project-manager App's private key: board-sync.yml feeds it to
actions/create-github-app-token to mint tokens. That App holds
contents:write, issues:write, organization_projects:write and
workflows:write (measured, same script as the table above). So pre-merge
execution reaches a credential that can itself write code and workflows.
Consequence for the options below: Option B is more expensive than this
document previously said. Granting workflows: write broadly is not granting
branch-push — it is granting pre-merge CI execution with secrets, including
that App key, to every App that receives it. That is a materially different
trade and it should be priced as one.
Credit: found by principal-architect on the #2590 review, after both I and
the reviewer had accepted the push-vs-merge framing as sufficient.
On re-opening the QA decision
Changing any App's workflows permission — granting or revoking — is a
security-relevant permission-surface change, and therefore a founder decision
under CLAUDE.md's escalation list. The grant was put on the table explicitly on
#2503 rather than silently foreclosed, and the founder's decision on 2026-08-12
was the documented hand-off. Re-opening it needs a new founder decision, not an
agent's judgement call.
That decision was taken on the QA case in isolation. #2592 asks the wider
question the least-privilege argument actually raises: who, if anyone, needs
push access to .github/workflows/**, and why.
The procedure
1. The authoring agent writes and COMMITS the YAML
In its own worktree, with its own identity. Do not let someone else author your work:
git -c user.name='upsquad-qa-engineer[bot]' \
-c user.email='<app-id>+upsquad-qa-engineer[bot]@users.noreply.github.com' \
commit -m "ci(<scope>): <what the lane does>"
The commit will sit unpushed. That is expected — go to step 2.
2. devops-engineer pushes the branch
Dispatch devops-engineer with the worktree path, the branch name and the
commit SHAs. It pushes with its own token:
TOK=$(sg upsquad-devs -c 'python3 scripts/gh-token.py devops-engineer')
git push "https://x-access-token:${TOK}@github.com/upsquad-ai/upsquad-core.git" \
HEAD:<branch>
Commit authorship is preserved. The push credential and the commit author
are independent: on #2498 all four commits show as authored by
upsquad-qa-engineer[bot] while only the transport was devops. Nothing about
the audit trail is weakened by this.
3. devops-engineer posts the hand-off comment
This is the step that was missing on #2498, and it is the point of the whole procedure. Without it a reviewer has to reconstruct why the author and the pusher differ, and "unexplained" is indistinguishable from "irregular".
GH_TOKEN=$(sg upsquad-devs -c 'python3 scripts/gh-token.py devops-engineer') \
gh api repos/upsquad-ai/upsquad-core/issues/<pr>/comments --method POST \
-f body="Pushed \`<sha>\` on behalf of \`<author-app>\` — that App lacks \`workflows: write\` (core#2503). Authorship is unchanged; only the transport credential differed. I own the CI-lane review per CLAUDE.md's routing table."
Use /issues/<pr>/comments, not /pulls/<pr>/comments — GitHub treats PRs as a
subtype of issues, and the issues endpoint is for general commentary. Reserve
/pulls/…/comments for line-specific review comments.
4. devops-engineer reviews the CI lane
Unchanged by this runbook — it is what the routing table already says. Doing the review is not optional just because the same agent did the push; if anything the transport makes the review more clearly owned.
What to check when you review a lane you pushed for someone else
Pushing is not endorsing. The CI review is a separate act, and the recurring defects in this repo are all shapes that look fine from one level up:
- No workflow-level
paths:filter on anything intended to be required. A path-filtered job SKIPs and reportssuccess, and a skipped check satisfies branch protection identically to a passing one. - No bare
needs: <gate>withoutif: ${{ always() }}plus aGate integrityfirst step. When the gate job fails, the dependent job never starts and reportsskipped— a bypass no step-level guard can reach (core#2473; observed live in core#2389 when the org Actions quota ran out and six required checks reportedskippedat once). - No
continue-on-errorat job or step level on anything gating a merge. - The job name must be unique across every workflow file. Two jobs publishing
the same context can satisfy a required check without the real one running
(core#2510;
Go lintis a live duplicate today, core#2467). - The job name must be ASCII if it is ever to become a required context — required contexts are matched byte-exact (core#2504).
grep -cexits 1 on zero matches, which underset -euo pipefailkills the command substitution. That ispipefailin the safe direction for a count guard; write down which direction you are relying on.- Execution, not colour.
go testreportsokfor a package in which every selected test SKIPped. Assert the tests RAN — by name where you can, because a count floor is satisfied by any N results.
scripts/check-required-check-gating.py mechanises the first four for the
required contexts. It cannot see a lane that is not required yet, which is
most new lanes — so read them by hand.
If you are devops-engineer and someone hands you a branch
- Read the diff before pushing. You are lending your credential; the reason you hold it is that you are the one who reviews these.
- Push, then post the hand-off comment in the same turn. A hand-off recorded later is a hand-off nobody sees.
- If the branch touches
.github/workflows/**and production code, say so in the comment — the routing table wants a second reviewer for the non-CI half. - Do not amend or rebase the author's commits to "tidy" them. Rewriting them under your identity destroys exactly the authorship the hand-off preserves.