Rhiza Architecture¶
Visual diagrams of Rhiza's architecture and component interactions.
System Overview¶
flowchart TB
subgraph User["User Interface"]
make[make commands]
local[local.mk]
end
subgraph Core["Task layer"]
shim[Makefile<br/>template-owned shim]
cli[rhiza-task<br/>pinned CLI]
template[template-bundles.yml<br/>Bundle Config]
end
subgraph Config["Configuration"]
pyproject[pyproject.toml]
ruff[ruff.toml]
precommit[.pre-commit-config.yaml]
editorconfig[.editorconfig]
end
subgraph CI["GitHub Actions"]
ci[CI Workflow]
release[Release Workflow]
e2e[E2E Workflow]
weekly[Weekly Workflow]
end
make --> shim
local -.-> shim
shim --> cli
cli --> pyproject
ci --> make
release --> make
e2e --> make
weekly --> make
Makefile Hierarchy¶
flowchart TD
subgraph Entry["Entry Point"]
Makefile[Makefile<br/>template-owned shim]
end
subgraph CLI["Pinned CLI"]
rhizatask[rhiza-task@X.Y.Z<br/>uvx-provisioned]
registry[task registry<br/>layer:name]
end
subgraph Settings["Settings"]
table["[tool.rhiza-task]<br/>pyproject.toml / rhiza.toml"]
env[.rhiza/.env<br/>developer-local]
end
subgraph Local["Local Customization"]
localmk[local.mk<br/>Repo-owned]
shadow[explicit rules<br/>shadow a task]
end
Makefile -->|"%: forwards to"| rhizatask
Makefile -.->|includes| localmk
Makefile -.->|beats the catch-all| shadow
rhizatask --> registry
registry -->|resolves against| table
registry -.-> env
There is no make layer left to load. core ships no .rhiza/rhiza.mk and .rhiza/make.d/
no longer exists: every fragment it once held retired into
rhiza-task, a pinned CLI, in two steps —
eleven at 0.2.0 and the last five at 0.3.0.
What core does still ship is the front door, Makefile, at 71 lines instead of 1481. It
pins RHIZA_TASK, bootstraps uv if the runner has none, and forwards every unmatched target
to the CLI through a %: catch-all. What used to be "which fragments were synced?" is now
"which tasks does the pinned version have, for the language layers this repository has?" —
uvx rhiza-task list answers it.
For one release the CLI printed that file itself (uvx rhiza-task shim > Makefile) and each
repo owned the copy. That put a template inside the task runner, and the pin inside a
generated file: bumping a repo's gates was a hand edit /rhiza:update could not make. The
template owns it again, so RHIZA_TASK travels with the sync — the property
RHIZA_CHECKS_VERSION already had. Repo-owned targets live in local.mk, which the
Makefile -includes and no sync touches.
| was | is |
|---|---|
bootstrap.mk — install, uv bootstrap |
the install task, plus three lines of the shim |
test.mk — test, coverage, typecheck, stress, mutation |
the python/rust/go layers and the testing extras — except mutation, dropped rather than carried (#1492) |
quality.mk — fmt, lint, rhiza-test |
the neutral quality tasks |
book.mk, marimo.mk |
the book and marimo tasks |
doctor.mk |
the doctor task |
releasing.mk |
/rhiza:release and bump-my-version |
docker.mk, github.mk, lfs.mk, paper.mk, presentation.mk |
tasks of the same names, added in rhiza-task 0.3.0 |
custom-env.mk, custom-task.mk — example stubs |
local.mk, which core ships un-ignored so a repo can commit its own targets (#1574) |
bundles.mk — mother-repo only |
rhiza's own local.mk |
Bundles still own capabilities; what a bundle contributes is now configuration and
documentation rather than make recipes. The docker bundle ships the Dockerfile, paper
ships the docs/paper/ convention, and their targets come from the CLI whatever bundles a
project selected.
Extending a Task¶
flowchart LR
subgraph Local["local.mk (repo-owned)"]
rule["install:<br/>explicit rule"]
extra[the extra step]
end
subgraph CLI["Pinned CLI"]
task["uvx rhiza-task install"]
end
invocation[make install] --> rule
rule -->|calls| task
rule -->|then| extra
An explicit rule beats the shim's %: catch-all, so a rule of the same name in local.mk
intercepts the invocation and decides what the task is wrapped in. This replaces the
double-colon anchors of the make layer — see Hook Naming.
Release Pipeline¶
flowchart TD
tag[Push Tag v*] --> validate[Validate Tag]
validate --> build[Build Package]
build --> draft[Draft GitHub Release]
draft --> pypi[Publish to PyPI]
pypi --> conda[Generate Conda Recipe<br/>with grayskull]
draft --> devcontainer[Publish Devcontainer]
pypi --> finalize[Finalize Release]
conda --> finalize
devcontainer --> finalize
subgraph Conditions
pypi_cond{Has dist/ &<br/>not Private?}
conda_cond{PyPI publish<br/>succeeded?}
dev_cond{PUBLISH_DEVCONTAINER<br/>= true?}
end
draft --> pypi_cond
pypi_cond -->|yes| pypi
pypi_cond -->|no| finalize
pypi --> conda_cond
conda_cond -->|yes| conda
conda_cond -->|no| finalize
draft --> dev_cond
dev_cond -->|yes| devcontainer
dev_cond -->|no| finalize
Template Sync Flow¶
flowchart LR
upstream[Upstream Rhiza<br/>jebel-quant/rhiza] -->|template.yml| sync[/rhiza:update]
sync -->|updates| downstream[Downstream Project]
subgraph Synced["Synced Files"]
workflows[.github/workflows/]
rhiza[.rhiza/]
configs[Config Files]
end
subgraph Preserved["Preserved"]
localmk[local.mk]
src[src/]
tests[tests/]
end
sync --> Synced
downstream --> Preserved
Directory Structure¶
flowchart TD
root[Project Root]
root --> rhiza[.rhiza/]
root --> github[.github/]
root --> src[src/]
root --> tests[tests/]
root --> docs[docs/]
root --> book[_book/<br/>build output]
root --> shim[Makefile<br/>rhiza-task shim]
rhiza --> semgrep[semgrep.yml]
rhiza --> env[.env]
github --> workflows[workflows/]
workflows --> ci[rhiza_ci.yml]
workflows --> release[rhiza_release.yml]
workflows --> e2e[rhiza_e2e.yml]
workflows --> more[... one per feature]
shim --> tasks[uvx rhiza-task <task>]
.rhiza/ Directory Structure and Dependencies¶
flowchart TB
subgraph rhiza[".rhiza/ (template-owned)"]
direction TB
pointer[template.yml<br/>which bundles to sync]
lock[template.lock<br/>what was synced]
semgrep[semgrep.yml<br/>static analysis rules]
env[".env (optional, gitignored)<br/>developer-local settings"]
end
subgraph project["Project Files (repo-owned)"]
direction TB
Makefile[Makefile<br/>template-owned shim]
localmk[local.mk<br/>own targets]
pyproject["pyproject.toml<br/>[tool.rhiza-task] settings"]
ruff_toml[ruff.toml<br/>linting]
pytest_ini[pytest.ini<br/>test config]
python_version[.python-version<br/>the Python to fetch]
end
cli[uvx rhiza-task]
Makefile -->|forwards to| cli
localmk -.->|shadows a task| cli
cli -->|reads| pyproject
cli -.->|reads| env
cli -->|reads| python_version
cli -->|uses| pytest_ini
cli -->|uses| ruff_toml
pointer -->|drives| sync[/rhiza:update]
sync -->|records| lock
Two directories that earlier versions of this diagram showed are gone, and both went for the same reason — code and dependency lists distributed by file-copy became dependencies:
.rhiza/requirements/— retired: four.txtfiles that pinned per-target tooling. Every tool is provisioned where it is used now (uv run --with,uvx), so a target's tooling travels with the target (#1380)..rhiza/tests/— retired: the conformance checks a consumer's repository is held to, formerly synced as seven modules plus aconftest.py. They are thepytest-rhizadependency ofmake rhiza-testnow, pinned by[tool.rhiza-task]'spytest-rhiza(#1540). A repo that synced before that keeps the folder on disk, inert — the gate names modules, not paths.
CI/CD Workflow Triggers¶
flowchart TD
subgraph Triggers
push[Push]
pr[Pull Request]
schedule[Schedule]
manual[Manual]
tag[Tag v*]
end
subgraph Workflows
ci[CI]
e2e[E2E]
codeql[CodeQL]
release[Release]
weekly[Weekly]
scorecard[Scorecard]
end
push --> ci
push --> e2e
push --> codeql
pr --> ci
pr --> e2e
pr --> codeql
schedule --> weekly
schedule --> scorecard
manual --> ci
tag --> release
Every gate a pull request must pass is a job of rhiza_ci.yml — the pre-commit hooks,
deptry, docs-coverage, the security scan, the licence scan — not a workflow of its own.
That is what the required status checks in .github/rulesets/main-branch-protection.json
name, and why renaming a job breaks branch protection.
Python Execution Model¶
flowchart LR
subgraph Commands
make[make test]
direct[Direct Python]
end
subgraph UV["uv Layer"]
uv_run[uv run]
uvx[uvx]
end
subgraph Tools
pytest[pytest]
prek[prek]
deptry[deptry]
end
make --> uv_run
uv_run --> pytest
uvx --> prek
uvx --> deptry
direct -.->|Never| pytest
style direct stroke-dasharray: 5 5
Naming Conventions and Organization Patterns¶
Task Naming (rhiza-task)¶
Task names follow these conventions:
-
Lowercase with hyphens:
docs-coverage,view-prs,marimo-validate— neverdocsCoverageorDocs_Coverage. -
The same name means the same thing in every language:
testis pytest in a Python project,cargo nextestin a crate andgo testin a module. That parity is what lets the CI workflows callmake typecheckwithout knowing the language. -
Sections group them:
Python,Rust,Go,Quality,Book,Dev,Testing extrasand one per bundle-owned group (Docker,Git LFS,Paper,Presentation,GitHub Helpers).uvx rhiza-task listprints them grouped.
Target Naming¶
Make targets follow consistent patterns:
- Lowercase with hyphens: Target names use lowercase with hyphens
- ✅
install-uv,docker-build,view-prs -
❌
installUv,docker_build,viewPRs -
Verb-noun pattern: Action-oriented targets use verb-noun format
install-uv- Install the uv tooldocker-build- Build Docker image-
view-prs- View pull requests -
Namespace prefixes: Related targets share a common prefix
- Docker:
docker-build,docker-run,docker-clean - LFS:
lfs-install,lfs-pull,lfs-track,lfs-status - GitHub:
view-prs,view-issues,failed-workflows,workflow-status
Help Text¶
make help is the shim's one non-delegating rule, and it prints two lists:
-
The CLI's tasks, grouped by the section each declares, from
uvx rhiza-task list. Nothing in the repository states those groups — see Task Naming. -
Repo-owned targets, scraped from a
##comment on the rule itself:
The shim greps $(MAKEFILE_LIST), so this is what lets a repo move its targets into
local.mk without losing them from make help. The ##@ section headers of the make
layer are gone with the layer that parsed them.
Hook Naming¶
Retired with the make layer. bootstrap.mk anchored pre-install::/post-install::
and their sync counterparts as double-colon no-ops so a consumer could chain onto them,
and that was the documented way to add project hooks. uvx rhiza-task install knows nothing
about make targets, so there is nothing to chain onto.
Shadow the target instead: an explicit install: rule in local.mk beats the shim's %:
catch-all, so it can call the CLI and then the extra step.
File Organization Patterns¶
- Directory naming:
- Lowercase with hyphens:
template-bundles.yml,docs/reference/ -
Plural for collections:
requirements/,templates/,tests/ -
Test organization (
tests/): - Tests grouped by purpose, not by feature
api/- Makefile API testsbundles/- the bundle contract and per-bundle syncstructure/- Project structure validationintegration/- End-to-end workflowse2e/- one real toolchain run per language layerdeps/- Dependency validation
No bundle ships test code any more. The conformance checks a consumer's repository is
held to used to be synced into .rhiza/tests/; they are the pytest-rhiza dependency
of make rhiza-test now (#1540).
- Dependency provisioning (the
.rhiza/requirements/lists are gone): - Libraries the test suite imports live in
pyproject.toml[dependency-groups] - Per-target tooling (pytest plugins, interrogate, marimo, zensical, …)
is installed on the fly by its
maketarget viauv run --with/uvx
Template Bundle and Profile Naming¶
template-bundles.yml defines two layers: bundles (file-owning building blocks) and profiles (user-facing presets). See ADR-0010 for the rationale.
Bundles¶
- Lowercase, hyphen-separated:
core,github,tests,github-tests - Feature bundles are local-first: they do not own hosted workflow files
- Platform overlays use a
<platform>-prefix:github-tests,github-book,gitlab - ✅
github-tests(GitHub Actions for thetestsfeature) - ✅
github-book(GitHub Actions for thebookfeature) -
❌ embedding workflow files directly in
testsorbook -
Bundle metadata:
description- Clear, concise explanationstandalone- Whether bundle can be used independentlyrequires- Hard dependencies on other bundlesrecommends- Soft dependencies that enhance functionality
Profiles¶
- Lowercase, hyphen-separated:
local,github-project,gitlab-project - Intent-focused: Named after the hosting and automation context, not the tool
- ✅
local(no hosted automation) - ✅
github-project(standard GitHub project) -
❌
no-workflows,full-setup -
Profile metadata:
description- Clear summary of the intended contextbundles- Ordered list of bundles this profile expands to
Setting Naming¶
The make layer's forty-odd SCREAMING_SNAKE_CASE variables — the _BIN paths, the
_FOLDER accumulators, the colour codes — are settings of the pinned CLI now, and the
naming follows the surface they are written on:
-
kebab-casein TOML:source-folder,pytest-rhiza,mkdocs-extra-packagesin[tool.rhiza-task](pyproject.toml, orrhiza.tomlfor a project with no Python manifest). -
RHIZA_-prefixedSCREAMING_SNAKE_CASEin the environment: the same setting, upper cased and prefixed —RHIZA_SOURCE_FOLDER,RHIZA_CI_OS_MATRIX. This is the surface a CI job or alocal.mkexportuses. -
Resolution order: defaults →
.rhiza/.env→ the TOML table →RHIZA_*→ CLI flags.
Three make variables survive, all in the shim and all about reaching the CLI at all:
RHIZA_TASK (the pin), INSTALL_DIR and UVX/UV.
Documentation Naming¶
Documentation files use SCREAMING_SNAKE_CASE:
README.md- Directory/project overviewARCHITECTURE.md- Architecture diagramsEXTENDING_RHIZA.md- Customization and extension guideQUICK_REFERENCE.md- Command referenceSECURITY.md- Security policy
Workflow Naming (.github/workflows/)¶
GitHub Actions workflows use the pattern rhiza_<feature>.yml:
rhiza_ci.yml- Continuous integrationrhiza_release.yml- Release automationrhiza_e2e.yml- One real toolchain run per language layerrhiza_codeql.yml- CodeQL analysis
Rationale: The rhiza_ prefix clearly identifies template-managed workflows, distinguishing them from user-defined workflows.
Key Design Principles¶
1. Single Source of Truth¶
- Python version:
.python-versionfile (not hardcoded) - Dependencies:
pyproject.toml(not duplicated in makefiles) - Bundle definitions:
template-bundles.yml(not scattered)
2. Catch-All Delegation¶
The Makefile forwards anything it cannot resolve itself:
FORCE is what keeps every task phony — .PHONY takes no patterns, but a phony
prerequisite is never up to date, so make book still runs next to a book/ directory.
This allows: - New tasks to arrive with a version bump, not a file sync - No include lists, and no ordering to get wrong - An explicit rule to shadow any task, which is how a project extends one
3. Extension Points¶
Users can extend Rhiza without modifying template files — and the Makefile is now one of
the files they must not modify, since core ships it and every sync overwrites it:
local.mk: own targets, and wrapping a task by shadowing its name. TheMakefile-includes it andcoreleaves it un-ignored, so it is committed like any source file. Shadowing reaches a task make resolves — not one the CLI reaches internally, and not CI, which never runs make.local-setup.sh: a native binary the project needs before any gate. Every layer'sinstallruns it, which is what puts it on the path of local make, both CI platforms and the devcontainer at once. Committed, un-ignored bycorefor the same reasonlocal.mkis.[tool.rhiza-task]: settings, inpyproject.tomlorrhiza.toml.RHIZA_*in the environment: the same settings for a CI job, or for alocal.mkexportwhen the value must be committed.exclude:in.rhiza/template.yml: opting a managed file out of the sync entirely.
The full account, with the failure mode of each, is the Customization Guide.
4. Fail-Safe Defaults¶
- Missing
uvis installed by the shim, into./bin, before any task runs - A layer's toolchain absence skips the e2e suite with a reason rather than failing it
- Graceful degradation when optional features are unavailable
The one place that principle is deliberately not applied: a path-scoped gate skips a
source_folder that does not exist, so it reports success having measured nothing. That is
why a repository whose source root is not src/ must declare it — see #1505, #1511, #1516,
and the source-folder line in this repository's own pyproject.toml.
5. Documentation as Code¶
- Every repo-owned target carries a
##help comment, enforced by a pre-commit hook - Every architectural decision has an ADR
- README files in every major directory
- Docs are gated: links resolve, bundles are documented, and every
maketarget a document names must exist (tests/docs/test_doc_consistency.py)