Skip to content

Quality baseline

Every project generated by copier-everything — regardless of language — starts with the same quality foundation: a deterministic local gate, REUSE-compliant licensing, automated releases, hardened CI with integrity-verified binaries, and continuous dependency updates.

At a glance

  • prek hooks


    Always-on. Git hygiene, shell, Actions lint, secrets, licensing, spelling, YAML, Markdown, TOML, and commit-message — all offline and deterministic.

  • REUSE licensing


    Always-on. hawkeye maintains SPDX headers; reuse lint verifies overall compliance. Every file is covered from the first commit.

  • Conventional Commits


    Always-on. commitizen enforces the commit format; release-please automates versioning, changelog, and tags on every merge to main.

  • Hardened CI


    Always-on. A reusable gate called by PR and main workflows. SHA-pinned actions, checksum-verified binaries, fail-closed on mismatch.

  • Checksum-pinned binaries


    Always-on. Six release binaries installed by CI are each verified against a committed SHA256 before extraction; an asset-less tool (bats) is pinned by commit. Kept current by Renovate + a dedicated workflow.

  • Renovate


    Always-on. Keeps actions digests, pre-commit hook revs, and pinned binary versions current in automated PRs.

  • Claude Code guard hooks


    Always-on. A .claude/ PreToolUse set blocks hand-edits that fight the automation — version bumps, tool-owned files, misplaced config. Deletable; fail-open without Claude Code.

The quality gate (prek hooks)

The local gate is implemented as a prek (pre-commit) hook set in .pre-commit-config.yaml. Most hooks are pip-backed and self-bootstrap a pinned virtual environment — no system install needed. They run identically locally (uvx prek@0.4.8 run --all-files) and in CI.

Local gate principle

The local gate is deterministic and offline. Checks that require network access — link checking, online vulnerability databases, the full zizmor advisory lookup — run separately in CI. This keeps the local hook set reliable and fast regardless of connectivity.

Full hook inventory:

Group Hook ID Source Purpose
Meta check-useless-excludes repo: meta Warn about hook excludes that match no file in the repo.
Meta prek-validate-config local (language: system) Validate .pre-commit-config.yaml with prek itself whenever the file changes.
Copier check-copier-src-path local (language: system) Assert .copier-answers.yml's _src_path records a remote template URL, not a local filesystem path — a local path silently breaks copier update for every other clone and in CI. Stdlib-only regex check (no PyYAML dependency), runs when the answers file changes.
Git hygiene check-added-large-files builtin Block files larger than 1 MB from being committed.
Git hygiene check-merge-conflict builtin Detect unresolved merge-conflict markers.
Git hygiene no-commit-to-branch builtin Prevent direct commits to main.
Git hygiene check-case-conflict builtin Detect filenames that clash on case-insensitive filesystems.
Git hygiene check-shebang-scripts-are-executable builtin Ensure scripts with a shebang line are marked executable.
Git hygiene check-executables-have-shebangs builtin Ensure executable files carry a shebang line.
Git hygiene detect-private-key builtin Block committed private key material.
Git hygiene mixed-line-ending builtin Normalize line endings to LF.
Git hygiene trailing-whitespace builtin Remove trailing whitespace; preserves Markdown hard line breaks.
Git hygiene end-of-file-fixer builtin Ensure every file ends with exactly one newline.
Git hygiene check-yaml / check-json / check-toml builtin Validate that YAML, JSON, and TOML files parse cleanly. (check-json excludes .vscode/ — those files are JSONC.)
Shell shellcheck shellcheck-py Lint shell scripts with shellcheck; also placed on PATH so actionlint can lint run: blocks without a system install.
Shell shfmt shfmt-py Format shell scripts with 2-space indent (matching .editorconfig).
GitHub Actions actionlint actionlint-py Lint workflow YAML and shellcheck every run: block. pip-backed, self-bootstraps. No system install needed.
GitHub Actions zizmor zizmor-pre-commit Security-audit workflows offline — covers template injection, excessive permissions, artipacked. The full online audit (known-vulnerable-actions, ref-confusion) runs as a dedicated CI step. See CI architecture.
Security gitleaks gitleaks/gitleaks Detect secrets (API keys, tokens, credentials) in staged changes.
Licensing hawkeye-format local (language: system) Add or update SPDX headers via hawkeye. Config: .config/licenserc.toml. Requires a local hawkeye binary — see system tools.
Licensing reuse fsfe/reuse-tool Verify full REUSE compliance (reuse lint). Pip-backed, self-bootstraps.
Spelling typos crate-ci/typos Check-only spell check (no auto-write). Config: .config/typos.toml. Scans . and .config/ explicitly — hidden directories are skipped by typos by default.
YAML yamllint adrienverge/yamllint Enforce YAML style beyond parse-validity: indentation, duplicate keys, truthy values. Config: .config/yamllint.yaml. Runs without --strict — error-level rules block; warnings are advisory.
Markdown rumdl rvben/rumdl-pre-commit Lint and auto-fix Markdown style. Config: .config/rumdl.toml.
TOML taplo local (language: system) Format TOML files with taplo. Requires a local taplo binary — see system tools.
Commit message commitizen commitizen-tools/commitizen Enforce Conventional Commit format. Runs on the commit-msg stage, not pre-commit.

language: system tools

Two always-on hooks — taplo (TOML formatter) and hawkeye-format (SPDX header enforcement) — use language: system. prek cannot auto-bootstrap system hooks; they expect a pre-installed binary on PATH.

Install them locally so uvx prek@0.4.8 run --all-files produces the same result as CI. The template ships no Brewfile; a canonical setup provides them via the nivintw/dotfiles Brewfile. Adopters supply their own equivalent. In CI, both are installed as version-pinned, SHA256-verified release archives — see checksum-verified binaries.

Why not pip-backed?

taplo and hawkeye are native binaries (Rust) with no pip distribution. Unlike shellcheck-py or actionlint-py, there is no PyPI wrapper to self-bootstrap. They follow the same install pattern as trivy and osv-scanner — local via a package manager, CI via a pinned curl download.

REUSE licensing

Every generated project is REUSE-compliant from the first commit. Two tools enforce this together:

  • hawkeye (hawkeye format) adds and maintains the SPDX-FileCopyrightText and SPDX-License-Identifier header on every source file. Config lives in .config/licenserc.toml, which reads the year, holder, and license from a [properties] block so they change in one place.
  • reuse lint validates full REUSE compliance after hawkeye runs. It catches files that can't carry an inline header (binary assets, generated files, files with no comment syntax).

Files that carry no inline comment syntax — JSON configs, Python version pins, lockfiles — and generated files are covered by path annotations in REUSE.toml instead of inline headers. When a project uses frontmatter-first Markdown (where an SPDX comment on line 1 would break the YAML parser), all **/*.md paths are added to the REUSE.toml annotations block rather than headered inline.

Coverage mechanism Applies to
Inline SPDX header (hawkeye-maintained) Source files with a comment syntax: .py, .sh, .yaml, .toml, .md (unless frontmatter), .html, etc.
REUSE.toml path annotation **/*.json, **/.vscode/*, uv.lock, .python-version (Python shapes), and optionally **/*.md (frontmatter-first projects)
LICENSES/ directory Full license text(s) — only the chosen license is rendered; the unchosen alternative is excluded.

Conventional Commits and release-please

The template wires up automated, continuous releases from day one. Two tools divide the work strictly:

Tool Role Config
commitizen Commit-message linter only. The commitizen prek hook (commit-msg stage) enforces plain Conventional Commits (cz_conventional_commits). No gitmoji — release-please cannot parse a leading emoji in the commit type. commitizen does not bump versions or cut releases. .cz.toml
release-please Owns versioning entirely. On every push to main it scans Conventional Commits since the last release, maintains a Release PR that bumps the version and CHANGELOG.md, and auto-merges it (--auto --rebase) once required checks pass. When the Release PR lands, release-please cuts the vX.Y.Z tag and GitHub Release. .config/release-please-config.json, .config/.release-please-manifest.json

release-please uses release-type: simple, which produces bare vX.Y.Z tags without assuming a package manager. For Python shapes, an extra-files TOML updater mirrors the version into pyproject.toml [project].version (what a wheel build reads); for non-Python shapes the version lives only in the manifest and the git tag.

Referenced issues aren't auto-closed at release

release-please's changelog renders every issue reference in a bundled commit as closes #N in the Release PR body — which GitHub's native parser would act on when the Release PR auto-merges, closing issues a commit merely referenced. main.yml defuses this: after release-please updates the Release PR, a step rewrites those closing keywords in the PR body (never CHANGELOG.md, which GitHub doesn't parse) so only a genuine Closes #N on a feature PR — at its own merge — closes anything. A bare Refs #N in a commit is safe: it links without closing.

Release authentication

The release job authenticates as a GitHub App rather than using the default GITHUB_TOKEN. An App-minted token is a distinct actor: the Release PR's required checks actually run (a GITHUB_TOKEN push cannot trigger checks on its own PR), and merging the PR can trigger downstream workflows that GITHUB_TOKEN would suppress.

Two repository-level settings wire this up:

  • CI_CLIENT_ID — a repository variable (non-secret) holding the App's Client ID.
  • CI_APP_PRIVATE_KEY — a repository secret holding the App's private key.

The App also needs Contents: read+write and Pull requests: read+write permissions, must be installed on the repo, and should be in the bypass list of any ruleset protecting main so it can push the release tag and merge the Release PR.

Skips cleanly without the App

A preflight job checks for both the variable and secret before the release job runs. Until you configure the App, every push to main runs the quality gate and exits green — no failures, no blocked runs. Set the variable and secret whenever you are ready to enable auto-releases.

CI architecture

The CI is built around a reusable core so both pull requests and pushes to main run the exact same checks, defined once.

Workflow file Trigger Role
ci.yml on: workflow_call The reusable quality gate. A dedicated secret-scan job checks out with fetch-depth: 0 and runs a checksum-pinned gitleaks git --redact over the full git history (guarded to fail loudly on a shallow checkout, which would silently scan only a partial history) — distinct from the prek gitleaks hook, which scans only staged changes and is a no-op in CI's clean checkout. The lint-and-test job runs the prek hook set, plus dedicated steps for system-binary tools (hawkeye, taplo) and the full online zizmor workflow audit (with a GH token, for the advisory-database audits that the offline hook skips). Module-specific steps (Terraform setup, Docker image scan, Helm kubeconform) also live here, gated by module presence.
pr.yml on: pull_request Calls ci.yml. The open-PR pipeline: no releases, no write access. Require the ci / lint-and-test check in branch protection or a ruleset.
main.yml on: push to main, workflow_dispatch Calls ci.yml, then — gated on needs: ci — runs release-please. Passes skip-quality-gate: true for release-please's own bump commit (it was already validated as the Release PR), avoiding doubled CI minutes without ever skipping a real change.
link-check.yml on: pull_request (Markdown paths only), workflow_dispatch Runs lychee on all **/*.md files to detect dead links. Kept separate from ci.yml because link checking is a network operation — inherently flaky and dependent on third-party uptime. Make ci required in branch protection; treat link-check as advisory. Excludes live in .config/lychee.toml (passed via --config), so a repo can add its own unverifiable hosts there without editing the workflow — which a copier update would otherwise overwrite.
label-hygiene.yml on: issues (closed) Strips the status:* progression labels (status:triage, status:ready, status:in-progress, status:in-review, status:blocked) from an issue when it closes, so a closed issue never keeps wearing a stale open-state label. Self-disabling: it removes only the labels actually present, so a repo that doesn't use the taxonomy (the template doesn't provision these labels) gets a clean no-op. Leaves type:*/priority:* intact as historical record. Re-checks the issue is still closed immediately before stripping — reopening emits no event to cancel an already-scheduled run, so this guards against a reopen racing the label removal.
approve-bot-prs.yml on: pull_request_target (opened, reopened, synchronize) Auto-approves PRs authored by the repo's own CI App — Renovate dependency PRs and release-please Release PRs — so those unattended flows keep landing if a ruleset later requires approving reviews. The trusted identity is the CI_APP_SLUG repository variable (the App's slug; its bot login is <slug>[bot]): both the PR's author and the pushing actor must match, and only same-repo heads qualify — a fork, another bot, or a human pushing onto a bot branch is never approved. Triggers on pull_request_target (not pull_request) so the workflow DEFINITION is anchored to the base branch — a same-repo PR editing the trust-gate condition can't have its own weakened copy execute for its own approval. Safe here specifically because the workflow holds no secrets, does no checkout, and runs no third-party actions. The approval comes from the workflow's GITHUB_TOKEN, since an App cannot approve its own PR; that requires the repo setting "Allow GitHub Actions to create and approve pull requests", and with it off the run warns and stays green. Approval is not merge: required status checks still gate every merge. Self-disabling until CI_APP_SLUG is set.
dependabot-auto-merge.yml on: pull_request_target (opened, reopened, synchronize) The security floor (see Dependabot security floor). Severity-gates Dependabot security PRs and enables native auto-merge (--auto --rebase) for critical/high advisories (CVSS ≥ 7.0) once required checks pass; medium/low security PRs and all version updates are left open. Reading an advisory's CVSS needs a token with Dependabot alerts: read, which GITHUB_TOKEN cannot have — so it reuses the release App (CI_CLIENT_ID + CI_APP_PRIVATE_KEY, granted Dependabot alerts: read) via dependabot/fetch-metadata's alert-lookup. Uses pull_request_target (like approve-bot-prs.yml) because a Dependabot pull_request run is denied the write token + secret access this needs; safe here for the same reasons — no secrets in PR code, no checkout. Fail-closed: gated to dependabot[bot] same-repo PRs, self-disables until CI_CLIENT_ID is set, and warns cleanly if "Allow auto-merge" is off. The template ships no dependabot.yml — the floor is security-only; version freshness stays Renovate's job.

All uses: action references are SHA-pinned with a human-readable version comment — for example, actions/checkout@df4cb1c... # v6.0.3 — so Renovate can update the digest while a reviewer can read the version. Every checkout sets persist-credentials: false to mitigate the artipacked finding (a checkout token left in .git/config becomes a read-write credential accessible to later steps).

Checksum-verified binaries

Six release binaries that CI installs by hand are each verified against a committed SHA256 before use. They cannot self-bootstrap like pip-backed hooks, so a compromised or MITM'd download must fail closed rather than execute. A seventh tool with no downloadable asset (bats) is pinned by commit instead — see the pinning scheme below.

Binary Used for Active when Upstream checksum?
hawkeye SPDX header enforcement (hawkeye check) Always Yes — reads upstream checksum file
taplo TOML formatting (taplo fmt --check) Always No — taplo publishes no checksum file. The SHA is computed from the downloaded asset at pin time (trust-on-first-use), then re-verified on every subsequent CI run. Tampering or MITM after the pin is set is still caught; the pin itself lacks independent attestation.
gitleaks Full-history secret scan (gitleaks git --redact) in the secret-scan CI job Always Yes — reads upstream checksum file
osv-scanner Dependency CVE scan against the OSV database (uv.lock) Python shapes Yes
trivy Dockerfile misconfig scan (trivy config) and image CVE scan (trivy image); IaC misconfig scan for Terraform Docker or Terraform module Yes
kubeconform Kubernetes manifest validation against upstream schemas (helm template \| kubeconform) Helm module Yes

Download → verify → extract. The previous streaming pattern (curl ... | tar xz) was replaced: download the archive to a temp file, run sha256sum -c (the step fails closed if the hash mismatches), then extract. Every install step runs under set -euo pipefail. A bad byte never reaches tar or the shell.

Pinning scheme. Each binary uses a pair of env vars annotated for Renovate:

# renovate: datasource=github-releases depName=korandoru/hawkeye
HAWKEYE_VERSION: "6.5.1"
HAWKEYE_SHA256: "d6eb0505..."

Renovate's custom regex manager reads the # renovate: annotation and bumps *_VERSION. Because the github-releases datasource has no asset-digest concept, it cannot update *_SHA256 — a stale hash would fail CI closed. A Renovate postUpgradeTask closes this gap.

Asset-less tools — a *_COMMIT pin. bats-core publishes no downloadable release asset to checksum, so there is nothing to SHA256. It is installed from the exact git commit its release tag points at — a *_COMMIT pin (BATS_VERSION + adjacent BATS_COMMIT), refreshed with the version by the same script. This closes the unpinned, mutable apt-get install bats supply-chain gap the previous CI install carried; before installing, CI verifies the pinned commit really is v${BATS_VERSION}'s tag and fails loudly on any mismatch. The postUpgradeTask tamper gate covers it identically — a *_COMMIT that changes while *_VERSION is unchanged is a moved tag, the same supply-chain signal as a swapped asset.

Keeping hashes current automatically

scripts/refresh-binary-checksums.sh recomputes each SHA256 from its pinned version — reading the upstream-published checksum file for trivy, osv-scanner, hawkeye, kubeconform, and gitleaks; hashing the asset directly for taplo (which publishes none), and resolving the tag's commit id for the asset-less *_COMMIT pin (bats). renovate.json wires the script as a postUpgradeTask (executionMode: branch), so the central self-hosted Renovate runner runs it during the upgrade and folds the refreshed hash into Renovate's own commit — no separate bot pushing onto the Renovate branch.

The runner must authorize the script in its allowedCommands; absent that, the hash stays stale and the fail-closed mismatch stands — re-pin by hand with the script, then commit.

The postUpgradeTask command also sets BASE_REF to the branch's merge-base with the default branch, activating a tamper gate: the script refuses to re-pin a SHA whose adjacent *_VERSION is unchanged versus that ref — an upstream asset swapped on an already-published tag — and exits with a TAMPER ALERT instead. A human re-pinning locally (no BASE_REF) skips this gate by design; there's no reliable base ref to diff against outside the automated branch structure.

Renovate

Every generated project ships .github/renovate.json to keep actions digests, pre-commit hook revisions, and pinned binary versions current in automated PRs.

What Renovate manages Mechanism
GitHub Actions digests helpers:pinGitHubActionDigests preset — keeps uses: SHA digests current while preserving the human-readable # vX.Y.Z comment.
Pre-commit hook revisions Built-in pre-commit manager ("pre-commit": {"enabled": true}) — bumps rev: values in .pre-commit-config.yaml.
Pinned binary versions Custom regex manager that reads # renovate: datasource=... depName=... annotations on *_VERSION env vars in workflow files. Covers all six SHA256-pinned binaries plus the bats *_COMMIT pin.
Ruff Grouped into a single PR so a new lint rule landing under extend-select = ALL is one reviewable change rather than a surprise red gate.

After Renovate bumps a binary version, a postUpgradeTask automatically updates the adjacent SHA256 in the same commit so CI stays green — see checksum-verified binaries.

Dependabot security floor

Renovate is the freshness ceiling — broad, scheduled dependency currency. Dependabot is the security floor — the fast, event-driven path for vulnerability remediation. The two divide the work deliberately and don't overlap: the template ships no dependabot.yml, so Dependabot opens only security PRs (once you enable "Dependabot security updates" in repo settings), never version-update PRs that would collide with Renovate.

dependabot-auto-merge.yml (see the CI architecture table) makes a critical/high security fix land fast, not just open fast: it reads the advisory's CVSS via dependabot/fetch-metadata and enables GitHub-native auto-merge for CVSS ≥ 7.0 once required checks pass. Lower-severity security PRs open and wait for review.

Inert until you opt in

Like the release App, this floor self-disables until it's configured: it does nothing until you enable Dependabot security updates and grant the release App (CI_CLIENT_ID + CI_APP_PRIVATE_KEY) the Dependabot alerts: read permission it needs to read advisory severity. Until then it's a clean no-op — the workflow ships to every project but merges nothing.

Claude Code guard hooks

Every generated project ships a small set of Claude Code guard hooks under .claude/ — always-on, and aimed at the one class of mistake the other gates catch only after the fact: an edit that fights the repo's own automation. They are wired in .claude/settings.json as PreToolUse hooks on Edit/Write/MultiEdit, so they run before a write lands and can block it with an explanation. Each derives what it protects from this repo's own config (the release-please manifest, release-please-config.json's extra-files), so they track whatever a given project actually generates.

Guard Behavior
guard_version_bumps Blocks an edit that sets the version-of-record (the .config/.release-please-manifest.json value, or any file release-please mirrors it into via extra-files — pyproject.toml, galaxy.yml) to anything other than the canonical version. release-please owns the bump; rewriting the identical value is allowed. Fails open (warns) if canonical can't be resolved.
guard_managed_files Blocks a hand-edit to a tool-owned file: uv.lock (uv), the root CHANGELOG.md (release-please), .copier-answers.yml (copier), and anything under LICENSES/ (the license tooling). Root-anchored on the file's nearest .git, so a docs/CHANGELOG.md you authored is untouched.
guard_config_drift Blocks a language-agnostic config table ([tool.typos], [tool.rumdl], [tool.lychee], [tool.yamllint]) written into pyproject.toml — that config belongs in its .config/ home, since Python is optional here. Also warns on template/-vs-root twin drift, but that half fires only in this template's own dogfood repo (it needs a template/ dir), so a generated project never sees it.

A companion prek hook, check-copier-src-path (in the hook inventory above), guards the same "don't break the automation" theme from the commit side — asserting .copier-answers.yml's _src_path is a remote template URL rather than a local path.

Deletable, and fail-open without Claude Code

The hooks are plain files a project can delete (each block message says as much). They are invoked only by Claude Code's own Edit/Write tooling, so a repo whose contributors don't use Claude Code simply never triggers them — they add nothing to CI or the local gate and can't fail a build.

Security scanning

Security coverage is layered across the local gate and CI. The two always-on tools run everywhere; the rest activate with their module.

Tool Scope Where Condition
gitleaks Detects committed secrets — API keys, tokens, credentials prek hook (staged changes) + CI secret-scan job (gitleaks git --redact, full git history) Always
zizmor Audits GitHub Actions workflows: template injection, excessive permissions, artipacked, ref-confusion, known-vulnerable-actions prek hook (--offline AST audits) + CI dedicated step (full online audit with GH token) Always
uv audit Checks resolved Python dependencies against the PyPA advisory database prek hook (gates on pyproject.toml or uv.lock changes) Python shapes
osv-scanner Scans uv.lock against the OSV vulnerability database (broader coverage than PyPA alone) prek hook + CI binary install Python shapes
trivy config Dockerfile misconfiguration scan (HIGH/CRITICAL; bundled offline policies — no policy-bundle fetch in the hook) prek hook Docker module
trivy image Container image CVE scan (HIGH/CRITICAL, --ignore-unfixed) after docker build CI step Docker module
checkov Terraform IaC misconfiguration scan (pip-backed, self-bootstraps — provides a second opinion alongside trivy) prek hook Terraform module
trivy config Terraform IaC misconfiguration scan (all severities; IaC misconfigs are user-owned resources, so LOW/MEDIUM matter) prek hook Terraform module

For the module-conditional tools in detail, see Modules & levers. The principle that decides where each check lands — the local gate is deterministic and offline; network-dependent and heavy checks go to CI — is discussed in Design model.