Playbook: Dedicated reviewer-only GitHub App (p24-reviewer)

See also: git-identity-and-roles.md — the canonical identity × role × action matrix (where p24-reviewer sits 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 the radieu account. 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 are pull_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 / 168439358 appear in cleartext here and in config by design. Only REVIEWER_APP_PRIVATE_KEY_B64 is 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 — see ai-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:

FieldValue
GitHub App namep24-reviewer
Homepage URLhttps://github.com/radieu/p24-infra (any valid URL)
WebhookUncheck “Active” — this App is poll/CLI-driven, it receives no webhooks
Repository permissions → Pull requestsRead and write (pull_requests:write) — post reviews/approvals/comments
Repository permissions → ContentsRead-only (contents:read) — read the diff being reviewed; never write
Repository permissions → IssuesRead and write (issues:write) — comment on the linked issue + set review labels
Repository permissions → MetadataRead-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 radieu account (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, or actions: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:

  1. Create the App at github.com/settings/apps/new with the manifest in §1.

  2. 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.

  3. Install the App — the App’s Install App tab → install on the radieu account. 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>).

  4. Hand the three values to secret-manager (do NOT paste them into chat/issues — see §3):

    • REVIEWER_APP_ID = the App ID from step 2
    • REVIEWER_APP_PRIVATE_KEY_B64 = base64 -w0 p24-reviewer.*.pem of the downloaded key
    • REVIEWER_APP_INSTALLATION_ID = the installation ID from step 3

    Then delete the downloaded .pem locally once base64’d into SOPS. (REVIEWER_APP_ID and REVIEWER_APP_INSTALLATION_ID are 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:

KeyValueSecret?
REVIEWER_APP_IDreal (5208530)no — App ID is public
REVIEWER_APP_PRIVATE_KEY_B64real (base64 of the App’s PEM)YES — the only secret here
REVIEWER_APP_INSTALLATION_IDreal (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 the administration.env.sops widening 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,
      age1sn08ypcqx9gj7l4j5v3kq62msfxwrqupm4x9m4yhzaez69q65fns0umhmr

Also 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 reviewer reads REVIEWER_APP_ID, REVIEWER_APP_PRIVATE_KEY_B64, REVIEWER_APP_INSTALLATION_ID. Resolution is os.environ-first, then $REVIEWER_APP_ENV, then REVIEWER_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.
  • --check works 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-reviewer the PRIMARY approve, which was then proven not to count (§6). Owner decision “option 2”: keep p24-reviewer review-only, so the APPROVE is posted by App 2109526 directly (the counting approver) and p24-reviewer is used only for comments / CHANGES_REQUESTED. The redundant reviewer-first-approve + reviewDecision second-approve (#7618) is dropped, leaving a single counting approve. That refactor is a pending human-confirmed edit to the protected: true worker file (bump version: / 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=$?
fi

Keep 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-reviewer approval is posted but does NOT count toward the required review on main. Worker logs (review-pr-7604, -7606, -7607, -7608) show the APPROVE posted as p24-reviewer with exit 0, yet reviewDecision stayed REVIEW_REQUIRED / mergeStateStatus=BLOCKED and gh pr merge failed with “base branch policy prohibits the merge”. The App’s reviews carry authorAssociation: NONE. Likely cause (inferred, not proven): the App holds contents:read only, so GitHub does not treat it as an eligible reviewer for required_approving_review_count; App 2109526 (contents:write) is counted. Approving as App 2109526 flipped the same PRs to APPROVED / CLEAN. So App 2109526 remains the counting approver; p24-reviewer is 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): keep p24-reviewer review-only (do NOT grant contents: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 clear reviewDecision): 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 the protected: true worker 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):

RepoReview base branchDefault branch protected?require_last_push_approval?Action needed
radieu/p24-inframaintruetrue (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-platformdevmain=falsenone — 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-Agencymaintrueprotection 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-brandmainfalseno branch protection → no such ruleNone — nothing required ✅
radieu/gmail-toolsmainfalseno branch protection → no such ruleNone — 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
done

Do 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-reviewer approve 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-reviewer stays review-only, no contents:write). Where a repo has no such rule, nothing is required. The open items are the two unreadable protected repos (et-op dev, Art-Agency main) — 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-infra main). radieu/et-operational-platform is a GitHub Free private repo, so it cannot have branch protection / require_last_push_approval at all — there is no counting gate, so the approve identity does not need contents:write. et-op instead runs a post-merge detective audit (guard-pr-base-branch.yml → “Audit merge that landed on an integration branch”, #2352) whose isValidApprover recognizes only p24-reviewer or a human. App 2109526 (p24-infra-admin) is therefore the wrong approver for et-op twice over: it is unrecognized by that audit (→ no-approval false-positive) and needlessly extends the privileged App’s blast radius into et-op. The review-pr worker must approve et-op PRs as p24-reviewer — a distinct non-author identity, recognized by the audit as-is, with no et-op-side change needed (p24-reviewer is installed fleet-wide, §7). This is a per-repo refinement of the APPROVE step in the protected: true review-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 confirm p24-reviewer is installed + active on et-op (expected true from repository_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-reviewer is installed on it — satisfied fleet-wide because the App was installed with repository_selection: all (§2 step 3). A new repo added to REVIEW_REPOS later needs no install step as long as it belongs to the radieu account.
  • Once the §5 worker edit lands, PR reviews across all these repos are approved by p24-reviewer automatically, 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 new REVIEWER_APP_PRIVATE_KEY_B64 to secret-manager to update secrets/github-reviewer-app.env.sops (rekey via bms-4 SSH, not CI). REVIEWER_APP_ID and REVIEWER_APP_INSTALLATION_ID are 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.