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

CI providers

A CI system renders a job log with structure a plain terminal has no notion of: foldable sections, and annotations that surface on a pull request rather than in the log. Every system spells that differently, and several have no syntax for it at all.

magus emits that structure through a provider spell. The binary knows the generic shape - open a section, close it, raise a notice - and a spell knows one vendor's syntax. This is the same arrangement as remote caching: the extension point is a spell so a provider magus has never heard of is something you write, not something you wait for.

flowchart LR
  subgraph core["magus binary - names no CI system"]
    run["run / cache<br/>a project failed"]
    contract["Annotator contract<br/>internal/ci/annotate"]
    quote["QuoteWith<br/>per-line, in Go"]
  end

  subgraph spell["provider spell - Buzz"]
    ops["enabled, group_start,<br/>group_end, annotate"]
    prefixes["quote_prefixes<br/>read once"]
  end

  vendor["CI system<br/>job log + pull request"]

  run --> contract
  contract -->|"generic verbs"| ops
  ops -->|"vendor syntax"| vendor
  prefixes -.->|"declared once"| quote
  quote -->|"replayed output, de-fanged"| vendor
Diagram source - renders with JavaScript enabled.

The dotted line is the one performance-shaped seam: quoting runs over every replayed line, so the spell declares its command prefixes once and magus does the matching. Everything else crosses into the spell at most once per failure.

Wiring one up

A provider is an ordinary spell, selected in your magusfile:

import "spells/github/actions" as github;

magus\ci.provider(github);

magus ships two: spells/github/actions (which also carries the Actions remote cache backend - one spell per vendor, two contracts) and spells/gitlab/ci.

Wiring one unconditionally is the intended usage. A provider reports whether it is active, magus probes that once per run, and an inactive provider costs nothing - so the same magusfile works on your laptop, in GitHub Actions, and on a system neither of them knows about.

What magus does with it

Only two things today, both on failure:

  • The failing project's output is wrapped in a section, so a fan-out log stays navigable.
  • The failure is raised as an error annotation, so it reaches a reviewer without anyone opening the log.

Sections request expanded for a failure. That is a request, not a guarantee - see the capability table below - and a provider that cannot honor it leaves the output inline rather than folding it. A collapsed failure is a failure nobody sees, which is worse than no fold at all.

The contract

Every op is optional. A spell implements the verbs its provider supports and omits the rest; magus treats an undeclared op as "this provider does not do that", which is the normal case rather than an error.

Op Returns Called
enabled() bool once per run, cached
group_start({id, title, collapsed}) bool once per failing project
group_end({id}) bool once per failing project
annotate({level, message, title, code, file, line, end_line, col, end_col}) bool once per failure
quote_prefixes() [str] once per run, cached

Nothing here is called per log line. That is deliberate: crossing into a spell's VM for every line of a failing build's output would cost more than the whole feature is worth. quote_prefixes is the shape that keeps the one per-line job affordable - the spell declares its command prefixes once and magus does the matching itself.

Why the arguments look the way they do

The vocabulary is the union of what real systems need, not the intersection - the intersection is nearly empty:

System Section open Section close Collapse Annotations
GitHub Actions ::group::TITLE ::endgroup:: always collapsed ::error/warning/notice:: with file, line, col, title
GitLab CI section_start:<unix>:<id> section_end:<unix>:<id> [collapsed=true] none in-log (report artifacts)
Azure Pipelines ##[group]TITLE ##[endgroup] - ##vso[task.logissue ...] with a code field
Buildkite --- TITLE / +++ / ~~~ none - implicit three modes out-of-band: buildkite-agent annotate
TeamCity ##teamcity[blockOpened name=...] blockClosed name=... - message status='ERROR'
AWS CodeBuild, CircleCI none none - none

Hence: a section carries an id distinct from its title, because GitLab and TeamCity key sections by name while GitHub and Azure use only a title. group_end may legitimately do nothing, because Buildkite has no end marker - a new section closes the previous one. Annotations carry a source location and a code, because every system that supports annotations at all supports those, and code is where magus's MGSxxxx diagnostics belong. And nothing assumes the output is a stream, because Buildkite raises annotations by running a binary.

id is opaque. magus passes something readable, such as a project path, and does not normalize the character set - what is legal differs per system, and encoding one system's rule into the shared contract would put that system back into the layer that is supposed to name nobody. GitLab's spell folds the id with strings\kebabCase because GitLab rejects slashes; that rule lives in the spell that needs it.

Security

A provider spell is third-party code running in your magus process, and the values magus hands it are influenced by whatever your build printed. Read this before writing or adopting one.

Warning

message and title are untrusted. They carry a failing process's own output, so their content is whatever some test, compiler, or transitive dependency chose to print. Never interpolate them into a shell command, a URL, or anywhere else their content becomes syntax. A dependency that printed a payload would otherwise be executing it on every machine that builds the project.

What magus guarantees at the boundary:

  • Bounded. Messages are truncated (with the cut marked) and short fields clamped, so a process that printed megabytes cannot make a spell's string handling a denial-of-service vector.
  • De-fanged. Control characters are stripped before a value reaches a spell, keeping tab and newline. A message cannot smuggle escape sequences that re-take control of a terminal when echoed.
  • Time-bounded. Every op runs under a short deadline. A spell that blocks on a network call costs one timeout, not the build.
  • Clamped prefixes. quote_prefixes is capped in count and length, and an empty prefix is rejected - it would match every line.

What magus does not guarantee: a spell has the full host module surface, including os\exec and http. Loading a spell is trusting it, exactly as with a remote cache backend. Spells are not individually sandboxed; they run with the magus process's privileges, constrained only by the process-wide sandbox policy where that applies. If your build output can contain secrets, a hostile provider spell could exfiltrate them.

Replayed output is quoted

magus captures a failing process's output and replays it into the job log. Left alone, a test printing ::error:: would have the runner execute it - forging annotations, or closing a section magus opened. Providers declare their command prefixes via quote_prefixes, and magus neutralizes matching lines before replay by dropping the prefix's first character, leaving text a reader can still read.

One exception: concurrency

magus caps build concurrency on GitHub-hosted runners, which report their host's CPU count while giving the job a slice of it. That check lives in Go (internal/cache/defaults.go) rather than in the GitHub spell, and it is the only place magus names a CI system outside a spell.

The reason is ordering, not preference: the concurrency limiter is built before the magusfile is evaluated, so no provider spell is loaded in time to answer. Anything a spell can answer, a spell does.

Writing a provider

The shared spell contract, and the cache and secret provider variants alongside this one, are in Writing a spell.

Start from spells/gitlab/ci/spell.buzz - it is the shorter of the two and exercises the parts of the contract GitHub does not (an id distinct from the title, a timestamp per marker, collapse as a flag, and no annotation support at all).

Reach for the host modules rather than reimplementing them: strings\kebabCase folds an id, os\env reads the runner environment, std\print writes a marker. Method names are camelCase even where the module reference declares them otherwise, and namespace access is a backslash.

Gate everything on enabled(). A provider that returns false outside its own system makes an unconditional magus\ci.provider(...) free everywhere else.

cici-providerannotationslog-groupinggithub-actionsgitlabspellsextensionsecurity
Last updated (a170f9b2)
Earlier changes on this page (1)

Full history ↗ · Blame source ↗

Glossary

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. See workspace.

Magusfile

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

Spell

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

Module

A magus stdlib namespace a magusfile imports for host capabilities: filesystem, exec, vcs, and more. See the module reference.

Buzz

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

Cache

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

Sandbox

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

Service

A long-running or shared process magus manages across runs, distinct from a one-shot target. See services.

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

Remote cache

A CI-only backend that shares content-addressed artifacts across runners: a cold machine replays a build another runner already did instead of rebuilding. Every remote artifact must be signed by a trusted key. See remote-cache.

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.

Admonitions

Call-outs are rendered from GitHub-style alert blockquotes and carry a colored accent per type:

Note

Context worth knowing, but not a warning.

Warning

Something that can bite you if ignored.

The types are NOTE, TIP, IMPORTANT, WARNING, and CAUTION.