Establish .config/ as the home for tool configuration, enforced by .repo/ - #11
Merged
Merged
Conversation
The working tree had accumulated pure-CRLF churn (361 insertions / 361 deletions across devcontainer.json, devcontainer-lock.json and validate.yaml, with `git diff --ignore-cr-at-eol` empty). The repo had no .gitattributes and core.autocrlf unset, so a Windows-mounted WSL checkout rewrote files on save. Add .gitattributes and renormalize, then retire the workaround it makes redundant: the `fix-crlf` postCreateCommand step that sed-stripped CR from .devcontainer/scripts/*.sh on every container create. Keep strip_crlf() in initialize.sh. It is not redundant: .gitattributes only governs files Git checks out, and .devcontainer/.env is gitignored, generated locally and hand-edited, so a Windows editor can reintroduce CR at any time -- which `docker run --env-file` hard-rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Declare one canonical location for linter, formatter and hook config before the configs exist, so the template does not replicate a root-dotfile junk drawer into every repo scaffolded from it. .config/ is dotted deliberately: it is repo infrastructure and sits alongside .devcontainer/, .github/ and .repo/. Visible entries at the root are content you edit; dotted ones are machinery that operates on it. - Move lefthook.yml to .config/lefthook.yml. Lefthook discovers this natively (MainConfigNames in internal/config/loader.go is ["lefthook", ".lefthook", ".config/lefthook"]) -- no flag, env var or wrapper involved. Verified with `lefthook validate --verbose`. - Modernize it to the v2 jobs API; mise already pins lefthook 2.1.10 but the file still used the v1 `commands` key. - Add markdownlint, yamllint, actionlint and codespell configs, each reached by an explicit config-path flag from its caller. - Add .config/README.md indexing every file, its tool, and how it is reached; document the first-match-wins search order that lets a stray root lefthook.yml silently shadow .config/lefthook.yml. - Add a "Where Configuration Lives" section to CONFIGURATION.md with the four-way decision rule, and record why there is no .editorconfig. - Fix the repo's own lint debt so the new gates pass from day one: 114 markdownlint findings to 0 (fence languages, table delimiter style, reflow to the 120-col rulers), one over-long compose line (split inside a folded scalar; the parsed document is unchanged), and a codespell false positive fixed by renaming a shell variable rather than by weakening the dictionary. - Correct stale config/observability/ paths left over from an earlier refactor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ship working defaults rather than an empty convention: markdownlint, yamllint, actionlint and codespell now run locally on staged files and again in CI, using the configs added in the previous commit. Version pinning has exactly one source. .devcontainer/mise.toml pins all four tools with fully-qualified backends (npm:, pipx:, aqua:), and CI resolves the same file through jdx/mise-action via MISE_GLOBAL_CONFIG_FILE, so local and CI cannot drift. Also pin arduino/setup-task to 3.52.0 to match the go-task Feature instead of floating on 3.x. Split Task into taskfiles/ modules included from the root Taskfile.yml. Verified that included taskfiles execute with the repo root as working directory, so the relative config paths resolve. Every lint invocation names its config explicitly (--config, -c, -config-file); only lefthook relies on discovery, and only because it searches .config/ natively. Verified end to end: all four gates pass clean, each fails on a real defect, and the lefthook jobs glob-scope correctly (yaml/actions skip when no matching file is staged). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This template's layout is replicated into every repo scaffolded from it,
so a convention that holds only while someone remembers it will not hold.
Turn the rules into checks that fail a build instead of a review.
`.repo/` mirrors `.github/` and `.devcontainer/`: infrastructure that
operates on the repository rather than being part of its content. It is a
uv project exposing a `repo` CLI, installed by the devcontainer bootstrap
and by CI, and invoked identically from both plus pre-commit.
Three policies, each splitting declaration from detection so the reasoning
has a home (violations.py) separate from the mechanics (check.py):
config CFG-01..07 tool config lives in .config/, every file is indexed
and has a caller, nothing at the root shadows it
ports PORT-01..05 the port table, forwardPorts/portsAttributes and
compose published ports agree and stay in range
hooks HOOK-01..04 lefthook and CI stay in step
Because there is no prose rules layer, enforcement has to explain itself:
every violation carries a stable code, the reason the rule exists, and the
action that resolves it.
The hooks policy records one-way checks explicitly rather than ignoring
them -- LOCAL_ONLY and CI_ONLY name each check that runs in only one place
with its reason (build takes minutes, compose needs a Docker daemon,
block-devcontainer-env has nothing to assert in CI). HOOK-04 fails when an
entry outlives what it excused, so the allowlist cannot quietly widen.
Verified: 11 negative tests, one per violation class, each confirmed to
fire and then clear. The package builds and installs with the real
`uv tool install ./.repo`, and the installed `repo` executable runs clean
directly, through Task, and from a subdirectory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branch protection lives only in the GitHub UI by default, where it is neither reviewable nor restorable. Commit it as JSON, and guard the one failure mode that makes it dangerous. A required status check is matched by a CI job's display name, so renaming a job in validate.yaml without updating the ruleset leaves every pull request waiting forever on a check that can never report -- and clearing it needs admin access at exactly the moment the repo has become unmergeable. RS-03 catches that before the commit lands. RS-04 is the mirror: a job that runs without being required is advisory, so a red run can still merge. Implemented as a fourth `repo` policy rather than a separate rulesets-validate workflow (which is how platform does it). The governance job and pre-commit hook already run `repo check`, so this needs no new workflow, no new CI job, and no new parity-allowlist entry -- and being a policy means it reports its own reasoning like the others. Shape only: it never diffs live GitHub state, because reading rulesets needs administration:read and the default GITHUB_TOKEN does not have it. RULESETS.md documents the manual drift check instead of adding a long-lived PAT. Verified with four negative tests, including the job-rename deadlock. Also refresh README: the task table, the lint-config pointer, and a CI section that described three jobs when there are now seven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the src/ layer, rename the package repo_governance -> governance, and split the two meanings of "violations". src/ exists to stop Python importing a local source tree in place of the installed package, which only happens when the package sits in the working directory. This one sits under .repo/, which is never where anyone works -- verified that neither name is importable from the repo root. .repo/ already provides the separation src/ would, so the level bought nothing and cost a segment on every path: .repo/src/repo_governance/policies/config/check.py .repo/governance/policies/config/check.py The package name also no longer repeats what the directory already says. The distribution stays repo-governance, which is the unique name and the one that would matter if this were ever published; the import name is generic but the tool is installed into an isolated uv venv holding only itself and pyyaml. Two smaller cleanups: - governance/violations.py -> reporting.py. It holds the Violation and Report primitives, while every policy also has its own violations.py holding declarations. One filename for two different jobs was a needless re-read every time. - The POLICIES registry moves from cli.py into policies/__init__.py, so adding a policy touches only the policies package and cli.py never names an individual policy. Verified: wheel contains exactly the 17 expected modules and no strays, `uv tool install ./.repo` succeeds, and all 15 negative tests still fire and clear against the reinstalled binary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
astral-sh/setup-uv stopped publishing major-only tags after v7.6, so `@v9` does not resolve and the Repo Structure job failed at "Set up job" before running a step. v8.x and v9.0.0 have no floating aliases. actionlint cannot catch this -- it does not resolve action refs over the network. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The repo already declares the right philosophy in
CONFIGURATION.md— "One need, one place" — and already proves it once (mise.tomlredirected viaMISE_GLOBAL_CONFIG_FILE, ten cache dirs funnelled into one tree). What was missing is the generalization: there was no declared home for tool configuration, so the first contributor to add a linter would drop a dotfile at the root and junk-drawer accretion would begin.This is a template. Whatever it ships is replicated into every repo scaffolded from it, so the convention had to be settled before the configs exist — and backed by something stronger than memory.
What changed
Four visible root entries.
.config/is dotted deliberately: it is repo infrastructure and sits alongside.devcontainer/,.github/,.repo/. What is visible at the root is content you edit; what is dotted is machinery that operates on it.lefthook.ymlmoved into.config/and is found there with no flag, env var, or wrapper —MainConfigNamesin lefthook'sinternal/config/loader.gois["lefthook", ".lefthook", ".config/lefthook"].Taskfile.ymlstays at the root because go-task's discovery list is only the eightTaskfile.*variants and--taskfilewould break baretask <name>.What is now enforced
repo checkruns four policies — 20 violation classes. Because there is no prose rules layer, enforcement has to explain itself: every violation carries a stable code, the reason the rule exists, and the action that resolves it.configCFG-01..07.config/, every file is indexed and has a caller, nothing at the root shadows itportsPORT-01..05forwardPorts/portsAttributes↔ compose published ports agree, and stay in 15432–15460hooksHOOK-01..04rulesetsRS-01..04Two of these guard failure modes worth calling out:
CFG-06— lefthook's config search is first-match-wins, so a stray rootlefthook.ymlwould silently shadow.config/lefthook.yml: no warning, different hooks. Confirmed reproducible before writing the check.RS-03— a required status check is matched by a CI job's display name, so renaming a job without updating the ruleset leaves every PR waiting forever on a check that can never report, and clearing it needs admin access at exactly the moment the repo has become unmergeable.Lint gates (markdownlint, yamllint, actionlint, codespell) run pre-commit on staged files and again in CI. Tool versions have exactly one source:
.devcontainer/mise.toml, which CI resolves throughjdx/mise-actionviaMISE_GLOBAL_CONFIG_FILE.Judgment calls worth reviewing
codespell, notcspell— matchesmusher-dev/platform'sconfig/spelling/codespell.cfgand itscodespellpolicy module.rulesets-validateworkflow. Making it the fourthrepopolicy means no new workflow, no new CI job, and no new parity-allowlist entry, since the governance job already runsrepo check..gitattributesgoverns only files Git checks out..devcontainer/.envis gitignored and hand-edited, so a Windows editor can reintroduce CR at any time anddocker run --env-filehard-rejects it. The redundant*.shpostCreateCommandpass is gone;strip_crlf()ininitialize.shstays, with a comment explaining the asymmetry..repo/has nosrc/. Its purpose is to stop Python importing a local tree instead of the installed package, which only happens when the package is in the working directory — and this one is under.repo/, which is never where anyone works. The import namegovernancedoes collide with a real (unrelated) PyPI package; the distribution name stays the uniquerepo-governance, anduv tool installisolates the venv, so this is theoretical. A one-line hatchlingsourcesmapping decouples them if it ever matters.Divergence from
platform, accepted: platform uses visibleconfig/<domain>/buckets. Justin's call is to migrate platform to.config/later. Platform's rule file also contains an error — it lists Lefthook among tools that "reject every config-path flag," which is false; the corrected reasoning is in this repo'sCONFIGURATION.mdand is worth a follow-up PR upstream.Repo lint debt fixed along the way
The new gates had to pass on the repo's own files: 114 markdownlint findings → 0 (fence languages, table delimiter style, reflow to the 80/120 rulers), one over-long compose line (split inside a folded scalar — the parsed YAML document is byte-identical), and a codespell false positive fixed by renaming a badly-named shell variable rather than weakening the dictionary. codespell also caught a genuine typo in the new Python.
Verification
Run from the WSL host rather than inside the container, so real binaries were fetched instead of assumed: lefthook and markdownlint via
npx, actionlint and Task viago install(Task 3.52.0 — the same version the Feature pins), yamllint and codespell from PyPI wheels, and the actualuvbinary.task lint:all,repo check(directly, through Task, and from a subdirectory),shellcheck,lefthook validate— all passlefthook validate --verboseprintsloading config: .../.config/lefthook.yml; the pre-commit hook fired on every commit in this branchuv tool install ./.reposucceeds and the installedrepoexecutable runs cleanNot verified locally — no Docker daemon on this host: the mise pins actually resolving (
pipx:/aqua:backends),jdx/mise-actionin CI, and a full devcontainer rebuild. Those need this PR's CI run and a container rebuild.Note for reviewers
The
Repo StructureandLintCI jobs are new, so.github/rulesets/main-branch.jsonlists them as required status checks. The ruleset is not applied automatically — it must be imported once from repository settings, per.github/rulesets/RULESETS.md.🤖 Generated with Claude Code