Divvent

For Divvent’s own team. It is generated from the operations document in our repository, which is the one to edit. If you use Divvent, the help centre is for you.

6d. The dispatch App: a deploy that starts in seconds — BY HAND

A customer's Deploy writes a request and puts a message on the deploys queue. Its consumer, the dispatcher Worker, mints a GitHub App installation token and sends one workflow_dispatch of deploys.yml, so a runner starts in seconds instead of whenever GitHub's schedule next fires (DESIGN.md §10, decided 2026-09-23). Until the three secrets below exist it answers not configured, and the schedule claims every request exactly as before; the deploy log says unset DIVVENT_DISPATCH_APP_ID, … on every run so that state is never silent.

By hand because an App is created in GitHub's settings, and its key is shown once. **A second App, not divvent-agent**: the agent App's token is a platform login at the console, and the whole argument for putting a key on a Worker is that this one can start the deploy workflow and nothing else.

Never grant this App anything but Actions. Two Apps are installed on this repository, and an installation token cannot say which App minted it — so the console's agent exchange (§6b) tells them apart by what they can do: it admits an installation as the agent only if its token can read the repository's contents, which divvent-agent can (Contents: read) and this App must never. Tick a second permission on this App's settings page and its key could sign in at the staging console as the agent. The dispatcher checks this on every batch with its own App's unscoped token and refuses to dispatch if the App holds anything but Actions or can read a file; the console's Logs page then shows a dispatcher record, DispatchOverPrivileged, and deploys wait for the schedule until the permission is removed. docs/SECURITY.md §13 is the boundary.

**Keep divvent-hq/divvent private.** The fence is "can this token read a file", and in a public repository every token can — so the console would admit the dispatch App, or any App installed on the repository, as the agent. If it is ever made public the dispatcher refuses every batch and the Logs page shows fence public; the fix is the repository's visibility, not the App.

  1. Create the App. GitHub → divvent-hq → Settings → Developer settings → GitHub Apps → New GitHub App.
    • Name: divvent-dispatch. Homepage URL: https://divvent.co.uk.
    • Webhook: untick Active. It receives nothing.
    • Repository permissions: Actions → Read and write, and nothing else — above all not Contents, which is what makes an App the agent at the console. GitHub adds Metadata → Read-only itself and will not let it be removed. No organization permissions, no account permissions, no events.
    • Where can this GitHub App be installed: Only on this account.
  2. Take its id and a key. On the App's General page, copy the App ID. Under Private keys, Generate a private key: a .pem downloads, and it is the only copy.
  3. **Install it on divvent-hq/divvent only.** The App's page → Install App → divvent-hq → Only select repositories → divvent. The installation id is the number at the end of the page you land on, github.com/organizations/divvent-hq/settings/installations/<id>. It is not the App ID; swapping the two is a 404 the Worker names.
  4. **Put the three in the staging and production environments.** From the directory holding the .pem, with gh signed in to divvent-hq:
    gh secret set DIVVENT_DISPATCH_APP_ID --env staging
    gh secret set DIVVENT_DISPATCH_INSTALLATION_ID --env staging
    Get-Content -Raw .\divvent-dispatch.*.private-key.pem | gh secret set DIVVENT_DISPATCH_PRIVATE_KEY --env staging
    
    gh secret set DIVVENT_DISPATCH_APP_ID --env production
    gh secret set DIVVENT_DISPATCH_INSTALLATION_ID --env production
    Get-Content -Raw .\divvent-dispatch.*.private-key.pem | gh secret set DIVVENT_DISPATCH_PRIVATE_KEY --env production

    The first two prompt for the value. The key goes in on stdin, whole, both marker lines included, and never through a terminal's scrollback or a command's arguments — no divvent command takes any of the three. Then delete the .pem; GitHub keeps the public half, and a new key is one click.

    Production holds them and does not use them yet. production.yml promotes by deploy --prebuilt, which does not route a Worker's own secrets, and production declares no DIVVENT_REPOSITORY — so its dispatcher answers not configured whatever is set, which is right while deploys.yml runs --env staging only. They are there so production needs no second visit the day it gets a deploy pass of its own.

  5. **Let the next push to main put them on the Worker.** staging.yml's Deploy the control plane step is the only step that carries them, and the deployer puts them on divvent-staging-dispatcher and on no other Worker. The unset line disappears from that step's log. Two of the three set is refused before anything is created, naming the missing one.
  6. Watch one work. Press Deploy in the portal, or ask what is waiting:
    divvent deploys --env staging --runner by-hand --list

    A deploys run with the event workflow_dispatch should appear under Actions within seconds. If it does not, the console's Logs page has a dispatcher record. DispatchOverPrivileged (fence permissions or fence contents) is the App holding more than Actions: remove it on the App's settings page. GitHubRefused carries the step and GitHub's status — mint 401 is the App ID or the key, mint 404 the installation id, mint 422 the App is missing Actions write or is not installed on divvent, dispatch 404 the workflow. The Worker's own log has the sentence. The schedule claims the request either way.

  7. Register it. Console → Credentials: DIVVENT_DISPATCH_PRIVATE_KEY, kind github, owner divvent-hq, rotation in 90 days. Rotating is generating a new key on the App's page, gh secret set again, and running the environment's deploy: control-plane deploy writes every secret it is given on every run, so the new key is on divvent-staging-dispatcher when it finishes (it used to put a secret only where the Worker had none, and this step said to paste the key into the dashboard by hand; that is no longer needed, and a key pasted by hand is replaced by the environment's at the next deploy). Then delete the old key on the App's page; generating one does not revoke the other.
  1. **Restrict the environments to main (and v*).** Settings → Environments → staging → Deployment branches and tags → Selected branches and tags: main, and tags matching v* for the release's recovery drill (§9.4). production is already v* only (§9.3). This is the half of the fence the workflows cannot hold: the App's actions: write can dispatch any workflow on any branch that exists, and a branch's copy of a workflow need not carry the divvent-dispatch[bot] guard that main's do. With the rule, such a run gets none of the environment's secrets.

**What actions: write reaches.** Not only deploys.yml: the App can dispatch or re-run any workflow in the repository, cancel runs and delete their logs and artifacts. Every workflow but deploys.yml therefore skips any job whose github.triggering_actor is divvent-dispatch[bot] — a run the App started or re-ran shows every job skipped — and workflow-stages.test.ts fails a workflow that loses the guard. Rename the App and that login changes: change DISPATCH_APP_SLUG in packages/control/src/github-app.ts and every guard in the same commit, which the test will insist on.

To turn it off, uninstall the App: every mint is then a 404, the Worker records it once per batch, and the schedule is the floor again.