rhiza-task
The rhiza developer tasks as a pinned CLI rather than a synced make layer.
uvx rhiza-task@0.1.0 test
Sibling to pytest-rhiza, which did the
same thing for .rhiza/tests.
Why
The pain was never make's syntax — it was distribution by copying, which make
structurally cannot fix, because include cannot reach a remote file. Every consumer got
a full copy at a template tag, and everything downstream was damage control.
| before, per consumer repo | after |
|---|---|
.rhiza/rhiza.mk — 200 lines, synced |
gone |
.rhiza/make.d/*.mk — 1023 lines in 15 files, synced |
gone |
exclude: entries in template.yml, because "a deletion alone is undone by the next sync" |
not needed |
targets shadowed in the repo Makefile, make printing overriding commands for target as the mechanism working |
[tool.rhiza-task] |
| ~40 lines of GNU-make guard and Windows POSIX-shell probe | gone — no make, no shell |
install-uv — 30 lines of bootstrap.mk shell |
three lines of the shim: one file target, one curl |
Makefile |
6-line shim, generated by rhiza-task shim |
Version pinning becomes a dependency pin, which is a real mechanism instead of "copy files at tag v1.3.3 and hope nobody edited them."
Install
Nothing to install. uvx provisions it per invocation:
uvx rhiza-task@0.1.0 list # what is available
uvx rhiza-task@0.1.0 all # every gate, as CI runs them
uvx rhiza-task@0.1.0 test --strict # fail rather than skip when a gate measures nothing
For a consumer repository, generate the shim once:
uvx rhiza-task@0.1.0 shim > Makefile
make test, make book and the rest keep working — the shim forwards every target to the
pinned CLI, so a consumer still on an older reusable workflow needs no change.
Tasks
| section | tasks |
|---|---|
| Python | install test coverage typecheck security deps license docs-coverage all |
| Rust | install cargo-tools test coverage typecheck security deps license docs-coverage all |
| Go | install go-tools test coverage typecheck security deps license docs-coverage all |
| Quality | fmt semgrep rhiza-test test-pyproject todos |
| Testing extras | benchmark hypothesis-test stress mutation |
| Book | book serve marimo marimo-validate |
| Dev | doctor clean |
| GitHub Helpers | view-prs view-issues failed-workflows workflow-status latest-release whoami |
| Docker | docker-build docker-run docker-clean |
| Git LFS | lfs-install lfs-pull lfs-track lfs-status |
| Paper | paper paper-clean |
| Presentation | presentation presentation-pdf presentation-serve |
The last five sections are the bundle-owned fragments .rhiza/make.d/ had to keep because
nothing here answered for their targets. None is a gate — no all names them and no
workflow invokes them — so each is guarded on the CLI it wraps and skips when that tool
is absent, with --strict available to a caller who wants the fragment's hard failure
instead. Two changed behaviour on purpose, and say so in their module docstring:
lfs-install configures the repository and reports how to install the binary rather than
downloading one, and presentation reaches Marp through npx --yes rather than
npm install -g.
Three layers, one set of names
test is pytest in a Python project, cargo nextest in a crate and go test in a
module — the gate-parity contract rhiza's own CLAUDE.md documents, and the reason
rhiza_ci.yml can call make typecheck without knowing the language.
The make layer answered "which one?" at sync time, by copying exactly one of python.mk,
rust.mk and go.mk into a repository. A pinned CLI carries all three, so the answer is
the manifest present: pyproject.toml → python, Cargo.toml → rust, go.mod → go.
A repository with two gets both layers, in that order; layers = ["rust"] pins it. The
other layer stays addressable as rhiza-task rust:test, and rhiza-task list --all shows
what the layers you do not have call things.
RHIZA_CHECKS follows the same derivation, which is what the += accumulator in each
language fragment was standing in for: the neutral checks, plus test_pyproject and
test_docstrings for python, test_cargo_toml for rust, test_go_module for go.
coverage writes _tests/coverage.xml in every layer, at that exact path, because that
is what book's badge step reads and what CI uploads. In Python it is the --cov flags
test already carries, split out under the name the other two layers use; in Rust it is
cargo llvm-cov --cobertura; in Go it is a coverage profile piped through
gocover-cobertura, plus the floor check go test has no flag for.
Not ported: github.mk's seven gh wrappers — gh pr list is shorter than
make view-prs and always current — and install-uv, which is not a task because it
cannot be one: it is what provisions the runtime every task already runs under. The shim
keeps it, as a file target rather than a phony, so make <anything> still works on a
runner that ships no uv — which rhiza's pre-commit job, a required status check that
runs make fmt with no astral-sh/setup-uv step, depends on.
Fragments a repository owns go to local.mk
The shim's header says it replaces ".rhiza/rhiza.mk and the ten fragments in
.rhiza/make.d/". A repository carrying its own fragments has to relocate them first —
deleting rhiza.mk removes the -include .rhiza/make.d/*.mk that was reaching them, so
they stop being loaded without anything saying so.
In rhiza's own case that is bundles.mk: sync-self, sync-self-check,
explain-bundles, e2e and gitlab-docker-test. None of them is a candidate for this
package — no bundle ships them, and the tooling behind them (utils/link_dogfood.py,
utils/explain_bundles.py) lives in the mother repo. They belong in local.mk, which the
shim -includes for exactly this purpose and where an explicit rule beats the catch-all.
Worth doing deliberately rather than discovering later: sync-self is what maintains the
bundles/ ↔ root invariant the whole repository rests on.
Design
Reading all ten make fragments back to back, every recipe has the same three parts: a
guard on a folder existing, a provision via uv run --with or uvx, and a long, mostly
static argument list. So the model is declarative, with an escape hatch for the four
recipes that genuinely are not:
test— retry once on pytest exit 3 (xdist teardown race), never on 1/2/4mutation— run/html/move/results, reporting the first statusdoctor— semantic version comparison, formerly an awk function inside a make recipebook— aggregate gates, copy reports, export notebooks, build, badge
| module | what |
|---|---|
spec.py |
Task, Guard, Skip/Failed, the @task registry, layer resolution |
config.py |
six-layer resolution, replacing ?= and += |
uv.py |
the three ways rhiza reaches a tool |
runner.py |
prerequisite dedup, guards, outcome bookkeeping |
cli.py |
Typer app, generated from the registry |
tasks/*.py |
the gates themselves, loaded by entry point |
Configuration
Six layers, lowest precedence first: dataclass defaults → .rhiza/.env (kept unchanged)
→ rhiza.toml → [tool.rhiza-task] in the language manifest (Cargo.toml, then
pyproject.toml) → RHIZA_* or bare make-style environment variables → command-line
flags.
rhiza.toml is the language-neutral file, and the only committed settings surface a Go
module can have — it has no manifest to hide a table in, and .rhiza/.env is now
developer-local, since rhiza ships neither it nor the .rhiza/.gitignore whose entire
content was the !.env negation that kept it tracked. Settings sit at the top level
there; a [tool.rhiza-task] table is honoured too, and wins when both are present. It
ranks below the manifest so that adding it to a Python repo cannot silently outrank the
table already there.
# rhiza.toml — a Go module or a Rust crate, or any repo that would rather not
# thread settings through a manifest
source_folder = "cmd"
coverage_fail_under = 95
An empty value is unset in the two string-valued layers: RHIZA_CI_OS_MATRIX= in
.rhiza/.env, or an exported empty string, leaves the layer below it alone rather than
resolving to "". That is make's $(or ...) rule, and the reusable workflows depend on
it — rhiza_ci.yml exports one RHIZA_CI_OS_MATRIX for every caller and deliberately
leaves it empty for consumers, whose own .rhiza/.env is meant to answer.
[tool.rhiza-task]
source_folder = "src"
typechecker = "ty"
coverage_fail_under = 95
license_ignore_packages = ["docutils"]
The += accumulators (DEPTRY_FOLDERS, LICENSE_IGNORE_PACKAGES, RHIZA_CHECKS) have no
successor and need none: each was a bundle contributing something it owned, which a task
body now derives by asking whether the contributing task is registered. See deps and
license in tasks/python.py.
Three things that fall out for free
- Double-colon rules disappear. book.mk declares
test:: ; @:no-op stubs sobookcan depend on gates thetestsbundle may not have contributed. Here that question is"test" in REGISTRY— four stubs and the whole::mechanism gone. - Skip is a first-class outcome. jointview's own Makefile complains that an excluded
folder leaves "a green gate measuring nothing".
--strictturns every skip into a failure, so CI can assert a gate actually measured something. - Help stops being a parser. rhiza.mk runs awk over
$(MAKEFILE_LIST)hunting##and##@comments. Typer has descriptions natively, from the same registry the runner uses, so they cannot drift.
Adding a task
Register a module under the rhiza_task.tasks entry-point group — the same mechanism the
built-ins use, so a project's own task is a first-class citizen rather than an override.
That replaces -include local.mk. Repo-specific one-offs can also just stay in the
Makefile, where an explicit rule beats the shim's catch-all.
from rhiza_task.spec import Guard, task
from rhiza_task.uv import uvx
@task("audit", "run the in-house audit", section="Quality", needs=("install",), guards=(Guard("source_folder"),))
def audit(cfg):
"""Audit the source tree."""
uvx("my-auditor", cfg.source_folder, cwd=cfg.root)
Why not a Taskfile (or just)
Considered and rejected. go-task is a genuinely better make — real deps:, desc: giving
task --list for free, and preconditions:/status: that express Guard declaratively.
Its remote includes would even attack the same root problem.
Two reasons against. First, that feature is experimental and env-var-gated, and it would
be the single load-bearing dependency of the whole multi-repo task layer, whereas
uvx pkg@version is boring, stable and already used ~15 times per repo. Second, the four
recipes listed above are procedural; in YAML they stay embedded shell, which improves the
syntax around the mess without removing it — and embedded shell keeps the Windows
problem too.
just and poe don't apply: a Justfile or a noxfile still has to be copied into every
repo, which is the problem being deleted.
Migration
Make target names are the interface between the reusable workflows and the consumer
checkout — rhiza_ci.yml alone calls make test, typecheck, deps, fmt,
docs-coverage, security, license, rhiza-test. Consumers pin @v1.3.3, so old pins
keep calling make forever. Hence the shim, and hence task names identical to the retired
target names.
- Ship this package; consumers replace the synced make layer with
rhiza-task shim. template.ymlexcludes.rhiza/make.dand.rhiza/rhiza.mk, exactly as it already excludes.rhiza/tests.- Bump the reusable workflows to invoke
uvx rhiza-taskdirectly, with oneastral-sh/setup-uvstep in place ofinstall-uv— faster and cached, though the shim's own bootstrap means a job that forgets it still works. - Second pass: retire
github.mkand folddoctorinto the release checklist.
Open questions
- Python as a prerequisite for a Rust repo.
rust.mk/go.mkneeded only make; the Rust and Go layers here are Python callingcargoandgo, so a crate now needs uv to run its gates. That is the trade the whole package makes — distribution by pin instead of by copy — but it is the layer where it costs the most, and it is the one place the Taskfile argument stays strong. - Nested uv cost.
uvx rhiza-task testthen internallyuv run --with pytest .... Cached this should be milliseconds; measure before rolling out widely.
Development
uv sync --all-groups
uv run pytest
No test in the suite runs uv. Every task test patches the three entry points in uv.py and
asserts on the argument vector that would have been executed — which is exactly what the
make recipes expressed in $$-escaped shell, and could not assert.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rhiza_task-0.3.0.tar.gz.
File metadata
- Download URL: rhiza_task-0.3.0.tar.gz
- Upload date:
- Size: 118.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
35eaf94c22541925c550c93a9218946ad604d8a46946ff50a70e3f1883790042
|
|
| MD5 |
0d6a115eb50095408e3676a2716813c3
|
|
| BLAKE2b-256 |
2d70c3ddc6beec6e25642775567ed4b5eaf24d1922296fae71f32d0c3626aba5
|
Provenance
The following attestation bundles were made for rhiza_task-0.3.0.tar.gz:
Publisher:
rhiza_release.yml on Jebel-Quant/rhiza-task
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rhiza_task-0.3.0.tar.gz -
Subject digest:
35eaf94c22541925c550c93a9218946ad604d8a46946ff50a70e3f1883790042 - Sigstore transparency entry: 2512090566
- Sigstore integration time:
-
Permalink:
Jebel-Quant/rhiza-task@fecf48f8775e4e72d4c4687cd449cf10177a1bce -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/Jebel-Quant
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
rhiza_release.yml@fecf48f8775e4e72d4c4687cd449cf10177a1bce -
Trigger Event:
push
-
Statement type:
File details
Details for the file rhiza_task-0.3.0-py3-none-any.whl.
File metadata
- Download URL: rhiza_task-0.3.0-py3-none-any.whl
- Upload date:
- Size: 67.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7db38b823f5fab9d284b7d2a54b77ab25c5c0b7d50fa4b10aaf5dd51653e5161
|
|
| MD5 |
7dae39fc95c4a62d3541c420dd2d0be8
|
|
| BLAKE2b-256 |
835c527579798c3835027dfd98f0aaa584ab45634a1e0d69a28d2ffdfef7419b
|
Provenance
The following attestation bundles were made for rhiza_task-0.3.0-py3-none-any.whl:
Publisher:
rhiza_release.yml on Jebel-Quant/rhiza-task
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rhiza_task-0.3.0-py3-none-any.whl -
Subject digest:
7db38b823f5fab9d284b7d2a54b77ab25c5c0b7d50fa4b10aaf5dd51653e5161 - Sigstore transparency entry: 2512090620
- Sigstore integration time:
-
Permalink:
Jebel-Quant/rhiza-task@fecf48f8775e4e72d4c4687cd449cf10177a1bce -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/Jebel-Quant
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
rhiza_release.yml@fecf48f8775e4e72d4c4687cd449cf10177a1bce -
Trigger Event:
push
-
Statement type: