adopt · github apps · after the click
GitHub created the App. Now take the key.
GitHub sent you here with a one-time code in the address bar. This page reads that code only to assemble the command you paste; it never sends it anywhere. The exchange that turns the code into the App's private key runs entirely in your own shell. The code expires in one hour and the key is issued exactly once.
Exchange the code
The address bar reads https://assay.guide/apps-created.html?code=…. The exchange is two moves: save one small script, then run it with the role you just created and that one-time code. It needs no authentication (the code is the authentication) and it returns the App ID, the slug, and the private key in one response. If you opened each App in its own tab, every tab collects its own role code pair here, in your browser only, so you can create all nine and then run one command. This page reads the code only to fill it into the command below; it never sends the code anywhere. The exchange runs entirely in your own shell.
-
Save
create-app.sh· oncePaste this whole block into your terminal once. It writes
create-app.shinto the current directory and makes it executable. The script takes one or morerole codepairs as arguments and has nothing baked in, so you reuse the same file for all nine Apps, one at a time or all nine in a single run. It needscurlandjq.cat > create-app.sh <<'EOF' #!/usr/bin/env bash # create-app.sh — exchange one-time GitHub App manifest codes for Assay App # private keys. One OR MORE (role, code) pairs, all in a single run: # # ./create-app.sh [--cell <cell>] <role> <code> [<role> <code> ...] # # --cell is optional. Give it and every key in this run is named for the cell: # ~/.config/assay/<cell>-<role>-app.pem with a <CELL>_<ROLE>_APP_ID line. Leave # it off (or pass --cell assay) and you get the classic <role>-app.pem and # <ROLE>_APP_ID, exactly as before. # # Each <code> is the value after code= in the address bar GitHub sent you to # (https://assay.guide/apps-created.html?code=...). A code is single-use and # expires about an hour after GitHub issued it; if a pair fails with no key, # delete that half-made App on GitHub and create it again. # # For each pair this posts the code to GitHub's manifest-conversion endpoint # from YOUR machine and nowhere else, writes the returned private key to # ~/.config/assay/<role>-app.pem (or the cell-named path) at mode 0600, records # the App id in ~/.config/assay/apps.env, and deletes the raw response (which # also carries a client secret you never use). One generic script, all roles. set -euo pipefail umask 077 usage() { cat >&2 <<'USAGE' usage: ./create-app.sh [--cell <cell>] <role> <code> [<role> <code> ...] --cell <cell> optional; names every key <cell>-<role>-app.pem with a <CELL>_<ROLE>_APP_ID line. Omit it (or use "assay") for the classic <role>-app.pem / <ROLE>_APP_ID naming. <role> one of: reviewer worker verifier desk issue-loop intake-loop board-writer promote auditor <code> the one-time code from the address bar after GitHub created that App Pass as many role/code pairs as you like — all nine in one run. USAGE } valid_role() { case "$1" in reviewer|worker|verifier|desk|issue-loop|intake-loop|board-writer|promote|auditor) return 0 ;; *) return 1 ;; esac } # Optional leading --cell flag. It applies to every pair in the run, since a # cell names one fleet. Normalize it to [a-z0-9-]; "assay" and "" both mean the # classic house naming. cell="" if [ "${1:-}" = "--cell" ]; then if [ "$#" -lt 2 ]; then echo "create-app.sh: --cell needs a value." >&2 usage exit 2 fi cell=$2 shift 2 fi slug_cell=$(printf '%s' "$cell" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-') if [ "$slug_cell" = "assay" ]; then slug_cell=""; fi args=("$@") n=${#args[@]} if [ "$n" -eq 0 ] || [ $((n % 2)) -ne 0 ]; then echo "create-app.sh: expected role/code pairs (an even number of arguments), got $n." >&2 usage exit 2 fi # Pass 1 — validate every pair BEFORE any network call, so a typo in the last # pair does not leave you half-done. i=0 while [ "$i" -lt "$n" ]; do role=${args[$i]} code=${args[$((i + 1))]} if ! valid_role "$role"; then echo "create-app.sh: unknown role: $role" >&2 usage exit 2 fi if [ -z "$code" ]; then echo "create-app.sh: the code for role $role is empty." >&2 usage exit 2 fi i=$((i + 2)) done conf=$HOME/.config/assay mkdir -p "$conf" tmpfiles=() cleanup() { if [ "${#tmpfiles[@]}" -gt 0 ]; then rm -f "${tmpfiles[@]}"; fi } trap cleanup EXIT exchange_one() { role=$1 code=$2 # The cell (if any) rides in front of the role for both the key filename and # the apps.env key: cell1-worker-app.pem / CELL1_WORKER_APP_ID. No cell keeps # the classic worker-app.pem / WORKER_APP_ID. if [ -n "$slug_cell" ]; then name=$slug_cell-$role; else name=$role; fi env_key=$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_APP_ID json=$(mktemp) tmpfiles+=("$json") if ! curl -sS -X POST -H 'Accept: application/vnd.github+json' \ "https://api.github.com/app-manifests/$code/conversions" > "$json"; then echo "create-app.sh: network error exchanging the code for role $role." >&2 rm -f "$json" return 1 fi if ! jq -e .pem "$json" >/dev/null 2>&1; then echo "create-app.sh: no key for role $role — its code was already used or has expired." >&2 echo "Delete that half-made App on GitHub and create it again." >&2 rm -f "$json" return 1 fi pem=$conf/$name-app.pem jq -r .pem "$json" > "$pem" chmod 600 "$pem" app_id=$(jq -r .id "$json") slug=$(jq -r .slug "$json") rm -f "$json" # the response also holds a client secret printf '%s=%s\n' "$env_key" "$app_id" >> "$conf/apps.env" echo "Saved $pem (mode 0600) - $slug app id $app_id" echo " install: https://github.com/apps/$slug/installations/new" return 0 } # Pass 2 — exchange each pair. A failed pair is reported and the run continues # to the next, so one spent code does not abandon the others. failed=0 i=0 while [ "$i" -lt "$n" ]; do if exchange_one "${args[$i]}" "${args[$((i + 1))]}"; then :; else failed=1; fi i=$((i + 2)) done if [ "$failed" -ne 0 ]; then echo "create-app.sh: one or more exchanges failed (see above)." >&2 exit 1 fi echo "Done. Re-resolve your desk tools (for example /plugin) so the new Apps are picked up." EOF chmod +x create-app.sh -
Which App did you just create?
When you reached this page from the create button, the role travels with you and is already selected below: the command in step 3 is complete, and this App’s
role codepair is already in the batch collected in step 4. Confirm it, or click a different role to override. If the role did not travel (an older link, or a carrier GitHub dropped), nothing is selected and the command stays incomplete until you pick; a wrong role would write the wrong filename and the wrongapps.envkey. If you are unsure, the App’s name on GitHub ends in its role (for example…-worker). -
Run it · pick a role above
Your one-time code is read from the address bar and filled in here; the role fills in from the create button you came through (or from your pick above), and the cell, if you named one, rides along as
--cell <cell>. Run this in the same directory where you savedcreate-app.sh. Nothing is sent from this page. The code leaves your machine only when your owncreate-app.shposts it, from your shell.No JavaScript, or the fields did not fill in? Take the code from your address bar (the value after
code=) and run./create-app.sh <role> <code>yourself with the role you created; add--cell <cell>before the role if you named a cell../create-app.sh <role> <code>
-
Create all nine in one run · nothing collected yet
Each App tab you picked a role in has added its
role codepair to this list, kept in your browser only (localStorage) and never sent anywhere. When you have created all the Apps you want, copy the one command below and run it once; it exchanges every collected code and writes every key in a single pass. The codes sit inlocalStorageonly until you use them; each is single-use and expires about an hour after GitHub issued it, so run this while they are fresh, then clear the list.# pick a role in each App tab; the collected command appears here
The key files land at ~/.config/assay/<role>-app.pem, mode 0600; umask 077 and an explicit chmod 600 both guarantee it, and the desk tools refuse anything else. Each apps.env line names the App by role: REVIEWER_APP_ID=1234567, ISSUE_LOOP_APP_ID=…. If you passed --cell <cell>, the cell rides in front of both, <cell>-<role>-app.pem and <CELL>_<ROLE>_APP_ID, so two fleets never overwrite each other. If the exchange reports no key for a role, that code has been used or has expired; delete the half-made App on GitHub and create it again.
Give the App a face
Download the icon for the App you just created and upload it as its avatar (App settings → Display information) so a review or commit is visually attributable. One mark per role, drawn in the site's ledger-green line; swap them for your own whenever you like. The point is only that each identity looks like itself in a PR's reviewer list.
| Mark | Role | File | Download |
|---|---|---|---|
| reviewer | app-icon-reviewer.png |
Download | |
| worker | app-icon-worker.png |
Download | |
| verifier | app-icon-verifier.png |
Download | |
| desk | app-icon-desk.png |
Download | |
| issue-loop | app-icon-issue-loop.png |
Download | |
| intake-loop | app-icon-intake-loop.png |
Download | |
| board-writer | app-icon-board-writer.png |
Download | |
| promote | app-icon-promote.png |
Download | |
| auditor | app-icon-auditor.png |
Download |
Then install it, and do the next one
Open https://github.com/apps/<slug>/installations/new (the slug is what the exchange printed), choose the account and Only select repositories, and record the installation ID from the resulting address bar as <ROLE>_INSTALL_ID_<OWNER> in apps.env. Full detail under Then install each App.
Verify the grant · before the role does any work
An App can be created and installed and still be under-granted, and the gap shows up mid-pass, as a 403 wearing some other error's clothes, rather than at boot. So check it now. From a checkout of any repository the App is installed on, run the preflight for the role you just finished:
deskroster preflight --role <role> --root .
The line to read is app-scopes-vs-duties: it must come back checked-clean. If instead it names a missing scope, the App is short of the role's duties; each of the six desk roles needs pull requests, issues, and contents at Read & write. Open the App's Permissions & events page in GitHub settings, raise the named permission, and then accept the pending permission update on every installation: an edited App does not gain the grant until the account owner accepts it, once per account it is installed on. Re-mint the role's token fresh (desktoken <role> --fresh) before re-running the preflight; a cached token still carries the old grant for the rest of its reuse window, so a plain re-mint reads the same stale answer.
Checked-clean is not the whole grant. app-scopes-vs-duties compares the installation against those three writes and nothing else, so it cannot tell you whether the reviewer holds the one read it also needs: Administration: Read-only, without which the ready-flip cannot read 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 fails closed on every pull request. Set that toggle when you create the App — the permission table carries it, with the reason beside it — and read a clean preflight as evidence about the three writes only.
board-writer, promote and auditor are infrastructure identities rather than desk roles; no desk role mints their tokens, so the preflight does not cover them. The auditor is the one App where a missing write is the specification: its grants are Administration and Contents at Read-only and nothing more.
Then go back to the table for the next role. Nine Apps, nine codes, nine keys, one file of IDs.
# ~/.config/assay/apps.env — when all nine are done
REVIEWER_APP_ID=1234567
REVIEWER_INSTALL_ID_MY_ORG=87654321
WORKER_APP_ID=1234568
WORKER_INSTALL_ID_MY_ORG=87654322
VERIFIER_APP_ID=…
DESK_APP_ID=…
ISSUE_LOOP_APP_ID=…
INTAKE_LOOP_APP_ID=…
BOARD_WRITER_APP_ID=…
PROMOTE_APP_ID=…
AUDITOR_APP_ID=…