adopt · github apps · identities

Create the GitHub Apps

Nine GitHub Apps, and the create click on each one is yours: GitHub has no API for it. That is the setup cost, and this page is where you pay it. Every desk role (a standing role window in the pipeline) posts under its own App, so a review, a verification, or a board write is attributed to an identity the author cannot post as. Six desk roles, plus a promote identity that holds the fleet’s only workflows grant and moves only on a human dispatch, and an auditor that holds no write permission of any kind and exists so the settings checker never has to borrow one that does. GitHub does accept a pre-filled manifest, which turns nine forms of fourteen toggles each into nine clicks. The manual route is below it, unchanged, for anyone who would rather see every toggle.

Option A: one click per App, from a manifest

Each button below posts a GitHub App manifest to GitHub: name, homepage, and the exact permission set for that role, nothing else. Each opens in a new tab, so this page stays put and you can fire off all nine in turn. GitHub shows you the pre-filled registration page; you review it and press Create GitHub App. Nothing is created until you do, and nothing on this site sees the result. GitHub hands the new App's private key back to you, through a one-time code, on the next page, where each tab collects its role and code so you can exchange all nine with one command.

Before you start, tell the forms where the Apps should live and, if you run more than one fleet, which cell they belong to. Every field is optional and is used only to fill the forms on this page; nothing is sent anywhere until you press a button.

Owner and naming

Register under an organization you administer. Empty means your personal account, which can transfer the App to an organization later from its settings page.

App names are unique across all of GitHub, so each is prefixed: myorg-assay-reviewer becomes the bot login myorg-assay-reviewer[bot]. Pick your own handle.

Running one fleet? Leave this empty. Running several, and want each fleet’s Apps to read as their own family? Name the cell and each App becomes <cell>-<role>: the key file, the apps.env key, and the bot login all carry the cell, so two fleets never collide. An empty cell (or the value assay) keeps the classic <prefix>-assay-<role> naming, unchanged.

The nine Apps, one manifest each

  1. reviewer

    myorg-assay-reviewer

    pull requests: write · issues: write · contents: write · checks: read · commit statuses: read · actions: read · administration: read

  2. worker

    myorg-assay-worker

    pull requests: write · issues: write · contents: write · checks: read · commit statuses: read · actions: read

  3. verifier

    myorg-assay-verifier

    pull requests: write · issues: write · contents: write

  4. desk

    myorg-assay-desk

    pull requests: write · issues: write · contents: write · checks: read · commit statuses: read · actions: read · administration: read

  5. issue-loop

    myorg-assay-issue-loop

    pull requests: write · issues: write · contents: write

  6. intake-loop

    myorg-assay-intake-loop

    pull requests: write · issues: write · contents: write

  7. board-writer

    myorg-assay-board-writer

    contents: write (only needed when main is branch-protected)

  8. promote

    myorg-assay-promote

    contents: write, workflows: write (the one workflows grant in the fleet; used only by the human-dispatched promote job)

  9. auditor

    myorg-assay-auditor

    administration: read · contents: read (no write permission of any kind)

The six desk roles share one floor: three writes

reviewer, worker, verifier, desk, issue-loop, and intake-loop are the roles the desk tools mint tokens for, and every one of them needs pull requests, issues, and contents at Read & write. That is not a preference: deskroster preflight reads the installation's granted scopes against the role's duty set and refuses to run a role that is missing any of the three, so an under-granted App does not fail at the moment it matters. It fails at boot, with the missing scope named. A verifier that can file an issue but goes silent on a PR thread, or a reviewer that cannot land the commit its own lane makes, is a capability gap that surfaces three quarters of the way through a pass wearing some other error's clothes.

Narrowing one of the three adds nothing to the review; attribution is what carries it. A verdict is posted by an identity the PR author cannot post as, and the forge itself rejects an author's approval of their own pull request. Those are the controls; a withheld scope is only a way to break the tools. board-writer, promote, and auditor are infrastructure identities rather than desk roles, so the floor does not apply to them: they keep the narrow grants above. A floor is not the whole grant, though — the reviewer needs one read on top of the three, for the reason below.

The reviewer needs one read on top: Administration

Beyond the three writes the reviewer App needs Administration at Read-only. It is a read, not a fourth write: it opens the settings half of a repository and nothing that can change one. What it buys is the ready-flip. Before a pull request is flipped out of draft, the flip gate has to know which status checks the branch actually requires, and GitHub answers that question in two different places. The legacy per-branch protection endpoint is the complete answer, and it returns 403 to any token without Administration. The rules API is the fallback, and it can see rulesets only.

That gap has one shape, and it is more common than it sounds. It catches a protected branch whose required checks are not expressed in a ruleset — classic branch protection, or a ruleset that protects the branch without carrying a required status checks rule. Both read to the fallback the same way: the branch is protected, and the rules API names no required contexts, which is indistinguishable from a branch that genuinely requires none. The second case is the quieter one, because the repository looks thoroughly modern: a ruleset is in force, it is simply a ruleset of deletion and non-fast-forward rules with no checks rule in it. So the gate does the only honest thing and fails closed: could-not-check, never read as green, and the pull request is not flipped however green it looks. On such a repository the flip does not degrade, it never happens.

There are two ways out, and they are not equivalent. Grant the reviewer App Administration: Read-only — the durable one: it works whichever way the branch is protected, on every repository the App is installed on, and it is the one to reach for first. Or add a required-status-checks rule to the branch's ruleset, which puts the list somewhere the fallback can already see — a real fix, but a per-branch, per-repository one, and it is a change to the enforcement surface, so it is an administrator's act rather than a setup step. The refusal names the permission in its own text rather than leaving it to be diagnosed; the code and its comment are in the public repository, in the forge client's required-status-checks read.

Two consequences worth stating rather than discovering. Every reviewer App needs it, a second fleet's own reviewer under its own cell name included: the gate is per repository, not per fleet. And deskroster preflight does not cover it — app-scopes-vs-duties compares the grant against the three writes and nothing else, so a reviewer can report checked-clean at boot and still be unable to flip anything. A clean preflight is evidence about the three writes; it is not evidence about this one. The desk App should hold the same read, so a coordinator shepherding a pull request sees the answer the reviewer sees; worker, verifier, issue-loop and intake-loop flip nothing and do not need it.

Adding a permission to an App that already exists is two acts, not one: raise Administration to Read-only under the App's Permissions & events page and save, then accept the pending permission update on every installation — an edited App does not gain the grant by being edited. Tokens already minted keep the scopes they were minted with, so re-mint fresh (desktoken <role> --fresh) before testing the flip, or the next read answers 403 out of a cached credential and looks like the change did not take.

The auditor holds no write at all, on purpose

The repository-hardening checker is a GET-only instrument: it compares a repository's live settings (visibility, secret scanning, rulesets and their bypass lists, default workflow permissions) against a checklist, and applies nothing, because every one of those settings is an administrator's act and belongs to a human. Running such a checker under a desk role's token would hand a read-only tool a credential that can post, file, flip, and push. So it gets its own identity: Administration and Contents at Read-only, Metadata: Read-only like every App, and nothing else. A write attempted with its token is refused by GitHub, not by the tool's own good manners.

The price is named rather than bought off. A few fields GitHub only shows to a caller that can also change them, the security_and_analysis block and a ruleset's bypass_actors list, come back null or absent under this identity, which is indistinguishable from the setting being off. The checker reports those as could-not-check, never as a pass, and a human administrator re-runs them. That is the same answer any non-admin caller gets today. The alternative, a standing settings-changing credential sitting next to a GET-only tool, buys the missing fields at a price the checker's own rule forbids.

Every manifest also carries metadata: read, which GitHub grants to every App, and no webhook: these Apps are identities that tools mint tokens for, not services that listen. If GitHub's page shows the webhook as active, untick it before creating. On the registration page the name is editable; if GitHub says it is taken, change it there.

After you press Create GitHub App, GitHub sends you to apps-created.html with a one-time code in the address bar. That page shows the two commands that turn the code into the App's private key and ID on your machine. The code is valid for one hour and the key is issued exactly once. Finish one App before starting the next.

Option B: by hand, from the permission table

The manifest route fills in exactly this. If you prefer to set each toggle yourself, or want to audit what the manifests asked for, open github.com/settings/apps/new (or github.com/organizations/<org>/settings/apps/new) once per role and enter the following. Leave every permission not listed at No access: a missed toggle is a capability gap that surfaces later as a 403 wearing some other error's clothes; an extra toggle is an over-grant nobody will notice.

One row of the table is a floor rather than a preference. Each of the six desk roles (reviewer, worker, verifier, desk, issue-loop, intake-loop) needs Pull requests, Issues, and Contents all at Read & write, because deskroster preflight checks the granted scopes against the role's duties and refuses to run the role if any of the three is missing. Set one of them to Read-only and that role does not run at all. board-writer, promote, and auditor are not desk roles and keep the narrow grants shown. For the auditor the narrowness is the point: every one of its permissions is Read-only, and it is the only App on this page that holds no write of any kind.

The Administration column is the one permission the preflight does not police. The reviewer needs it at Read-only or the ready-flip cannot read the required-checks list on a protected branch whose required checks are not expressed in a ruleset, and fails closed — the reason is above, and the flip simply never happens without it. The desk should hold the same read. It is a read of repository settings, never a write to them; the auditor exists to prove that half can stand alone.

  1. GitHub App name

    <prefix>-assay-<role>, or <cell>-<role> if you name a cell for the fleet. The name becomes the slug and the bot login (<slug>[bot]), and must be unique across GitHub.

  2. Homepage URL

    Anything; https://assay.guide/desk-roles.html works.

  3. Callback URL, Setup URL

    Leave empty. Untick Expire user authorization tokens and Request user authorization (OAuth) during installation; these Apps never act for a user.

  4. Webhook

    Untick Active. No URL, no secret.

  5. Repository permissions

    The row for the role, below. Metadata: Read-only is pre-set and mandatory.

  6. Subscribe to events

    None.

  7. Where can this GitHub App be installed?

    Only on this account.

  8. Press Create GitHub App

    On the next page, note the App ID, then under Private keys press Generate a private key; the .pem downloads once.

RolePull requestsIssuesContentsChecksCommit statusesActionsAdministrationWhat it does with them
reviewerRead & writeRead & writeRead & writeRead-onlyRead-onlyRead-onlyRead-onlyposts review verdicts, files issues, reads CI rollups and run logs before a ready-flip; lands the review-lane commits the desk tools make on its behalf. Administration: Read-only is required, not optional: it is how the ready-flip reads the required-status-check list on a protected branch whose required checks are not expressed in a ruleset (classic protection, or a ruleset with no required-status-checks rule), and without it the flip fails closed and never happens. It authors no feature code; that is the worker's lane, and a convention, not this toggle
workerRead & writeRead & writeRead & writeRead-onlyRead-onlyRead-onlyauthors branches and draft PRs, reads CI run logs while shepherding a PR; never approves, so it never reads a required-checks list
verifierRead & writeRead & writeRead & writere-runs Verify tables, comments the result on the PR thread, commits the evidence; never approves a PR
deskRead & writeRead & writeRead & writeRead-onlyRead-onlyRead-onlyRead-only (recommended)coordinator: routes work, reads CI run logs while shepherding, lands coordination writes via PRs. The same Administration read the reviewer needs, so a coordinator shepherding a PR sees the same required-checks answer rather than a could-not-check the reviewer does not get
issue-loopRead & writeRead & writeRead & writeinbound issue lane
intake-loopRead & writeRead & writeRead & writeinbound intake lane; writes INTAKE entries
board-writerRead & writecommits STATUS.md past branch protection; the only identity on the ruleset bypass. Skip it if main is unprotected.
promoteRead & writeadditionally holds Workflows: Read & write, the one grant no desk App carries. Pushes reviewed, staged workflow files into .github/workflows/ when a human dispatches the promote job; nothing autonomous ever mints its token.
auditorRead-onlyRead-onlythe read half of repository settings, and here it is the whole grant. The hardening checker runs as this identity: it reads visibility, rulesets, default workflow permissions and the checklist's own files, and holds no write of any kind, so the forge refuses a write rather than the tool declining to make one. Admin-gated fields report could-not-check under it, by design.

Store each downloaded key as ~/.config/assay/<role>-app.pem, mode 0600, and the App ID as a <ROLE>_APP_ID= line in ~/.config/assay/apps.env (hyphens become underscores: ISSUE_LOOP_APP_ID). If you named a cell, the cell rides in front of both: <cell>-<role>-app.pem and <CELL>_<ROLE>_APP_ID. The desk tools refuse a key with any other mode.

The same key in CI, and where it never goes

In a workflow the key lives differently but by the same rule. Add the .pem as an Actions secret, encrypted and never printed, and the App ID as an Actions variable, which is not sensitive; the workflow mints its token at run time from the two, exactly as a workstation does. The private key belongs in those two places only: the config home at 0600, or a CI secret. It goes into the repository tree never: not in .env, not in a committed config file, not "just for now." A key that reaches git history is a key you must rotate.

Then install each App, also a click

Creating an App mints an identity; installing it grants that identity access to repositories. There is no API for this either. For each App open https://github.com/apps/<slug>/installations/new, choose the account, and choose Only select repositories: the repositories the fleet works in, no more. An installation is a credential for everything it covers.

When the install completes, the address bar reads …/settings/installations/<ID>. Record that as <ROLE>_INSTALL_ID_<OWNER>=<ID> in apps.env (owner upper-cased, hyphens to underscores: REVIEWER_INSTALL_ID_MY_ORG=123456), one line per account the App is installed on. The token minter picks the installation by the target repository's owner.

Two last human acts, both documented in docs/adopting-assay.md: add the board-writer App to the main ruleset bypass if you protect the branch, and run the Actions-variables step that tells the fleet which identities it trusts. A permission you add to an App later is pending until the account owner accepts it on the installation page; the App does not get it by being edited.

Then prove the grant rather than assuming it: from a checkout of a repository the App is installed on, deskroster preflight --role <role> --root . reads the installation's actual granted scopes and reports app-scopes-vs-duties checked-clean or names what is missing. Do this once per desk role before the role does any work; details on the page after the click.

How the fleet authenticates: App tokens only

Once the Apps exist and their keys are in place, the automation authenticates with one thing only: a short-lived installation token, minted from a role's .pem for each action, through the desk tools. It holds no personal login and reaches for no user-level gh or git credential, for nothing. Public downloads use curl against the release, which needs no authentication at all. This is a rule, not a convenience: every attribution on this page rests on it.

CapabilityIn the environment?Why
App installation tokenscarriedminted per action from the role's .pem: the only credential the fleet ever writes with
The desk toolscarriedmint the token, then do the API and git work on the App's behalf
curl to the public releasecarriedunauthenticated binary downloads; no gh, no login
A logged-in gh or personal git credentialneverwould let an agent author, push, or merge as the human, straight past the reviewer App and every gate on this page
Any human token, cookie, or sessionneverthe person's identity must not be reachable from inside the fleet
Why a personal gh login is a way around the whole thing

A gh authenticated as a person, or plain user git credentials, in an agent's environment is a workaround vector: it lets the agent act under the human's identity, authoring, pushing, and even merging as that user, past the reviewer App's attribution and every human gate this page installs. If the tooling can reach a personal login, the separation you just built becomes optional. So do not provision one in the agent environment, and warn whoever sets that environment up. Better still, do not install gh there at all; an absent tool is one fewer way around the gates.

The acts that need a person (creating an App, granting a permission, changing a workflow, editing a branch ruleset, pressing merge, and giving the approving sign-off) run in a separate human account, in a browser or that person's own shell. They are never wired into the fleet's identity, because the point of the separated Apps is that the machine cannot reach them. The promote App is the deliberate, narrow exception that keeps this true for CI: it holds the fleet’s only workflows grant, and its token is minted only inside a job a human has just dispatched against a reviewed diff. The grant exists so that no desk App ever needs it.

Sample icons, one per role

A coherent set, one per App, drawn in the site's ledger-green line: download-and-use starting points, so the fleet reads as a family in a PR's reviewer list. Upload each as its App's avatar on the App settings page; swap them for your own whenever you like. The point is only that each identity looks like itself.

reviewer
board-writer
worker
verifier
desk
intake-loop
issue-loop
promote
auditor