Template Architecture Reference¶
For generated hooks, retained replacements, optional checks, and feedback timing, see the generated-project check coverage audit.
Deep reference for the copier-pyproject template — the detail that used to
live in AGENTS.md. AGENTS.md is a lean router: it keeps the load-bearing
agent instructions inline and points here for the "what gets rendered" detail and
to context/ for the "why". Three sources of truth, no
duplication:
copier.ymlhelp:— the one-line prompt shown for each toggle.- This page — what each toggle/workflow renders and how the pieces fit.
- The
context/entry — why it exists, the trade-offs, the posture, recorded with thekeep-the-whyskill. The formerdocs/adr/archive is deleted: no new ADRs.
When a toggle's rationale changes, update its context/ entry through the
keep-the-why skill. When what it renders changes, edit this page and
copier.yml help. Do not restate either in AGENTS.md.
Template Variables (copier.yml)¶
Required inputs:
github_user,github_repo_name,author_full_name,author_email,short_descriptioncopyright_year(int, default: the current year) - the start year inLICENSE'sCopyright (c) <year>-present. It is asked, not hidden, so Copier records it in.copier-answers.ymlandcopier updatekeeps it. A project created before this question has no answer: a non-interactive update adopts the current year, andcopier update -d copyright_year=<year>keeps the original. See the LICENSE start year.
Free-form metadata:
package_keywords- extra comma-separated PyPI keywords prepended to the generatedkeywordslist (default empty); the template's own base keywords plus tooling and enabled-component keywords are always appended automatically. (The classifiers list is still auto-generated from enabled components — it carries aTODOmarker for the generated project's author to fill in project-specific classifiers.)repository_topics- comma-separated GitHub repository topics (default empty), asked and used only wheninclude_repo_settingsis enabled (when: "{{ include_repo_settings }}"). Rendered into.github/settings.ymlas thetopics:list (lower-cased, space/underscore hyphenated). Left empty, thetopics:key is omitted entirely so the Settings App leaves the repository's existing topics untouched. See repository settings as code.
Starting point:
preset-library/tool/web/full. Seeds the default of everyinclude_*toggle via the hiddenpreset_mapcomputed variable (when: false, never stored) — every preset includesdocs;library→docs+examples;tool→docs+cli+tui+pydantic_settings;web→docs+web+pydantic_settings+postgres+redis;full→ everything.default: library(the smallest surface, so an unattended--defaultsrun produces a plain package with docs). Toggles remain asked and stored — the preset only changes defaults; it hides nothing, though dependency-gated sub-questions (the DB UIs, web framework, worker broker, redis backend) still appear only when their parent toggle is enabled, independent of the preset. Theminimal/standard/custompresets and the byte-identical--defaultsguarantee were removed with the archetype presets.
Optional components (all boolean):
include_cli- CLI (cli_framework: Typer, or standard-libraryargparsefor a dependency-free command root exposing the sameversion/infocommands and component subcommands — see CLI framework choice)include_web- Web app + Dockerfile (framework choice: FastAPI or Litestar)include_gui- Tkinter GUIinclude_tui- Textual TUIinclude_mcp- MCP (Model Context Protocol) serverinclude_worker- FastStream message queue workerinclude_database- Async database layer (Advanced Alchemy + Alembic); see the database layerinclude_c_extensions- Cython support with multi-platform wheelsinclude_profiling- py-spy, scalene, cProfile toolsinclude_benchmarks- pytest-codspeed benchmarks and a non-blocking CodSpeed workflowinclude_launcher- uv-bootstrap launcher (PyCrucible) — small executable, downloads Python+deps on first runinclude_compiler- compiler (Nuitka) — source compiled to a native machine-code executableinclude_freezer- offline freezer (PyInstaller) — self-contained bundle, no Python on target
These three standalone-executable toggles are independent and combinable; each
maps to one architectural category (launcher / compiler / freezer). See
standalone executable toggles.
- include_examples - an examples/ folder with simple and advanced usage stubs
(enabled by the library preset default)
- include_docs - the Sphinx documentation subsystem, default: true in every
preset (docs-by-default; off is a deliberate opt-out for a README-only
project). Guards the ten Sphinx-site files under docs/ (conf.py,
check_warnings.py, expected_warnings.txt, index.rst, installation.rst,
usage.rst, modules.rst, the web-interface.rst/worker-interface.rst
component pages, and the cli-reference.md page — those three also on their
component toggles, the CLI page additionally on cli_framework == "typer"), the
tools/build_docs.py versioned-docs orchestrator, the docs
dependency group, the docs-build/docs-server/docs-linkcheck tox envs, the
sphinx-lint entry in the style tox env and prek hook, the sphinx keyword,
the docs-preview.yml/docs-linkcheck.yml/gh-pages.yml workflows and the
release.yml deploy-docs job, plus the README docs badge/link, SUPPORT.md
link, CONTRIBUTING.md docs section, and mise docs-* tasks. Exception:
REPOSITORY_SETUP.rst (at the repository root, outside the Sphinx tree; the maintainer repository-setup guide,
hard-referenced by README/CONTRIBUTING/ci.yml/
settings.yml/.sourcery.yaml/sonar-project.properties) always ships — only
its GitHub Pages step is include_docs-gated — and with docs off the
settings.yml homepage falls back to the repository URL. See
optional docs subsystem.
- include_pydantic_settings - pydantic-settings for config
- include_sourcery - Sourcery AI-refactoring config (.sourcery.yaml)
- include_sonarcloud - SonarCloud static-analysis (sonar-project.properties + a sonar CI job)
- include_all_contributors - all-contributors config (.all-contributorsrc) + README section
- include_smokeshow - publish the combined coverage HTML report to a tokenless ephemeral URL via smokeshow (a step in the coverage-report CI job; public repos only, default: false; see smokeshow coverage mirror)
- include_benchmarks - pytest-codspeed benchmark suite, benchmark tox/mise entry points, and a separate non-blocking CodSpeed workflow (requires CODSPEED_TOKEN; see opt-in benchmarking)
These are opt-in integrations kept as toggles (not always-on) precisely to
preserve the self-contained "green on first push, zero external accounts"
default: each is off in the base library/tool/web presets (only the full
preset seeds them on), so a plain project never pays for them unasked. include_smokeshow needs no account at all (a
tokenless public-repo-only coverage-HTML mirror in the coverage-report job —
smokeshow coverage mirror);
include_sourcery (config-only, external App) and
include_sonarcloud (needs a SonarCloud org + SONAR_TOKEN secret) require
out-of-band setup; the sonar job mirrors the Codecov opt-in/non-blocking
pattern (gated on the SONAR_TOKEN presence flag, skips fork PRs, visible
::notice:: skip when unset, not in the check gate). include_all_contributors
is more self-contained: besides .all-contributorsrc + the README section, it
renders an all-contributors.yml workflow that runs the all-contributors CLI
(delivered via mise's npm backend, mise exec -- all-contributors generate,
mirroring ghalint; version pinned in mise.toml and Renovate-tracked) and opens
a PR via peter-evans/create-pull-request (never pushes to the default branch,
so persist-credentials: false holds), so the bot/App is optional — it only
needs the Allow Actions to create PRs setting release-please already requires.
See
Sourcery, SonarCloud and all-contributors toggles.
- include_megalinter - MegaLinter as an opt-in extra CI quality layer
(mega-linter.yml + .mega-linter.yml), default: false.
Unlike the Sourcery/SonarCloud/all-contributors trio, MegaLinter is a self-contained Docker action needing
no external account — but it was previously shipped always-on and
undocumented, which both violated the fast/self-contained default and
duplicated prek. Now gated, and when enabled it is a lean complement: its
ENABLE_LINTERS is trimmed to only the linters prek/tox do not already
cover — BASH_SHELLCHECK, DOCKERFILE_HADOLINT (jinja-gated on include_web,
the only config with a Dockerfile), JSON_JSONLINT (scoped to skip the
JSONC-with-comments devcontainer.json/.vscode/*.json), and
COPYPASTE_JSCPD, and SPELL_CSPELL scoped to .md (a deeper,
dictionary-driven prose complement to prek's fast whole-tree typos — the one
deliberate overlap, not a replacement) — never
actionlint/yamllint/markdownlint/editorconfig (prek), never Python (tox), never
the repository security linters (check-security.yml + CodeQL/Scorecard). It runs
on the smaller cupcake flavor image, which cuts the dominant image-pull
cost that made it slow (the effective per-run lever). It runs on every push/PR
to the default branch — no paths filter, because COPYPASTE_JSCPD scans
source so a filter would fire on nearly every PR anyway; an always-run is
simpler and more predictable. Non-blocking on three levels: DISABLE_ERRORS:
true (reports findings but exits 0, so a lint nit never posts a red check —
not just the check-gate exclusion and GITHUB_STATUS_REPORTER: false). A
project that wants it to gate can require its code-scanning results via branch
protection. The job does carry
security-events: write and pushes MegaLinter's SARIF
(SARIF_REPORTER: true) to the code-scanning dashboard via
github/codeql-action/upload-sarif — a real Security-tab upload (not just the
upload-artifact archive), best-effort (continue-on-error, if: always())
and mirroring zizmor.yml's free-on-public / GHAS-on-private posture. The
MegaLinter-only
.shellcheckrc is gated with the toggle; the prek-shared configs
(.markdownlint.yml, .github/actionlint.yaml, .github/yamllint.yaml) stay
always-on. See
MegaLinter as a lean complement.
- include_pypi_publish - publish to PyPI on each release via trusted
publishing; on in every preset. Off drops the pypi-publish release job,
the PyPI publisher setup step, the README PyPI badges and Analysis links, and
points the install docs at git+https://github.com/<user>/<repo>; wheels
and sdists still attach to every GitHub Release. Because a Homebrew formula
and a Scoop pipx manifest install from PyPI, the hidden
can_distribute_natively (is_app and PyPI-or-an-executable) gates both
distribution toggles. See
optional PyPI publishing.
- include_homebrew - Homebrew tap distribution, default: false and
when: "{{ can_distribute_natively }}"-gated (only offered for app-like
projects that publish to PyPI or build an executable, not a library). The generated
project does not build a formula/cask itself: its release.yml fires a
repository_dispatch (update-formula/update-cask) at the owner's own
homebrew-tap repo, which owns the manifest logic via brew
bump-formula-pr/bump-cask-pr + brew update-python-resources. No tap
files are generated: REPOSITORY_SETUP.rst points to
copier-homebrew-tap
(scaffolds the tap and its dispatch listener) and
homebrew-tap-skills
(agent skills to maintain it). Needs a
HOMEBREW_TAP_TOKEN secret: a fine-grained PAT with Contents: write
only on the tap repo (no Pull requests scope — the tap opens its own PR
with its own GITHUB_TOKEN). See
Homebrew and Scoop distribution.
- include_scoop - Scoop bucket distribution (opt-in), the same
dispatch-not-generation pattern: release.yml fires update-manifest at
the owner's own scoop-bucket repo, which owns the manifest via its own
checkver/autoupdate (or updater script). No bucket files are generated:
the setup guide points to
copier-scoop-bucket
and scoop-bucket-skills.
Needs a
SCOOP_BUCKET_TOKEN secret with the same Contents: write-only PAT
scope. Both toggles pick a binary (Homebrew cask / Scoop binary-zip
manifest) vs. PyPI (Homebrew formula / Scoop pipx manifest) payload via the
primary_executable precedence (freezer > compiler > launcher; empty
⇒ PyPI fallback). See
Homebrew and Scoop distribution.
- include_repo_settings - repository metadata as code: a .github/settings.yml
(description/homepage/topics + squash-merge flags) synced by the "Settings"
GitHub App, default: false. Opt-in like the external-account integrations because the
App is an external install (and it escalates push→admin — mitigated via the
shipped CODEOWNERS + "Require review from Code Owners"). The setup guide's
Settings step enables code-owner review through a separate API request or,
for ruleset projects, a reviewed change to the committed policy,
after a distinct trusted reviewer is available. Initial protection keeps
ordinary solo-maintainer PRs mergeable. A prerequisite check gates App installation;
unavailable reviewers or unavailable/unverifiable protection defer the App. Labels are NOT managed
here — they stay App-free in labels.yml/label-sync.yml. The "Include in the
home page" sidebar toggles are not settable via any API (UI-only). See
repository settings as code.
- include_repo_ruleset - repository branch protection as code: a
.github/rulesets/main.json (squash-only, linear history, required CI checks —
the actual job names this template ships, Trivy gated on include_web) applied
by an App-free, idempotent ruleset-sync.yml workflow (list → find by name →
PUT/POST), default: false and full-preset only. Opt-in and independent of
include_repo_settings (the "Settings" App cannot manage rulesets). The
workflow needs a REPO_ADMIN_TOKEN fine-grained PAT (Administration: read &
write) — the Actions GITHUB_TOKEN has no administration scope — stored as a
secret of the ruleset-admin environment (required reviewer, default-branch
deployment policy; the job declares environment: ruleset-admin) so an edited
workflow cannot reach it unreviewed, and
warn-and-skips when the secret is absent (the Homebrew/Scoop posture).
required_approving_review_count is 0 (checks strictly enforced, no
human-approval requirement, no bypass_actors) so solo maintainers and
release-please/Renovate are not blocked. See
branch protection as code.
Framework/broker choices (when parent option is enabled):
web_framework- fastapi/litestar (wheninclude_webis true)worker_broker- kafka/nats/rabbitmq/redis (wheninclude_workeris true)database_backend- sqlite/postgres (wheninclude_databaseis true). Picks the async driver dependency (aiosqlite/asyncpg) and the default URL; the default follows whether the preset enablesinclude_postgres, but the devcontainer service is never forced on (backend alignment).
Testing choices:
async_style-none/asyncio/anyio. Async test runner for generated tests:none(synchronous tests only),asyncio(pytest-asyncio strict mode with explicit markers), oranyio(runs marked tests across both asyncio and trio backends). Defaults toasynciowheninclude_web,include_tui,include_mcp,include_worker, orinclude_databaseis enabled, andnoneotherwise. When TUI, MCP, worker, or the database layer is enabled, validation constrains the choice toasynciooranyiobecause those ship async tests (SQLAlchemy's async drivers are asyncio-only, so the db tests pin theanyiobackend to asyncio). See async testing style.
Evaluated and intentionally out (issue #392): pytest-cov — coverage is
measured with coverage run --module pytest plus coverage combine/report
in CI, so the pytest plugin would duplicate measurement/reporting over the
same run; pytest-dependency — the suite is designed order-free (markers
select; pytest-xdist ships for opt-in parallel runs), and upstream
documents xdist parallelization as incompatible with pytest-dependency;
anything beyond
async_style for pytest-asyncio — the asyncio/anyio choice above is the
whole answer, and a synchronous library stays runner-free.
Devcontainer services:
include_postgres- PostgreSQL serviceinclude_redis- Redis/Valkey serviceredis_backend- redis/valkey image (wheninclude_redisis true)include_pgadmin- pgAdmin UI (wheninclude_postgresis true)include_adminer- Adminer UI (wheninclude_postgresis true)include_dbeaver- CloudBeaver database management UIinclude_vpn- OpenVPN client
Always included (no toggle): a Keep-a-Changelog CHANGELOG.md created and
maintained by release-please — the template does not ship a
CHANGELOG.md.jinja seed file. release-please generates CHANGELOG.md on its
first release PR (per changelog-path in .github/release-please-config.json).
Do not re-add a seed template: a scaffold-time CHANGELOG.md overwrites the
existing changelog when an already-published project adopts the template via
copier copy/copier update, wiping its release history (this happened during a
real template adoption). Codecov coverage upload (.github/codecov.yml + CI step), Renovate
dependency management (.github/renovate.json extends the shared preset
github>hasansezertasan/renovate-config:python, which carries the base
config:recommended + helpers:pinGitHubActionDigests policy and the prek-hook
customManagers regex; Dependabot and the none opt-out were removed — there is
no longer a dependency_management option), a CITATION.cff
file with a validate-citation.yml workflow (the include_citation toggle was
removed — it is now always rendered and validated via
citation-file-format/cffconvert-github-action), and mise
(mise.toml + devcontainer feature) for tool version management and task
running. Pants and Trunk were removed entirely — the tox style env is the sole
lint/build orchestrator (see tox as the canonical lint runner).
import-linter enforces the generated package's architecture: one always-on
layers contract (in [tool.importlinter]) keeps the component group
(web/gui/tui/mcp/worker) mutually independent, with cli as an
orchestrator layer above them (the {{pkg}} Typer root, whose subcommands
lazy-import those components to launch them — see components as subcommands), all layered above
core above utils. The cli layer is present whenever include_console_root
is true (see the entry-points section below), not only when include_cli is set.
core is also where the shared application payload lives (core/app.py,
shared application payload): it is already the layer directly beneath the component group, so the
sharing needs no new layer and the contract is unchanged.
The component layers are Jinja-conditional on the enabled toggles (omitted when
none are enabled, leaving a core > utils contract), so no ignore_imports is
needed. The contract is exhaustive (containers = ["{{pkg}}"],
exhaustive = true): a subpackage of {{pkg}} that is not named in layers
breaks the contract, so a new top-level subpackage cannot silently accrete at the
package root — the fix is to place the code inside an existing layer, or to add
the new layer deliberately. exhaustive_ignores carries the non-layer
foundations (__main__, __metadata__, _version, and _c_extension when
include_c_extensions). The generated AGENTS.md documents the matching
placement table for AI agents.
Delivered via the style group + a lint-imports command run from both the
tox style env and a prek local system hook (uv run --locked --group style
lint-imports) — the same dual-run, single-version-source pattern as
basedpyright, since import-linter is likewise a whole-program analyzer that
needs the installed package and a grimp import-graph build. It is not
eligible as an upstream prek repo hook (those run in an isolated venv without
the project installed); the local system hook shells out through uv run,
which syncs and resolves the package. slotscheck (which imports src/ to
verify __slots__) is delivered the same dual-run way and for the same reason —
whereas the redundant type checkers ty/pyrefly/zuban stay style-env-only so
the fast gate carries one representative type checker (basedpyright), not five.
See import-linter architecture contract.
semgrep is the rules-database SAST layer over src/, delivered
style-env-only for the same reason ty/pyrefly/zuban are: it keeps no
on-disk rule cache, so a prek hook would make a registry request on every git
commit and fail for an offline contributor. Pinned in its own sast group —
Renovate tracks it through the native pep621 manager, and keeping it out of
style (which dev includes wholesale) spares every plain uv sync and every
Windows/macOS CI cell a ~215 MB install of a tool only the Linux style job
runs — and invoked as
semgrep scan --config p/python --error --metrics=off src — the ruleset is
named rather than --config auto so rule selection does not depend on
language auto-detection, and --metrics=off keeps scan telemetry off the wire.
Two consequences worth knowing: the run is not hermetic (rules are fetched
at scan time, so a semgrep release can turn a green main red and a registry
outage fails the env), and findings overlap ruff — select = ["ALL"] turns
all of flake8-bandit (S) on, so an md5 call trips both S324 and
insecure-hash-algorithm-md5, and such a line needs both # noqa and
# nosemgrep: <rule-id>. The genuine marginal value is the cross-statement
taint rules ruff's per-node checks cannot express. The rules are not vendored
into the template: the registry rules are under the Semgrep Rules License v1.0
("You may not distribute the rules"), which forbids shipping them to adopters.
See semgrep SAST.
Dependency hygiene (deptry + creosote) is an always-on gate: a deps
dependency group (own group, like sast, so a plain uv sync skips the tools),
a [tool.deptry] block, a tox -e deps env (deptry . then creosote
--deps-file pyproject.toml --section project.dependencies, not in env_list)
and a deps job in ci.yml that the check aggregate needs. deptry reports
missing, transitive, misplaced and obsolete dependencies; creosote reports
declared runtime dependencies nothing imports. The template seeds only the
ignores its own output needs, gated on the toggles that produce the imports
(known_first_party, uvicorn/packaging in per_rule_ignores, build hook,
benchmarks, worker probe script and Sphinx config in extend_exclude, and
creosote's --include-deferred so lazy imports count); a project adds its own. See
dependency hygiene.
Also always included (no toggle): structured GitHub issue forms
(.github/ISSUE_TEMPLATE/bug_report.yml + feature_request.yml + config.yml,
the latter disabling blank issues) whose component dropdown is Jinja-gated to
only list enabled runnable components (include_console_root/include_cli/
include_gui/include_tui/include_web/include_mcp/include_worker) and
whose uvx/pipx install options and required OS field appear only when a
runnable component (or, for OS, a C extension) makes them meaningful, a
pull_request_template.md with a real task-list checklist (feeding
task-completed-check.yml) and a fillable Closes # line (feeding
check-linked-issues.yml), a SUPPORT.md community-health file (points to
docs/issues/discussions and cross-references SECURITY.md/CONTRIBUTING.md), a
.gitattributes (LF normalization matching the ruff/EditorConfig policy, Linguist
overrides marking _version.py/CHANGELOG.md/uv.lock/cobo.lock as generated
and docs/
as documentation, export-ignore archive hygiene, and an export-subst entry
for .git_archival.txt), and AI-agent onboarding
file — a concise AGENTS.md (the cross-tool standard, also read by Claude Code)
so there is a single source of truth.
AGENTS.md intentionally carries no commit-attribution/Co-Authored-By block.
Per-host agent and skill files beyond AGENTS.md are delivered through
copier-skill-repo, not
this template.
The default AGENTS.md-only shape requires Claude Code v2.1.277 or later for
direct loading (v2.1.281 or later for sessions such as Amazon Bedrock or those
with telemetry disabled). Claude Code's default instruction mode skips
AGENTS.md if a CLAUDE.md, .claude/CLAUDE.md, or CLAUDE.local.md exists in
the working directory or an ancestor; the global ~/.claude/CLAUDE.md does not
count. Adopters retaining those project or ancestor files can set Project
instructions to claude-md-and-agents-md in /config. Older clients or
sessions with AGENTS.md support disabled need an adopter-maintained CLAUDE.md
containing @AGENTS.md. The template does not render that compatibility wrapper.
See the official loading rules.
The retired CLAUDE.md wrapper is not excluded: copier update deletes the
copy an older render left behind, including adopter customizations (git history
keeps them). Move any unique guidance into AGENTS.md before updating.
Also always included (no toggle): a .git_archival.txt (setuptools-scm's stable
node/node-date/describe-name $Format:...$ template). Paired with the
.gitattributes export-subst entry, git archive/GitHub source tarballs (the
install flow documented in docs/installation.rst) expand it to the real
tag-derived version, so hatch-vcs recovers the correct version without a .git
directory instead of stamping the [tool.hatch.version] fallback-version literal.
That fallback is kept at the obvious sentinel 0.0.0 (used only when an archive
has no reachable tag), not a plausible-looking 0.1.0.
Commitizen (Conventional Commit authoring/linting) is always included — it
is no longer gated behind an include_commitizen option. release-please still
owns versioning/changelog; Commitizen only authors/lints messages (see
Commitizen as a commit helper).
The .gitignore is always managed by
cobo (no toggle, like mise/renovate),
replacing the ad-hoc gibo workflow. template/.gitignore.jinja is a cobo-sealed
fenced block (# >>> cobo:begin >>> … # <<< cobo:end sha256=… <<<) of the
macOS/Windows/Linux/VisualStudioCode/Python/Node/GitHubPages boilerplates from github/gitignore,
followed by the ### Generated by the author section that carries the
project-specific overrides (the include_freezer spec negation and
include_c_extensions generated-C ignore live below the fence — the fence
must stay verbatim boilerplate). Node and GitHubPages are always-on (like
VisualStudioCode): a conditional seal would need a Jinja-templated cobo.lock,
so deciding once here beats every downstream project hand-editing the seal.
The block is sealed with cobo's --eol lf policy (cobo ≥ 0.4.0), which
normalizes terminators but keeps the macOS boilerplate's two lone-CR
character classes (Icon[<CR>], .HFS+ Private Directory Data[<CR>]) intact;
Copier's Jinja render still strips those CRs at scaffold time, so rendered
projects report up to date; locally modified until cobo fence verification
tolerates the render-stripped CR (see the NOTE below the fence and the
LF-seal tradeoff).
Two lockfiles ship:
root cobo.lock (two fragments — template/.gitignore.jinja and this repo's
own .gitignore, which is cobo-managed too as dogfooding) and
template/cobo.lock (path = .gitignore, shipped to generated projects). cobo
is delivered to generated projects as a mise tool (pipx:cobo, Renovate-tracked)
with a mise run gitignore-check task and a weekly, non-blocking
gitignore-drift.yml workflow (cobo update && cobo check --strict on schedule +
workflow_dispatch only — not in the check gate; network-flaky, same posture
as docs-linkcheck). Regenerate from the repo root with cobo update && cobo
sync (re-renders the fenced region in place, preserving the author section and
the eol = "lf" policy); refresh template/cobo.lock the same way from a scratch
dir dumping with --eol lf --out .gitignore --lock. Do not hand-edit inside
the fence (it breaks the sha256). See
cobo for .gitignore.
Git hooks are always included (there is no include_precommit
option). They are run via prek, a Rust-native
pre-commit replacement, configured by a native prek.toml (not a
.pre-commit-config.yaml). The dependency group is prek and contains only
prek; both the tox prek env (tox run -e prek) and the CI hooks job invoke
prek run --all-files. One of those hooks is zizmor
(zizmorcore/zizmor-pre-commit), which statically audits the GitHub Actions
workflows — it is the blocking half of the workflow-hardening setup (see the
zizmor.yml item under CI/CD Workflows below). Because prek run --all-files
runs in the CI hooks job, a workflow security regression fails CI with no extra
job. NOTE: verify zizmor changes with the prek hook (prek run zizmor
--all-files), not a bare uvx zizmor — the two can pin different zizmor versions
whose default-persona dangerous-triggers behavior differs, and the prek hook is
what actually gates generated projects.
Shell and YAML lint/format live in prek hooks, not a uv dependency group:
shellcheck (via the shellcheck-py wheel, so no system install), shfmt
(pre-existing: it formats the standalone scripts shellcheck lints), checkmake,
yamllint and yamlfmt (every project ships workflow YAML, so the YAML pair
engages from the first commit; the shell/Makefile hooks follow their upstream
hook manifests' file scoping). There is deliberately no shell/yaml uv group:
these tools run as version-pinned hook revs that Renovate keeps current in
generated projects (the customManagers regex in the shared preset, since
Renovate's built-in pre-commit manager only reads .pre-commit-config.yaml),
and a second uv pin of the same tools would be a second version source for the
same check. Evaluated and intentionally out (issue #392): yamlfix (the
google/yamlfmt hook is the single YAML formatter, so a second fixer cannot
fight it over document style) and shellcheck-makefile (it lints shell
inside recipes, complementing rather than replacing checkmake's Makefile
structure lint — that recipe-shell coverage stays unevaluated; workflow
run: blocks are checked by actionlint, including its shellcheck pass where a
shellcheck executable is available).
detect-secrets is a local, pre-commit-stage secret scanner (local system
hook via uv run --locked --group style detect-secrets-hook --baseline
.secrets.baseline) that catches a secret at git commit time, before it ever
enters history. It checks changed files against the committed
.secrets.baseline (generated with detect-secrets scan), so only new
potential secrets are flagged — not the already-triaged false positives already
recorded in the baseline. It layers with, and does not replace, the
history-spanning gitleaks scan in check-security.yml (see Supply-chain
security below): local prevention at the commit stage vs. CI detection across
full git history. A secret that slips a contributor's local hook is still
caught in CI, since prek run --all-files runs this hook there too. Baseline
maintenance (triaging a hit, or regenerating the baseline when the tree
legitimately changes shape) is documented for contributors in
CONTRIBUTING.md's "Secret Scanning" section; Renovate tracks the tool version
(the style group pin) but cannot manage the baseline's content.
The tox style env (backed by the uv-managed style dependency group) is the
canonical lint/type-check runner for the full suite; the prek-run prek.toml is
the fast local/CI gate. Its hook rev pins are kept current by Renovate (a
customManagers regex entry supplied by the shared preset
.github/renovate.json extends — github>hasansezertasan/renovate-config:python
— since Renovate's built-in pre-commit manager only reads
.pre-commit-config.yaml). There is no pre-commit.ci integration and no
sync-with-uv hook — Renovate owns every bump, including the tools shared with
the uv style group.
The tox style env is the single lint/type-check orchestrator for a generated
project — there is no Pants or Trunk config to drift against it (both were
removed; see tox as the canonical lint runner).
Formatter-canonical output
(formatter-canonical output). Every
shipped file is emitted in the exact form the generated project's own formatters
produce, not merely in a valid one — otherwise the adopter's first
prek run --all-files rewrites it, that project's hooks CI job fails, and each
copier update carries drift nobody authored. Two shipped configs make the
hand-written layout the canonical one instead of letting width-driven reflow
decide it: .github/yamlfmt.yml sets retain_line_breaks_single: true (the
basic formatter otherwise drops every blank line, including the deliberate
between-job structure of a 1,300-line ci.yml), and .taplo.toml sets
array_auto_expand/array_auto_collapse/align_comments to false (so a
generated file's canonical form does not depend on which components are enabled,
and a Renovate bump of one pin cannot break the gate on a line nothing touched).
The style env's taplo runs --check --diff rather than fix mode — in fix mode
it repaired its own input and exited 0, so the drift was invisible. The
matrixed guard is this repo's own .github/workflows/template-ci.yml: its
render job runs the rendered project's yamlfmt/end-of-file-fixer/
trailing-whitespace hooks per scenario and then asserts git diff
--exit-code, so a fixing hook that rewrites a file and still exits 0 is caught
too.
editorconfig-checker enforces .editorconfig (the source of truth) on the
axes no other tool owns — indent style/size and charset on config/markup files.
It is delivered via the uv style group (PyPI wrapper, command ec), invoked
in both the tox style env and a prek local hook. Unlike typos
(whose PyPI wrapper tracks its Go release tags), the editorconfig-checker
PyPI wrapper lags the Go releases, so a separate upstream prek rev pin would
drift permanently out of sync with the style group — hence the local hook
(single version source, like basedpyright) rather than an upstream repo hook.
Its .editorconfig-checker.json disables the checks other tools already own
(trailing whitespace + final newline → prek builtins; line endings →
.gitattributes/ruff; *.py line length → ruff) and excludes .rst/.md/.py
whose indentation is semantic/marker-relative (Sphinx, markdownlint, and ruff
are their authorities). Adding .jsonc/.cff to the 2-space .editorconfig
glob was a genuine correctness fix it surfaced (both were defaulting to 4).
ghalint enforces GitHub Actions workflow security policy (a different axis
from actionlint's correctness): least-privilege per-job permissions,
persist-credentials: false on checkouts, per-job timeout-minutes, full-length
action SHA pins, and secret-handling policy. It ships no PyPI wrapper and
no pre-commit hook, so it cannot join the uv style group or be a standard
prek repo hook; it is delivered via mise ([tools]
"aqua:suzuki-shunsuke/ghalint", aqua backend named explicitly) and invoked as a
prek local system hook that invokes it through mise exec -- ghalint run so
the mise-managed binary resolves deterministically after mise install without
requiring an activated mise shell (shims on PATH) — only mise itself need be on
PATH. The CI hooks job therefore runs jdx/mise-action (SHA-pinned) before
prek run so both mise and the binary exist. All shipped workflows are hardened to pass ghalint's strict
defaults; the only exception is a web-only .github/ghalint.yaml that
excludes the job_secrets policy for exactly the docker-publish-preflight and
docker-publish jobs (they must expose DOCKERHUB_USERNAME at job-env because
GitHub if: conditions cannot read the secrets context or step-level env).
The two docs-push jobs (gh-pages.yml deploy, release.yml
deploy-docs) set persist-credentials: false and publish via
JamesIves/github-pages-deploy-action (SHA-pinned), which authenticates from its
token input (default github.token) without ever writing the token into
.git/config — replacing the earlier hand-rolled git remote set-url +
ghp-import push. .nojekyll is emitted by the sphinx.ext.githubpages
extension at build time (so _static/ is served), not by the deploy step.
Rendering the template (tools/render.py)¶
Every consumer that renders the template calls one module — there is no second
copier copy spelling anywhere in the repo (single render entrypoint):
| Consumer | How it renders |
|---|---|
| render harness (render-and-inspect) | the render fixture in tests/conftest.py wraps tools.render.render |
template-ci.yml render matrix |
uv run tools/render.py render ../rendered --data … |
mise run example |
uv run tools/render.py render example |
mise run regenerate |
regenerate() — rewrites the committed derived artifacts |
mise run watch |
watch() — re-renders .watch-render/ on every save |
tools/render.py carries PEP 723 metadata, so uv run tools/render.py …
resolves copier itself — no root Python project, no mise shim. Answers layer
IDENTITY (a neutral octocat/example pair, used by the harness so golden
files do not move with a maintainer's own inputs) → --data-file (defaults to
.example-input.yml on the CLI) → explicit --data key=value pairs.
Subcommands:
render DST [--data k=v]… [--data-file F] [--ref REF]— one render.regenerate— rewrites every committed derived artifact. Today that isdocs/generated-project-trees.md, the exact file set each of the four presets produces, captured from a real render instead of typed by hand. Run it when a change adds or removes a generated file and commit the diff;tests/test_doc_trees.pyfails in the blockingrender-testsjob when the page is stale.artifact-drift.ymlre-runs it weekly (schedule +workflow_dispatch, non-blocking) to catch the drift a PR check cannot see — an unpinned copier release changing what the template emits. Future derived artifacts (schemas, references) plug in here and inherit both guards.watch [--out DIR] [--data k=v]…— authoring loop, watchestemplate/,copier.ymland.example-input.yml, renders into the gitignored.watch-render/, prints a per-rebake status line, and keeps going when a render fails. Needswatchfiles, whichmise run watchsupplies withuv run --with watchfiles; it is never wired into CI.
example/ stays gitignored — a pure smoke-test target of the same entrypoint,
not a drift-checked artifact (an untracked tree has no diff to check).
Shared Jinja helpers (_macros.jinja)¶
_macros.jinja at the repository root holds helpers several templates need.
It sits outside _subdirectory, so copier treats it as a template input and
can never render it into a generated project — no _exclude override required
(which would replace copier's default exclude list wholesale). Templates import
it on their first line.
It carries py_collection(name, items, kind), which emits a Python
tuple or list literal in the exact byte form ruff-format produces: one line when
the whole statement fits in 88 columns, otherwise one element per line with a
trailing comma. The right form depends on values only known at render time (the
package name, the broker module, how many components are enabled), so a template
that always picks one ships a file the adopter's first prek run --all-files
rewrites — see one shared collection macro.
Used by __main__.py, cli/app.py and tests/test_main.py for __all__, the
component dependency allowlists, and the guard tuples.
It also carries the agent_*() macros — the agent-instruction prose (commands,
package structure, conventions, pull requests, external references) that
AGENTS.md.jinja renders. Edit the prose in _macros.jinja.
Generated Project Structure¶
See generated-project-trees.md for the generated,
always-current file list per preset; the prose below explains what the modules
do.
Root modules in src/{{github_repo_name}}/:
__init__.py,__main__.py,__metadata__.py- Package setup_version.py- Auto-generated by hatch-vcs (excluded from coverage)
Subpackages (each with an __init__.py):
core/- Core infrastructure (always included):app.py- The version/runtime payload every interface renders (version(),info(),info_or_unknown(),MetadataUnavailableError). Each component is an adapter over it — the CLI exits 1, the web app answers 503, the MCP tool returns error text, the GUI/TUI showunknown, the worker falls back to0.0.0— so the payload and the metadata lookup exist once rather than once per component. Rendered only when a runnable component exists (primary_component); a library has nothing to report through it, so it ships no placeholder (#321;_excludekeeps an existing project's copy oncopier update). Its failure path is exercised through the sharedmissing_metadatafixture intests/conftest.py, which reaches every component's error branch by patching this one module (shared application payload).dirs.py- Project directory locations (~/.<package>)logging_setup.py- Centralized loggingconfig.py- Configuration (uses pydantic-settings if enabled). Wheninclude_pydantic_settingsis on, the docs build renders a Configuration reference straight from the liveSettingsmodel:docs/conf.pyloads thesphinxcontrib.autodoc_pydanticextension and the conditionaldocs/configuration.rstpage carries anautopydantic_settingsdirective, so the documented fields, types, defaults, constraints, and env-var names never drift from the model. The docs dependency group gainsautodoc-pydanticunder theinclude_pydantic_settingsguard.utils/- Dependency-free helpers (always included, as just its docstring-only__init__.py: the layer ships so the exhaustive import-linter contract names it, but with no placeholder module in it.tests/test_placeholder_modules.pyfails any non-__init__.pymodule undersrc/whose body is empty apart from a docstring,pass/..., or a__future__import, #321.copier.yml's_excludelists the dropped path socopier updateleaves an existing project's copy alone)cli/- thepkgTyper root (present wheninclude_console_root). Withinclude_cliit is the full CLI (version/infocommands + component subcommands); withoutinclude_cliit is a minimal launcher (component subcommands + a default callback, noversion/info). Withinclude_cliandcli_framework == "typer", the docs build generates a CLI reference straight from the live app:docs/conf.pyshellstyper {{pkg}}.cli.app utils docsinto the gitignoreddocs/_generated/cli.md, and the conditionaldocs/cli-reference.mdpage{include}s it (heading-demoted) so the documented commands/options — including the enabled component subcommands — never drift from--help. Thesphinx-clickroute is unusable here because current Typer vendors its own Click (typer._click), soTyperGroupfailssphinx-click'sisinstance(..., click.Command)check.sphinxcontrib-typerwas also evaluated — it works, but the build-time generation keeps the CLI reference uniform with the worker/web generators and pulls in no extra extension. Theargparsevariant has no generated referenceweb/- FastAPI/Litestar with/versionand/infoendpoints (conditional). The docs build emits the web app's OpenAPI schema straight from the live{{github_repo_name}}.web.app:appobject into the gitignoreddocs/_generated/openapi.yaml, and the conditionaldocs/web-interface.rstpageliteralincludes it — a versioned, machine-readable HTTP contract (routes, response schemas, status codes) that stays in lockstep with the code. Generation is framework-specific: Litestar uses its first-party CLI (litestar ... schema openapi --output), while FastAPI has no file exporter, sodocs/conf.pydumpsapp.openapi()toopenapi.{json,yaml}(JSON via the stdlib, YAML viapyyaml, added to the docs group under theinclude_web+FastAPI guard).gui/- Tkinter GUI launcher (conditional)tui/- Textual TUI (conditional)mcp/- MCP server withversionandinfotools (conditional)db/- async database layer (conditional oninclude_database):base.py(Advanced Alchemy's UUID/audit declarative base),models.py(one illustrativeItem),repositories.py,config.py(get_config()builds theSQLAlchemyAsyncConfigfromSettings.database_url, using the FastAPI or Litestar flavour of the config class wheninclude_web) andmigrations.py(the config instance thealchemy --configCLI imports). It is its own layer in the import-linter contract, between the components andcore. Withinclude_webthe app registers the framework's Advanced Alchemy plugin and exposesGET /health/db.Settings.database_urlis overridable through the prefixedDATABASE_URLenvironment variable. The Alembic tree is the rootmigrations/(Advanced Alchemy's ownenv.py/script.py.mako, excluded from ruff and deptry); runtox run -e alembic -- make-migrations -m "msg"then-- upgradefrom the project root. Why: the database layer.worker/- FastStream message queue worker (conditional). The broker is built by abuild_broker(url=None)factory that registers the subscriber/publisher and resolves the connection at call time (not import time), so tests can point a fresh, self-contained broker at a throwaway instance (e.g. testcontainers) without import-order/caching pitfalls; the module still exposes a defaultbroker = build_broker()for the entry point. See env-injectable broker factory. The docs build emits the worker's AsyncAPI schema straight from the liveappobject (docs/conf.pyshellsfaststream docs geninto the gitignoreddocs/_generated/asyncapi.yaml), and the conditionaldocs/worker-interface.rstpageliteralincludes it — a versioned, machine-readable request/response contract that stays in lockstep with the code. The docs dependency group gainsfaststream[cli]+pyyamlunder theinclude_workerguard.scripts/worker_probe.py(conditional oninclude_worker) - a dev-only helper that publishes oneVersionRequestviabuild_broker()and prints theVersionResponse, importingREQUESTS_DESTINATION/RESPONSES_DESTINATIONfromworker/app.pyrather than re-literalizing them. Lives underscripts/(outsidesrc/) so it ships in neither the sdist nor the wheel and stays outside coverage/import-linter's reach. Run via theworker-probemise task (mise run worker-probe) against a broker started by the devcontainer ordocker compose.
Other conditional files:
_c_extension.pyx,.pxd,.pyi- Cython extension filesprofile.py- Profiling script<pkg>.spec- PyInstaller spec file (conditional oninclude_freezer)
Test packages mirror source structure in tests/:
tests/cli/,tests/web/,tests/gui/,tests/tui/,tests/mcp/,tests/worker/,tests/db/(each conditional)tests/worker/holds both the in-memoryTest<Broker>unit tests (always run) and a broker round-trip integration test markedintegration(excluded from the default run). Its_broker_urlcontext manager reads a single seam: if the broker's env var (REDIS_URL/NATS_URL/KAFKA_BOOTSTRAP_SERVERS/RABBITMQ_URL) is set it connects to that live broker directly, otherwise it starts a testcontainer (Docker required for the local path). In CI, redis and nats run against a GitHub Actionsservices:container that the runner starts and injects before the job (redis is health-gated; nats has no container health check and relies on the test's own connect-retry), while kafka and rabbitmq stay on testcontainers — a per-broker CI mechanism derived from theci_servicefield onworker_broker_spec(present ⇒services:; itsimage/port/health_cmd/urlrender the block), no extra question. See real-broker integration tests (incl. the issue #169 amendment).
Entry points configured in pyproject.toml (console-script wiring — see
components as subcommands): the
highest-precedence enabled component (CLI > GUI > TUI > web > MCP > worker)
is the primary and owns the bare pkg command, wired to pkg.__main__:main
(the single entrypoint standalone builds also target). Every other enabled
component is exposed as a subcommand of the pkg Typer root
(pkg <name> — interactive for the TUI, else the component name), not a
separate pkg-<name> console script. The precedence lives in one place — the
primary_component computed variable in copier.yml (empty for a library) —
and every template (pyproject, README, docs/usage, docs/installation,
.vscode/launch.json, tox) derives the primary and each subcommand from it: a
component X is a subcommand exactly when primary_component != "X". Do
not re-spell the precedence as inline include_x or include_y … conditions.
The pkg Typer root lives in the cli/ package. It exists whenever
include_console_root (a hidden when: false computed var) is true —
include_cli or include_web or (≥2 of gui/tui/web/mcp/worker enabled). When
include_cli is off, cli/ is a minimal launcher (no version/info; bare
pkg launches the primary via an @app.callback(invoke_without_command=True)
default, secondaries are subcommands). include_web is listed because the
run/dev verbs need a root to live on (run/dev launch verbs);
a web-only project therefore gets the minimal launcher and a typer runtime
dependency, with bare pkg unchanged. Any other single-component non-CLI app
has no root and does not pull in typer — bare pkg launches that
component directly via __main__, through the _load_component() guard
described above.
include_console_root is the single source of truth for the cli/
package/test-dir guards, the typer core dependency, the import-linter cli
layer, and the __main__.py branch.
project.scripts: barepkg = "pkg.__main__:main"when the primary is a console component or when a GUI primary shares a console root with other components (so thepkg <name>subcommands keep a real terminal on Windows). Nopkg-<name>entries are emitted.project.gui-scripts:pkg = "pkg.__main__:main"only when GUI is the sole runnable component (windowless launcher, no console window on Windows); there is never a bare-name collision across the two tables. A non-primary GUI is reached via thepkg guisubcommand, not apkg-guigui-script.
The CLI framework choice (Typer) is recorded in CLI framework choice.
Each launcher command wraps its lazy component import in a private
_component_dependencies(component, *dependencies) context manager, so a
missing known dependency exits 1 with a message naming the component, the
missing module, and uv sync — instead of a bare ModuleNotFoundError
traceback. The match is exact, so an application import defect (a typo'd
from <dep>.user_plugin import X) propagates with its traceback intact. The
guard is emitted only when the root actually lazy-imports something (derived
from primary_component, so a CLI-only project renders without it) and covers
the minimal launcher's default callback too.
Dependencies the component imports later than that block — uvicorn (used
when the web app calls uvicorn.run()), textual, tkinter (imported only
when the GUI draws), and faststream.<broker> (a guarded re-export that raises
a nameless ImportError) — are imported eagerly by a _preflight(module) helper
inside the guard, which names the module when the interpreter does not. The GUI
passes a different hint= naming the platform's Tk package (python3-tk,
python3-tkinter, or a python-tk@X.Y pinned to sys.version_info), because
tkinter is a standard-library extension module that no dependency sync can
install; its allowlist covers the _tkinter C extension too. The worker's
allowlist names faststream.<broker> rather than the broker client. MCP is
preflighted too — its transitive dependencies (e.g. anyio) can surface nested
failures with unhelpful names that the preflight normalizes to mcp.
__main__.py additionally loads the root through _load_console_root(), which
preflights and applies the same translation to the modules imported at the root's
module scope: typer, plus pydantic/pydantic_settings via core.logging_setup →
core.config when include_pydantic_settings. root_dependencies is computed
from the enabled toggles, so a pure argparse root with no settings renders
without that guard.
run / dev launch verbs (web)¶
When include_web is enabled the console root also carries
<pkg> run [module:attribute] and <pkg> dev [module:attribute], sharing one
_run_web(app_path, *, dev_mode) that differs only in that flag. run uses the
{PROJECT}_HOST/{PROJECT}_PORT bind; dev adds reload=True and forces a
127.0.0.1 bind. web/app.py owns the launch itself: resolve_app_path
validates the module:attribute shape (defaulting to DEFAULT_APP_PATH,
i.e. <pkg>.web.app:app) and run_server hands the import string to uvicorn,
which is the only form autoreload can re-import.
include_console_root is therefore true whenever include_web is, so a
web-only project (the web preset) gains a minimal launcher — and with it a
typer runtime dependency and a cli/ package — rather than a second console
script alongside the subcommand scheme. Bare <pkg> still launches the primary
component. Where web is not primary, <pkg> web remains and is literally
<pkg> run with no target. The worker keeps <pkg> worker only: its reload
lives in faststream[cli], which stays a docs-group dependency. See
run/dev launch verbs.
A project with exactly one runnable component has no console root at all, so
__main__.py binds that component directly — the standalone-executable
entrypoint. It renders a sibling _load_component() with the same preflight,
exact-match rule and hints, covering the component's own dependencies and the
settings stack the import chain reaches through core.logging_setup →
core.config. Because that merged allowlist mixes real distributions with
tkinter, the hint is picked per module: the Tk hint for tkinter/_tkinter,
uv sync for everything else. The five per-component elif branches
__main__.py used to carry are now one block parameterized by sole_component.
A library consumer's own import <pkg>.web.app stays unguarded and raises a
plain ModuleNotFoundError — deliberately: they are already reading a traceback
in their own code, where the failing import and its caller are both visible, and
a module-scope guard would run on every successful import forever.
launcher_components, need_import_guard, launched_components,
component_label, component_dependencies, component_preflight,
preflight_used, root_dependencies, sole_component,
sole_component_dependencies and sole_component_preflight are when: false
computed variables in copier.yml, read by cli/app.py.jinja,
__main__.py.jinja and both test modules so the four cannot drift. The two
guards live in different files and never coexist in one project, so
component_dependencies is the only thing that can catch them disagreeing about
what a component depends on.
The rationale — why the hint is uv sync and never pip install pkg[<extra>],
why matching is exact, why some dependencies need an eager import, why Tk is
special, and why direct library imports are out of scope — is recorded in
component dependency guard.
Devcontainer Structure¶
The .devcontainer/docker-compose.yml.jinja consolidates all services:
devcontainer- Main development container (always included)- Uses
ghcr.io/astral-sh/uv:python3.14-bookworm-slimimage directly (no Dockerfile) devcontainer.json.jinjafeatures:git:1andmise:1(both always)- When
include_vpnis true, devcontainer setsnetwork_mode: "service:vpn"and depends on thevpnservice so all traffic routes through the VPN tunnel depends_onwaits oncondition: service_healthyfor services that define a healthcheck (postgres, redis, kafka, rabbitmq) andservice_startedfor the rest (nats, vpn)- Database services (conditional):
postgres- PostgreSQL with healthcheck (conditional oninclude_postgres)redis- Redis/Valkey with healthcheck (conditional oninclude_redisor aredisworker broker; image set byredis_backend)pgadmin/adminer- DB UIs behind thetoolscompose profile (conditional oninclude_pgadmin/include_adminer)- Message broker services (conditional on
include_worker+worker_broker): kafka- Bitnami Kafka (KRaft mode), healthcheck viakafka-topics.shnats- NATS with JetStream (no healthcheck; minimal image)rabbitmq- RabbitMQ with management UI, healthcheck viarabbitmq-diagnosticsredis- Redis (shared with the databaseredisservice above)cloudbeaver- DBeaver CloudBeaver (conditional oninclude_dbeaver)vpn- OpenVPN client sidecar (conditional oninclude_vpn)- Uses
dperson/openvpn-clientwithNET_ADMINcap and/dev/net/tundevice - Mounts
./vpn:/vpn— drop.ovpnconfig + optionalvpn.authcredentials there - Conditional
vpn/subdir is rendered with a README and.gitignore(whitelists README only) - Devcontainer shares the vpn service's network namespace via
network_mode; port forwards must be declared on thevpnservice rather thandevcontainer
Build System¶
- Builder:
hatchlingwithhatch-vcsplugin - Versioning: Git tags via hatch-vcs; archive/tarball installs recover the
tag via
.git_archival.txt(export-subst), and thefallback-version = "0.0.0"sentinel is used only when neither live git metadata nor an expanded.git_archival.txtis available - Build Command:
uv build
Evaluated and intentionally out (issue #392): setuptools-scm — out as a
direct requirement (hatch-vcs already builds on its version-inference logic,
surfaced via [tool.hatch.version.raw-options]; adopting it as the version
source itself would mean swapping the whole backend hatchling → setuptools),
while the template standardizes on hatch-vcs (dynamic version from git tags,
archive recovery via .git_archival.txt); hatch-babel — there is no
i18n story yet, so it waits for a real translation workflow instead of shipping
preemptively; hatch-autorun — it injects a .pth that auto-executes code
at interpreter startup (before first import), which the supply-chain posture
rejects; that is distinct from ordinary build-time hooks (hatch-vcs's
version-file writer, the c-extension compiler), which stay.
CI/CD Workflows¶
- CI (
ci.yml.jinja): Matrix tests on Windows/Ubuntu/macOS, Python 3.11-3.14 - Per-component jobs + path filtering
(per-component CI): a
changesjob (dorny/paths-filter) emits a boolean per component pluscore. The test job is split intotest-core(always runs) and onetest-<component>per enabled component, each gated onneeds.changes.outputs.<component> == 'true' || …outputs.core == 'true'and runningtox run -- -m "<component> …".core(deps, sharedcore/, package-root modules, rootconftest.py, root tests, andci.ymlitself) forces the full fan-out.worker-integrationcarries the same gate. Markers are auto-applied bytests/conftest.py: root-level tests becomecore, while tests in a component directory use the top-leveltests/<dir>/; the default localtox runis unchanged (everything minusintegration). - Asymmetric matrix + draft policy
(asymmetric matrix, draft skip): component
tests run all supported interpreters on Ubuntu and one representative
interpreter on macOS/Windows; C-extension renders keep the full grid.
styleruns once on Linux and sweeps mypy's win32/darwin platform axis. Aninclude_clirender gets a dedicated three-OScli-installedjob, so the real console script is checked without receiving pytest marker args. Everyci.ymljob is draft-gated, andready_for_reviewstarts a fresh run. - Docs keep their own job. Because the component
test-*jobs passtox run -- -m …(which replaces tox's default env list), aninclude_docsrender gets a dedicateddocs-doctestjob runningtox run -e docs-buildthentox run -e docs-doctest; it is part of thecheckgate (tested documentation examples). Thedocs-buildstep is what makes the Sphinx warning gate gate: it pairssphinx-build -wwithdocs/check_warnings.py, whereasdocs-doctestis-b doctestwith no-W.docs-preview.ymlruns the same gate but is skipped for fork PRs and is not a required context, so it can never block a merge on its own (Sphinx warning gate). The job checks out atfetch-depth: 0—sphinx-last-updated-by-gitwarns "Git clone too shallow" on a depth-1 clone and the gate turns that into an error. Per-component examples (cli_usage.py,web_usage.py, …) are Jinja-gated on their toggle andliteralincluded into the correspondingusage.rstsection;tests/test_docs_examples.pyrglobs everydocs/examples/*.pyand imports them all, so a broken import fails the suite. The top-levelREADME.mdis separately collected by the ordinary pytest run as the intentionally small Markdown doctest surface; Markdown undertests/is held out bycollect_ignore_globintests/conftest.py, since--doctest-globmatches basenames (tested documentation examples). - Per-component coverage gates
(per-component CI, decomposing
the former single union gate):
each
test-<component>cellcoverage combines its per-interpreter data, keeps a non-gating--fail-under=0report for fast feedback, and uploadscoverage-<component>-<os>(include-hidden-files: true). Acoverage-<component>job then merges that component's OS cells and enforcesfail_under = 99scoped to the subtree (--include="*/<pkg>/<c>/*,*/tests/<c>/*";coverage-coreinstead--omits every component). The union principle is preserved within each component (its cross-OS cells), not across all code — andchecklists each path-gated job in alls-green'sallowed-skips(a skip otherwise counts as a failure), so a path-skipped, unchanged component keeps thecheckgate green. A central non-gatingcoverage-reportjob (if: !cancelled() && draft != true) merges whatever ran, renders combined HTML/XML, and handles Codecov/smokeshow.relative_files = true(pyproject) lets cross-runner paths merge, complementing the[tool.coverage.paths]remap. Wheninclude_sonarcloud, thecoverage-xmlartifact thesonarjob consumes comes fromcoverage-report(sonarnowneeds: coverage-report). - Codecov upload (from
coverage-report) runs whenever the repo is public or aCODECOV_TOKENsecret is set — two job-level presence flags gate it because thesecretscontext is unavailable inif::CODECOV_TOKEN_SET(secrets.CODECOV_TOKEN != '') andREPO_IS_PUBLIC(!github.event.repository.private). On a public repo the codecov-action uploads tokenless, so owner pushes and fork PRs both report coverage with no secret; the token is only needed for a private repo (or to dodge tokenless rate-limits). Only a private repo with no token hits the::notice::visible skip instead of failing the run — coverage reporting is best-effort (the upload step'sfail_ci_if_errordefaults to false), not load-bearing for a green build. Setup is documented in the generated project'sREPOSITORY_SETUP.rst"Repository setup" guide. - When
include_smokeshowis set,coverage-reportadditionally publishes the combinedhtmlcov/to a tokenless ephemeral public URL viasmokeshow upload htmlcov(public repos only) — an account-free browsable coverage report alongside Codecov. See smokeshow coverage mirror. - When
include_launcher/include_freezer/include_compilerare set, adds matchingbuild-{launcher,freezer,compiler}-checkjobs (per-OS,fail-fast: false) that build the standalone executable on every PR/push — a build-only packaging guard so a dep/Python bump that breaks bundling fails against the offending diff instead of silently at release time (the test suite never exercises the built binary). Each verifies the binary was produced, smoke-runs it with--helpwheninclude_cliis set, and uploads a 7-day preview artifact. They closely follow therelease.ymlbuild jobs (same setup + build commands), skipping the release-only rename/publish steps, and they gate thecheckaggregation job. See executable build checks. - When
include_workeris set, adds a singleworker-integrationjob — Ubuntu-only (Docker is reliably present on GitHub's Ubuntu runners but not macOS/Windows) — that runstox run -e integration(an on-demand tox env invokingpytest -m integration) against a realworker_brokerstarted via testcontainers. The default OS matrix stays broker-free and fast (theintegrationmarker is deselected by default via the pytestaddopts); this job is the only thing exercising the real driver, and it gates thecheckaggregation job. It is path-gated like the component test jobs (skips its container spin-up unless the worker tree or sharedcorechanged; per-component CI). See real-broker integration tests. - Release + CD (
release.yml.jinja): one unified workflow (there is no separatecd.yml). Standardized on release-please — no longer configurable (see release-please). Jobs: release-please: opens/maintains a release PR from Conventional Commits on push tomain; on merge, tags the commit and creates a draft GitHub Release (draft: truein.github/release-please-config.json). Exposesrelease_created,tag_name,versionas outputs.- All later jobs gate on
needs.release-please.outputs.release_created == 'true'. build: runs behind the main-onlypublishenvironment, builds with uv, and creates GitHub-hosted, Sigstore-signed build provenance plus a release-attachable bundle for every distribution in public repositories (or Enterprise private repositories opted in throughENABLE_PRIVATE_ATTESTATIONS). Wheninclude_c_extensionsis set, runs as a per-platformfail-fast: falsematrix (Ubuntu/Windows/macOS) producing and attesting the multi-platform Cython wheels + sdist;pypi-publishuploads the distributions only.pypi-publish(wheninclude_pypi_publish, on in every preset): trusted publishing (id-token: write, environmentpublish). Off, the job and itsneedsedges are not rendered;buildkeeps thepublishenvironment for attestation minting, and the distributions still reach the GitHub Release throughattach-github-release(optional PyPI publishing).build-launcher(wheninclude_launcher, PyCrucible) /build-freezer(wheninclude_freezer, PyInstaller) /build-compiler(wheninclude_compiler, Nuitka) /docker-publish(wheninclude_web): conditional matrix jobs. The three executable jobs are independentfail-fast: falsematrices producing per-platform artifacts named<repo>-executable-{launcher,freezer,compiler}-<os>, all caught byattach-github-release's<repo>-executable-*download pattern (see executable naming). Docker tags feedneeds.release-please.outputs.tag_nameinto the metadata-actionvalue=because a push-triggered run has no tag ref.attach-github-release: uploads distributions, provenance bundles, the SBOM, and optional executables to the still-draft release.finalize-release: un-drafts the release and reconciles the phantom next-release PR (close + re-dispatch — bounded to one re-run).deploy-docs(needs: finalize-release): runstools/build_docs.py siteand publishes the result viaJamesIves/github-pages-deploy-action. Lives in this workflow rather than reacting torelease: publishedbecause an event fired byfinalize-release'sGITHUB_TOKENcannot trigger another workflow (the same loop-prevention rule that forces theworkflow_dispatchre-dispatch above).gh-pages.ymlis kept for manual redeploys and is likewise version-aware. Docs are built with Sphinx + the Shibuya theme (autodoc API reference), not MkDocs (see Sphinx with Shibuya). Publishing is versioned (versioned docs): only the released version is built (its slug set by thedocs_version_granularityquestion —X.Y/X/X.Y.Z); prior versions are copied untouched from thegh-pagesbranch (never rebuilt), alatestalias tracks the newest, and a rootindex.htmlredirects to it. The switcher is Shibuya-native, fed from a generated (gitignored)docs/_static/versions.jsonviahtml_context, and each page footer carries asphinx-last-updated-by-gitdate. Bothdeploy-docsand the manualgh-pages.ymlsetclean-exclude: pr-preview/**so a release never wipes the live PR previewsdocs-preview.ymlmaintains under that path (see PR docs previews below); the numeric version-slug directories (e.g.0.3/) are re-supplied in./siteeach run. The manualgh-pages.ymlchecks out the latest published release's tag before building so a manual redeploy never overwrites released docs with unreleasedmain— viagh api .../releases/latest, notgit describe, which would pick up the tag release-please creates while the release is still a draft.setup-python/setup-uvrun after that checkout so the tagged docs build on the interpreter the release shipped on. It then restorestools/build_docs.pyfrom the dispatch ref (orchestration, like the workflow file itself) but carries the tag's bakedDEFAULT_VERSION_GRANULARITYacross that swap viaDOCS_VERSION_GRANULARITY, so a granularity answer changed since the last release cannot republish that tag under a second slug and repointlatestat the duplicate. Both jobs fetchgh-pagesthrough anls-remoteguard (exit 2 = absent, anything else aborts): a swallowed transport error would makebuild_docs.pyassemble a site with no prior versions, and the clean-on-deploy would then delete the whole published archive.preserve_from_gh_pages()enforces the same rule one layer up — a version slug came from agit ls-treeof the branch, so failing to read it back raises instead of silently dropping it (only thelatestalias is optional).notify-released-issues(needs: finalize-release): a singleactions/github-scriptstep that maps the release's commit range (previous published tag → this tag) to the PRs that carried it, resolves each PR'sclosingIssuesReferences, and comments the release link on those issues. Inline (no committed script), best-effort. See released-issue notifications.- Versions come from git tags via hatch-vcs, so release-please never edits a
static version literal and
uv.lockcannot desync. - PR title linting (
check-pr-title.yml): validates the PR title (not individual commits) against Conventional Commits viaamannn/action-semantic-pull-request. - Linked-issue check (
check-linked-issues.yml): fails a PR that has no linked issue (the GitHub Development relationship, created by aCloses #Nkeyword in the PR body) vianearform-actions/github-action-check-linked-issues. It checks the realclosingIssuesReferencesrelationship, not just body text; apply theno-issuelabel to bypass the check. Keep it as a required status check alongsidecheck-pr-title. - PR task-list completion check (
task-completed-check.yml): onpull_request_target(opened/edited/synchronize/reopened), runskentaro-m/task-completed-checker-actionto post an informational check run. Usespull_request_target(least-privilegechecks: write+pull-requests: read) so the check also runs on fork PRs; wrap throwaway lists in<!-- ignore-task-list-start -->/<!-- ignore-task-list-end -->to skip them. The required context is the job (Check PR task list): an enforce step re-evaluates the current PR body and fails the job while any checkbox is unticked, so the verdict cannot go stale across overlapping runs. Bot-authored PRs skip both steps and stay green via a follow-up step that publishes the check run as a success. (An earlier revision required the action'sTask Completed Checkercheck run itself; ruleset evaluation stopped honoring those synthetic runs and blocked every bot PR.) - Branch-name linting (
check-branch-name.yml): validates the PR's head branch name (github.head_ref) against the Conventional Branch format (<type>/<description>). Allowed prefixes are exactly the spec's set —feature/feat,bugfix/fix,hotfix,release,chore, plus the AI-agent prefixesai/copilot/cursor/claude/codex— not the broader Conventional Commits type set (docs,style,refactor, etc. are commit types, not branch types). It is a dependency-free inline shell regex (no marketplace action — the maintained options were either abandoned or on a deprecated Node runtime), run underpull_request_targetwithpull-requests: writeso it can post a sticky failure comment on fork PRs, mirroringcheck-pr-title.yml. Therenovate/*andrelease-please--*automation branches are whitelisted so bot PRs are never blocked. Branch names never reachmain(squash-merge uses the PR title), so this is repo hygiene — not load-bearing for release-please. Keep it as a required status check (context: Validate branch name) alongside Validate PR title, Verify linked issue, Check PR task list (task-completed-check.yml— required as the job name; its enforce step fails the job on unticked boxes), and check —ci.yml's aggregate gate, the only one of the five that says anything about the code. - Supply-chain security (always included, static workflows):
codeql.yml: CodeQL analysis on push/PR tomain+ weekly schedule. Job-level visibility gate — code scanning needs GitHub Advanced Security on private/internal repos, so it would otherwise fail every run there; skipping keeps private forks green. The condition coalesces two visibility sources over a'public'default —||returns the first truthy operand — because neither source covers every trigger on its own:github.event.repository.visibilityis documented but empty onschedule(that event carries no payload), whilegithub.repository_visibilityis the only source a scheduled run could have but is undocumented (absent from both the contexts and default-environment-variable references, though actionlint models it). Private/internal repos therefore skip onpush/pull_requestalways, and onscheduletoo if the undocumented property resolves; a public repo always runs either way. Unknown deliberately fails open — silently disabling the scan everywhere is a worse failure than the red X the guard removes.scorecard.yml(ossf/scorecard-action): OpenSSF Scorecard onbranch_protection_rule/push/weekly schedule;publish_results: trueandid-token: writeso the public Scorecard badge (added to the generated README) resolves, uploading SARIF to code-scanning. Public repos only — enforced by a job-level visibility gate, since both the publish and the upload fail on private repos and would leavemainpermanently red. Uses the same two-source-coalescing condition ascodeql.ymlabove (it shares the payload-lessscheduletrigger), and likewise fails open when visibility is unknowable rather than silently dropping the scan.dependency-review.yml(actions/dependency-review-action): onpull_request, fails on high+ severity vulnerabilities and comments a summary on failure. Job-levelif: github.event.repository.visibility == 'public'— the action needs GitHub Advanced Security on private repos, so it would otherwise fail every PR there; skipping keeps private forks green. Needs the repository's dependency graph enabled — with it off the action errors ("Dependency review is not supported on this repository") rather than passing empty, so it is a[HUMAN]step inREPOSITORY_SETUP.rst(UI-only: thesecurity_and_analysis[dependency_graph]PATCH silently no-ops).check-security.yml.jinja: an active scanning pass on PR/push tomain+ weekly cron, complementing the three above (which are SAST / repo-posture / PR-diff). Jobs:gitleaks(gitleaks/gitleaks-action, always): secret scan over full git history (fetch-depth: 0) — catches secrets CodeQL does not and reaches past commits that GitHub's push protection never guarded. Free for personal accounts/public repos; an org must add aGITLEAKS_LICENSEsecret (noted inline in the workflow).pip-audit(always):uv exports the locked shipped deps (--all-extras --no-dev --no-emit-project --no-hashes) and audits them withuvx pip-audit. Component and settings deps are coredependencies(each console script imports its component unconditionally), so a plain export already covers the whole shipped tree;--all-extrasis retained as a harmless safeguard should a future extra reintroduce shipped deps (and applies equally to the releasesbomjob's export — though that job omits--no-hashes, since the SBOM keeps per-pin integrity hashes; only pip-audit drops them, as its resolver would otherwise demand a hash for every transitive pin). Unlikedependency-review(PR-diff only, GitHub Advisory DB), this re-audits the entire resolved tree against the PyPI Advisory DB on the weekly cron, so a CVE disclosed after a dependency merged is caught while it is still pinned. The same export + audit also runs locally as a push-onlypip-auditprek hook (stages = ["pre-push"]), so a vulnerable pin is reported before CI rather than after it.trivy-image(include_webonly): builds the generatedDockerfileand scans the image withaquasecurity/trivy-action(severity CRITICAL,HIGH,ignore-unfixed: true,exit-code: 1). Gated oninclude_webbecause that is the only configuration that produces a Dockerfile; the filesystem-scan overlap withpip-auditis deliberately avoided (image/OS-layer scanning is the genuine gap). These SHA-pin actions like every other workflow (Renovatehelpers:pinGitHubActionDigestskeeps them current). This is a standalone workflow (likecodeql/scorecard), not wired into theci.ymlcheckaggregation gate. A CycloneDX SBOM (<repo>-sbom.cdx.json) is generated from the locked runtime deps by asbomjob inrelease.ymland attached to each GitHub Release (viaattach-github-release), not kept as a throwaway CI artifact.
- Workflow hardening + zizmor (always included). Every generated workflow follows a strict zizmor posture, enforced two ways:
- Blocking gate: the
zizmorprek hook (see the prek section above) runs in the CIhooksjob and locally; it hard-fails on any finding. - Dashboard:
zizmor.yml(static,zizmorcore/zizmor-action, SHA-pinned) uploads SARIF to the Security tab on push/PR tomain. Non-blocking by design (advanced-security mode never fails on findings); least-privilege (permissions: {}top-level,security-events: writeon the job) and itself passes the audit it runs. Public repos get code scanning free; private repos need GitHub Advanced Security, and the action's SARIF upload is neither visibility-aware norcontinue-on-error, so it would fail every run there. Bothadvanced-securityandannotationsare therefore driven offgithub.event.repository.visibility: SARIF on public repos, mutually-exclusive inline workflow annotations on private ones — the audit still runs either way, only the Security-tab dashboard is lost.
The hardening conventions every workflow (new or edited) must keep so the gate
stays green are agent instructions, so they live inline in AGENTS.md's
Workflow hardening rules (persist-credentials, top-level permissions: {},
no untrusted ${{ }} in run:, and the justified
# zizmor: ignore[dangerous-triggers] markers on the intentional
pull_request_target workflows). Do not remove those markers.
This repo's OWN workflows (.github/workflows/) get the same treatment plus a
zizmor.yml dashboard; this repo's prek.toml scopes the hook with
files = '\.github/workflows/.*\.ya?ml$' so it audits the template's static
.yml workflows too but skips the un-renderable *.jinja templates (whose
rendered form is audited by the generated project's own hook, and end-to-end by
rendering a project and running prek run zizmor --all-files in it).
9. PR documentation previews (docs-preview.yml, rendered only when
include_docs, static workflow). On pull_request
(opened/synchronize/reopened/closed) docs-preview.yml holds no write token: a meta job records the event action and a read-only build job runs the Sphinx docs build and uploads the HTML as artifacts. The workflow_run workflow docs-preview-deploy.yml (always the default branch's copy of the file, so a PR cannot edit it; dangerous-triggers ignore justified inline; runs no PR code, takes the PR number from the event payload and reduces the artifact's action file to an allowlisted word) hands the lifecycle to rossjrw/pr-preview-action
(explicit deploy/remove, since auto needs a pull_request event; its own comment is disabled because it does not forward pr-number, so a marocchino/sticky-pull-request-comment step posts the preview comment): deploy to pr-preview/pr-<N>/ on gh-pages on
open/update, remove on close, with a sticky preview-URL PR comment throughout.
Guarded by if: github.event.pull_request.head.repo.full_name ==
github.repository to same-repo PRs only — a fork PR's GITHUB_TOKEN is
read-only and cannot deploy, so forks are skipped cleanly (fork support awaits
the action's v2). The build workflow is plain pull_request; only the deploy workflow carries a dangerous-triggers ignore. Not wired into the check gate
(best-effort). Previews live under pr-preview/**, disjoint from the root docs
deploy, and the two JamesIves publishes carry clean-exclude: pr-preview/**
so a release never wipes them. See
PR docs previews.
10. Docs link check (docs-linkcheck.yml, rendered only when include_docs, static workflow).
Runs Sphinx's linkcheck builder on a weekly cron + workflow_dispatch
to catch dead links/moved anchors in the docs. Deliberately not on
pull_request and not in the check gate — link checking hits the
network and is flaky, so it is non-blocking (same posture as
check-security.yml's cron). A matching on-demand docs-linkcheck tox env
lets maintainers run it locally. Two other docs-lint additions ship alongside
it: sphinx-lint (a local prek hook backed by the style group + a tox
style command, linting the .rst sources) and the check-case-conflict
prek builtin (cross-platform filename-collision guard). See
docs linting.
The HTML build itself carries a warning-allowlist gate: the docs-build
tox env builds with sphinx-build -w docs/_build/warnings.txt (write the
warning stream to a file rather than -W failing on the first one), then runs
docs/check_warnings.py, which normalizes the emitted warnings (path prefixes
stripped back to docs/) and diffs them against the committed
docs/expected_warnings.txt allowlist. It fails on both unexpected new
warnings and expected ones that stopped being emitted, so the list can't rot.
The allowlist ships effectively empty (comment header only — zero tolerated
warnings); a genuinely-unavoidable upstream warning is added as an explicit,
reviewable line in the same PR rather than suppressed wholesale. This runs in
the local loop (tox run -e docs-build) and in both CI docs builds
(docs-preview.yml, release.yml's deploy-docs), complementing
sphinx-lint's .rst-source checks with build-time semantic warnings (missing
xrefs, autodoc import failures).
Downstream template updates are not a shipped workflow — they are handled by
Renovate's copier manager
(copier-manager updates). Renovate (already the canonical updater, and a documented one-time
setup) detects the generated project's .copier-answers.yml, watches the template
repo (_src_path) via the git-tags datasource, and when a newer tag exists
runs the real copier update and opens a PR with the re-rendered diff. It is on
by default (the shared preset sets no enabledManagers), so generated projects
get it via the .copier-answers.yml they already ship — nothing added to the
template's renovate.json. Renovate's App token can push .github/workflows/*
(the repo GITHUB_TOKEN cannot), so no per-repo update PAT is needed. Two things
make this work and are load-bearing — do not undo them without re-reading
copier-manager updates:
(1) the template's own release-please (below) must keep cutting tags, because
the copier manager is tag-based, not HEAD-based; (2) copier.yml must define no
_tasks — any task forces copier ... --trust, which the hosted Mend Renovate
App disables (allowScripts is self-hosted-only), so a task-bearing template
breaks the manager. The former _tasks: git init was removed for this reason (the
scaffold instructions now tell the user to git init). Caveat: Renovate does not
fail its artifacts check on copier update merge conflicts
(renovate#31600), so a
copier PR can look mergeable while carrying <<<<<<< markers — the generated
project's REPOSITORY_SETUP.rst "Template updates" note says to review
for them.
Shipped pins under template/** are frozen from this repo's Renovate (a
disabled packageRules entry in .github/renovate.json matching
template/**). This is load-bearing for keeping copier update conflict-free —
do not re-enable it. The shipped literals — the confirmed conflict source is
the plain template/.github/workflows/*.yml action SHAs (the .jinja-suffixed
files are not matched by Renovate's stock managers), plus any mise.toml.jinja
tool versions, prek.toml.jinja rev pins, or deps a customManager reaches —
are seed values only: every generated project
ships its own Renovate config and owns those bumps thereafter. If this repo also
bumped them, copier update's 3-way merge would see both the new template render
(theirs) and the downstream file (ours) having moved the same literal to
different values → a conflict on nearly every release. Freezing keeps
theirs == base, so the downstream's Renovate value always wins the merge
cleanly. This repo's OWN root workflows/prek.toml/mise.toml stay
Renovate-managed (template CI stays current); newly-generated projects start with
slightly-stale-but-valid pins that their own Renovate catches up on the first
run. To ship less-stale seeds, refresh the template/** pins deliberately (by
hand or a one-off scoped run) right before cutting a template release — accepting
that a deliberate bump reintroduces conflicts only for projects that had not yet
caught up. See
frozen template pins.
Template self-versioning (this repo)¶
The template repository versions itself with release-please so its changes
produce semver git tags (v1.0.0, …). These tags are the datasource Renovate's
copier manager consumes in generated projects (see the copier-manager note above) — so
tagging is load-bearing, not cosmetic. This is distinct from the
template/-shipped release-please that versions generated projects. Root-level
files: .github/release-please-config.json (release-type: "simple" — the repo
has no source version file, so it maintains only the manifest + CHANGELOG.md +
tag; draft: false, no release artifacts to attach),
.github/release-please-manifest.json (seeded { ".": "0.0.0" }; release-please
defaults the first release to v1.0.0 regardless of bump-minor-pre-major —
add a Release-As/release-as override for the first PR if a 0.x start is
wanted), and .github/workflows/release.yml (a single release-please job on
push to main, no build/publish jobs). Do not add a build/publish step — the
template is not a distributable package.
One-time repo setup (same contract release-please needs everywhere): this
repo must squash-merge with the commit message set to the PR title (already
its practice — see the feat: … (#NNN) history) so the check-pr-title-validated
title is what release-please parses on main, and Settings → Actions → General
→ Workflow permissions → Allow GitHub Actions to create and approve pull
requests must be enabled so release.yml can open its release PR (already
required by the existing PR-opening automation such as gitignore-drift.yml).
Enable via gh repo edit hasansezertasan/copier-pyproject --enable-squash-merge
--enable-merge-commit=false --enable-rebase-merge=false
--squash-merge-commit-message=pr-title and
gh api -X PUT repos/hasansezertasan/copier-pyproject/actions/permissions/workflow
-f default_workflow_permissions=read -F can_approve_pull_request_reviews=true.
The default stays least-privilege read, as in generated projects: every
workflow declares its own permissions:, and release.yml's release-please
job grants itself the write scopes it needs. Both settings are one-time and
idempotent to re-apply (in a fork, substitute its OWNER/REPO in every command);
re-check them first when the release PR stops opening:
gh api repos/hasansezertasan/copier-pyproject --jq '.allow_squash_merge and
(.allow_merge_commit | not) and (.allow_rebase_merge | not) and
.squash_merge_commit_title == "PR_TITLE"' must print true, and
gh api repos/hasansezertasan/copier-pyproject/actions/permissions/workflow
must report read and true.
Required Merge Strategy (release-please depends on it)¶
release-please derives version bumps and changelog sections solely from the
Conventional Commit messages that land on main. This template validates the
PR title but not in-PR commits, so generated repos must use "Squash and
merge" with the squash commit message set to the PR title — that is the only
strategy under which the lint-validated title becomes the commit on main.
Merge-commit and rebase-merge promote unvalidated branch commits and will cause
release-please to miss releases or bump them incorrectly.
Configure each generated repo (Settings → General → Pull Requests):
- Allow squash merging (ideally disable merge commits and rebase merging).
- Set the squash "Default commit message" to "Pull request title".
- Enable Automatically delete head branches.
- Keep
check-pr-titleas a required status check.
release-please also needs Settings → Actions → General → Workflow permissions
→ Allow GitHub Actions to create and approve pull requests enabled, or it
cannot open/maintain the release PR. Generated repos should additionally enable
release immutability (Settings → General). The generated project's
REPOSITORY_SETUP.rst documents all of these as a one-time "Repository
setup" guide (a repo-root file, not part of the docs site, and
pointed to from the slimmed contributor-facing CONTRIBUTING.md), including
ready-to-run gh repo edit / gh api commands and the PyPI trusted-publishing
registration, each step tagged [AGENT] (scriptable) or [HUMAN]
(browser-only) so it doubles as a setup-skill manifest — keep that guide in
sync when these requirements change. See
maintainer setup guide.
Each setup step carries a [CHECK] block (an idempotent check where exit 0
means already done, a secret-presence check, or an explicit No scriptable check
marker), single-sourced in the setup doc alongside [AGENT]/[HUMAN] (no
separate steps.yaml) — every new setup step must carry one. No skill is
shipped to walk them; an agent or maintainer reads the guide directly. See
repo-setup decision.
Bump rules follow .github/release-please-config.json: feat → minor, fix/perf →
patch, feat!/BREAKING CHANGE → major — but bump-minor-pre-major: true
keeps breaking changes at a minor bump while pre-1.0.
PyPI Trusted Publishing Setup¶
For generated projects to publish to PyPI (only when include_pypi_publish):
- Go to https://pypi.org/manage/account/publishing/
- Add pending publisher with:
- PyPI Project Name:
<package-name> - Owner:
<github-username> - Repository:
<repo-name> - Workflow:
release.yml(the publish step is inline in this workflow, so this is the filename PyPI's OIDC check matches — not a reusablecd.yml) - Environment:
publish
Template adoption and update guidance¶
Generated projects ship no adoption skill. The guidance is two parts of the
generated REPOSITORY_SETUP.rst: update hazards inside Template updates
(conflict scan, new-question defaults, reset release state, the update-anchor
check) and a final untagged Adopting into an existing project reference
section (baseline, ruff and source-skeleton hazards, release state, check
coverage and manifest parity, validation); the docs and MegaLinter hazards are
gated by the project's answers. The tagged steps and the untagged reference
section are read straight from the file. The README's pre-scaffold prompt establishes the
recoverable baseline, and _message_after_copy points at the rendered section
before tools or commits run. No external companion plugin or Copier post-copy
task is needed. Repository-settings mutations stay in REPOSITORY_SETUP.rst.
Outside .copier-answers.yml, README.md and REPOSITORY_SETUP.rst, generated
files carry no copier or template provenance; tests/test_adoption_reference.py
scans representative renders for it.