ADR 0004: running a branch on a hosted runner
- Status: Proposed
- Date: 2026-09-26
- Sibling of: ADR 0003. 0003 brings another kernel to your checkout; this page takes your commit to CI's machine. They answer different questions and compose.
Context
Two needs arrive at a laptop running many agents. The first is proof: a change that CI might fail for reasons your machine cannot show, such as CI's kernel build, its mise toolchain, its uid and HOME, its CPU count, or linux/amd64. The second is offload: a test suite you would rather not run beside ten agents.
Today the only route to CI's machine is a pull request. A branch push triggers nothing
(ci.yaml runs on push to main only). ci.yaml does carry workflow_dispatch, but a
branch dispatch of it runs cold: its local-tier restore and save are pull_request only,
and MAGUS_VCS_BASE_REF=last-passed has no run log on a new branch to read.
Everything else already exists. magus queue gate --sandbox=required is the box the merge
queue gates a candidate in, and ci.yaml's shards run inside it. setup-magus builds
magus from the checked-out tree. .github/actions/magus passes an argv through its
environment, so a word-split argv never reaches a shell as code. GitHub's dispatch API
returns the run it created (changelog 2026-02-19), and gh workflow run prints its URL
since v2.87.0. hack/ci/merge-queue.buzz already dispatches queue.yaml with gh.
Decision
-
.github/workflows/run.yaml, triggered byworkflow_dispatchalone, with inputsargv(the magus argv aftermagus) andsandbox(requiredby default, orbest-effort). One job onubuntu-latestwithcontents: readand no secret beyond the job's own token. It checks out the dispatched ref with no persisted credential, builds magus from that tree, prints the revision withmagus vcs checkpoint -o name, and runsqueue gate --sandbox=<mode> --cache .magus -- magus <argv>through.github/actions/magus. It reads main's signed remote tier and writes no shared tier. It uploads.magus/logs/asmagus-logson every outcome, kept 7 days. -
A repository script, since replaced by
hack/remote/on-actions.buzz, three steps, none of which waits:dispatchneeds exactly one of--ref <branch>and--push. With--refit asks GitHub for the branch tip and refuses unless it is HEAD. With--pushit pushes HEAD torun-<sha12>, a branch named for the commit. It refuses a dirty tree unless you pass--head, and then says it tests HEAD without your changes. It refuses an argv word the runner would split or glob. It prints the run URL, theresultandforgetcommands, and the host, request count and elapsed time.result --run <id>makes one request and says where the run stands, which commit it ran against yours, and how to fetch its logs once it completes. It exits 1 once the run completed without success, and 0 otherwise.forget --run <id>deletes a completed run with its logs and artifacts. It deletes the run's branch only when that branch isrun-<sha12>of the run's own commit, and prints what stays visible.
Every step refuses a run of any workflow but
run.yaml. -
Transport is
gh. It holds the laptop's token, and the repository's queue glue already shells to it. An HTTP spell comes only if the measurement below says the loop earns one. -
No waiting. An agent polls
resultwhen it has nothing better to do. The guard'sci-watchdeny stays as written. -
The workflow must be on main to dispatch at all. GitHub dispatches only a workflow file that exists on the default branch. After that,
--ref <branch>runs that branch's own copy ofrun.yaml, magusfile changes included.
What "no record" means on a public repository
egladman/magus is public. Anyone logged in to GitHub can open the Actions tab and read a run's head branch and full logs. GitHub keeps runs, logs and artifacts for 90 days by default, and a public repository can set 1 to 90. The branch you dispatch on is public the moment you push it.
So a run CAN mean: no pull request, no review thread, no commit on main, no changelog
entry, and after forget, no run in the list and no branch this script pushed. It CANNOT
mean nobody saw it. Between dispatch and forget, the run, its logs and its branch are
open to every logged-in user. After forget, the commit stays fetchable by its id, and
GitHub does not say a deleted run is gone from its side. Treat a dispatched commit as
published.
Secrets
Anyone with write access can read every repository secret, and a dispatch at any branch
runs that branch's copy of the file with those secrets in reach. run.yaml names no
secrets.* beyond the job's token, and hack/remote/on-actions.buzz starts run.yaml alone.
The guard should prove the rest: deny a dispatch of any workflow whose file at the ref
reads a secret beyond GITHUB_TOKEN (release.yaml and release-index.yaml carry
workflow_dispatch and read MAGUS_SIGNING_KEY).
Beside ADR 0003
--platform (0003) |
a dispatched run (this page) | |
|---|---|---|
| runs where | this machine, a VM kernel | GitHub's runner, CI's own machine |
| what runs | your working tree, mounted read-only, dirty allowed | HEAD of a pushed branch, clean unless --head |
| reproduces | the kernel: landlock, /proc, seccomp |
0003's "not reproduced" column: CI's kernel build, the mise toolchain, HOME and uid, CPU count, linux/amd64 |
| results land | the local platform store; query output names it |
a run log and artifacts; an output ref is a citation, not a replay |
| round trip | seconds once the image is warm | minutes: every run builds magus from source |
| machine budget | yours | GitHub's, free on a public repository with standard runners |
| network | none without --fetch |
every step, named by the step |
Use --platform for the inner loop. Dispatch for the proof, and to move work off a busy
laptop.
Alternatives
A. Dispatch ci.yaml from a branch
Rejected as the first version. It runs the full plan cold on every call, its concurrency group and history were written for main and pull requests, and a person wants one argv, not the whole gate.
B. --platform github, a value of ADR 0003's flag
Rejected. The relay mounts your checkout and returns a store; a runner can do neither. A
run takes a pushed commit, answers in minutes, and returns a log. Folding it into
--platform would promise a contract it cannot keep.
C. A private mirror
Push to egladman/magus-scratch and dispatch there, so no run is public. Rejected: it
spends minutes a public repository gets free (2,000 per month on the Free plan, then
$0.006 per Linux minute), duplicates secrets and variables, and hands every agent a second
remote.
D. One workflow and one repository script (decided)
magus buzz hack/gha-run.buzz -- dispatch --ref <branch> -- run test .
magus buzz hack/gha-run.buzz -- dispatch --push -- run go::go-test . -- -run TestPipePeer
magus buzz hack/gha-run.buzz -- result --run <id>
magus buzz hack/gha-run.buzz -- forget --run <id>
Consequences
- A person or agent gets CI's machine for one argv with no pull request, in the same box the queue gates in. It costs a pushed branch, a public run and minutes of wall time.
- Results do not come home as cache entries. The remote tier is CI-only: the
github-actions spell reads a token the runner gives JavaScript actions alone, and CI's
entries carry no image digest a laptop could hit on (0003). What comes back is the log,
the
magus-logsartifact, and output refs to cite withdescribe target --cache --against. - Runs share the Free plan's 20 concurrent jobs with the merge queue.
- The refusals ledger (
docs/doctrine.md, "A record of refusals") gains three rows:--platform github, since a runner can neither mount a checkout nor return a store; a default dispatch on push, since a run is a public record you opt into per commit; and a private mirror, for the minutes, the duplicated secrets and the second remote.
Open questions
- The measurement: dispatch three times from a branch (the landlock tests, a shard's
argv, a deliberately red argv) and record time to the first log line, the total, and
whether
resultnames a mismatch. The matrix goes on this page with dates and run ids. - Who started a run.
dispatchcannot record the run id on the agent's job row: the job store has no field for it. A guard carve-out fromci-watchfor a run this job started needs that field first. - The secrets rule above, as a guard rule over
gh workflow runandgh api .../dispatches. - A laptop-side
spells/github/workflowsspeaking HTTP, besidereview, if the measurement says the loop earns it. No engine verb is proposed.
Amendments
2026-09-29: the script becomes the hack/remote/on-actions.buzz prefix
The script is hack/remote/on-actions.buzz, a prefix in front of the command you would run
here, like sudo or nice:
magus buzz hack/remote/on-actions.buzz -- magus affected ci
Its own options come first; the first word that is not one starts the command, which must
be magus and passes through untouched. By default it pushes HEAD to run-<sha12>,
dispatches run.yaml, waits, prints the command's output, exits with its exit code, and
then deletes the run and the branch unless --keep. Its first line says where the command
runs, that it spends CI minutes, and the run URL. That replaces decision 4 for the default;
--detach prints the run id and returns without waiting, as dispatch did. --ref,
--head and --sandbox keep their meaning, and --push is the default. --result <run>
replaces result, --delete <run> replaces forget, whose name hid a remote deletion, and
--ls lists every run and pushed branch left behind. Every refusal above still holds.
run.yaml runs queue gate itself instead of through .github/actions/magus, which
returns neither the output nor the exit code, and uploads both as the command-output
artifact the script reads.