Playbook: Dedicated reviewer-only GitHub App (p24-reviewer)
See also:
git-identity-and-roles.md— the canonical identity × role × action matrix (wherep24-reviewersits among all Git identities).
Purpose: a NEW GitHub App whose only job is reviewing pull requests — posting
APPROVE / REQUEST_CHANGES / review comments on behalf of the auto-review pipeline. It replaces
the use of the shared App 2109526 (“n8n.io pull request flow”) for the review approve step.
Status (2026-10-08): the App is live. A human created p24-reviewer
(App ID 5208530, installation ID 168439358), generated the private key, installed it,
and handed the three values to secret-manager — secrets/github-reviewer-app.env.sops now holds the
real REVIEWER_APP_ID / REVIEWER_APP_INSTALLATION_ID / REVIEWER_APP_PRIVATE_KEY_B64 (merged
in PR #7516, no longer placeholders — do not recreate or overwrite that file). The code
(scripts/gh-app-token --app reviewer) shipped in #7483. A live mint with the real key was
verified 2026-10-08 (token minted, installation sees 30 repos, token repo permissions narrowed to
pull_requests:write, no admin/push). The §5 review-pr-worker.md switch (protected: true, human-
confirmed) is applied in the PR that updates this note; the live approve test on a real PR is the
last open check.
Installed on ALL repositories (intentional, user decision 2026-10-06). The App was installed with
repository_selection: all, not scoped to two repos — it is the review identity for every repo of theradieuaccount. This is deliberate (the review-pr worker reviews a fleet of repos — see §6/§7), so the blast radius of its private key spans all repos. That is exactly why the permission set must stay review-only (§1): the only grants arepull_requests:write,contents:read,issues:write,metadata:read. It can never push code, mutate secrets, change settings, or merge, on any repo.App ID and installation ID are NOT secrets —
5208530/168439358appear in cleartext here and in config by design. OnlyREVIEWER_APP_PRIVATE_KEY_B64is secret (SOPS-only, §3).
Issue: #7478
Why a separate App (not App 2109526, not a machine user)
App 2109526 is used fleet-wide for pushes, gh secret set, migrations, administration:write,
and workflow edits — its installation grant is issues:write, secrets:write, contents:write,
metadata:read, pull_requests:write, administration:write, workflows:write (see
github-app-token-integration.md §When to use). Its private key
therefore has a large blast radius. Using it to post review approvals works (that is the current
#6039 mechanism), but it means the “reviewer” identity can also rewrite repo contents and secrets.
p24-reviewer is a least-privilege identity: it can comment and approve PRs and read contents,
and nothing else. A leak of its key cannot push code, touch secrets, or change settings.
Decisions already fixed by the user (2026-10-05):
- Identity type: NEW GitHub App (not a machine user, not a narrowing of 2109526).
- Scope: review + comments only. NO merge, NO
contents:write. Merge stays with the existing worker/CI path (unchanged — seeai-pr-auto-review.md§Escalation).
1. App manifest (the permissions a human sets at create time)
Create at github.com/settings/apps/new (personal account radieu), with:
| Field | Value |
|---|---|
| GitHub App name | p24-reviewer |
| Homepage URL | https://github.com/radieu/p24-infra (any valid URL) |
| Webhook | Uncheck “Active” — this App is poll/CLI-driven, it receives no webhooks |
| Repository permissions → Pull requests | Read and write (pull_requests:write) — post reviews/approvals/comments |
| Repository permissions → Contents | Read-only (contents:read) — read the diff being reviewed; never write |
| Repository permissions → Issues | Read and write (issues:write) — comment on the linked issue + set review labels |
| Repository permissions → Metadata | Read-only (metadata:read) — mandatory baseline |
| Repository permissions → Checks | (optional) Read and write (checks:write) — only if a future step posts a check-run; omit otherwise |
| Where can this App be installed? | Only on this account |
As built (2026-10-06): the App was installed on All repositories of the
radieuaccount (repository_selection: all), not a hand-picked subset — see the Status note above and §2/§6. The permission set above is unchanged and is what keeps a fleet-wide install safe.
Do NOT grant
contents:write,secrets:write,administration:write,workflows:write, oractions:write. The review identity must never be able to push code, mutate secrets, change settings, or merge. (Merge is done by the existing worker/CI path with App 2109526 /--admin, not by this App.)
2. HUMAN one-time setup (genuinely Tier 3 — ✅ DONE 2026-10-06/08)
These four steps needed the GitHub UI. All four are complete (App ID 5208530, installation ID
168439358, real creds in SOPS via #7516). They are kept here as the rotation/re-create reference —
if the App is ever rebuilt, repeat them:
-
Create the App at
github.com/settings/apps/newwith the manifest in §1. -
Generate a private key — on the new App’s page → Private keys → Generate a private key. Downloads a
*.pem. Record the App ID shown at the top of the App’s General page. -
Install the App — the App’s Install App tab → install on the
radieuaccount. It was installed on “All repositories” (repository_selection: all, intentional — this is the fleet-wide review identity; see §6/§7). After install, read the Installation ID from the URL of the installation settings page (github.com/settings/installations/<INSTALLATION_ID>). -
Hand the three values to secret-manager (do NOT paste them into chat/issues — see §3):
REVIEWER_APP_ID= the App ID from step 2REVIEWER_APP_PRIVATE_KEY_B64=base64 -w0 p24-reviewer.*.pemof the downloaded keyREVIEWER_APP_INSTALLATION_ID= the installation ID from step 3
Then delete the downloaded
.pemlocally once base64’d into SOPS. (REVIEWER_APP_IDandREVIEWER_APP_INSTALLATION_IDare not secret — only the private key is; they live in SOPS alongside the key purely for one-shot export convenience.)
3. Secrets (secret-manager operation — Class A, ✅ DONE in 7516)
This is not a dev-coder task — it was delegated to the secret-manager role (request issue
#7482). The file now exists with REAL values (PR #7516, merged 2026-10-08). Workers must not
recreate or overwrite it. This section is the as-built record + rotation reference.
secrets/github-reviewer-app.env.sops holds exactly:
| Key | Value | Secret? |
|---|---|---|
REVIEWER_APP_ID | real (5208530) | no — App ID is public |
REVIEWER_APP_PRIVATE_KEY_B64 | real (base64 of the App’s PEM) | YES — the only secret here |
REVIEWER_APP_INSTALLATION_ID | real (168439358) | no — installation ID is public |
Recipients — 3 (developer + bms-4 infra-task worker key + lap2-windows):
- developer key
age1utmdm6cj2ray70g5nnupza8uvyuren26xk6a6nd4a58arax5xa7qaax7al - bms-4 infra-task worker key
age1qrccz84pj8huj8hmx6452te2zugane0slgxs09h9s05m4zpgpetq8p9up0 - lap2-windows key
age1sn08ypcqx9gj7l4j5v3kq62msfxwrqupm4x9m4yhzaez69q65fns0umhmr(added in #7516 — the scaffold’s original two recipients were not decryptable on that workstation; owner-approved 2026-10-06 for this narrow file specifically, distinct from theadministration.env.sopswidening that was reverted in #7488)
AGE_KEY_GHA is deliberately not a recipient: this file is excluded from secrets-sync.yml
and never reaches CI or any server plaintext .env. Rekey it via SSH on bms-4, not CI — same
constraint as pinbox24-gitlab.env.sops.
The live .sops.yaml creation rule (sits before the catch-all .*\.env\.sops$ rule):
# github-reviewer-app.env.sops — narrow file holding ONLY the p24-reviewer App creds
# (REVIEWER_APP_ID / REVIEWER_APP_PRIVATE_KEY_B64 / REVIEWER_APP_INSTALLATION_ID), #7478/#7482.
# The review-pr worker on bms-4 decrypts it with `sops -d` and exports the three vars before
# calling `scripts/gh-app-token --app reviewer`. NOT deployed by secrets-sync.yml (no server
# plaintext .env, no sync job) — same pattern as pinbox24-gitlab.env.sops. 3 recipients only:
# developer + bms-4 infra-task worker key + lap2-windows. AGE_KEY_GHA is NOT a recipient (rekey
# via bms-4 SSH). Values are REAL as of 2026-10-06 (App created by human, #7478).
- path_regex: (^|[/\\])secrets[/\\]github-reviewer-app\.env\.sops$
input_type: dotenv
output_type: dotenv
age: >-
age1utmdm6cj2ray70g5nnupza8uvyuren26xk6a6nd4a58arax5xa7qaax7al,
age1qrccz84pj8huj8hmx6452te2zugane0slgxs09h9s05m4zpgpetq8p9up0,
age1sn08ypcqx9gj7l4j5v3kq62msfxwrqupm4x9m4yhzaez69q65fns0umhmrAlso added to both CI SOPS-health skip-lists (self-test.yml #7538, sops-pr-canary.yml #7540)
since AGE_KEY_GHA cannot decrypt it.
Also add the SOPS-file-map row to CLAUDE.md (§Secrets — SOPS file map).
4. scripts/gh-app-token --app reviewer (dev-coder — shipped in #7478)
The helper now takes --app {default|reviewer}:
# default App 2109526 — byte-for-byte unchanged, every existing caller keeps working:
scripts/gh-app-token --permissions pull_requests:write
# NEW: reviewer App p24-reviewer:
scripts/gh-app-token --app reviewer --permissions pull_requests:write--app reviewerreadsREVIEWER_APP_ID,REVIEWER_APP_PRIVATE_KEY_B64,REVIEWER_APP_INSTALLATION_ID. Resolution is os.environ-first, then$REVIEWER_APP_ENV, thenREVIEWER_ENV_CANDIDATES— because the creds are never synced to a plaintext.env, the primary path is the worker exporting them from SOPS.- Separate cache file (
/tmp/.gh-reviewer-app-token-cache) so the two profiles never clobber each other’s cached default token. --checkworks per-profile and never prints the token.
How the review-pr worker should invoke it (the env-export the §5 edit must add):
# Decrypt the reviewer creds and export them for the gh-app-token child process ONLY.
# Never echo the values; `sops -d | grep` keeps each one in a var, exported into the env.
eval "$(sops -d --input-type dotenv --output-type dotenv secrets/github-reviewer-app.env.sops \
| grep -E '^REVIEWER_APP_(ID|PRIVATE_KEY_B64|INSTALLATION_ID)=' \
| sed 's/^/export /')"
APP_TOKEN=$(scripts/gh-app-token --app reviewer --permissions pull_requests:write 2>/dev/null || true)
unset REVIEWER_APP_ID REVIEWER_APP_PRIVATE_KEY_B64 REVIEWER_APP_INSTALLATION_ID(On bms-4 the worker already has the age key to sops -d this narrow file — it is a recipient.)
5. review-pr worker switch (APPLIED — protected file, human-confirmed edit)
infra/agent-prompts/review-pr-worker.md is protected: true
(change-policy: PRODUCTION — human confirmation required before ANY edit, #6173). The switch below
was applied in a human-confirmed edit (worker v3.2.0, 2026-10-08). The worker file is now the source
of truth — the applied block differs slightly from the snippet below: the token mint runs in a
subshell (mint_reviewer_token) so REVIEWER_APP_* never enter the worker’s environment, and
request-changes / comment also fall back to the worker identity if the App post fails. The snippet is
kept as the original design record.
Fallback behaviour: App 2109526 (#6039) is FALLBACK 1 in the block below, so a failed reviewer
mint never stops reviews — it only shows up as review posted as: p24-infra-admin (App 2109526 fallback) in the worker log. Editing the protected file by hand requires human confirmation (or
P24_ALLOW_PROTECTED_EDIT=1 to bypass .claude/hooks/pre-write-edit-protected-frontmatter.sh; a
worker never sets this); bump version: and last-modified: when you do.
SUPERSEDED by the #7625 decision (2026-10-08). The snippet below makes
p24-reviewerthe PRIMARY approve, which was then proven not to count (§6). Owner decision “option 2”: keepp24-reviewerreview-only, so the APPROVE is posted by App 2109526 directly (the counting approver) andp24-revieweris used only for comments / CHANGES_REQUESTED. The redundant reviewer-first-approve +reviewDecisionsecond-approve (#7618) is dropped, leaving a single counting approve. That refactor is a pending human-confirmed edit to theprotected: trueworker file (bumpversion:/last-modified:); it is deferred from the docs-only PR that records this decision. The snippet below is kept as the original design record.
Exact edit — Phase 2 Step 7, the if [ "$VERDICT_WORD" = "approve" ] block (original
reviewer-primary design — now superseded per the note above). Replace the PRIMARY approve path so it
mints the reviewer App token (falling back to App 2109526, then worker identity):
VERDICT_WORD="approve" # or "request-changes" or "comment" -- from Step 6
if [ "$VERDICT_WORD" = "approve" ]; then
# PRIMARY: approve as the p24-reviewer App (least-privilege, non-pusher identity -> satisfies
# require_last_push_approval). Export its creds from the narrow SOPS file for this child only.
eval "$(sops -d --input-type dotenv --output-type dotenv secrets/github-reviewer-app.env.sops \
| grep -E '^REVIEWER_APP_(ID|PRIVATE_KEY_B64|INSTALLATION_ID)=' | sed 's/^/export /')" 2>/dev/null || true
APP_TOKEN=$(scripts/gh-app-token --app reviewer --permissions pull_requests:write 2>/dev/null || true)
unset REVIEWER_APP_ID REVIEWER_APP_PRIVATE_KEY_B64 REVIEWER_APP_INSTALLATION_ID
APPROVE_IDENTITY="p24-reviewer"
if [ -z "$APP_TOKEN" ]; then
# FALLBACK 1: reviewer App unavailable (SOPS decrypt failed / mint error) -> App 2109526.
APP_TOKEN=$(scripts/gh-app-token --permissions pull_requests:write 2>/dev/null || true)
APPROVE_IDENTITY="p24-infra-admin (App 2109526 fallback)"
fi
if [ -n "$APP_TOKEN" ]; then
GH_TOKEN="$APP_TOKEN" gh pr review ${PR_NUMBER} --repo ${REPO} --approve --body "$REVIEW_BODY"
REVIEW_EXIT=$?
unset APP_TOKEN
echo "approve posted as: $APPROVE_IDENTITY"
else
echo "WARN: no App token (reviewer or default) -- falling back to worker-identity approve (may self-collide)"
REVIEW_EXIT=1
fi
if [ "$REVIEW_EXIT" -ne 0 ]; then
# FALLBACK 2: worker identity (may self-collide with the pusher -> human merge).
gh pr review ${PR_NUMBER} --repo ${REPO} --approve --body "$REVIEW_BODY"
REVIEW_EXIT=$?
if [ "$REVIEW_EXIT" -ne 0 ]; then
echo "WARN: worker-identity approve also failed (exit $REVIEW_EXIT) -- posting as a plain comment instead"
gh pr comment ${PR_NUMBER} --repo ${REPO} --body "${REVIEW_BODY}
_Verdict: APPROVE (posted as comment -- the review bot could not formally approve this PR)._"
fi
fi
else
# request-changes / comment also under the reviewer App (both are pull_requests:write):
eval "$(sops -d --input-type dotenv --output-type dotenv secrets/github-reviewer-app.env.sops \
| grep -E '^REVIEWER_APP_(ID|PRIVATE_KEY_B64|INSTALLATION_ID)=' | sed 's/^/export /')" 2>/dev/null || true
APP_TOKEN=$(scripts/gh-app-token --app reviewer --permissions pull_requests:write 2>/dev/null || true)
unset REVIEWER_APP_ID REVIEWER_APP_PRIVATE_KEY_B64 REVIEWER_APP_INSTALLATION_ID
if [ -n "$APP_TOKEN" ]; then
GH_TOKEN="$APP_TOKEN" gh pr review ${PR_NUMBER} --repo ${REPO} --${VERDICT_WORD} --body "$REVIEW_BODY"
else
gh pr review ${PR_NUMBER} --repo ${REPO} --${VERDICT_WORD} --body "$REVIEW_BODY"
fi
REVIEW_EXIT=$?
fiKeep log which identity posted (the echo "approve posted as:" line) — required by deliverable 4.
Merge is unchanged: the reviewer App has no contents:write, so the subsequent gh pr merge
still runs via the existing worker/CI/App-2109526 --admin path. The reviewer App only approves;
approval + merge stay separate identities.
6. Repo rulesets — does the reviewer App satisfy require_last_push_approval?
The App now reviews every repo in REVIEW_REPOS (not just the two originally named — see §7).
As of 2026-10-08 that list is: radieu/p24-infra, radieu/et-operational-platform,
radieu/Art-Agency, radieu/radekkonarski-personal-brand, radieu/gmail-tools
(scripts/dispatcher-review-config.json). One property does hold for all of them: p24-reviewer
is a distinct App identity that never pushes PR branches (its token is only ever used for gh pr review, never clone/push), so it can never be the “last pusher”. That is necessary but not
sufficient for its approval to count — see the verified limitation below.
VERIFIED LIMITATION (2026-10-08, #7574): the
p24-reviewerapproval is posted but does NOT count toward the required review onmain. Worker logs (review-pr-7604,-7606,-7607,-7608) show the APPROVE posted asp24-reviewerwith exit 0, yetreviewDecisionstayedREVIEW_REQUIRED/mergeStateStatus=BLOCKEDandgh pr mergefailed with “base branch policy prohibits the merge”. The App’s reviews carryauthorAssociation: NONE. Likely cause (inferred, not proven): the App holdscontents:readonly, so GitHub does not treat it as an eligible reviewer forrequired_approving_review_count; App 2109526 (contents:write) is counted. Approving as App 2109526 flipped the same PRs toAPPROVED/CLEAN. So App 2109526 remains the counting approver;p24-revieweris useful for review comments / labels. The earlier statement here that “no protection change is needed on any repo for the App to work” was wrong. Owner decision 2026-10-08 (“option 2”, #7625): keepp24-reviewerreview-only (do NOT grantcontents:write— that would raise its fleet-wide blast radius). The counting approve = App 2109526;p24-reviewer= comments / CHANGES_REQUESTED only. Interim worker fallback that already achieves this (approve as App 2109526 when the reviewer approval did not clearreviewDecision): PR #7618 (v2; the first attempt, #7616, was closed after review). The #7625 follow-up makes App 2109526 the worker’s primary approve path and drops the redundant reviewer-first-approve — a pending human-confirmed edit to theprotected: trueworker file (see §5).
Per-repo findings (checked 2026-10-08 with the worker gh token, which lacks administration:read,
so protection details for private repos return 404/403 — the protected flag and default branch
are still readable):
| Repo | Review base branch | Default branch protected? | require_last_push_approval? | Action needed |
|---|---|---|---|---|
radieu/p24-infra | main | true | true (authoritative, CLAUDE.md §Branching) | p24-reviewer approve is not counted (verified 2026-10-08); App 2109526 approve is — use it as the counting approver ⚠️ |
radieu/et-operational-platform | dev | main=false | none — GitHub Free private repo, so branch protection / require_last_push_approval are unavailable by plan (not merely unreadable) | Approve as p24-reviewer, NOT App 2109526 (#7823). No counting gate exists, so contents:write is not needed; et-op’s post-merge audit-merged-to-an-integration-branch guard recognizes only p24-reviewer or a human, so an App-2109526 approve would be a no-approval false-positive. See the #7823 note below. |
radieu/Art-Agency | main | true | protection details unreadable (no administration:read) | Flag to confirm. If it requires approving reviews, expect the same non-counting behaviour as p24-infra |
radieu/radekkonarski-personal-brand | main | false | no branch protection → no such rule | None — nothing required ✅ |
radieu/gmail-tools | main | false | no branch protection → no such rule | None — nothing required ✅ |
Commands used (re-run with an administration:read-scoped token to read the protected repos’ rule
details directly):
for r in radieu/p24-infra radieu/et-operational-platform radieu/Art-Agency \
radieu/radekkonarski-personal-brand radieu/gmail-tools; do
b=$(gh api "repos/$r" --jq .default_branch)
echo "$r @ $b protected=$(gh api "repos/$r/branches/$b" --jq .protected)"
gh api "repos/$r/branches/$b/protection/required_pull_request_reviews" \
--jq '{count:.required_approving_review_count, last_push:.require_last_push_approval}' 2>/dev/null || true
doneDo not change any repo’s branch protection as part of #7478. Where a repo enforces a required-review rule, being a non-pusher is not enough: the
p24-reviewerapprove is not counted (verified on p24-infra, 2026-10-08), so the counting approve comes from App 2109526 — now the settled design (owner decision 2026-10-08, #7625:p24-reviewerstays review-only, nocontents:write). Where a repo has no such rule, nothing is required. The open items are the two unreadable protected repos (et-opdev, Art-Agencymain) — a human/sys-admin with admin access should confirm whether they require approving reviews.
#7823 — the approve identity is per-repo, and for et-op it is
p24-reviewer(NOT App 2109526). The #7625 “App 2109526 is the counting approver” rule is correct only for repos with a counting required-review gate (p24-inframain).radieu/et-operational-platformis a GitHub Free private repo, so it cannot have branch protection /require_last_push_approvalat all — there is no counting gate, so the approve identity does not needcontents:write. et-op instead runs a post-merge detective audit (guard-pr-base-branch.yml→ “Audit merge that landed on an integration branch”, #2352) whoseisValidApproverrecognizes onlyp24-revieweror a human. App 2109526 (p24-infra-admin) is therefore the wrong approver for et-op twice over: it is unrecognized by that audit (→no-approvalfalse-positive) and needlessly extends the privileged App’s blast radius into et-op. The review-pr worker must approve et-op PRs asp24-reviewer— a distinct non-author identity, recognized by the audit as-is, with no et-op-side change needed (p24-revieweris installed fleet-wide, §7). This is a per-repo refinement of the APPROVE step in theprotected: truereview-pr-worker.md(§5) and ships as a human-confirmed edit (#7823). It does not change p24-infra’s approve path (App 2109526 stays primary there). A human/sys-admin should confirmp24-revieweris installed + active on et-op (expectedtruefromrepository_selection: all) before the worker edit lands. Full rationale + the rejected options (b)/(c):ai-pr-auto-review.md§Enforcement model → E3a.
7. Consumer wiring — generic, fleet-wide (no secrets in any target repo)
Review jobs for every repo in REVIEW_REPOS are executed by p24-infra’s review-pr worker on
bms-4 (scripts/dispatcher-review-config.json / the dispatcher’s scan_prs_for_review — see
ai-pr-auto-review.md §Configuration). That worker runs in the p24-infra
checkout and decrypts secrets/github-reviewer-app.env.sops locally. This is the same for any repo
the fleet reviews — nothing is per-repo:
- No secret is added to any target repo — target repos (et-op, Art-Agency, gmail-tools, radekkonarski-personal-brand, …) have no SOPS access and need none. The reviewer App creds live only in p24-infra’s SOPS, read only on bms-4.
- The only target-repo-side requirement is that
p24-revieweris installed on it — satisfied fleet-wide because the App was installed withrepository_selection: all(§2 step 3). A new repo added toREVIEW_REPOSlater needs no install step as long as it belongs to theradieuaccount. - Once the §5 worker edit lands, PR reviews across all these repos are approved by
p24-reviewerautomatically, identical to p24-infra’s.
Verification (App is live — run on bms-4, which is a SOPS recipient)
# 1. Creds resolve + mint succeeds (never prints the token):
scripts/gh-app-token --app reviewer --check # exit 0 + "OK" on stderr
# 2. Token is valid across the installed (all) repos and is pull_requests-scoped:
TOK=$(scripts/gh-app-token --app reviewer --permissions pull_requests:write)
GH_TOKEN="$TOK" gh api repos/radieu/p24-infra --jq .full_name # -> radieu/p24-infra
GH_TOKEN="$TOK" gh api repos/radieu/et-operational-platform --jq .full_name
GH_TOKEN="$TOK" gh api repos/radieu/Art-Agency --jq .full_name # all-repos install
unset TOK
# 3. Least-privilege proof: a contents write must FAIL (403) — the App has no contents:write:
# (run against a throwaway branch; expect HTTP 403)Rotation
- Day-to-day token issuance is fully autonomous (1h installation tokens via
--app reviewer) — Tier 1, no human action. Same model as App 2109526. - Private-key rotation (the
*.pem) is Tier 3 — human-only, in the GitHub App UI (github.com/settings/apps/p24-reviewer→ Private keys → generate new, delete old), then hand the newREVIEWER_APP_PRIVATE_KEY_B64to secret-manager to updatesecrets/github-reviewer-app.env.sops(rekey via bms-4 SSH, not CI).REVIEWER_APP_IDandREVIEWER_APP_INSTALLATION_IDare stable and do not rotate. - A leaked reviewer token is dead in ≤1h regardless; a leaked private key must be rotated as
above, but its blast radius is only
pull_requests:write+contents:read+issues:write— it cannot push code, mutate secrets, or change settings.
See secret-rotation-access-matrix.md for the matrix row.