Skip to content

Tasks

Every task is rhiza-task <name>. The catalogue below is the registry the CLI generates its own help from — rhiza-task list is the authoritative version for your repository, and rhiza-task list --all adds the layers you do not have.

list also leaves out a bundle this repository does not use: the Docker tasks without a <docker_folder>/Dockerfile, Paper without paper_folder, Presentation without presentation_file, and Git LFS without a filter=lfs line in .gitattributes. They still run by name (and skip), and list --all shows them.

The shape every task shares

Reading all ten make fragments back to back, every recipe turned out to have the same three parts:

flowchart LR
    G["guard<br/><small>does the subject exist?</small>"] -->|absent| S([skipped])
    G -->|present| P["provision<br/><small>uv run --with / uvx</small>"]
    P --> A["argument vector<br/><small>long, mostly static</small>"]
    A --> R([ok / failed])

That is why a guard and a skip appear all over this page: they are the model, not special cases.

Python

task needs does
install — create the venv and sync dependencies
test install run all tests
test-lowest install run the tests against the oldest dependencies the manifest allows
coverage install measure coverage and write _tests/coverage.xml
typecheck install run ty and/or mypy (typechecker = ty \| mypy \| both)
security install run the bandit security scan
deps install run deptry over the contributed folders
license install scan for copyleft licences
docs-coverage install check docstring coverage with interrogate
all fmt deps test docs-coverage security license typecheck rhiza-test run every gate, as CI does

test and coverage share one argument builder, so the two cannot drift: both write _tests/coverage.xml, _tests/coverage.json, _tests/html-coverage/ and _tests/html-report/report.html. That fixed path is a contract — it is what book's badge step reads and what CI uploads.

test retries once, on exit 3 only

Exit 3 is the xdist teardown race. A retry on 1, 2 or 4 would be re-running a real failure and hoping, so it never happens.

test-lowest shares that argument builder too, and adds --resolution lowest-direct: every direct requirement resolves to the oldest version its specifier allows, so a floor like typer>=0.15 is read back rather than merely declared. It is not a prerequisite of all — it resolves from the manifest rather than the lock file, which is a different question from "is this commit green" — so CI names it as its own job.

Why test-lowest runs --isolated

Asking uv for a different resolution mode makes it discard the lock file: without --isolated the project environment is re-resolved in place, and .venv and uv.lock are left downgraded once the gate finishes. On a runner nobody notices; on your machine it is a gate that rewrites a tracked file and changes what every later task runs against. The ephemeral environment costs one resolve and leaves nothing behind.

A requirement with no lower bound has no floor

--resolution lowest-direct reads every direct requirement's specifier, so a bare pytest in a dependency group is not "any version is fine" — it resolves to the oldest release on PyPI, pytest 2.0.0, whose sdist no longer builds. The gate then fails before measuring anything, naming the requirement that did it:

hint: `pytest` (v2.0.0) was included because `demo:test` (v0.1.0) depends on `pytest`

The fix is a floor on the named requirement, not a flag here. It applies to [dependency-groups] and [project.optional-dependencies] as much as to [project.dependencies], since all three are direct. This is not test-lowest being stricter than the plain uv sync --all-extras --all-groups --resolution lowest-direct it replaces: that resolves the same set the same way and fails on the same requirement. What the task adds is floors for the packages it injects, so -n auto and the plugins cannot be the thing that breaks.

Why there is no Rust or Go test-lowest

It is one of the few places the gate-parity contract does not reach, and for reasons belonging to the other two toolchains rather than to this package. Go already resolves this way: module resolution is minimum version selection, so go test builds against the versions go.mod names and a floors gate would be test again. Cargo does the opposite and offers no stable way to ask — -Z minimal-versions is nightly-only — so a task here would be a gate that most consumers' toolchains reject.

It is a task so that setup runs

The job this replaces called uv directly, and a caller outside the task graph is a caller outside install — so a repository whose local-setup.sh provisions a native binary got a green test and a red floors job on identical code, reporting a resolution failure that was really a missing dot. Anything that runs the suite should arrive through install; that is the whole reason the hook is anchored there.

Rust

task needs does
install — install the toolchain and fetch dependencies
cargo-tools — install the cargo subcommands the gates need
test install cargo-tools run the test suite with nextest, then the doctests
coverage install cargo-tools measure coverage and write _tests/coverage.xml
typecheck install lint with clippy, warnings as errors
security install cargo-tools scan dependencies for known advisories
deps install cargo-tools report unused dependencies
license install cargo-tools run the licence compliance scan
docs-coverage install fail on any undocumented public item
all fmt test docs-coverage security deps license typecheck rhiza-test run every gate, as CI does

Go

task needs does
install — install the toolchain and download dependencies
go-tools — install the Go tools the gates need
test install run the test suite
coverage install go-tools measure coverage and write _tests/coverage.xml
typecheck install go-tools vet and lint (the compiler already type-checks)
security install go-tools scan dependencies for known vulnerabilities
deps install report dependency drift
license install go-tools run the licence compliance scan
docs-coverage install go-tools fail on any undocumented exported item
all fmt test docs-coverage security deps license typecheck rhiza-test run every gate, as CI does

Go has no coverage-floor flag, so coverage pipes a profile through gocover-cobertura and checks the floor itself.

Quality

task needs does
fmt — run the pre-commit hooks over all files
semgrep — run the semgrep static analysis rules
rhiza-test install run the rhiza repository checks
test-pyproject install run the pyproject.toml structure checks, verbosely
todos — list every TODO, FIXME and HACK comment
complexity — fail on a block above the cyclomatic-complexity ceiling
docs-examples install check the fenced examples in the docs tree

fmt skips without a .pre-commit-config.yaml, semgrep without a .rhiza/semgrep.yml, and docs-examples without a docs folder — or with one holding no fence it can check. None is a defect — see Skip is an outcome.

docs-examples doctests every pycon fence — all of one file's joined into a single session with ELLIPSIS, so a stale output is reported at the line of the example that printed it, and a fence flagged +RHIZA_SKIP is left out and counted as unchecked. It parses every python fence with compile, every bash fence with bash -n — parsed, never executed — every toml fence with tomllib and every yaml fence with a real parser. The legacy result block is no longer run or diffed — write pycon, where the expected output sits under the statement that produced it — and is named on the unchecked line instead. Fences in any other language are reported as unchecked with a count, because silence there would read as full coverage. It answers the question no other gate does: not "is there a docstring?" but "is what the documentation claims still true?"

Its subject is the docs folder, plus the data fences — toml and yaml — in README.md. That split exists so no fence is checked twice: README.md belongs to pytest-rhiza's test_readme_validation under rhiza-test, which parses its code fences and does not look at data ones. The two gates therefore divide by language rather than by file, and README's contribution is reported on its own line so a partial look at a file is never mistaken for a full one.

Two of those five can go unavailable on a machine that runs the gate fine otherwise, and both say so on their own line rather than passing quietly: bash may be absent (a stock Windows runner), and the yaml parser is provisioned rather than depended on — rhiza-task is a published CLI, so a runtime dependency is an install cost every consumer pays on every invocation, which two fences do not justify. In either case those fences are counted out of the checked total, never assumed sound. toml has no such caveat: tomllib is stdlib at this package's >=3.11 floor.

Parsing is not validation. A toml fence that parses may still name a setting that does not exist, and a yaml fence that parses may still be an invalid workflow; checking either would need the schema. What this closes is the narrower gap where a fence stopped being the language it claims.

complexity is the one task in this section that is Python-only: it runs radon, so it is registered in the python layer even though it reads like a neutral gate. It measures every block in source_folder and fails when one scores above complexity_max (default 15).

It is deliberately not a prerequisite of all. all is the aggregate a consumer's CI invokes, so adding a gate to it would fail builds in repositories that changed nothing — name complexity in a workflow step to opt in, as this repository's ci.yml does. A repo whose blocks are larger should raise the ceiling rather than skip the gate; 15 suits a codebase that already argues its complex blocks in comments.

rhiza-test runs the checks that used to live in a synced .rhiza/tests/ folder, now provisioned as pytest-rhiza. Which checks run is derived from the language layer, not configured: the neutral ones, plus test_pyproject and test_docstrings for Python, test_cargo_toml for Rust, test_go_module for Go.

Which pytest-rhiza runs them is configured, by pytest_rhiza — a pinned release by default, and the project environment's own copy when the setting is empty.

Testing extras

task needs does
benchmark install run the performance benchmarks
hypothesis-test install run the property-based tests
stress install run the stress and load tests

No all names the three, and each is guarded on the folder convention it needs — benchmarks/, stress/, or any test file at all — so a project without one skips rather than fails. book is the one aggregate that does name them, which is how their reports reach the published book.

Book

task needs does
book test benchmark stress hypothesis-test paper build the companion book
book-nav — check that every mkdocs nav entry resolves in the built book
serve book build the book and serve it on port 8000
marimo install start the Marimo editor
marimo-validate install check that every Marimo notebook runs

book aggregates the report-producing gates, copies _tests/ into docs/reports/, exports every Marimo notebook to docs/notebooks/*.html, builds the site with zensical, and generates a coverage badge. It skips entirely without a mkdocs.yml.

paper is a prerequisite but needs no copy step, unlike _tests/: tectonic writes the PDF beside its source, and paper_folder (default docs/paper) is already inside docs_dir, so the site build picks it up as an asset and mkdocs.yml only names it in nav. A repository with no paper — or no tectonic — still builds its book, because a skipped prerequisite does not block a dependent; only a failed one does. Under --strict that skip becomes a failure and would block book, which is worth knowing but is not specific to paper: benchmark and stress guard on folders most repositories do not have.

book-nav is the other side of that permissiveness, and deliberately not a prerequisite of book. Skipping a producer is what lets a repository without tectonic still build its book — and the cost is that mkdocs.yml can name a target the build never wrote. zensical does not catch it: it reports No issues found for a nav entry whose page is missing and for one whose asset is missing, so such a site builds green and publishes a 404 in its own navigation. That is not hypothetical — it is what https://jebel-quant.github.io/rhiza-task/paper/paper.pdf did.

So the check is a separate gate, for the ref that publishes rather than for every build. Markdown targets are matched against both forms mkdocs may write (faq.html and faq/index.html, per use_directory_urls); assets are matched verbatim; external targets are left to a link checker. It skips before the book is built and when the config declares no nav, because both are questions that are not askable rather than answers that are wrong.

Its prerequisite list is also where make's no-op stubs came from: book.mk had to declare test:: ; @:, benchmark:: ; @:, stress:: ; @: and hypothesis-test:: ; @: so that book could depend on gates the tests bundle may never have contributed. The runner skips unregistered prerequisites, so all four stubs are gone.

serve uses Python's own HTTP server rather than an editor's built-in one, because the JetBrains server refuses to serve gitignored directories and _book is one.

Dev

task needs does
doctor — check local prerequisites
clean — remove build artifacts and stale local branches
setup — run the repository's own environment setup hook

setup runs a repo-owned local-setup.sh at the repository root, and every layer's install names it as a prerequisite — so it reaches local make test, CI and a devcontainer through one insertion point rather than a step in each workflow.

It is the seam for a native binary a project needs before its gates can run: graphviz for a docs plugin, libpq for psycopg, pandoc. rhiza's template owns every configuration file in a managed repository, so before this there was nowhere to put that step — and the workaround that was documented, shadowing install in local.mk, never ran. The Makefile shim forwards the goal to this CLI, so install resolves here rather than in make, and CI invokes the CLI directly without going through make at all.

Deliberately not a system-packages = [...] setting. graphviz is spelled the same on apt and brew; libgl1-mesa-glx is not, and a list cannot express "download this tarball" — which is what rhiza itself does for tectonic. The hook is a script, so platform detection stays with the people who know which platforms they build on, and this package acquires no apt/brew/winget logic — the rule lfs-install states.

A hook that exists but is not executable fails, with the chmod hint — a provisioning step someone wrote and believed was running is exactly what must not pass quietly.

Windows runs the same hook, handed to sh rather than started directly, because the OS execs no .sh — it answers %1 is not a valid Win32 application before the shebang is read. GitHub's windows-latest runners ship git-bash, so there is an sh on PATH to hand it to, and one hook file still covers every platform. Two consequences: the shebang is not consulted there, so a hook that means bash should keep to POSIX shell; and a machine with no sh at all fails naming sh, rather than naming the hook that never started. The execute bit is not asked about on Windows either — os.access(X_OK) calls every existing file executable there, so the question would pass vacuously.

An absent hook, by contrast, succeeds; it does not skip. That is deliberate and is the one place in this package where "nothing happened" is not a skipped. Skip means work was asked for and did not happen, which is why --strict promotes it to a failure. Nothing is asked for by a repository that declares no hook — and since most declare none, skipping would fail every --strict invocation on the common case, book --strict included, five prerequisites down. The [INFO] no local-setup.sh line is what keeps that outcome legible.

doctor is the second procedural escape — it compares semantic versions, which used to be an awk function inside a make recipe.

It names uv and git, and nothing else, and a miss is a failure rather than a warning. Every other binary the package reaches for — docker, gh, git-lfs, tectonic, marp — is a Guard on the one task that wraps it, and reports itself on that task's skipped line with an install URL. doctor.mk also probed GNU make, which was honest while the task layer was make; nothing here needs it, so it is not probed at all.

Template

task needs does
update — sync the rhiza template into this repository

The one task here that replaces a document rather than a make recipe. jebel-quant/rhiza#1654 logs an upgrade that took eight steps, a clone into ~/.local/share, three scripts run in sequence and four commits — and the guide written from it says git add -A, where the upstream document says never to. update is those steps as one command:

TEMPLATE_REF=v1.8.0 uvx rhiza-task update

The ref is a setting rather than a flag, because a task body takes a Config and nothing else. The environment layer is the spelling above; a repo pinning its next target in pyproject.toml works too. Leave it unset to re-sync at whatever .rhiza/template.yml already names.

It wraps, and does not reimplement. rhiza-claude's docs/headless.md settles that: "A separate CLI would be a second implementation of sync to keep in step with the first, and that is the one thing this project has decided not to maintain. One operation, one entry point." So nothing here parses template.yml, resolves a bundle, walks the lock or merges a file — sync.py, resolve_conflicts.py and stage_synced.py do, and this runs them in their documented order and reads their documented exit codes. resolve_conflicts.py is reached only on exit 1, which means "synced, with conflicts"; exit 2 means the sync refused and applied nothing, so there is no half-done state to unpick.

Why it lives in this package: rhiza-task is the only piece of the ecosystem on PyPI, so uvx rhiza-task update needs no install — and the install was the step people tripped over.

It stops before the commit, and prints the command instead:

[INFO] staged the synced files. To commit exactly that set:
[INFO]   SKIP=check-managed-files git commit -m "chore: apply rhiza sync v1.8.0"

That SKIP= is not decoration. rhiza-hooks' check-managed-files refuses a commit touching any path in the lock's files: list, and this commit is by construction exactly that list — better seen once than hidden. A sync that resolved conflicts has just taken the template's side of each one, which is right for a managed file and still a diff worth reading before it becomes history.

RHIZA_CLAUDE_DIR points at an existing rhiza-claude clone — the ~/.local/share/rhiza-claude that headless.md tells you to make. Set, it is read and never written to: a git reset --hard into your working clone is not something a sync command should be able to do to you. Unset, this package keeps its own shallow clone under XDG_CACHE_HOME and refreshes that, because a stale sync.py is a correctness risk and its own copy has nothing to lose.

This is the one task this repository cannot run against itself

rhiza-task is not rhiza-managed and has no .rhiza/ — see CLAUDE.md — so update skips here, and CI's gates job cannot dogfood it the way it does every other gate. Its vectors are unit-asserted and exercised against a scratch repository, but nothing in this repository proves sync.py still takes the arguments it is passed. What would is a job in rhiza-claude running its scripts against this front door.

Bundle-owned sections

The last five sections are the bundle-owned fragments. None is a gate: no all names them and no workflow invokes them. Each is guarded on the CLI it wraps and skips when that tool is absent, with --strict for a caller who wants the hard failure instead.

GitHub Helpers

task does
view-prs list open pull requests
view-issues list open issues
failed-workflows list recent failing workflow runs
workflow-status show recent runs for the release workflow
latest-release show information about the latest GitHub release
whoami check github auth status

Docker

task needs does
docker-build — build the Docker image
docker-run docker-build run the Docker container
docker-clean — remove the Docker image

docker-build forwards GH_PAT and UV_EXTRA_INDEX_URL to buildx as BuildKit secrets (--secret id=gh_pat,env=GH_PAT, --secret id=uv_extra_index_url,env=UV_EXTRA_INDEX_URL) when they are set, so a private Git dependency or private index that the bundle's Dockerfile resolves in CI resolves locally too. Only when set: buildx fails on a secret whose variable is missing, while the Dockerfile treats a missing mount as a no-op. They are never passed as build arguments, which docker history would show. A Dockerfile that declares no such mount gets a warning about an unused secret and nothing else.

Git LFS

task does
lfs-install configure git-lfs for this repository
lfs-pull download the LFS files for the current branch
lfs-track list the patterns tracked by git-lfs
lfs-status show the status of LFS files

lfs-install changed behaviour on purpose

It configures the repository and reports how to install the binary, rather than downloading one. The module docstring says so, which is the convention for every deliberate behaviour change.

Paper

task does
paper compile the LaTeX paper to PDF
paper-clean remove the LaTeX build artifacts

The engine is tectonic, not a TeX distribution's driver. One binary to install instead of a distribution plus the list of packages a document happens to cite, because tectonic resolves what the document cites from its own bundle and caches it. So the vector is the document and one flag: convergence and bibtex are the engine's own loop, and there is no interaction mode to pin because tectonic never prompts. The cost is that a cold cache needs the network, where a provisioned distribution did not.

paper-clean follows from that: tectonic has no clean subcommand, so the task is plain Python and guards on no tool at all — the one task in this section that works on a machine which cannot build the paper. It removes the PDF and the auxiliary files belonging to the folder's top-level .tex documents, so a committed diagram.pdf with no diagram.tex survives.

paper.mk preferred a file called basanos.tex — one downstream repository's paper, named in a template every consumer synced. That is now two conventional names, main.tex and paper.tex, then alphabetical order.

Presentation

task does
presentation generate the HTML slides with Marp
presentation-pdf generate the PDF slides with Marp
presentation-serve serve the slides with Marp's live preview

presentation reaches Marp through npx --yes — also a deliberate change, also recorded in the module docstring.

Skip is an outcome

skipped is not a soft failure and not a pass. It is the third status, and it is what makes one aggregate work across repositories with different bundles installed:

      ok  install
      ok  typecheck
 skipped  fmt  no .pre-commit-config.yaml

Because it is first-class, --strict can promote every skip to a failure — which is how a consumer's CI asserts that a gate actually measured something rather than quietly finding nothing to do.

all is not everything

all is the aggregate CI runs, and two registered tasks sit outside it on purpose, so a reader can tell "skipped deliberately" from "forgotten":

  • semgrep — no all names it. Upstream rhiza runs it on a weekly cadence, not per pull request.
  • complexity and docs-examples — adding either to all would fail builds in repositories that changed nothing, so a repo opts in by naming it in its own CI, which is what this one does.
  • update — it changes the repository rather than measuring it, which is the opposite of what an aggregate of gates is for. A sync inside all would mean rhiza-task all rewriting managed files mid-run.
  • the bundle-owned sections — none is a gate, as above.

Anything else registered is in all for its layer.