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.
-
The .NET SDK pinned in
global.json(currently the10.0.xband), installed and on yourPATH. Download from dotnet.microsoft.com. Required bymake ci'sbuild/teststeps. -
pre-commitinstalled and on yourPATH:pipx install pre-commit # or: brew install pre-commit -
A Python 3.10 interpreter installed on your system (required by the commitizen and sync-pre-commit-deps hooks). Install instructions per platform:
Platform Install command macOS (Homebrew) brew install python@3.10Linux (Debian/Ubuntu) sudo apt install python3.10Linux (any, via pyenv) pyenv install 3.10Windows Download from python.org (the installer registers it with the pylauncher aspy -3.10).Why exactly 3.10? Some hooks require Python
>=3.10, but pre-commit otherwise uses each hook's own default interpreter (python3in their manifests) — which on many systems (notably macOS) is an older 3.9 that fails to build the hook environments withrequires a different Python. The template therefore setslanguage_version: python3.10explicitly on the affected hooks in.pre-commit-config.yaml. (A top-leveldefault_language_versionwould not work here: pre-commit only applies it to hooks that don't already declare their ownlanguage_version, and these hooks do.)python3.10is a version request that pre-commit resolves to a real interpreter on Windows, Linux, and macOS (on Windows through thepylauncher). A Python 3.10 interpreter therefore needs to be installed, though it does not need to be your defaultpython3. To standardize on a newer version, change thelanguage_versionvalues in.pre-commit-config.yaml(see Troubleshooting).
From the repository root:
make setupThis runs:
git config --local include.path ../.gitconfigwhich makes the repo's local config include the committed .gitconfig.
That config sets core.hooksPath = .githooks/, activating the committed hook
scripts. Because the hooks live in .githooks/ and are wired up through
include.path, pre-commit install is not required.
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.). |
.githooks/commit-msg |
Runs commitizen to enforce Conventional Commits message format. |
.pre-commit-config.yaml |
Declares the hook repos and versions, and pins the Python interpreter (language_version: python3.10) on the Python hooks that require it. |
- 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, install prerequisites, and run that one command.
make ci ← single source of truth (runs locally too)
└── pre-commit run --all-files
└── hooks in .pre-commit-config.yaml
.github/workflows/ci.yml ← thin stub: checkout → setup → `make ci`
azure-pipelines.yml ← thin stub: checkout → setup → `make ci`
The same make ci a developer runs on their laptop is exactly what runs on
GitHub Actions and Azure DevOps. 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. |
This template ships with the .NET build and test steps already wired into the
ci target in the Makefile:
ci: check-pre-commit lint restore audit build test ## Run the full CI check suite
restore: check-dotnet ## Restore with locked mode (fails on dependency-graph drift)
dotnet restore --locked-mode
audit: check-dotnet ## Fail on vulnerable packages; warn on deprecated ones
dotnet list package --vulnerable --include-transitive # fails CI on any hit
dotnet list package --deprecated --include-transitive # reported, non-fatal
build: check-dotnet ## Build the solution with analyzers as errors (CI gate)
dotnet build --configuration Release --no-restore -p:ContinuousIntegrationBuild=true
test: check-dotnet ## Run the test suite (analyzers as errors)
dotnet test --configuration Release --no-restore -p:ContinuousIntegrationBuild=true-p:ContinuousIntegrationBuild=true flips on TreatWarningsAsErrors (see
Directory.Build.props), so a warning from any
analyzer fails make ci. A plain dotnet build during normal local dev keeps
warnings as warnings, so day-to-day work stays friction-free; the gate only
bites in make ci. (build/test use --no-restore to reuse the locked
restore from the restore target.)
To add more steps (e.g. npm test), append them as ci dependencies or extra
recipe lines. Because the logic is in the Makefile, those steps run identically
locally and on every CI platform — no YAML changes required.
The template ships several supply-chain safeguards wired into make ci:
-
Lock files.
Directory.Build.propssetsRestorePackagesWithLockFile=true, so each project has a committedpackages.lock.jsonpinning the exact version and content hash of every dependency, including transitive ones. Therestoretarget uses--locked-mode, which fails CI if the resolved graph drifts from the committed lock files.When you add, remove, or bump a package,
make ciwill fail with a locked-mode error until you regenerate the lock files: run a plaindotnet restore(no--locked-mode) locally, then commit the updated**/packages.lock.json. Commit these files — they are intentionally not git-ignored. -
Vulnerability & deprecation scan. The
audittarget runsdotnet list package --vulnerable --include-transitive(fails CI on any known CVE, transitive included) and--deprecated(reported but non-fatal). This needs network access toapi.nuget.orgat CI time; on a restricted/air-gapped runner it cannot reach the advisory database. -
SHA-pinned GitHub Actions.
.github/workflows/ci.ymlpins each third-party action to a full commit SHA (git tags are mutable and a compromised tag could inject code into CI), with the human-readable tag in a trailing comment..github/dependabot.ymlkeeps those pins current via automated update PRs — otherwise pinned SHAs silently go stale and miss upstream security patches.
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
mainanddevelop, and - pull requests targeting
develop.
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 prerequisite installation (Python,
pre-commit, and the .NET SDK).
Everything else — the actual checks — is shared via make ci.
- Hook versions are pinned via
revin.pre-commit-config.yaml. - The Python interpreter is pinned to 3.10 in both CI stubs (matching the
per-hook
language_version; see Requirements). - The .NET SDK is pinned in
global.json; both CI stubs install the SDK from it (global-json-file/useGlobalJson). - Analyzer package versions are pinned centrally in
Directory.Packages.props. - Dependency versions (including transitive) are pinned by content hash in the
committed
**/packages.lock.jsonfiles; CI restores in--locked-mode. - Third-party GitHub Actions are pinned to full commit SHAs, kept current by
.github/dependabot.yml. - 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.
| Target | Description |
|---|---|
make help |
Show available targets (default when running make). |
make setup |
Configure the repo to use the shared git config and pre-commit hooks. Runs the preflight checks first. |
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 restore |
Restore NuGet packages in locked mode (fails if the dependency graph drifts from the committed lock files). |
make audit |
Fail on known-vulnerable packages (incl. transitive); report deprecated ones. |
make check-pre-commit |
Verify the pre-commit tool is installed. |
make check-python |
Verify a Python 3.10 interpreter is available for the hooks. |
`pre-commit` not found— install the tool (see Requirements).make cifails with a NuGet locked-mode /NU1004error — the dependency graph no longer matches the committed lock files (you added, removed, or bumped a package). Run a plaindotnet restore(no--locked-mode) locally, then commit the updated**/packages.lock.json. See Supply-chain hardening.- commitizen / sync-pre-commit-deps fails to build /
requires a different Python— a Python 3.10 interpreter could not be found. Install it (see Requirements) and re-runmake check-pythonto confirm it is detected. The interpreter is pinned per-hook vialanguage_version: python3.10in.pre-commit-config.yaml; to standardize on a different version, change thoselanguage_versionvalues (e.g. topython3.11) and make sure that interpreter is installed. - 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.