My baseline git template. It ships a shared git configuration and a set of pre-commit hooks so every repo started from this template gets consistent commit hygiene and commit-message formatting out of the box.
All tooling lives in a repo-local virtualenv (.venv/) created by
make setup. Nothing is installed globally, and no specific global Python
version is required.
-
GNU make, git, and a POSIX shell (on Windows: Git Bash or WSL).
-
Any Python interpreter
>= 3.10reachable aspython3. It is used only to create.venv/, so whatever you already have works — system, Homebrew, pyenv, uv, asdf. If your defaultpython3is older, point make at another interpreter instead of changing your system default:make setup PYTHON=python3.12 # or an absolute pathCheck what make will use with
make check-python.
That is the whole list — pre-commit itself is not a prerequisite.
make setup installs the pinned version from
requirements-dev.txt into .venv/.
Why a venv instead of a required global Python version? Some hooks (commitizen, sync-pre-commit-deps) are
language: pythonand need Python>= 3.10. Their upstream manifests declarelanguage_version: python3, which pre-commit resolves to the interpreter running pre-commit itself — not to whateverpython3is on yourPATH. So apre-commitinstalled under an old interpreter (e.g. macOS's bundled Python 3.9) builds those hook environments with 3.9 and fails withrequires a different Python.Rather than papering over that with per-hook
language_versionpins — which forced every contributor to install one exact Python version globally — the template runs pre-commit from.venv/, built from any Python>= 3.10. The hooks inherit that interpreter, so.pre-commit-config.yamlneeds no interpreter pins at all and your machine's Python setup is left alone.
From the repository root:
make setupThis does three things:
- Creates
.venv/and installs the pinned tooling (requirements-dev.txt) into it. - Runs
git config --local include.path ../.gitconfig, which makes the repo's local config include the committed.gitconfig. That config setscore.hooksPath = .githooks/, activating the committed hook scripts. Because the hooks live in.githooks/and are wired up throughinclude.path,pre-commit installis not required. - Runs
pre-commit install-hooksto pre-build the hook environments, so your first commit isn't slowed down by it.
You never need to activate .venv: the hook scripts in .githooks/ and every
make target invoke .venv/bin/pre-commit by absolute path.
Run make help (or just make) to list the available targets.
| File | Purpose |
|---|---|
.gitconfig |
Sets core.hooksPath = .githooks/ so the committed hooks are used. |
.githooks/pre-commit |
Runs the pre-commit-stage hooks (formatting, secret detection, etc.) via .venv. |
.githooks/commit-msg |
Runs commitizen via .venv to enforce Conventional Commits message format. |
.pre-commit-config.yaml |
Declares the hook repos and pinned versions (rev). |
requirements-dev.txt |
The pinned tooling installed into .venv/ — just pre-commit. |
.venv/ |
The repo-local virtualenv. Created by make setup, git-ignored, disposable (make clean). |
Each hook's own dependencies are installed by pre-commit into its own cached
environments (~/.cache/pre-commit, or ~/Library/Caches/pre-commit on macOS),
not into .venv/.
- pre-commit-hooks: trailing whitespace, end-of-file fixer, YAML checks, large-file guard (blocks files over 500 kB by default), case-conflict detection (catches filename collisions on case-insensitive filesystems like macOS/Windows), illegal Windows names, merge-conflict markers, private-key detection, byte-order-marker fix, and mixed line endings.
- gitleaks: scans for hardcoded secrets.
- commitizen (
commit-msgstage): validates commit messages follow the Conventional Commits format, e.g.:feat: add user login fix(api): handle null response chore: bump dependencies - sync-pre-commit-deps: keeps hook dependency versions in sync.
This template is designed so that wiring it into any CI/CD platform is simple and deterministic. It follows the industry-standard thin wrapper pattern (see Martin Fowler on Continuous Integration): all the actual check logic lives in the repository behind a single command, and each CI platform's config does nothing more than check out the code, provide a Python interpreter, and run that one command.
make ci ← single source of truth (runs locally too)
└── .venv/ (built from requirements-dev.txt)
└── pre-commit run --all-files
└── hooks in .pre-commit-config.yaml
.github/workflows/ci.yml ← thin stub: checkout → python → `make ci`
azure-pipelines.yml ← thin stub: checkout → python → `make ci`
The same make ci a developer runs on their laptop is exactly what runs on
GitHub Actions and Azure DevOps — including building the venv, so the CI stubs
install nothing themselves. To change what CI does, edit the
Makefile and .pre-commit-config.yaml
— not the platform YAML.
| File | Purpose |
|---|---|
Makefile (make ci) |
The portable entrypoint. All check logic lives here. Extend it with your project's build/test commands. |
.github/workflows/ci.yml |
Thin GitHub Actions stub that runs make ci. |
azure-pipelines.yml |
Thin Azure DevOps stub that runs make ci. |
Add your build/test steps to the ci target in the Makefile. For
example, for a .NET project:
ci: lint test ## Run the full CI check suite
test: ## Run the test suite
dotnet testBecause the logic is in the Makefile, those steps run identically locally and on every CI platform — no YAML changes required.
An SBOM is the clearest example of a check whose contract is portable while its implementation cannot be. This template therefore defines the contract and deliberately ships no generator:
| Portable — keep this shape in every project | Project-specific |
|---|---|
A sbom target, in the ci chain, after whatever builds |
The generator and its toolchain |
| Output under the same gitignored artifacts directory the other reports use | The dependency-manifest path it reads |
| Published by the CI stubs alongside the test and coverage reports | The SBOM's subject name and version |
Nothing portable can generate the document itself, because an SBOM's substance is
the resolved dependency graph, and that graph only exists inside an ecosystem's
resolver — NuGet's project.assets.json, a package-lock.json, a populated
virtualenv. A repo-level tool either shells out to those resolvers anyway or
parses manifests and guesses, and guessing gets transitive and
conditionally-referenced packages wrong. Standardising a generator here would also
mean forcing its toolchain onto every project made from this template, which is
exactly what the Requirements list above exists to avoid.
This repository has no sbom target of its own on purpose: it ships no artifact.
Its only dependencies are pre-commit and the rev-pinned hook repos — a developer
toolchain nobody deploys — so an SBOM of it would inventory something that is
never shipped.
dotnet-template implements this
contract for .NET, with Microsoft's sbom-tool producing SPDX 2.2 from
project.assets.json, if you want a worked example.
Two decisions worth making deliberately when you do implement it:
- Generate from the build output, not the source tree. An SBOM should describe what you ship, which means it comes after the build, the way a coverage report comes after the tests.
- A per-pull-request SBOM and a release SBOM are different artifacts. The
first is a drift detector — it catches a dependency added without its lockfile
updated. The authoritative one describes a released artifact and should be
attached to the release and attested (for example with
actions/attest-sbom). Puttingsbomincigets you the first; the second belongs to a release workflow.
The thin-wrapper pattern minimizes platform-specific config but cannot eliminate it. Each platform requires its own small YAML stub, and a few concerns are inherently platform-specific and cannot be pushed into a portable script:
-
The stub file itself — GitHub needs
.github/workflows/*.yml; Azure DevOps needsazure-pipelines.yml. The template ships both, pre-wired tomake ci. -
Triggers (which branches/events run CI) — expressed differently on each platform. Both stubs ship pre-configured to run CI on:
- pushes to
main, and - pull requests targeting
main, from any source branch.
Feature branches are intentionally absent from the push trigger: a branch that has an open pull request would otherwise build the same commits twice for no extra signal.
The pull-request trigger must cover every branch you gate with a required status check (see Branch protection). A required check that never runs is never reported, and the pull request waits on it forever.
Adjust the
on/trigger/prsections in.github/workflows/ci.ymlandazure-pipelines.ymlto change this. - pushes to
-
Secrets, service connections, OIDC, and permissions — managed in each platform's settings/YAML, never in the repo.
-
Runner/agent image and which Python interpreter is on the agent (the base for
.venv).
Everything else — the actual checks — is shared via make ci.
pre-commitis pinned inrequirements-dev.txtand installed into an isolated.venv/, so CI and laptops run the same version regardless of what is installed globally.- Hook versions are pinned via
revin.pre-commit-config.yaml. - Both CI stubs pin the interpreter used to build
.venv(currently 3.14) for reproducible runs. Any>= 3.10works; the pin is not a hook requirement. - Both stubs pin the runner/agent image (currently
ubuntu-24.04) instead of usingubuntu-latest. That label is remapped to a new Ubuntu release periodically, which would move the build environment on the platform's schedule rather than yours. - Both stubs cache pre-commit hook environments keyed on the config file, so unchanged hooks are not rebuilt.
Bump these versions deliberately when you want to upgrade.
CI that merely reports a failure does not keep a broken commit off main.
A commit cannot be rejected because CI failed — CI only starts once the commit
exists. What actually protects a branch is a rule that refuses the merge until
a check has already passed, which lives on the GitHub side, not in a git hook.
This template keeps those rules in the repo, as
.github/rulesets/*.json, and rebuilds them with the
GitHub CLI:
make rulesets-apply # create/update the rulesets on GitHub
make rulesets-diff # fail if GitHub no longer matches the repo
make rulesets-export # overwrite the JSON from GitHub (after a UI edit)Rulesets are reconciled by name, not by id: GitHub assigns ids per
repository, so a committed id would be meaningless in a repo created from this
template. apply looks up each file's name, updates the ruleset if it exists
and creates it if it does not — so it is idempotent, and works on a fresh repo.
make rulesets-apply requires the GitHub CLI (gh), authenticated with admin
rights on the repo. Unlike everything else here, gh is not installed by
make setup, and these targets are deliberately not part of make ci — see
.github/rulesets/README.md for why, and for what
the shipped rulesets enforce.
The workflow is trunk-based. main is the only long-lived branch: there is
no develop to stage through, and no release branches. Everything else is a
short-lived branch that exists just long enough to carry one pull request, and
is deleted after it merges.
With main-protection active, main cannot be written to directly. Every
change reaches it the same way:
-
Branch off
main. Name it however you like — the rules place no constraint on source branches, and a feature branch needs no protection of its own, since it cannot reachmainexcept through the gate below. Keep it short-lived; the point of trunk-based work is that branches merge in days, not weeks. Commits are checked locally by the hooks frommake setup. -
Open a pull request into
main. A directgit push origin mainis rejected by the ruleset, as is a force-push and a branch deletion. -
Let
cifinish and pass. It is a required check, so the merge button stays disabled until it reports success. The branch must also be up to date withmainfirst (strict_required_status_checks_policy), so ifmainmoved, update the branch and letcirun again. -
Merge with a merge commit, then delete the branch.
mainaccepts only merge commits — squash and rebase are not offered. That choice is about authorship: a merge commit leaves your commits untouched, so each keeps the signature you made, and GitHub signs the merge commit itself to satisfyrequired_signatures. The trade is thatmainis not linear.Rebase merges are not allowed, and cannot be. A rebase merge rewrites each commit into a new object, which discards the author's signature, and GitHub has no key with which to re-sign on the author's behalf. Enabling
rebasealongsiderequired_signaturesproduces a merge button that always fails with "Base branch requires signed commits. Rebase merges cannot be automatically signed by GitHub." A squash merge does work, but it collapses the branch into one new GitHub-signed commit, so your own signatures do not survive ontomain.
What this buys you, and what it does not: the rules keep a red build from
reaching main by accident. They are not a hard stop, because the shipped
main-protection lets a repository admin bypass them inside a pull request
(bypass_mode: "pull_request") — which is also what makes the required approval
satisfiable on a one-maintainer repo, since you cannot approve your own pull
request. Drop the bypass_actors entry to make the check absolute, and drop
required_approving_review_count to 0 at the same time or nothing will ever
merge.
Keep the check name and the trigger in sync. The required check is the
cijob id in.github/workflows/ci.yml, and that workflow'spull_requesttrigger must list every branch the rulesets protect. Rename the job, or protect a new branch without extending the trigger, and the required check is simply never reported — the pull request then waits on it indefinitely rather than failing. Both files have to change together.
| Target | Description |
|---|---|
make help |
Show available targets (default when running make). |
make setup |
Create .venv, then configure the repo to use the shared git config and pre-commit hooks. |
make venv |
Create/update .venv from requirements-dev.txt (no-op when up to date). |
make ci |
Run the full CI check suite — the single command CI/CD pipelines invoke. Runs identically locally. |
make lint |
Run all pre-commit hooks against all files. |
make clean |
Remove .venv (rebuild with make setup). |
make check-python |
Verify the interpreter used to build .venv is >= 3.10. |
make rulesets-apply |
Create/update this repo's GitHub rulesets from .github/rulesets/. Needs gh. |
make rulesets-diff |
Report drift between .github/rulesets/ and the live rulesets. Needs gh. |
make rulesets-export |
Overwrite .github/rulesets/ with the live rulesets. Needs gh. |
Override the interpreter for any of these with PYTHON=..., e.g.
make setup PYTHON=python3.12.
`pre-commit` was not found in this repository's .venv/— the venv is missing (fresh clone, new worktree, ormake clean). Runmake setupfrom the repository root.Error: python3 ... but >= 3.10 is required— the interpreter make would use to build.venvis too old. Install any newer Python and either put it on yourPATHaspython3or pass it explicitly:make setup PYTHON=python3.12. You do not need to change your system default.- commitizen / sync-pre-commit-deps fails to build /
requires a different Python— the hooks are being run by a pre-commit outside.venv/(a global install under an old interpreter). Confirmmake setuphas been run and thatgit config --get core.hooksPathprints.githooks/; if a straypre-commit installoverwrote.git/hooks/, delete those generated files socore.hooksPathtakes effect again. See Requirements for why the interpreter is chosen this way. .venvbroke after a Python upgrade (e.g. Homebrew replaced the interpreter it was built from) — recreate it:make clean && make setup.- Hook is ignored / not running — confirm
make setuphas been run (git config --get include.pathshould print../.gitconfig) and that the hook scripts in.githooks/are executable.
MIT.
That covers this template's own files — the Makefile, the git and pre-commit
configuration, the CI stubs, the rulesets and this documentation. A repository
created from the template inherits those files under the same terms, and MIT lets
you relicense your own work on top of them however you like. Replace the copyright
holder in LICENSE with your own when you do.