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. The plan job 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.
Coverage badges are published, never committed
The README shows three language badges: Go, Buzz, and TypeScript. None of them lives in
the tree. The CD site job (.github/workflows/cd.yaml) measures all three on every push
to main with magus run coverage-render ., renders the SVGs, and publishes them with
the docs site under assets/. Nothing to refresh, nothing to commit.
A committed badge cannot be gated consistently. Go coverage differs by platform, and Node's line coverage moves with its patch version, so a badge measured on one machine is stale on the next. What stays local is a floor per language, each a threshold on the figure the suite that just ran measured:
- Go: 70%, in the root
testtarget, over this module with generated files removed. - Buzz: 62%, in
buzz-test. - TypeScript: 67%, in
console'stest, merged across the console andlibs/textsearch.
Lower a floor deliberately, in the change that explains why.
The published Go figure is a different number from the floor on purpose: it spans the
whole Go codebase, and no single machine compiles all of that. 119 non-test files sit
behind //go:build constraints, so macOS and Linux read 67.6% and 66.9% for the same
commit. The published figure is therefore split in two.
The denominator is static. Which statements exist does not depend on where you
stand, so coverage.buzz enumerates every statement block of every Go file in every
module by running go tool cover -mode=set over one file at a time. That instruments
syntactically, resolving no import and consulting no build context, so a Windows-only
file yields its block table on a Mac and lands in the denominator at zero. Verified
against a real profile: over the 418 files both a go test -coverprofile run and this
pass covered, the block sets were identical.
The numerator is what the CD job's Linux run entered, across every Go module's suite. A file only another platform compiles stays in the denominator at zero, so the published figure can only understate coverage, never overstate it.
Pull requests
Pull requests are squash merged, so the title becomes the commit subject main
records. .github/workflows/pr.yaml checks two things about every pull request:
- The title is a conventional commit subject:
<type>(<scope>): <description>, with the scope and a!optional. The types arebuild,chore,ci,docs,feat,fix,perf,refactor,revertandtest. The description keeps the lowercase imperative this repository writes, with no trailing period, in 100 bytes at most.feat(git): install version-controlled buzz hookspasses;Add git hooks.does not. Check one withmagus run pr-title . -- "<title>". - A
feat,fixorperfchange to shipped code adds a changelog fragment, as does any title marked breaking with!. Every other type passes without one, and so do changes that touch only docs, tests, workflows, repository tooling and plans;tools/changelog.buzzlists what does not ship. A change no user can notice is not afeat,fixorperf, so retitle it rather than add an entry. Check a branch withmagus run pr-changelog . -- "<title>".
Fragments. The entry is its own file, changes/unreleased/<branch>.md: a
### <group> heading and one entry, exactly as it will read in the changelog.
Never edit CHANGELOG.md's [Unreleased]; it stays empty so concurrent pull
requests never touch one shared file, and regenerating it refuses while it holds
entries. The docs changelog page renders the fragments, and cutting a release
folds them into its manifest. changes/README.md has the format; a malformed
fragment or an unknown group fails the check.
Your branch's own commits are squashed away, so they keep the lowercase
imperative style with no prefix. magus run git-hooks-install . installs this
repository's git hooks from tools/git-hooks/; its commit-msg hook applies the
title rule only to commits made directly on main. magus run git-hooks-remove .
takes them out again. To add a hook, see
Your own hooks, in Buzz.
Workflow targets, not inline logic
The GitHub Actions workflow files are intentionally thin. Every meaningful
CI operation lives in a target in magusfile.buzz or in magus itself, not
in a workflow YAML step. The workflow orchestrates: it sets up the
toolchain, calls magus, carries magus's output to where the host wants it,
and uploads artifacts.
Concretely:
- The workflow YAML names jobs and steps, sets env vars, uploads
artifacts, and holds thin provider glue: a few lines that redirect what
magus printed into a file the host reads, such as
>> "$GITHUB_OUTPUT"or>> "$GITHUB_STEP_SUMMARY". Glue moves bytes. It never decides anything: no conditionals, loops, parsing (jq,sed,awk) or string building. If a step needs one of those, the work belongs in magus or a target, and magus should print the finished bytes. - magus and the magusfile targets hold the actual work: which projects
to fan out over, what to render, how to interpret drift. What a host
consumes is rendered by magus in a provider-neutral shape, with
-ochoosing the encoding,-o template=...included. - A target that wants host-specific behavior declares it via a charm
(
gha,cd,rw) rather than by sniffing the environment it runs in.
Why glue in the workflow rather than a target: the file names
($GITHUB_OUTPUT, $GITHUB_STEP_SUMMARY) belong to the host, and a
redirect is the host's own interface for them. A target that wrote those
files had to read stdin, detect whether it was on a terminal, skip the
cache, and turn a charm into a write switch, and it made the plan job's
two magus invocations contend for one project lock. The CI plan step
(.github/workflows/ci.yaml) is the worked example: it computes the plan
once with magus affected ci --plan, renders it twice with magus run --stdin --dry-run -o 'template=...' < plan.json, and redirects each render.
magus never learns either file's name, so a port to another CI system
rewrites the redirects and nothing else.
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.