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.

  1. Save create-app.sh · once

    Paste this whole block into your terminal once. It writes create-app.sh into the current directory and makes it executable. The script takes one or more role code pairs 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 needs curl and jq.

    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
  2. 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 code pair 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 wrong apps.env key. If you are unsure, the App’s name on GitHub ends in its role (for example …-worker).

  3. 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 saved create-app.sh. Nothing is sent from this page. The code leaves your machine only when your own create-app.sh posts it, from your shell.

    ./create-app.sh <role> <code>
  4. Create all nine in one run · nothing collected yet

    Each App tab you picked a role in has added its role code pair 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 in localStorage only 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.

MarkRoleFileDownload
reviewer App icon reviewer app-icon-reviewer.png Download
worker App icon worker app-icon-worker.png Download
verifier App icon verifier app-icon-verifier.png Download
desk App icon desk app-icon-desk.png Download
issue-loop App icon issue-loop app-icon-issue-loop.png Download
intake-loop App icon intake-loop app-icon-intake-loop.png Download
board-writer App icon board-writer app-icon-board-writer.png Download
promote App icon promote app-icon-promote.png Download
auditor App icon 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.

Check

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.

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=…