Extending Rhiza¶
Two different jobs share this page:
- Extending a rhiza-managed project — adding your own targets, settings and steps to a
repository that syncs from a template, in ways that survive the next
/rhiza:update. - Extending Rhiza itself — adding a bundle to this repository so every consumer can select it.
The first is what most readers want. Both rest on the same rule: template-owned files are overwritten by the next sync, so an extension has to live somewhere the sync does not write.
Table of Contents¶
- Where extensions live
- Extending a managed project
- Add a target
- Extend a template task
- Change a setting
- What not to do
- Adding a bundle to Rhiza
- Troubleshooting
- See Also
Where extensions live¶
| Location | Purpose | Committed? |
|---|---|---|
local.mk |
Your own make targets, and shadowing a template task | ✅ Yes — it is deliberately not gitignored |
[tool.rhiza-task] in pyproject.toml |
Project settings, typed by TOML | ✅ Yes |
rhiza.toml |
The same settings for a project with no Python manifest | ✅ Yes |
pyproject.toml (elsewhere) |
Dependencies, scripts, other tools' config | ✅ Yes |
.rhiza/.env |
Developer-local setting overrides | ❌ No — gitignored |
The Makefile is not on that list, and that is the change to absorb. It used to be
repo-owned above an include line, which is where a decade of advice told you to put hooks and
variables. It is now shipped by the core bundle like every other config file: it pins
RHIZA_TASK, forwards unmatched targets to that CLI, and is overwritten wholesale by the next
sync. Anything appended to it is lost, silently — the edit works, reviews fine, merges, and
disappears at a sync weeks later.
Two things make that safe rather than merely strict. local.mk exists and is committable, so
repo-owned targets have a real home; and check-managed-files (a rhiza-hooks hook every
language layer runs) fails the commit when a managed file differs from HEAD, so the mistake
surfaces at commit time instead of at sync time.
Never edit anything under .rhiza/ either. The one exception is .rhiza/.env, which the
template does not ship at all — it is yours to create, and gitignored, so it is for values that
should not travel with the repository.
Extending a managed project¶
Add a target¶
Put it in local.mk. The Makefile -includes that file, and no sync touches it:
# local.mk
train-model: ## Train the model
@uv run python scripts/train.py
smoke: test ## Run the suite, then hit the deployed endpoint
@./scripts/smoke.sh
Two properties worth knowing:
- A
##comment puts the target inmake help, listed under Repo-owned targets —rhiza-task listcannot know about them, so theMakefile'shelprule greps them out ofMAKEFILE_LISTand appends them. - Template tasks work as prerequisites.
smoke: testabove resolvestestthrough the catch-all, so ordering your work after a template task needs nothing special.
Extend a template task¶
The pre-install:: / post-install:: hooks are gone. They were anchors declared by the
synced make layer, and the CLI that replaced it knows nothing about make targets. Nothing
warns you about this: a post-install:: rule in local.mk today is a target nobody invokes.
Shadow the task instead. An explicit rule beats the %: catch-all, so the rule runs and can
call the task itself wherever you want it in the sequence:
# local.mk
# The real task first, then the extra work.
test: $(UVX)
@$(UVX) $(RHIZA_TASK) test
@./scripts/publish-test-report.sh
UVX, UV and RHIZA_TASK are all defined by the Makefile above the -include, so a
shadowing rule always drives the same pinned CLI as everything else. Naming $(UVX) as a
prerequisite keeps the bootstrap that installs uv on a runner that has none.
Shadowing reaches a task only when make is what resolves it. A shadowing rule fires when you type its name, or when another make rule names it as a prerequisite. It does not fire when the task is reached as a prerequisite inside the CLI:
testneedsinstall, but the shim forwards the goaltesttorhiza-task, which resolvesinstallin its own task graph and never consults a make rule of that name. CI never runs make at all — every workflow invokesuvx "$RHIZA_TASK" <gate>directly.So shadowing is for adding work around a task you invoke. For work that must happen before every gate — installing a native binary, say — use the setup hook below, which is anchored where the CLI can see it.
Provide a native binary¶
Some projects need a tool on the machine before any gate can run: graphviz for a docs
plugin, libpq for psycopg, pandoc. Put it in an executable local-setup.sh at the
repository root:
#!/usr/bin/env bash
set -euo pipefail
command -v dot >/dev/null 2>&1 || sudo apt-get update && sudo apt-get install -y graphviz
Every language layer's install runs it, and install is the prerequisite of essentially
every gate — so one file covers a local make test, both CI platforms and the devcontainer,
with no workflow edit anywhere. Commit it, like local.mk: core leaves it un-ignored
because anything CI invokes has to be in the repository.
Guard the expensive part yourself, as above — it runs on each fresh CI job, and on every
local invocation. Two behaviours worth knowing: a hook that exists and is not executable
fails with a chmod +x hint, because a provisioning step someone wrote and believed was
running is exactly what must not pass quietly — while having no hook at all simply succeeds,
so a project that needs nothing pays nothing.
The hook is POSIX shell; a Windows-only project needs its own arrangement.
Change a setting¶
Settings belong to the task runner, which resolves each one through a chain — later wins:
rhiza-task's own defaults.rhiza/.env(developer-local,UPPER_SNAKE_CASEkeys)rhiza.tomlpyproject.toml's[tool.rhiza-task]tableRHIZA_*environment variables- CLI flags
The documented home for a committed setting is the table:
[tool.rhiza-task]
source-folder = "mypackage"
typechecker = "mypy"
coverage-fail-under = 80
ci-os-matrix = ["ubuntu-latest", "macos-latest"]
A project with no Python manifest — a crate, a Go module — uses rhiza.toml, which takes the
same keys either inside a [tool.rhiza-task] table or flat at the top level. Where both files
exist, pyproject.toml wins.
For a one-off, an environment variable outranks both files:
Check rather than assume. uvx rhiza-task print coverage-fail-under prints what a setting
currently resolves to, which is the fastest way to find out that an override is not being read
at all. That failure mode is worth taking seriously: a gate pointed at a folder that does not
exist skips rather than fails, so a misresolved source-folder makes typecheck, security
and docs-coverage all report success having measured nothing.
What not to do¶
| Don't | Because | Do instead |
|---|---|---|
Append to the Makefile |
Template-owned; the next sync overwrites it | local.mk |
Edit anything in .rhiza/ (except .env) |
Same | Upstream PR, or exclude: in .rhiza/template.yml |
Write post-install:: anywhere |
The anchors are gone; nothing invokes it | Shadow the task |
Set SOURCE_FOLDER or similar in a makefile |
The CLI reads settings, not make variables | [tool.rhiza-task] |
| Commit a fix to a template-owned workflow | It vanishes at the next sync | Upstream PR, or exclude: |
If a template-owned file genuinely needs to differ in your repository, exclude: in
.rhiza/template.yml stops the sync delivering it — you then own that file, and stop getting
its improvements. Prefer an upstream PR where the change makes sense for everyone.
Adding a bundle to Rhiza¶
This half is for work in this repository. A bundle is a named group of files plus an entry
in .rhiza/template-bundles.yml; the filesystem expresses ownership, so bundles/<name>/
holds exactly what a consumer selecting <name> receives, at the paths they receive it.
Worked example: linter¶
- Create the bundle directory
- Add the bundle files. Files under
bundles/linter/land in the downstream project at the same relative path. A Ruff override extending the layer's own config:
- Declare it in
.rhiza/template-bundles.yml, with any dependencies. This one extendspython-core'sruff.toml, so it requires that layer:
linter:
description: "Optional Ruff overrides for stricter linting"
standalone: false
requires: [python-core]
- Add a sync test in
tests/bundles/so the bundle is exercised the way a consumer receives it. Extendingtest_bundle_combinations.pyis usually enough:
class TestLinterBundleSync:
"""Syncing python-core + linter adds the Ruff override file."""
@pytest.fixture(autouse=True)
def synced(self, tmp_path: Path, root: Path) -> None:
sync_bundles(root, ["core", "python-core", "linter"], tmp_path)
self.project = tmp_path
def test_ruff_override_exists(self) -> None:
assert (self.project / ".ruff.toml").is_file()
def test_ruff_override_extends_the_layer_config(self) -> None:
content = (self.project / ".ruff.toml").read_text(encoding="utf-8")
assert 'extend = "ruff.toml"' in content
test_bundle_content_validity.py picks up any new YAML or JSON automatically.
-
Document it in CLAUDE.md's bundle overview, README.md's bundle tables, and
docs/reference/BUNDLE_TAXONOMY.md. All three are gated — see the checklist. -
Run the suite.
make testcovers the bundle and documentation gates;make rhiza-testruns the conformance checks. If the bundle belongs to a language layer,make e2eis what actually executes its gates against a real toolchain. -
Open one PR with the directory, the YAML entry, the tests and the documentation together, so a reviewer can see the whole bundle.
Review checklist¶
Each item names the gate that enforces it, so a miss shows up locally rather than as a surprise in CI:
- Bundle metadata with a
description, andrequiresreferencing existing bundles (gate:tests/bundles/test_template_bundles.py::TestTemplateBundles) bundles/<name>/exists, is non-empty, and claims no file another bundle owns — except within a language layer, where overlap is the design (gate:tests/bundles/test_template_bundles.py::TestTemplateBundles)- The bundle is named in CLAUDE.md
(gate:
tests/docs/test_doc_consistency.py::TestBundleDocumentation) - The bundle is listed in README.md's bundle tables
(gate:
tests/docs/test_doc_consistency.py::TestReadmeBundleList— which also fails on a table row for a bundle that does not exist) - The bundle appears in
docs/reference/BUNDLE_TAXONOMY.md(gate:tests/docs/test_doc_consistency.py::TestBundleTaxonomyDoc) - Platform compatibility is picked up automatically from the YAML
(gate:
tests/bundles/test_bundle_matrix.py— nothing to write, but a failure for<name>points at a YAML, ownership or dependency problem) - The synced output has a focused test (the one you wrote in step 4)
Troubleshooting¶
For sync failures and recovery commands, see docs/troubleshooting.md.
A target does not appear in make help¶
make help lists the CLI's tasks and then greps MAKEFILE_LIST for repo-owned rules carrying
a ## comment. No comment, no listing — and a target defined anywhere other than local.mk
(or a file it includes) is not in MAKEFILE_LIST at all.
A post-install:: rule never runs¶
It never will: the anchors retired with the synced make layer. See Extend a template task.
A shadowing rule is ignored¶
An explicit rule beats the pattern rule, so this is almost always a name mismatch — check
uvx rhiza-task list for the exact task name. Note also that the shadow replaces the task:
if the rule does not call $(UVX) $(RHIZA_TASK) <task>, the template's work simply does not
happen.
A setting has no effect¶
Run uvx rhiza-task print <setting> to see what resolves. The usual causes are a key in the
wrong case (.rhiza/.env takes UPPER_SNAKE_CASE, the TOML tables take kebab-case), a
[tool.rhiza-task] table shadowing the rhiza.toml you were editing, or an assignment in a
Makefile, which the CLI does not read.
A gate passes but measures nothing¶
Almost always a path setting pointing somewhere that does not exist — the gates skip a missing
folder rather than failing. uvx rhiza-task print source_folder and confirm the folder holds
the code you expect.
An edit to a workflow keeps disappearing¶
The file is template-owned. check-managed-files should have refused the commit; if it did not
run, the sync silently reverted the edit. Send the change upstream, or exclude: the file and
own it.
See Also¶
- Customization Guide — the same extension points with more worked examples, plus CodeQL and documentation configuration
- Quick Reference — command and file cheat sheet
- Tools Reference — what each tool in the stack does
- Bundle Taxonomy — every bundle and profile
- Makefile Customisation — the shim, the pin, and where settings live
- rhiza-education Lesson 10: Customising Safely — tutorial walkthrough of these mechanisms