Skip to content

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.yml help: — 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 the keep-the-why skill. The former docs/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_description
  • copyright_year (int, default: the current year) - the start year in LICENSE's Copyright (c) <year>-present. It is asked, not hidden, so Copier records it in .copier-answers.yml and copier update keeps it. A project created before this question has no answer: a non-interactive update adopts the current year, and copier 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 generated keywords list (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 a TODO marker 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 when include_repo_settings is enabled (when: "{{ include_repo_settings }}"). Rendered into .github/settings.yml as the topics: list (lower-cased, space/underscore hyphenated). Left empty, the topics: 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 every include_* toggle via the hidden preset_map computed variable (when: false, never stored) — every preset includes docs; 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 --defaults run 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. The minimal/standard/custom presets and the byte-identical --defaults guarantee were removed with the archetype presets.

Optional components (all boolean):

  • include_cli - CLI (cli_framework: Typer, or standard-library argparse for a dependency-free command root exposing the same version/info commands and component subcommands — see CLI framework choice)
  • include_web - Web app + Dockerfile (framework choice: FastAPI or Litestar)
  • include_gui - Tkinter GUI
  • include_tui - Textual TUI
  • include_mcp - MCP (Model Context Protocol) server
  • include_worker - FastStream message queue worker
  • include_database - Async database layer (Advanced Alchemy + Alembic); see the database layer
  • include_c_extensions - Cython support with multi-platform wheels
  • include_profiling - py-spy, scalene, cProfile tools
  • include_benchmarks - pytest-codspeed benchmarks and a non-blocking CodSpeed workflow
  • include_launcher - uv-bootstrap launcher (PyCrucible) — small executable, downloads Python+deps on first run
  • include_compiler - compiler (Nuitka) — source compiled to a native machine-code executable
  • include_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 (when include_web is true)
  • worker_broker - kafka/nats/rabbitmq/redis (when include_worker is true)
  • database_backend - sqlite/postgres (when include_database is true). Picks the async driver dependency (aiosqlite/asyncpg) and the default URL; the default follows whether the preset enables include_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), or anyio (runs marked tests across both asyncio and trio backends). Defaults to asyncio when include_web, include_tui, include_mcp, include_worker, or include_database is enabled, and none otherwise. When TUI, MCP, worker, or the database layer is enabled, validation constrains the choice to asyncio or anyio because those ship async tests (SQLAlchemy's async drivers are asyncio-only, so the db tests pin the anyio backend 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 service
  • include_redis - Redis/Valkey service
  • redis_backend - redis/valkey image (when include_redis is true)
  • include_pgadmin - pgAdmin UI (when include_postgres is true)
  • include_adminer - Adminer UI (when include_postgres is true)
  • include_dbeaver - CloudBeaver database management UI
  • include_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 is docs/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.py fails in the blocking render-tests job when the page is stale. artifact-drift.yml re-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, watches template/, copier.yml and .example-input.yml, renders into the gitignored .watch-render/, prints a per-rebake status line, and keeps going when a render fails. Needs watchfiles, which mise run watch supplies with uv 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 show unknown, the worker falls back to 0.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; _exclude keeps an existing project's copy on copier update). Its failure path is exercised through the shared missing_metadata fixture in tests/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 logging
  • config.py - Configuration (uses pydantic-settings if enabled). When include_pydantic_settings is on, the docs build renders a Configuration reference straight from the live Settings model: docs/conf.py loads the sphinxcontrib.autodoc_pydantic extension and the conditional docs/configuration.rst page carries an autopydantic_settings directive, so the documented fields, types, defaults, constraints, and env-var names never drift from the model. The docs dependency group gains autodoc-pydantic under the include_pydantic_settings guard.
  • 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.py fails any non-__init__.py module under src/ whose body is empty apart from a docstring, pass/..., or a __future__ import, #321. copier.yml's _exclude lists the dropped path so copier update leaves an existing project's copy alone)
  • cli/ - the pkg Typer root (present when include_console_root). With include_cli it is the full CLI (version/info commands + component subcommands); without include_cli it is a minimal launcher (component subcommands + a default callback, no version/info). With include_cli and cli_framework == "typer", the docs build generates a CLI reference straight from the live app: docs/conf.py shells typer {{pkg}}.cli.app utils docs into the gitignored docs/_generated/cli.md, and the conditional docs/cli-reference.md page {include}s it (heading-demoted) so the documented commands/options — including the enabled component subcommands — never drift from --help. The sphinx-click route is unusable here because current Typer vendors its own Click (typer._click), so TyperGroup fails sphinx-click's isinstance(..., click.Command) check. sphinxcontrib-typer was 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. The argparse variant has no generated reference
  • web/ - FastAPI/Litestar with /version and /info endpoints (conditional). The docs build emits the web app's OpenAPI schema straight from the live {{github_repo_name}}.web.app:app object into the gitignored docs/_generated/openapi.yaml, and the conditional docs/web-interface.rst page literalincludes 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, so docs/conf.py dumps app.openapi() to openapi.{json,yaml} (JSON via the stdlib, YAML via pyyaml, added to the docs group under the include_web+FastAPI guard).
  • gui/ - Tkinter GUI launcher (conditional)
  • tui/ - Textual TUI (conditional)
  • mcp/ - MCP server with version and info tools (conditional)
  • db/ - async database layer (conditional on include_database): base.py (Advanced Alchemy's UUID/audit declarative base), models.py (one illustrative Item), repositories.py, config.py (get_config() builds the SQLAlchemyAsyncConfig from Settings.database_url, using the FastAPI or Litestar flavour of the config class when include_web) and migrations.py (the config instance the alchemy --config CLI imports). It is its own layer in the import-linter contract, between the components and core. With include_web the app registers the framework's Advanced Alchemy plugin and exposes GET /health/db. Settings.database_url is overridable through the prefixed DATABASE_URL environment variable. The Alembic tree is the root migrations/ (Advanced Alchemy's own env.py/script.py.mako, excluded from ruff and deptry); run tox run -e alembic -- make-migrations -m "msg" then -- upgrade from the project root. Why: the database layer.
  • worker/ - FastStream message queue worker (conditional). The broker is built by a build_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 default broker = build_broker() for the entry point. See env-injectable broker factory. The docs build emits the worker's AsyncAPI schema straight from the live app object (docs/conf.py shells faststream docs gen into the gitignored docs/_generated/asyncapi.yaml), and the conditional docs/worker-interface.rst page literalincludes it — a versioned, machine-readable request/response contract that stays in lockstep with the code. The docs dependency group gains faststream[cli] + pyyaml under the include_worker guard.
  • scripts/worker_probe.py (conditional on include_worker) - a dev-only helper that publishes one VersionRequest via build_broker() and prints the VersionResponse, importing REQUESTS_DESTINATION/RESPONSES_DESTINATION from worker/app.py rather than re-literalizing them. Lives under scripts/ (outside src/) so it ships in neither the sdist nor the wheel and stays outside coverage/import-linter's reach. Run via the worker-probe mise task (mise run worker-probe) against a broker started by the devcontainer or docker compose.

Other conditional files:

  • _c_extension.pyx, .pxd, .pyi - Cython extension files
  • profile.py - Profiling script
  • <pkg>.spec - PyInstaller spec file (conditional on include_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-memory Test<Broker> unit tests (always run) and a broker round-trip integration test marked integration (excluded from the default run). Its _broker_url context 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 Actions services: 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 the ci_service field on worker_broker_spec (present ⇒ services:; its image/port/health_cmd/url render 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: bare pkg = "pkg.__main__:main" when the primary is a console component or when a GUI primary shares a console root with other components (so the pkg <name> subcommands keep a real terminal on Windows). No pkg-<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 the pkg gui subcommand, not a pkg-gui gui-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-slim image directly (no Dockerfile)
  • devcontainer.json.jinja features: git:1 and mise:1 (both always)
  • When include_vpn is true, devcontainer sets network_mode: "service:vpn" and depends on the vpn service so all traffic routes through the VPN tunnel
  • depends_on waits on condition: service_healthy for services that define a healthcheck (postgres, redis, kafka, rabbitmq) and service_started for the rest (nats, vpn)
  • Database services (conditional):
  • postgres - PostgreSQL with healthcheck (conditional on include_postgres)
  • redis - Redis/Valkey with healthcheck (conditional on include_redis or a redis worker broker; image set by redis_backend)
  • pgadmin / adminer - DB UIs behind the tools compose profile (conditional on include_pgadmin / include_adminer)
  • Message broker services (conditional on include_worker + worker_broker):
  • kafka - Bitnami Kafka (KRaft mode), healthcheck via kafka-topics.sh
  • nats - NATS with JetStream (no healthcheck; minimal image)
  • rabbitmq - RabbitMQ with management UI, healthcheck via rabbitmq-diagnostics
  • redis - Redis (shared with the database redis service above)
  • cloudbeaver - DBeaver CloudBeaver (conditional on include_dbeaver)
  • vpn - OpenVPN client sidecar (conditional on include_vpn)
  • Uses dperson/openvpn-client with NET_ADMIN cap and /dev/net/tun device
  • Mounts ./vpn:/vpn — drop .ovpn config + optional vpn.auth credentials 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 the vpn service rather than devcontainer

Build System

  • Builder: hatchling with hatch-vcs plugin
  • Versioning: Git tags via hatch-vcs; archive/tarball installs recover the tag via .git_archival.txt (export-subst), and the fallback-version = "0.0.0" sentinel is used only when neither live git metadata nor an expanded .git_archival.txt is 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

  1. CI (ci.yml.jinja): Matrix tests on Windows/Ubuntu/macOS, Python 3.11-3.14
  2. Per-component jobs + path filtering (per-component CI): a changes job (dorny/paths-filter) emits a boolean per component plus core. The test job is split into test-core (always runs) and one test-<component> per enabled component, each gated on needs.changes.outputs.<component> == 'true' || …outputs.core == 'true' and running tox run -- -m "<component> …". core (deps, shared core/, package-root modules, root conftest.py, root tests, and ci.yml itself) forces the full fan-out. worker-integration carries the same gate. Markers are auto-applied by tests/conftest.py: root-level tests become core, while tests in a component directory use the top-level tests/<dir>/; the default local tox run is unchanged (everything minus integration).
  3. 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. style runs once on Linux and sweeps mypy's win32/darwin platform axis. An include_cli render gets a dedicated three-OS cli-installed job, so the real console script is checked without receiving pytest marker args. Every ci.yml job is draft-gated, and ready_for_review starts a fresh run.
  4. Docs keep their own job. Because the component test-* jobs pass tox run -- -m … (which replaces tox's default env list), an include_docs render gets a dedicated docs-doctest job running tox run -e docs-build then tox run -e docs-doctest; it is part of the check gate (tested documentation examples). The docs-build step is what makes the Sphinx warning gate gate: it pairs sphinx-build -w with docs/check_warnings.py, whereas docs-doctest is -b doctest with no -W. docs-preview.yml runs 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 at fetch-depth: 0 — sphinx-last-updated-by-git warns "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 and literalincluded into the corresponding usage.rst section; tests/test_docs_examples.py rglobs every docs/examples/*.py and imports them all, so a broken import fails the suite. The top-level README.md is separately collected by the ordinary pytest run as the intentionally small Markdown doctest surface; Markdown under tests/ is held out by collect_ignore_glob in tests/conftest.py, since --doctest-glob matches basenames (tested documentation examples).
  5. Per-component coverage gates (per-component CI, decomposing the former single union gate): each test-<component> cell coverage combines its per-interpreter data, keeps a non-gating --fail-under=0 report for fast feedback, and uploads coverage-<component>-<os> (include-hidden-files: true). A coverage-<component> job then merges that component's OS cells and enforces fail_under = 99 scoped to the subtree (--include="*/<pkg>/<c>/*,*/tests/<c>/*"; coverage-core instead --omits every component). The union principle is preserved within each component (its cross-OS cells), not across all code — and check lists each path-gated job in alls-green's allowed-skips (a skip otherwise counts as a failure), so a path-skipped, unchanged component keeps the check gate green. A central non-gating coverage-report job (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. When include_sonarcloud, the coverage-xml artifact the sonar job consumes comes from coverage-report (sonar now needs: coverage-report).
  6. Codecov upload (from coverage-report) runs whenever the repo is public or a CODECOV_TOKEN secret is set — two job-level presence flags gate it because the secrets context is unavailable in if:: CODECOV_TOKEN_SET (secrets.CODECOV_TOKEN != '') and REPO_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's fail_ci_if_error defaults to false), not load-bearing for a green build. Setup is documented in the generated project's REPOSITORY_SETUP.rst "Repository setup" guide.
  7. When include_smokeshow is set, coverage-report additionally publishes the combined htmlcov/ to a tokenless ephemeral public URL via smokeshow upload htmlcov (public repos only) — an account-free browsable coverage report alongside Codecov. See smokeshow coverage mirror.
  8. When include_launcher/include_freezer/include_compiler are set, adds matching build-{launcher,freezer,compiler}-check jobs (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 --help when include_cli is set, and uploads a 7-day preview artifact. They closely follow the release.yml build jobs (same setup + build commands), skipping the release-only rename/publish steps, and they gate the check aggregation job. See executable build checks.
  9. When include_worker is set, adds a single worker-integration job — Ubuntu-only (Docker is reliably present on GitHub's Ubuntu runners but not macOS/Windows) — that runs tox run -e integration (an on-demand tox env invoking pytest -m integration) against a real worker_broker started via testcontainers. The default OS matrix stays broker-free and fast (the integration marker is deselected by default via the pytest addopts); this job is the only thing exercising the real driver, and it gates the check aggregation job. It is path-gated like the component test jobs (skips its container spin-up unless the worker tree or shared core changed; per-component CI). See real-broker integration tests.
  10. Release + CD (release.yml.jinja): one unified workflow (there is no separate cd.yml). Standardized on release-please — no longer configurable (see release-please). Jobs:
  11. release-please: opens/maintains a release PR from Conventional Commits on push to main; on merge, tags the commit and creates a draft GitHub Release (draft: true in .github/release-please-config.json). Exposes release_created, tag_name, version as outputs.
  12. All later jobs gate on needs.release-please.outputs.release_created == 'true'.
  13. build: runs behind the main-only publish environment, 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 through ENABLE_PRIVATE_ATTESTATIONS). When include_c_extensions is set, runs as a per-platform fail-fast: false matrix (Ubuntu/Windows/macOS) producing and attesting the multi-platform Cython wheels + sdist; pypi-publish uploads the distributions only.
  14. pypi-publish (when include_pypi_publish, on in every preset): trusted publishing (id-token: write, environment publish). Off, the job and its needs edges are not rendered; build keeps the publish environment for attestation minting, and the distributions still reach the GitHub Release through attach-github-release (optional PyPI publishing).
  15. build-launcher (when include_launcher, PyCrucible) / build-freezer (when include_freezer, PyInstaller) / build-compiler (when include_compiler, Nuitka) / docker-publish (when include_web): conditional matrix jobs. The three executable jobs are independent fail-fast: false matrices producing per-platform artifacts named <repo>-executable-{launcher,freezer,compiler}-<os>, all caught by attach-github-release's <repo>-executable-* download pattern (see executable naming). Docker tags feed needs.release-please.outputs.tag_name into the metadata-action value= because a push-triggered run has no tag ref.
  16. attach-github-release: uploads distributions, provenance bundles, the SBOM, and optional executables to the still-draft release.
  17. finalize-release: un-drafts the release and reconciles the phantom next-release PR (close + re-dispatch — bounded to one re-run).
  18. deploy-docs (needs: finalize-release): runs tools/build_docs.py site and publishes the result via JamesIves/github-pages-deploy-action. Lives in this workflow rather than reacting to release: published because an event fired by finalize-release's GITHUB_TOKEN cannot trigger another workflow (the same loop-prevention rule that forces the workflow_dispatch re-dispatch above). gh-pages.yml is 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 the docs_version_granularity question — X.Y/X/X.Y.Z); prior versions are copied untouched from the gh-pages branch (never rebuilt), a latest alias tracks the newest, and a root index.html redirects to it. The switcher is Shibuya-native, fed from a generated (gitignored) docs/_static/versions.json via html_context, and each page footer carries a sphinx-last-updated-by-git date. Both deploy-docs and the manual gh-pages.yml set clean-exclude: pr-preview/** so a release never wipes the live PR previews docs-preview.yml maintains under that path (see PR docs previews below); the numeric version-slug directories (e.g. 0.3/) are re-supplied in ./site each run. The manual gh-pages.yml checks out the latest published release's tag before building so a manual redeploy never overwrites released docs with unreleased main — via gh api .../releases/latest, not git describe, which would pick up the tag release-please creates while the release is still a draft. setup-python/setup-uv run after that checkout so the tagged docs build on the interpreter the release shipped on. It then restores tools/build_docs.py from the dispatch ref (orchestration, like the workflow file itself) but carries the tag's baked DEFAULT_VERSION_GRANULARITY across that swap via DOCS_VERSION_GRANULARITY, so a granularity answer changed since the last release cannot republish that tag under a second slug and repoint latest at the duplicate. Both jobs fetch gh-pages through an ls-remote guard (exit 2 = absent, anything else aborts): a swallowed transport error would make build_docs.py assemble 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 a git ls-tree of the branch, so failing to read it back raises instead of silently dropping it (only the latest alias is optional).
  19. notify-released-issues (needs: finalize-release): a single actions/github-script step that maps the release's commit range (previous published tag → this tag) to the PRs that carried it, resolves each PR's closingIssuesReferences, and comments the release link on those issues. Inline (no committed script), best-effort. See released-issue notifications.
  20. Versions come from git tags via hatch-vcs, so release-please never edits a static version literal and uv.lock cannot desync.
  21. PR title linting (check-pr-title.yml): validates the PR title (not individual commits) against Conventional Commits via amannn/action-semantic-pull-request.
  22. Linked-issue check (check-linked-issues.yml): fails a PR that has no linked issue (the GitHub Development relationship, created by a Closes #N keyword in the PR body) via nearform-actions/github-action-check-linked-issues. It checks the real closingIssuesReferences relationship, not just body text; apply the no-issue label to bypass the check. Keep it as a required status check alongside check-pr-title.
  23. PR task-list completion check (task-completed-check.yml): on pull_request_target (opened/edited/synchronize/reopened), runs kentaro-m/task-completed-checker-action to post an informational check run. Uses pull_request_target (least-privilege checks: 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's Task Completed Checker check run itself; ruleset evaluation stopped honoring those synthetic runs and blocked every bot PR.)
  24. 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 prefixes ai/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 under pull_request_target with pull-requests: write so it can post a sticky failure comment on fork PRs, mirroring check-pr-title.yml. The renovate/* and release-please--* automation branches are whitelisted so bot PRs are never blocked. Branch names never reach main (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.
  25. Supply-chain security (always included, static workflows):
  26. codeql.yml: CodeQL analysis on push/PR to main + 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.visibility is documented but empty on schedule (that event carries no payload), while github.repository_visibility is 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 on push/pull_request always, and on schedule too 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.
  27. scorecard.yml (ossf/scorecard-action): OpenSSF Scorecard on branch_protection_rule/push/weekly schedule; publish_results: true and id-token: write so 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 leave main permanently red. Uses the same two-source-coalescing condition as codeql.yml above (it shares the payload-less schedule trigger), and likewise fails open when visibility is unknowable rather than silently dropping the scan.
  28. dependency-review.yml (actions/dependency-review-action): on pull_request, fails on high+ severity vulnerabilities and comments a summary on failure. Job-level if: 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 in REPOSITORY_SETUP.rst (UI-only: the security_and_analysis[dependency_graph] PATCH silently no-ops).
  29. check-security.yml.jinja: an active scanning pass on PR/push to main + 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 a GITLEAKS_LICENSE secret (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 with uvx pip-audit. Component and settings deps are core dependencies (each console script imports its component unconditionally), so a plain export already covers the whole shipped tree; --all-extras is retained as a harmless safeguard should a future extra reintroduce shipped deps (and applies equally to the release sbom job'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). Unlike dependency-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-only pip-audit prek hook (stages = ["pre-push"]), so a vulnerable pin is reported before CI rather than after it.
    • trivy-image (include_web only): builds the generated Dockerfile and scans the image with aquasecurity/trivy-action (severity CRITICAL,HIGH, ignore-unfixed: true, exit-code: 1). Gated on include_web because that is the only configuration that produces a Dockerfile; the filesystem-scan overlap with pip-audit is deliberately avoided (image/OS-layer scanning is the genuine gap). These SHA-pin actions like every other workflow (Renovate helpers:pinGitHubActionDigests keeps them current). This is a standalone workflow (like codeql/scorecard), not wired into the ci.yml check aggregation gate. A CycloneDX SBOM (<repo>-sbom.cdx.json) is generated from the locked runtime deps by a sbom job in release.yml and attached to each GitHub Release (via attach-github-release), not kept as a throwaway CI artifact.
  30. Workflow hardening + zizmor (always included). Every generated workflow follows a strict zizmor posture, enforced two ways:
  31. Blocking gate: the zizmor prek hook (see the prek section above) runs in the CI hooks job and locally; it hard-fails on any finding.
  32. Dashboard: zizmor.yml (static, zizmorcore/zizmor-action, SHA-pinned) uploads SARIF to the Security tab on push/PR to main. Non-blocking by design (advanced-security mode never fails on findings); least-privilege (permissions: {} top-level, security-events: write on 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 nor continue-on-error, so it would fail every run there. Both advanced-security and annotations are therefore driven off github.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-title as 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):

  1. Go to https://pypi.org/manage/account/publishing/
  2. Add pending publisher with:
  3. PyPI Project Name: <package-name>
  4. Owner: <github-username>
  5. Repository: <repo-name>
  6. Workflow: release.yml (the publish step is inline in this workflow, so this is the filename PyPI's OIDC check matches — not a reusable cd.yml)
  7. 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.