Skip to content

Lesson 10 — Customising Safely

Rhiza manages a set of files in your project. This raises an obvious question: what if you need to change something that Rhiza controls? This lesson covers the right ways to customise a Rhiza-managed project without getting into conflicts when you update.

The core rule

If you edit a file that Rhiza manages, the next /rhiza:update will overwrite your change.

This is intentional. Template-managed files are meant to stay consistent with the template. Since template v1.5.0 it is also enforced rather than merely advised: the check-managed-files hook refuses a commit that touches a file the lock records, so a local edit fails at commit time instead of surfacing as a surprise revert in the next update PR. (/rhiza:update's own sync commit bypasses it — that commit is the one thing allowed to write those files.)

The right response to needing a customisation is not to edit the file — it is to use one of the extension mechanisms below.

Option 1: Add your own targets in local.mk

The Makefile is template-owned and -includes local.mk, which no sync touches and core deliberately does not gitignore. Commit it: anything CI invokes has to be in the repository.

# local.mk — committed, never synced

seed-db:  ## Seed the local development database
    @uv run python scripts/seed_db.py

Targets carrying a ## comment are listed by make help under Repo-owned targets, so they stay discoverable next to the template's tasks.

custom-task.mk and custom-env.mk are gone. They were fragments of the synced make layer that retired in template v1.4.0 (see Lesson 5 and ADR-0011). local.mk replaces the first; [tool.rhiza-task] replaces the second.

Extending a template task

The old pre-install:: / post-install:: hooks are gone too — the tasks now live in the pinned rhiza-task CLI, which knows nothing about make targets. The replacement is to shadow the target from local.mk: an explicit rule always beats the shim's %: catch-all, so your rule can call the task and then do the extra work.

# local.mk
report: $(UVX)
    @$(UVX) $(RHIZA_TASK) test
    @./scripts/publish-test-report.sh

RHIZA_TASK and UVX are defined by the Makefile above the -include, so a shadowing rule runs the same pinned CLI as the rest of the project, and naming $(UVX) as a prerequisite keeps the bootstrap that installs uv on a runner without one.

Shadowing only reaches a task make itself resolves. It wins when you type the name, or when another make rule names it as a prerequisite. It does not win when the CLI reaches a task internally — test needs install, but the shim forwards the goal test to rhiza-task, which resolves install in its own task graph, consulting no make rule. And CI never invokes make at all: every workflow calls uvx "$RHIZA_TASK" <gate> directly. So shadowing adds work around a task you invoke; work that must happen before every gate belongs in the setup hook below.

Option 2: Set task behaviour in [tool.rhiza-task]

Settings that used to be make variables are now a table in your pyproject.toml, which the template never overwrites:

[tool.rhiza-task]
source-folder = "lib"          # default: "src"
typechecker = "both"           # ty | mypy | both (default: ty)
coverage-fail-under = 85       # default: 90
docs-coverage-fail-under = 90  # default: 100
complexity-max = 20            # default: 15 (radon's number, not its A-F rank)
ty-version = ""                # default: "==0.0.78"; empty means "newest"
deploy-pages = false           # default: true
mkdocs-extra-packages = ["mkdocstrings[python]"]

Both spellings work. rhiza-task normalises - to _ when it reads the table, so source-folder and source_folder are the same setting. The upstream README documents the snake_case form; this curriculum uses kebab-case, which is the more common TOML convention. Pick one and be consistent — nothing enforces which.

source-folder is the load-bearing one. The scoped gates — typecheck, security, deps, docs-coverage, semgrep — all read it, and if it points at nothing they exit green having measured nothing. A standard src/ layout needs no setting at all; a project whose code lives elsewhere must say so, or its gates quietly stop being gates. Rhiza's own repo hit exactly this and fixed it by naming the folder.

Three of those settings are newer than the table and exist for reasons worth knowing, because each replaces a worse workaround:

  • docs-coverage-fail-under was a literal 100 until rhiza-task v1.5.0. A codebase arriving at 89% had one gate it could not pass and no way to say so, and the two remaining answers were both worse than a number: write docstrings for every nested helper in one sitting, or shadow the whole recipe in local.mk — which CI never invokes, so the gate then passes locally and fails in CI. Both were observed on the same upgrade. Treat a lowered value as a ratchet to raise again, not a destination; the default is unchanged, so a repo that says nothing is gated exactly as before.
  • ty-version pins what typecheck provisions the checker as. ty is still 0.0.x and its diagnostics change between patch releases, so an unpinned --with ty makes the verdict a fact about the day the job ran: one consumer saw a single warning locally, where the project's lock happened to supply ty 0.0.18 for --with ty to reuse, and twenty-four errors on a runner with no ty to reuse — same commit, different checker. mypy-version exists too and is deliberately left empty, because mypy is post-1.0 and moves slowly enough that a pin would mostly buy staleness. Set either empty for "whatever is newest".
  • deploy-pages turns off the reusable book workflow's publish to GitHub Pages. No task reads it — a workflow does, and that is the point. rhiza_book.yml takes a deploy-pages input, so before this setting the only place to answer was the caller: the github-book bundle's workflow stub, a template-owned file. Editing it worked, got reviewed, got merged, and got overwritten by the next sync — and check-managed-files now refuses the commit outright. The toggle existed with nowhere to live short of excluding the file from management.

There is also template-ref, which is not a gate setting at all: it is the ref that the update task moves .rhiza/template.yml to (see Lesson 11). Empty means "re-sync at whatever the file already names".

For per-developer overrides that should not be committed, .rhiza/.env still works — and since the template stopped shipping the .gitignore negation that kept it tracked, it is now genuinely developer-local: a CI checkout never contains it.

The full resolution order, lowest precedence first, is explicit and testable:

# Source Committed?
1 Built-in defaults —
2 .rhiza/.env No
3 rhiza.toml Yes
4 [tool.rhiza-task] in pyproject.toml / Cargo.toml Yes
5 RHIZA_* environment variables —
6 Command-line flags —

Two files at layers 3 and 4 because neither alone covers all three languages: pyproject.toml is Python-only, and a Go module has no manifest to hide a table in. Rust and Go projects use rhiza.toml. It ranks below the manifest deliberately, so adding one to a Python repo cannot silently outrank the table already there. Layer 5 is what lets CI override anything without editing a file.

Option 3: Install system dependencies in local-setup.sh

New in template v1.7.0. If your project needs a native binary 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

if ! command -v dot >/dev/null 2>&1; then
    sudo apt-get update && sudo apt-get install -y graphviz
fi

Every language layer's install runs it first, and install is a prerequisite of essentially every gate — so one file covers a local make test, GitHub Actions, GitLab CI and the devcontainer, with no workflow edit anywhere. Guard the expensive part yourself: the hook runs on every fresh CI job. A hook that exists but is not executable fails with a chmod +x hint, on the principle that a provisioning step someone believes is running must not pass quietly; having no hook at all simply succeeds.

Option 4: Exclude a file and own it locally

If you need to diverge significantly from a template file — for example, your project needs a custom CI workflow that is incompatible with the template's version — add the file to exclude: in .rhiza/template.yml:

exclude: |
  .github/workflows/rhiza_ci.yml

From that point on, /rhiza:update will never touch that file. You are responsible for maintaining it, including applying any relevant upstream changes manually.

Warning: Be deliberate here. Excluded files are excluded permanently until you remove them from the list. Security fixes in the template will not reach excluded files automatically.

Option 5: Fork the template for your organisation

This is the recommended approach for teams. Instead of pointing all your projects at Jebel-Quant/rhiza, you fork it into your own organisation:

Jebel-Quant/rhiza  →  your-org/rhiza

Your fork becomes your org's source of truth. You customise it to reflect your team's standards — different default Python version, additional CI steps, org-specific secrets, whatever you need. All your projects then point their repository: at your-org/rhiza.

When Jebel-Quant/rhiza releases a new version, you decide when and what to pull into your fork. You are in control of what flows downstream.

# .rhiza/template.yml in a project from your org
repository: your-org/rhiza
ref: v1.8.0

This pattern scales well: one place to manage standards, automated propagation to all consuming repos, and full control over what gets adopted and when.

What you should never do

Don't edit template-managed files directly unless you also add them to exclude:. You will lose your changes the next time you run /rhiza:update.

Don't work around updates to avoid dealing with PRs. The /rhiza:update PR is the system working correctly. If an update surfaces too many changes you don't want, the right answer is to use exclude: for files you own locally, not to stop updating.

Don't diverge silently. If you make a local change and do not exclude the file, the next /rhiza:update PR will show the revert of your change. This is confusing for reviewers. Be explicit: if you own a file, exclude it.

Summary of extension points

Need Mechanism Committed?
Your own make targets, or extending a task you invoke local.mk at the repo root Yes
Change how a template task behaves (folders, typechecker, thresholds) [tool.rhiza-task] in pyproject.toml Yes
Change what a reusable workflow does (e.g. stop publishing to Pages) deploy-pages in [tool.rhiza-task] — not the workflow stub, which is managed Yes
A native binary every gate needs Executable local-setup.sh at the repo root Yes
A per-developer override of one setting .rhiza/.env No — gitignored
Permanently own a specific file Add to exclude: in template.yml Yes
Customise the template itself for your whole org Fork the template repo —

Next: Lesson 11 — The Rhiza Ecosystem