Testing the template itself¶
Render-and-inspect is the fast matrixed layer; generate-and-run stays separate¶
Id: c9bcdf52-af5d-4438-9c9f-d55e8226812c
Type: decision
Status: active
Evidence: confirmed
Source: ADR-024 (docs/adr/024-render-and-inspect-template-test-suite.md); issue #365 for the parallel run
Revisit when: the golden set grows past a couple of stable artifacts, or a root Python project appears
See: template-testing.md#toolsrenderpy-is-the-only-place-the-template-is-rendered — f41fa3a2-20e2-4ba5-a3d0-c53a0074a9d0 — as of 2026-10-11
tests/ renders the template through Copier's Python API into temp
directories and asserts on the output tree: every preset renders, plus one
render per broker and per web framework; every generated YAML parses; the
rendered pyproject.toml is valid TOML; and pytest-regressions golden files
snapshot pyproject.toml for the library and full presets only. It runs in
template-ci.yml's render-tests job from an ephemeral uv env, in parallel.
Reason: the template's correctness across presets and toggle combinations
had no automated guard — example/ covered one option set when a human
remembered. A Jinja mistake emitting malformed YAML in a conditional file, or a
whitespace regression in a guarded block, merged green. Rendering without
installing is fast enough to fan out; pyproject.toml carries pins that move
on every update, so a broad golden set would be churn without coverage.
Rejected alternative: pytest-copie — a terser fixture that does not earn a
second dependency beyond copier.
Rejected alternative: only generate-and-run — proves the project works but is slow and heavy; "renders and is structurally valid" is a strictly weaker, far cheaper property, so the two layers stay separate.
Consequence: an intentional change to the two golden artifacts needs a regeneration and a reviewed diff.
Generated files must be formatter-canonical, not merely valid¶
Id: 9b931598-7e52-4ff1-bb07-ef65c01d5369
Type: decision
Type: constraint
Type: incident
Status: active
Evidence: confirmed
Source: ADR-030 (docs/adr/030-generated-files-must-be-formatter-canonical.md); issue #213
Revisit when: yamlfmt or taplo change the semantics of the pinned non-default knobs, or the generated project's formatter set changes
See: template-testing.md#render-and-inspect-is-the-fast-matrixed-layer-generate-and-run-stays-separate — c9bcdf52-af5d-4438-9c9f-d55e8226812c — as of 2026-10-11
A generated file must already be in the exact byte form the generated project's
own formatters produce — yamlfmt, ruff-format, the prek builtins and taplo.
Three things follow: formatter config is chosen so the hand-written layout is
canonical (retain_line_breaks_single for yamlfmt; no array auto-expand,
auto-collapse or comment alignment for taplo; no yamlfmt max_line_length);
template sources are written in that form (one-line folded scalars, no comment
trailing a sequence, TOML arrays one-per-line with trailing comma, Jinja
whitespace control so no conditional leaves more than two blank lines or one at
EOF, literal UTF-8 author tokens instead of tojson); and taplo runs in strict
check mode. template-ci.yml's render job runs the rendered project's own
fixing hooks per scenario and gates on git diff, with an integrations
scenario and a non-ASCII author name covering toggles nothing else rendered.
yamlfmt's basic formatter would drop every blank line, but the blank lines
between jobs and step groups in the roughly 1,300-line ci.yml are deliberate
structure; the _single variant (not plain retain_line_breaks) collapses
runs to exactly one, which keeps the formatter idempotent.
Reason: on a pristine v1.3.0 scaffold the first prek run (exit 1) rewrote
ci.yml and ISSUE_TEMPLATE/bug_report.yml through yamlfmt, and the style
run (exit 0) rewrote pyproject.toml and prek.toml through taplo's fix mode,
so the adopter's hooks CI failed and every later copier update showed drift
they never caused. Nothing
caught it because this repo ran only checking tools over a render and the
harness asserts YAML parses, not that it is canonical; taplo's fix mode made the
defect invisible. Width-driven reflow would make a file's canonical form depend
on which components are enabled, and comment alignment would let a Renovate
bump break a line nothing touched. The diff, not hook exit codes, is the gate
because a fixing hook that rewrites and exits 0 sails through an exit-code
check. A third, latent case: .cspell.yml rendered author tokens through
tojson, which escapes non-ASCII to \uXXXX; yamlfmt normalizes them back, so
any adopter with a non-ASCII name and MegaLinter got a file their own hooks
rewrote.
Rejected alternative: chase the formatters' output in the templates — the config is pinned to make the written layout canonical instead.
Rejected alternative: set yamlfmt max_line_length — it re-wraps folded
scalars across files the template does not otherwise touch, trading one class
of churn for a larger one. Long rendered lines are acceptable: yamllint's
line-length is level: warning.
Consequence: hand-aligned trailing comments and hand-wrapped folded scalars are unavailable; a conditional block in rendered YAML/TOML requires thinking about whitespace control; the pinned knobs fail loudly if a release changes them.
Collection literals are rendered through one shared macro¶
Id: 47b21a49-5fc4-46b8-b450-7d9f3225706c
Type: decision
Status: active
Evidence: confirmed
Source: ADR-030, 2026-09 amendment (docs/adr/030-generated-files-must-be-formatter-canonical.md); the agent-prose macros in the same file are recorded in ADR-035
Revisit when: ruff-format changes its magic-trailing-comma or width behaviour, or Copier gains a way to share helpers inside _subdirectory
See: template-testing.md#generated-files-must-be-formatter-canonical-not-merely-valid — 9b931598-7e52-4ff1-bb07-ef65c01d5369 — as of 2026-10-11
py_collection(name, items, kind) in _macros.jinja emits a tuple or list
literal in the form ruff-format produces: one line when the statement fits in
88 columns, one element per line otherwise. The file sits at the repository
root, outside _subdirectory, and templates import it on their first line. It
is used for __all__, the dependency allowlists and the guard tuples.
Reason: a literal rendered from a Jinja loop has no single correct spelling
— the width depends on values known only at render time (package name, broker
module, component count) — so a template that always emits one form ships a
file the adopter's first hook run rewrites. Outside _subdirectory the file is
an input Copier can never render into a project and needs no _exclude
override, which would replace Copier's default exclude list wholesale.
Rejected alternative: a second hand-written copy of the width branch — the drift this replaced.
tools/render.py is the only place the template is rendered¶
Id: f41fa3a2-20e2-4ba5-a3d0-c53a0074a9d0
Type: decision
Type: constraint
Status: active
Evidence: confirmed
Source: ADR-031 §1 (docs/adr/031-single-render-entrypoint.md); issues #181, #183, #164, #184
Revisit when: Copier's API changes, or a root pyproject.toml appears (it must keep the repo root importable under pytest)
See: template-testing.md#render-and-inspect-is-the-fast-matrixed-layer-generate-and-run-stays-separate — c9bcdf52-af5d-4438-9c9f-d55e8226812c — as of 2026-10-11
A plain Python module with PEP 723 metadata wraps copier.run_copy. The render
harness fixture, the generate-and-run CI matrix, the committed docs artifacts,
the watch loop and the mise tasks all call it. Answers layer as a neutral
identity, then --data-file, then explicit answers; programmatic renders use
only the neutral identity so golden files stay stable regardless of a
maintainer's own example input. The CI matrix's kitchen-sink scenario is
spelled as the full preset rather than a hand-listed toggle set.
Reason: "render this template with these answers" had forked into the
harness, an inline CI invocation, the example task and three proposals each
adding a script — six spellings where the answers baseline, the vcs_ref or
the defaults handling can quietly disagree, and a CI matrix rendering a shape
no other consumer can reproduce. A Python module, not a shell script, because
the pytest harness must import the same code path. The preset spelling means a
component added to full is exercised end-to-end without editing the matrix.
Rejected alternative: per-purpose shell scripts — two renderers again.
Consequence: adding another copier copy invocation is a review defect; a
new consumer imports render() or calls the CLI.
Consequence: tools/render.py is import-reachable from the tests only
because the repo root is on sys.path under pytest (tests/__init__.py plus
prepend import mode). A future root pyproject.toml must keep that true.
The generated-project tree page is a committed derived artifact with two drift guards¶
Id: 54918c4d-49dd-4ce8-94a6-3d472010173f
Type: decision
Status: active
Evidence: confirmed
Source: ADR-031 §2–3 (docs/adr/031-single-render-entrypoint.md); generalizes the cobo drift-guard posture of ADR-012
Revisit when: copier is pinned in the render path, or another derived artifact joins regenerate
See: template-testing.md#toolsrenderpy-is-the-only-place-the-template-is-rendered — f41fa3a2-20e2-4ba5-a3d0-c53a0074a9d0 — as of 2026-10-11
See: ci-topology.md#network-dependent-checks-run-on-a-weekly-cron-never-in-the-merge-gate — 3e5b9525-9087-4125-b19f-80f6c226c01f — as of 2026-10-11
See: gitignore-generation.md#cobo-generates-the-gitignore-with-a-hash-sealed-fence-and-lockfiles — 3e3bc38e-bc1e-4e65-b74d-e91848262157 — as of 2026-10-11
regenerate renders the four presets and writes the exact file set each
produces into docs/generated-project-trees.md, committed, plain fenced blocks.
Two guards, one per direction of drift: a blocking test in render-tests
asserts the committed page equals a fresh render (this repo changed), and a
weekly non-blocking artifact-drift.yml regenerates and diffs (the toolchain
changed underneath, since copier is installed unpinned). Golden files are not
folded into regenerate.
Reason: the documented structure of a generated project is the first thing
a scaffolding template's docs get wrong, and a hand-maintained page cannot
show "this toggle adds these five files" as review signal. Committed rather
than built into a gitignored directory precisely for that diff; example/
stays gitignored because a diff-based guard cannot apply to an untracked tree.
A PR-time check cannot see a new copier release changing the output with no
commit here. Golden files already have the same job and their own regeneration
path.
Consequence: a PR changing what a preset renders must regenerate the page
or fail render-tests; regenerate is the extension point for future derived
artifacts.
The watch loop is an authoring aid whose target is validated before every rebake¶
Id: 36ddff82-32d4-4234-a9cf-ea40d694df58
Type: decision
Status: active
Evidence: confirmed
Source: ADR-031 §5 (docs/adr/031-single-render-entrypoint.md)
Revisit when: the rebake stops removing its target tree
See: template-testing.md#toolsrenderpy-is-the-only-place-the-template-is-rendered — f41fa3a2-20e2-4ba5-a3d0-c53a0074a9d0 — as of 2026-10-11
watch re-renders into a gitignored directory on every save under the template
sources and survives a failed render. Its output path is rejected when it
touches a watched source in either direction, or when it is a non-empty
directory without a .copier-answers.yml. watchfiles is supplied by the mise
task, not declared by the script.
Reason: each rebake removes its target tree. "Do not delete what this tool did not render" is checkable; enumerating which directories are precious is not. No other consumer should pay for a dependency only the loop needs.