Contributing to magus
Thanks for being here. magus is a one-person project, and anyone who takes the time to try it, file an issue, or send a patch is doing me a favor.
Issues and PRs are both welcome. Replies may be slow, which is about how much time I have rather than how interested I am. If you're planning something big, open an issue first so we can talk it through before you spend an evening on it.
Using AI tools
I use AI assistance to build magus, so it would be strange to tell you not to. Use whatever you like.
If you're new to the codebase, one suggestion rather than a rule: make your first couple of PRs without it, and keep them small. A docs fix, a test, a one-line change. Nothing controversial.
That's less about the tools than about the size of the change. What I'd rather not get is a big multi-file PR rewriting how something works, from someone who hasn't had a chance to poke around yet. These tools are about as good as the context of the person steering them, and when they're wrong they're usually wrong in a way that still reads fine, which is hard to spot if you're new to a codebase. Doing a couple of small things by hand is the quickest way past that.
After that, go for it. I'm looking at the change, not at how you made it: can you
explain what it does and why, does it match the conventions below, does
magus affected ci pass. You don't need to tell me either way.
And if any of this is unclear, or the setup fights you, please open an issue. I'd much rather answer a question than have someone give up on it quietly. Friction is the thing this project exists to remove, so hitting some on the way in is worth telling me about.
Build and test
git clone https://github.com/egladman/magus
cd magus
go build ./cmd/magus
go test -race ./...
Integration tests sit behind //go:build integration and are named
TestIntegration*. go test ./... runs the fast unit tests; go test -tags=integration ./... runs everything.
Lint and the vuln check run as pinned binaries from mise.toml, so the linter's
large dependency tree stays out of magus's module graph entirely:
magus run lint
They deliberately do not go through go tool. That compiles the tool from source
with whichever go leads PATH, which is how CI once built golangci-lint with a
different toolchain than the one it analyzed with, and panicked. mise install
gets you both binaries at the pinned versions.
Adding to a public surface
magus promises that a magusfile which works today keeps working, and there is no plan for a 2.0. docs/concepts/compatibility is the promise itself; this is how to work inside it.
Add, never substitute. A new magus\project key goes beside the existing
ones, a new protobuf field takes a new number, a new flag joins the current set.
If you find yourself changing what an existing name means, that is the thing the
promise forbids, and the answer is a second name rather than a redefinition.
Three gates catch a break before review does, one per surface:
| Surface | Gate |
|---|---|
| CLI | internal/cli/testdata/api.lock, a drift-gated snapshot of every subcommand, flag, config key, and target |
| protobuf | buf breaking, composed into lint |
| magusfile keys | the required_version floor, plus doctor's check that the floor is accurate |
A diff to api.lock is the signal to read carefully: a line removed there is a
removed public surface. Regenerate it with go generate ./internal/cli/...
after an intentional addition, and treat a deletion as a question rather than a
regeneration.
The fourth surface has no gate: std/ host-module method names. A whole-tree
review in August 2026 established this, and it is worth knowing before you touch a
descriptor. Method.Name in a std/ module is run through std.CamelCase and
becomes the identifier every magusfile calls, so renaming it breaks them - and
nothing catches it:
- MGS1025's removed-API table covers only the
magus.*namespace, so it cannot soften afs.*oros.*rename, and it is a friendlier error rather than a working alias either way. internal/interp/bindings/testdata/magus-api.lockpins onlymagus.*member names. No lock file lists std method names at all.
So a std rename is a silent break that review has to catch by eye. If you make one
deliberately, it belongs under Breaking in the changelog with the old and new
spellings, and every in-repo caller must move with it - magusfile.buzz,
spells/, docs/**/*.buzz, tools/, testdata scripts, and the .txtar fixtures
under cmd/magus/testdata/.
The same descriptor's Doc: string is not documentation either: it is codegen
input, reaching the generated .d.ts, docs/reference/buzz/*.md, and editor
hover text. A wrong Doc teaches every Buzz author the opposite of the contract,
which is how three fs and vcs methods came to promise a false/zero/empty
return while their bodies raised.
A new key means the workspace needs a newer magus. Additive changes are safe
for existing users and unsafe for the repo's own magusfile, which CI runs against
a pinned release. The rule is release-first: if magusfile.buzz starts needing a
feature no release contains, cut the release before the magusfile depends on it.
When you hit this locally the symptom is a load error from the pinned binary,
which is the mechanism working rather than a problem to route around.
Raise required_version in magus.yaml in the same commit that adds the key,
naming the release that will carry it. Doctor's floor check asserts the two agree,
so a floor left behind is caught rather than remembered.
This used to say the opposite: raise it in the release commit, because a floor
naming an unreleased version would reject a source build of the very commit that
raised it. Between releases a build describes itself from the last tag, so a tree
42 commits past v0.3.0 reports v0.3.0-42-gabc and compares as 0.3.0. That
hazard is gone. ward.CheckRequiredVersion exempts dev builds outright, and
types.IsDevMagusVersion counts anything carrying a -g<sha> suffix, which is
every build from source. The floor is enforced only against a clean release tag,
which is exactly who it is for.
Two things follow from raising it early, and both are the mechanism working:
audit.yaml's compat job pins the newest release and will fail until the one
you named ships, and a released binary meeting the workspace gets
MGS1021
naming the version it needs. The alternative is worse: a floor that still admits
the old release lets it load far enough to fail on whichever key it happens to hit
first, which reads as a typo rather than a version problem.
Before 1.0, renaming is on the table; after, it is not. magus is pre-1.0, so a badly chosen name can still be fixed, and the changelog records it under Breaking. That window closes at 1.0. If you are adding a name you are not sure about, say so in the pull request - it is much cheaper to argue about it now than to keep it forever.
Deprecating means "there is a better way now", not "this stops working". Keep the old surface working, point at the replacement where someone meets it rather than only in the changelog, and list it under Deprecated.
Performance changes need evidence
This is the rule I care about most. Any change that claims to be faster ships
with a checked-in Benchmark* and the benchstat numbers behind it. No
speculative micro-opts.
Capture a baseline, make the change, then compare:
go test -run=^$ -bench=. -benchmem -benchtime=2s -count=10 ./PKG > before.txt
# ... your change ...
benchstat before.txt after.txt
Put the benchstat rows in the commit message, not the tree. Leave an inline
optimization: comment at the hot path, in the form used in
internal/cache/mtime.go:
// optimization: <what changed in one line>.
// measured: <BenchmarkName> <delta> (benchstat, n=N).
// trade-off: <legibility/portability cost>.
// assumes: <platform/kernel/build constraint>.
so the trade-off is reviewable without re-running the bench. Per-OS fast paths
(see internal/cache/reflink/) always keep a portable
fallback; never gate behavior on a fast path.
Docs site
The docs site under docs/ renders into docs/gen/, which is not committed.
.github/workflows/cd.yaml renders it on every push to main and publishes
that. To look at your change, use the dev server rather than opening a file:
magus run build docs
magus run build console
magus run serve .
serve is the workspace-root loop for both deployables at once. It hosts one
server with the GitHub Pages layout - / is docs/gen, /console/ is
console/gen - and re-renders the affected tree when you edit a source, so a
reload shows the change with no restart. It refuses to start until both gen/
trees exist, which is what the two builds above are for; a docs-only change
still needs the console one, once.
Reach for magus run generate docs when you want the drift gate and the
integrity, orphan, and abandoned-URL checks rather than a preview. It is the
slower path, and it is what CI runs.
It was committed for years. The reason it no longer is: every page embeds the commit that last touched its source, so committing a source change immediately staled the rendered output committed beside it. That bought a second "refresh generated metadata" commit after every real one, and amending could not escape it, because the new hash restales the footer it just recorded. Rendering at deploy time knows the final commit already.
What still gates on drift is the generated Markdown that is tracked:
MAGUS.md, the root CHANGELOG.md, docs/src/gen, and the derived pages under
docs/reference/. A plain magus run generate docs fails if any of those was
left un-regenerated, so CI still catches a forgotten regen for everything a
reader can find in the repository.
One thing under docs/ stays tracked on purpose:
docs/active.urls.lockis the ledger proving a previously published URL never starts 404-ing. That gate only works if the ledger outlives a single build.
The playground wasm used to be a third, vendored because CI had no TinyGo to
rebuild it. CI's stock Go builds it now, so it is ordinary render output under
gen/ (magus run build-playground docs).
Pages use extensionless URLs (/magus/documentation/, served from
docs/gen/documentation/index.html). If you rename or move a page, keep the old URL alive
by listing it under aliases: in the page's frontmatter, so external links do
not die:
---
title: Download
aliases: [install] # clean, gen-root-relative old paths
---
The build emits a redirect stub at each alias and fails if an alias collides with a real page or is claimed twice.
Dogfooding: how magus builds magus
CI runs the pipeline twice, in series, because there are two questions.
From source, and it gates. preflight and the ci shards build magus from the
commit under test and run the workspace with it. Only this pass can answer "does this
change work" - a magusfile needing a new host binding has no released binary able to
load it. A failure blocks the PR.
From the previous release, and it informs. The compat job runs the newest
published magus against the same workspace: can the binary users already have still
drive it? continue-on-error, deliberately. A failure means the magusfile now depends
on something unreleased, so the remedy is to cut a release, not patch the PR. Gating on
it would redden every such change until an unrelated release happened.
Expect compat to fail when you add a flag, target, or host-binding shape the
magusfile uses. That is the release-first signal working. Do not move a job off the
released binary to clear it - that deletes the check instead of answering it.
A source build resolving a git-ref must be reachable from main; an unverified build
would otherwise run with the job's permissions. source-path skips that gate by
construction, since the caller checked the tree out itself.
Your worktree's merge driver resolves with your own build
The rule above is about CI. Locally there is one place where "the released binary" is the wrong answer, and it used to cost an afternoon.
magus init registers the git merge driver as whichever magus leads PATH.
That is correct for someone using magus, because the registration survives an
upgrade-in-place. In a magus worktree it is backwards: PATH holds a release, and
the merge driver is part of what you are changing. A rebase then resolves every
generated conflict with the release, not with your tree.
That is not a theoretical gap. A released driver regenerated the whole docs site once per conflicted file, so a rebase over a handful of generated files looked exactly like a hang, while the version in the tree resolved the same file in under a second. Worse, it was silent: git reads a driver that exits non-zero as "could not merge", so a broken driver and no driver look identical.
Nothing to run. A workspace that has built its own ./magus registers that as
its driver, and magus doctor fails if the registered one cannot read this
workspace. To pin a specific build instead - so a long rebase cannot have the
driver rebuilt under it - register one by hand and it will stick:
git config --worktree merge.magus.driver "$PWD/magus vcs merge-driver %O %A %B %L %P"
git config --worktree keeps the override local to that checkout. .git/config
is shared by every linked worktree, so an absolute path there would aim all of
them at one checkout's binary.
Naming
Names are the API most people meet first, so they get decided deliberately
rather than by whatever the file was called. From the outside the results can
look arbitrary - go lives in spells/golang/, ls and describe both list
things - so here is the reasoning, which is not arbitrary.
Source layout and registered identity are independent
A spell's directory and the name it registers answer different questions, and they are allowed to differ.
The directory is where a contributor finds the code. The registered name
(mgs_getName) is the identity users type and every listing prints.
spells/golang/ <- idiomatic directory name for Go source
registers "go" <- the language's actual name
Both are right. golang is the conventional directory spelling; go is what
the language is called. Forcing one to match the other would make one of them
wrong. Do not "fix" a mismatch on sight - check which question each is
answering first.
A registered name has to stand alone
The registered name appears in magus describe spells, in diagnostics, and in
error text, always without the directory around it to supply context. So it must
be unambiguous on its own:
- Name the thing, not the job it does here. The S3 backend registers
aws-s3, nots3-cache. Its siblings are named for products, and a capability name reads as a different kind of entity in the same list. - Qualify when the bare word names nothing.
spells/github/actions/registersgithub-actions, notactions- "GitHub Actions" is the product's real name, andactionsalone identifies nothing. - Never take a word the core model already owns.
spells/gitlab/ci/used to registerci, which collides with thecitarget thatmagus affected cianchors on. A listing then showed acispell beside acitarget meaning entirely different things. It registersgitlab-ci.
One verb, one job
Subcommands are split by the question they answer, not by the noun they touch, so two verbs never differ only in verbosity:
lsenumerates. Breadth. What exists here, what can I run. Everyday.describeexplains one thing fully. Depth. A definition plus the complete record. Occasional.
That is why magus ls shows targets but not source globs: the globs are the
full record, which is magus describe project's job, and printing them in both
would make the boundary mush. When adding output, ask which question it answers
and put it in exactly one place.
Prefer a noun on an existing verb over a new subcommand. magus ls targets
rather than magus targets, because the latter invites magus spells, then
magus charms, and the surface becomes a pile of noun-commands with no rule to
learn. One verb, a noun that says what.
Enumeration is spelled ls everywhere - magus ls, magus run ls,
magus memory ls - never list.
Package names mirror the contract they serve
Where a package exists to serve one wire contract, it takes that contract's name,
so the two are correlated by reading rather than by grep. A wire-mapping
subpackage internal/handler/<name> owns the over-the-wire concerns of the
protobuf package magus.<name>.v1:
proto/magus/graph/v1 <-> internal/handler/graph
proto/magus/status/v1 <-> internal/handler/status
proto/magus/viewer/v1 <-> internal/handler/viewer
Adding proto/magus/foo/v1 means adding internal/handler/foo - same name, no
exceptions for the wire packages. Two subpackages there are deliberately not
proto-backed and so are not part of the mapping: mcp is a protocol adapter and
trailrpc is a transport concern.
internal/handler/README.md is the authority and
carries the full table plus what does and does not belong in the layer; keep the
rule there rather than restating it per package.
Hints belong in hint
A command path printed inside output goes through
internal/hint, never a string literal. A drift test walks every
registered command and asserts it still resolves, so a rename cannot leave a
hint pointing at a command that no longer exists. That has already happened once:
a failing target printed magus query <ref> long after the command became
magus query output <ref>.
Generated files in this repository
The general rules are in Generated files and your toolchain: linters never
gate on generated output, formatters cover it as long as the generator emits
formatter-normal output, and the scope for both derives from ctx.writesFiles rather
than a hand-maintained ignore list. What follows is how that lands here.
| Language | Formatter | Generated output | Why |
|---|---|---|---|
| Go | gofmt, via go-fmt |
covered | internal/generate/emit gofmts before writing, so the check is a no-op |
| TypeScript | Biome, via biome-format |
excluded (!src/gen/**) |
bundler and protobuf output is not Biome-normal and never will be |
| Markdown | dprint | excluded (dprint.json) |
generators do not emit dprint-normal output yet; see below |
Two things worth knowing before you change any of it:
-
Go formatting is gated by golangci-lint, not by the
formattarget.gofmt -llists unformatted files on stdout and exits 0, and magus reads an op's verdict from the exit code alone, so unformatted Go used to pass green..golangci.ymlenablesgofmtunderformatters:, which reports the same finding and exits 1. Theformattarget keepsgofmt -las the local reporter, andformat:rwflips it to-w. -
Markdown formatting is dprint, and generated Markdown is excluded from it. That exclusion is the documented fallback, not the goal: it stays until each Markdown generator emits dprint-normal output, at which point its entry comes out of
dprint.json. dprint over prettier because it needs no Node in the format path, and over Biome because Biome does not read Markdown at all..prettierignorewas deleted when prettier stopped being installed; it had outlived the tool by long enough that six of its seven paths no longer existed. -
The console lint target conflates formatting with linting, and should not. The typescript spell exposes
biome-checkandbiome-formatas separate ops precisely so a target can compose them independently, butconsole/magusfile.buzzcallsproc\exec("pnpm", ["exec", "biome", "check", "src"])directly, andbiome checkreports formatting as lint findings. That is the exact blur the concepts page argues against, in our own tree. Route it through the spell ops when you next touch it.
Byte-stability is the one rule with no exceptions, and this repository has paid for it more than once. Every instance was a real bug in a generator, never a reason to relax the rule:
- The knowledge store merged shards in Go's randomized map order, so which shard
supplied a node's
sourcechanged run to run. Node and edge counts never moved, so it surfaced only asmagus run generatefailing its drift gate on provenance lines. Fixed by sorting shard names before the merge (internal/graph/knowledge/store.go). catalog_fingerprintidentifies the binary, not the graph, so two builds of one source produced different values and a regeneration that changed no node and no edge still rewrote the file and failed CI with the fingerprint as the entire diff. It is excluded fromgraph export --reproduciblefor that reason.- The
@runtimeshard put locally observed diagnostics intoMAGUS.md's anchor ranking, so the committed index depended on which codes that machine had tripped.Routing()now excludes runtime edges.
Graph.Output and writeShard both sort before emitting, and both say why in a
comment. Do the same in any new generator.
When you add a generated artifact here, follow the five-step checklist in the concepts
page. The magus-specific part is that the root generate target already drift-gates
every generated file in the workspace, so a per-artifact drift test in Go is redundant
and will drift from the real //go:generate directive.
Workflow targets, not inline shell
The GitHub Actions workflow files are intentionally thin. Every meaningful
CI operation lives in a target in magusfile.buzz, not in a workflow YAML
step. The workflow just orchestrates: it sets up the toolchain, calls the
target, and uploads artifacts. Host-specific knowledge (GitHub Actions
concepts like $GITHUB_OUTPUT, the gha charm) lives at one boundary,
and every other piece of the pipeline stays portable.
Concretely:
- The workflow YAML names jobs and steps, sets env vars that host the host-specific plumbing, and uploads artifacts. It never contains logic that could live in a target.
- The magusfile targets hold the actual work: which projects to fan out over, what to render, how to interpret drift, what to write to a sink the workflow supplies via env.
- A target that wants host-specific behavior declares it via a charm
(
gha,cd,rw) and only reaches for a host-specific env var (ci_shardreads$GITHUB_OUTPUT) once that charm is set - without the charm it prints a preview instead. The workflow sets the env to whatever the host provides.
This way a future port to a different CI system only needs a thin workflow file plus the same magusfile. The targets and charms are portable; a target that must read a host-native env var confines that knowledge behind its charm check.
Workspace references: the bare path
A project reference is a workspace-relative path written bare. That is the only canonical spelling, in and out:
pkg/foo # a nested project, measured from the workspace root
. # the project the cwd is in
./pkg/foo ../foo # measured from the cwd
Every project-arg surface takes those, and every surface that prints a project
prints the bare path - rendered via types.ProjectLabel, which reads the root
as the workspace directory's name rather than a bare .. What magus prints is
what magus takes, so nothing has to be rewritten between the two.
project:pkg/foo is a different grammar, not a second path syntax: it is the
kind-prefixed node reference the graph commands (explain, query, path)
use where kinds mix, alongside target: and spell:.
The workspace://pkg/foo scheme is DEPRECATED. It still parses, with a warning
(internal/file/resolve.go), so a ref written against it keeps resolving; it is
not taught or printed any more, and it never bought a reading the bare path did
not already have. types.WorkspaceRef still renders it and is down to two error
wraps in magus.go; both it and ProjectLabel route through
types.ProjectRef{Path, Dir}, so new rendering rules go on the struct's methods
and both pick them up.
Release-signing key rotation
magus trusts a KEYRING, not a key: internal/selfupdate/release-keys.json lists
every Ed25519 public key a signature may come from, each with a state. Signing
uses exactly one; verification accepts any that is not revoked. The ring has
reviewable copies in docs/gen/install, the setup action, and the download
guide, and TestReleaseTrustAnchorMatchesInstallerAndCI fails CI when they
disagree. Never put a private Ed25519 seed in the repository or a release
artifact.
| state | signs | verifies |
|---|---|---|
active |
yes | yes |
standby |
no | yes |
retired |
no | yes |
revoked |
no | no |
The fingerprint is derived from the key (KeyID, the first 16 hex digits of its
SHA-256), never written down, so an entry cannot name a key it does not hold.
A planned rotation, which is why the ring exists. The old procedure was a
chain: ship a compatibility release signed by the current key that embeds the
replacement. It only worked for someone who passed THROUGH that release, and
SelectRelease takes the newest by default - so anyone a version or two behind
jumped straight to a signature their binary had never been told to trust and was
stranded with no in-band way back. The rotation was correct on paper and skipped
exactly the people who most needed it.
Instead, ship the replacement as standby long before it signs anything:
-
Generate the keypair. Add the public half to
release-keys.jsonasstandby, and to the installer and setup action. Run the trust-anchor test.Rebuild with
--no-cache.release-keys.jsonis embedded but matches none of the go spell's source globs, so an ordinarymagus run buildreportscachedand leaves a binary carrying the old ring. Shell completions have the same gap. -
Release normally, more than once. Every binary in the field now trusts a key that has signed nothing.
-
When you are ready, flip
standbytoactiveand the old entry toretired, and move theMAGUS_SIGNING_KEYsecret to the replacement private key. -
Run the Release index workflow. The served index is signed with the same key, so until it is re-signed
docs/gen/public/release/index.json.sigstill carries the old signature andmagus self updaterefuses it. That workflow is manual precisely for this: a rotation, a corrected manifest, and a yank are the moments the index must change without a version being cut. -
Drop a
retiredentry entirely once nothing you still need to verify was signed by it.
Revoking a compromised key. Set its state to revoked and delete it from the
installer and the setup action, then run the Release index workflow. Its
fingerprint is published in the signed index's revoked[], and a client refuses
any signature from a listed key.
This is worth stating precisely, because it is easy to overclaim. Revocation only works because the revoking index is signed by a DIFFERENT key - the standby one - which an attacker holding the compromised key does not have. Signing a revocation with the revoked key would prove nothing, so the client refuses an index that revokes its own signer.
What it does NOT fix: an attacker holding the active key signs whatever they
like, and binaries trusting that key accept it until they see a revocation. The
residual hole is that the attacker can keep serving an OLD index carrying no
revocation. expires_at is what bounds that - past it the client refuses the file
rather than trusting a stale one, converting an indefinite compromise into a
denial of service, which is the right trade. It is 180 days, and nothing
republishes on a timer: magus doctor warns as the deadline approaches and the
Release index workflow is the remedy.
If the compromise is real, treat it as an incident rather than a procedure: stop using the signing secret, publish the replacement binary and public key through an independent trusted channel, and ask affected users to reinstall manually. Do not describe that path as an automatic update.