magus v0.4.3 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
8 min read

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

  1. .github/workflows/run.yaml, triggered by workflow_dispatch alone, with inputs argv (the magus argv after magus) and sandbox (required by default, or best-effort). One job on ubuntu-latest with contents: read and 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 with magus vcs checkpoint -o name, and runs queue 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/ as magus-logs on every outcome, kept 7 days.

  2. A repository script, since replaced by hack/remote/on-actions.buzz, three steps, none of which waits:

    • dispatch needs exactly one of --ref <branch> and --push. With --ref it asks GitHub for the branch tip and refuses unless it is HEAD. With --push it pushes HEAD to run-<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, the result and forget commands, 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 is run-<sha12> of the run's own commit, and prints what stays visible.

    Every step refuses a run of any workflow but run.yaml.

  3. 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.

  4. No waiting. An agent polls result when it has nothing better to do. The guard's ci-watch deny stays as written.

  5. 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 of run.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-logs artifact, and output refs to cite with describe 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

  1. 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 result names a mismatch. The matrix goes on this page with dates and run ids.
  2. Who started a run. dispatch cannot record the run id on the agent's job row: the job store has no field for it. A guard carve-out from ci-watch for a run this job started needs that field first.
  3. The secrets rule above, as a guard rule over gh workflow run and gh api .../dispatches.
  4. A laptop-side spells/github/workflows speaking HTTP, beside review, 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.

adrdecisioncigithub-actionsremotesandboxqueuescope
Last updated (8b20a199)
Earlier changes on this page (3)

Full history ↗ · Blame source ↗

Glossary

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Engine

The interpreter a magusfile runs on; magus embeds the Buzz engine. See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

Affected

The set of projects touched by a change; magus affected <target> runs a target only over them. See affected.

Sandbox

The restricted filesystem and environment a target runs in, so builds stay reproducible and side-effect-free. See sandbox.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs: magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Concurrency

How many targets run at once. It is bounded by the pool's capacity and set with --concurrency, MAGUS_CONCURRENCY, or the concurrency config key. See server.

Invocation

One magus process's recorded facts (the targets it finished, their outcomes, the lease it acted as, and the session it ran in when a host delivered one), kept in a repo-scoped store every worktree shares. magus session lists them; the store prunes itself by last-fact age.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the server, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. See doctrine.

A job is not a run. magus run build web is a run, and no job exists for it. A job causes runs: its check executes as one, and a server job records the invocation of its last one. Jobs are listed with magus ls jobs and in the console's Jobs view; runs are listed in the Runs view.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate.

Lease

The grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it with magus job exec. A job is the piece of work; a lease is permission over it.

Conventions

Placeholders

Angle brackets mark a value you replace with your own; never type the brackets:

magus run <target>
magus completion <shell>    # e.g. bash, zsh, fish

<target>, <path>, <shell>, <name> and the like are stand-ins, not literal text.