python-template
Explanation

Why CI is shaped this way

The aggregator job, running hooks instead of restating them, and the job that stops the template rotting

.github/workflows/ci.yaml has six jobs where three would do. Each of the extra ones exists to close a specific failure that a simpler shape allows.

CI runs the hooks rather than restating them

The lint job does not call ruff and ty directly. It runs j178/prek-action, which executes the hooks declared in .pre-commit-config.yaml.

The alternative — listing the same commands in the workflow — creates two sources of truth that drift silently. A hook added locally is not run by CI until someone remembers to add it in a second place, and nobody notices the gap because both files look maintained. Running the hook file directly means task dev:hooks and CI cannot disagree.

uv sync --locked runs before the hook action because the ruff, ty and pytest hooks are uv run --no-sync, so they need the virtualenv to exist.

zizmor gets its own job

The lint job sets SKIP: zizmor, and a separate zizmor job runs it. That looks redundant, and it is deliberate: the branch ruleset requires a check named zizmor, and a hook running inside lint produces no such check. The skip stops it running twice.

The test job runs nothing

test is an aggregator. It declares needs: [lint, pytest, bootstrap], runs if: always(), and its only step compares each dependency's result against success.

The branch ruleset requires exactly one check name: test. That indirection means the pytest matrix can gain or lose a Python version, or a new job can join the gate, without anyone editing branch protection — a change that is easy to forget and invisible when forgotten.

if: always() matters: without it, a skipped dependency would skip the aggregator too, and a skipped required check is not a failing one. The explicit result comparison makes the job fail closed on skipped and cancelled as well as failure.

The bootstrap job is what stops the template rotting

bootstrap runs scripts/test-bootstrap.sh, which generates two projects from the committed tree — one flat, one with DDD layering — and runs the full gate on each, plus a real pnpm install and docs build on one.

The failure it exists to catch: someone adds a file containing python-template or pythontemplate and does not register it with the bootstrap rewrite. The template's own CI stays green, because the template is a valid project either way. The defect is invisible until somebody generates a real project months later and finds a stray placeholder in it.

That job is also why the smoke test builds from git archive HEAD rather than the working tree: it tests what a user would actually clone. The consequence is that uncommitted changes to bootstrap are not tested — commit before running it, or you will get a false pass.

commitlint only runs on pull requests

Its if: github.event_name == 'pull_request' guard, plus fetch-depth: 0 so the full commit range is available to lint. On a push to main the commits have already been through a PR.

On this page