From 3ec3464c415e7e0a9e3f582d2d1d2339e2cc7b3e Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 14:53:18 +0700 Subject: [PATCH 1/7] chore: enforce LF line endings via .gitattributes 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) --- .devcontainer/devcontainer.json | 16 +++++++++------- .devcontainer/scripts/initialize.sh | 8 ++++++++ .gitattributes | 11 +++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c292d89..f54e4f3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -102,16 +102,18 @@ "PATH": "/home/vscode/.local/share/mise/shims:/home/vscode/.local/bin:${containerEnv:PATH}" }, - // Host-side bootstrap: creates .devcontainer/.env from the template - // (if missing) and strips CRLF, so --env-file above has a valid target - // even on a fresh clone. + // Host-side bootstrap: creates .devcontainer/.env from the template (if + // missing) and strips CRLF from it, so --env-file above has a valid target + // even on a fresh clone. .env is gitignored, so .gitattributes cannot + // normalize it -- this guard is not redundant with that. "initializeCommand": ["bash", ".devcontainer/scripts/initialize.sh"], "waitFor": "postCreateCommand", - "postCreateCommand": { - "fix-crlf": "find .devcontainer/scripts -type f -name '*.sh' -exec sed -i 's/\\r$//' {} +", - "setup": ["bash", ".devcontainer/scripts/post-create.sh"] - }, + // Line endings are enforced repo-wide by .gitattributes (`* text=auto eol=lf`), + // so scripts arrive with LF on every platform. No CRLF fixup step is needed + // here; the one remaining guard lives in initialize.sh and covers the + // gitignored .env, which .gitattributes cannot reach. + "postCreateCommand": ["bash", ".devcontainer/scripts/post-create.sh"], "postStartCommand": ["bash", ".devcontainer/scripts/startup.sh"], "forwardPorts": [15432, 15433, 15434, 15435, 15436, 15440, 15441, 15442, 15443, 15444, 15445, 15446, 15447, 15448, 15460], diff --git a/.devcontainer/scripts/initialize.sh b/.devcontainer/scripts/initialize.sh index 2b4b333..aad2d88 100644 --- a/.devcontainer/scripts/initialize.sh +++ b/.devcontainer/scripts/initialize.sh @@ -13,6 +13,12 @@ # * Strip CRLF from .env (Windows/WSL safety — docker --env-file # rejects files with CRLF line endings). # +# Why this CRLF guard survives while the postCreateCommand one did not: +# .gitattributes (`* text=auto eol=lf`) normalizes every file Git checks +# out, which covers scripts/ and made the old `fix-crlf` step redundant. +# It cannot cover .env — that file is gitignored, generated locally, and +# hand-edited, so a Windows editor can reintroduce CR at any time. +# # Idempotent: safe to run on every container start. set -euo pipefail @@ -39,6 +45,8 @@ ensure_env_file() { fi } +# Not redundant with .gitattributes: .env is gitignored, so Git never +# normalizes it. See the header note. strip_crlf() { [[ -f "${ENV_FILE}" ]] || return 0 if grep -q $'\r' "${ENV_FILE}" 2>/dev/null; then diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..40ed034 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Normalize line endings for cross-platform development +* text=auto eol=lf + +# Ensure environment files never pick up Windows CRLF terminators +.env text eol=lf +*.env text eol=lf + +# Keep shell scripts and configuration consistent +*.sh text eol=lf +*.toml text eol=lf +*.py text eol=lf From 9905a15483c798acf9695ff1ffb858ea1b14cb19 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 15:09:48 +0700 Subject: [PATCH 2/7] refactor: establish .config/ as the home for tool configuration 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) --- .config/README.md | 64 ++++++++ .config/actionlint.yaml | 16 ++ .config/codespell.cfg | 21 +++ .config/lefthook.yml | 53 +++++++ .config/markdownlint.jsonc | 26 +++ .config/yamllint.yaml | 34 ++++ .devcontainer/.env.example | 4 +- .../stacks/observability/compose.yaml | 3 +- .gitignore | 13 +- CONFIGURATION.md | 148 +++++++++++++++--- README.md | 34 ++-- Taskfile.yml | 4 +- lefthook.yml | 16 -- 13 files changed, 378 insertions(+), 58 deletions(-) create mode 100644 .config/README.md create mode 100644 .config/actionlint.yaml create mode 100644 .config/codespell.cfg create mode 100644 .config/lefthook.yml create mode 100644 .config/markdownlint.jsonc create mode 100644 .config/yamllint.yaml delete mode 100644 lefthook.yml diff --git a/.config/README.md b/.config/README.md new file mode 100644 index 0000000..754dca9 --- /dev/null +++ b/.config/README.md @@ -0,0 +1,64 @@ +# `.config/` — Tool Configuration + +Every linter, formatter, and hook config lives here. One directory, one purpose: +if a tool needs a config file and it is not provisioning the container, it goes +in here. + +Policy and rationale: [`CONFIGURATION.md`](../CONFIGURATION.md). Enforcement: +`repo config check` (see [`.repo/`](../.repo/README.md)). + +## Index + +| File | Tool | How it is reached | +| --- | --- | --- | +| `lefthook.yml` | lefthook | **Auto-discovered.** Lefthook searches `.config/lefthook.*` natively | +| `lefthook-local.yml` | lefthook | Auto-discovered and merged. Gitignored; personal overrides only | +| `markdownlint.jsonc` | markdownlint-cli2 | `--config .config/markdownlint.jsonc` | +| `yamllint.yaml` | yamllint | `-c .config/yamllint.yaml` | +| `actionlint.yaml` | actionlint | `-config-file .config/actionlint.yaml` | +| `codespell.cfg` | codespell | `--config .config/codespell.cfg` | + +Call sites are [`taskfiles/lint.Taskfile.yml`](../taskfiles/lint.Taskfile.yml) +and [`.github/workflows/validate.yaml`](../.github/workflows/validate.yaml). +Tool versions are pinned in one place — +[`.devcontainer/mise.toml`](../.devcontainer/mise.toml) — and CI resolves them +from that same file via `jdx/mise-action`. + +## Rules + +1. **Flat.** `.config/.`. Create a `.config//` subdirectory + only when a tool genuinely owns several files (a style directory, a custom + dictionary set). One file per tool needs no folder. +2. **No leading dot on filenames.** The directory is already dotted; a second + dot adds nothing. +3. **Pass the path explicitly.** Except for lefthook, which finds this + directory on its own, every caller names its config with the tool's config + flag. Never rely on default discovery — that is what put these files at the + repo root in the first place. +4. **Every file must have a caller.** A config nothing reads is dead weight; + `repo config check` fails on orphans and on files missing from the table + above. +5. **Every ignore needs a reason.** Suppressions, allowlists, and disabled + rules carry an inline comment explaining why the exception is acceptable. + +## What does *not* live here + +| Thing | Where | Why | +| --- | --- | --- | +| `Taskfile.yml` | Repo root | Task only discovers `Taskfile.*` at the root; `--taskfile` would break bare `task ` | +| `mise.toml` | `.devcontainer/` | It provisions the container, rather than checking the code | +| `compose.yaml`, stack configs | `.devcontainer/` | Same — environment, not code quality | +| `.gitattributes`, `.gitignore` | Repo root | Git reads these from the root only | +| VS Code settings | `devcontainer.json` | `customizations.vscode.settings` is the single editor source | + +## A trap worth knowing + +Lefthook's config search is **first-match-wins**, in this order: + +```text +lefthook.* → .lefthook.* → .config/lefthook.* +``` + +A stray `lefthook.yml` at the repo root therefore **silently shadows** this +directory's copy — no warning, no error, just different hooks. `repo config +check` fails the build if one appears. diff --git a/.config/actionlint.yaml b/.config/actionlint.yaml new file mode 100644 index 0000000..6447bc0 --- /dev/null +++ b/.config/actionlint.yaml @@ -0,0 +1,16 @@ +# actionlint configuration. +# Read by: actionlint, via an explicit -config-file path. Note that actionlint's +# own default location is .github/actionlint.yaml -- this repo overrides it so +# every tool config sits in one place. +# Docs: https://github.com/rhysd/actionlint/blob/main/docs/config.md + +# Labels for self-hosted runners, if any are ever added. GitHub-hosted labels +# (ubuntu-latest, etc.) are known to actionlint and need no declaration. +self-hosted-runner: + labels: [] + +# `null` disables configuration-variable checking, which is the right default +# for a template: it does not know which `vars.*` a consuming repo will define. +# Replace with an explicit list (e.g. [DEPLOY_ENV]) to make actionlint reject +# any `vars.*` reference outside that list. +config-variables: null diff --git a/.config/codespell.cfg b/.config/codespell.cfg new file mode 100644 index 0000000..7a1c81f --- /dev/null +++ b/.config/codespell.cfg @@ -0,0 +1,21 @@ +# codespell configuration. +# Read by: codespell, via an explicit --config path. Never auto-discovered. +# Docs: https://github.com/codespell-project/codespell +[codespell] +# Binary, generated, and vendored content has no prose to spellcheck, and +# lockfile digests produce constant false positives. +skip = .git,.repo/.venv,node_modules,*.lock,devcontainer-lock.json,*.svg,*.png + +# Most of this repo's content lives in dotfiles and dot-directories +# (.config/, .devcontainer/, .github/, .repo/), so hidden files must be +# scanned or the check covers almost nothing. +# (Empty value = flag enabled. codespell passes config values through as +# CLI arguments, so `check-hidden = true` would pass a stray "true" that +# argparse silently consumes as a FILENAME rather than a flag value.) +check-hidden = +check-filenames = + +# Words this repo uses deliberately that codespell's dictionary flags. +# Every entry needs a comment saying why -- an unexplained ignore is a bug +# waiting to be reintroduced. Uncomment and extend when the first one appears. +# ignore-words-list = word1,word2 diff --git a/.config/lefthook.yml b/.config/lefthook.yml new file mode 100644 index 0000000..ee8b4c5 --- /dev/null +++ b/.config/lefthook.yml @@ -0,0 +1,53 @@ +# Lefthook — Git hook configuration. +# Docs: https://lefthook.dev +# +# Location: this file lives in .config/, not the repo root. Lefthook +# discovers it natively — `MainConfigNames` in internal/config/loader.go is +# ["lefthook", ".lefthook", ".config/lefthook"] — so no flag, env var or +# wrapper is involved. See CONFIGURATION.md → "Where configuration lives". +# +# Search order is first-match-wins: a stray root lefthook.yml would silently +# shadow this file. `repo config check` fails the build if one appears. +# +# Local overrides: .config/lefthook-local.yml (gitignored) is auto-merged. +# Debug: lefthook run pre-commit | lefthook dump | lefthook validate +# +# CI counterpart: .github/workflows/validate.yaml. Every job below must have +# a CI equivalent (or an explicit allowlist entry) — `repo hooks check`. + +assert_lefthook_installed: true + +# Keep in lockstep with the "npm:lefthook" pin in .devcontainer/mise.toml. +# v2 is required for the `jobs` API and the `validate` subcommand. +min_version: "2.1.10" + +# doublestar makes ** match 0+ directory levels (the intuitive semantics). +glob_matcher: doublestar + +output: + - meta + - summary + - failure + - execution_out + - skips + +# ============================================================================= +# PRE-COMMIT — fast, deterministic checks on staged files +# ============================================================================= + +pre-commit: + parallel: true + skip: + - merge + - rebase + jobs: + - name: block-devcontainer-env + # Defense-in-depth against committing .devcontainer/.env even if the + # .gitignore entry is accidentally removed. The file holds local dev + # secrets and should never enter version control. + run: | + if git diff --cached --name-only --diff-filter=A | grep -qx '\.devcontainer/\.env'; then + echo "Refusing to commit .devcontainer/.env (contains local secrets)." >&2 + exit 1 + fi + fail_text: "Staged .devcontainer/.env — it holds local secrets. Run 'git restore --staged .devcontainer/.env'." diff --git a/.config/markdownlint.jsonc b/.config/markdownlint.jsonc new file mode 100644 index 0000000..f673169 --- /dev/null +++ b/.config/markdownlint.jsonc @@ -0,0 +1,26 @@ +// markdownlint rules for this repo's Markdown. +// Read by: markdownlint-cli2, via an explicit --config path. Never +// auto-discovered — see taskfiles/lint.Taskfile.yml and +// .github/workflows/validate.yaml for the call sites. +// Rule reference: https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md +{ + "default": true, + + // Prose wraps at 120 to match the editor rulers configured in + // devcontainer.json (customizations.vscode.settings "editor.rulers": [80, 120]). + // Tables and code blocks are exempt: reflowing a wide reference table or a + // shell one-liner to fit hurts readability more than the long line does. + "MD013": { + "line_length": 120, + "tables": false, + "code_blocks": false, + "headings": false + }, + + // Bare URLs are fine in reference docs where the URL *is* the content. + "MD034": false, + + // The docs deliberately reuse headings like "Configuration" under different + // parents; siblings still must be unique, which is what this setting enforces. + "MD024": { "siblings_only": true } +} diff --git a/.config/yamllint.yaml b/.config/yamllint.yaml new file mode 100644 index 0000000..c2448c2 --- /dev/null +++ b/.config/yamllint.yaml @@ -0,0 +1,34 @@ +# yamllint rules for this repo's YAML (compose stacks, workflows, taskfiles). +# Read by: yamllint, via an explicit --config-file path. Never auto-discovered. +# Rule reference: https://yamllint.readthedocs.io/en/stable/rules.html + +extends: default + +rules: + # Compose files and workflows routinely carry long image digests and + # single-line shell commands that cannot be wrapped without breaking them. + line-length: + max: 120 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + + # GitHub Actions requires the key `on:`, which YAML 1.1 reads as boolean + # true. Every workflow would fail this rule for doing the only correct thing. + truthy: + check-keys: false + + # `---` is optional noise in single-document files; be consistent by not + # requiring it, rather than adding it to every stack. + document-start: disable + + # Compose files nest sequences under mappings; both indent styles are + # readable and the ecosystem is split. Accept either, consistently. + indentation: + spaces: 2 + indent-sequences: consistent + + comments: + min-spaces-from-content: 1 + + # Long reference tables in comments occasionally exceed the default. + comments-indentation: enable diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 1550b68..6124fdd 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -39,8 +39,8 @@ MINIO_ROOT_PASSWORD=minioadmin # === MinIO (observability stack) ============================ # NOTE: these credentials must also match the values hardcoded in -# config/observability/tempo-config.yaml -# config/observability/loki-config.yaml +# .devcontainer/stacks/observability/config/tempo-config.yaml +# .devcontainer/stacks/observability/config/loki-config.yaml # (those native YAML configs don't support env interpolation). MINIO_OBS_ROOT_USER=minioadmin MINIO_OBS_ROOT_PASSWORD=minioadmin diff --git a/.devcontainer/stacks/observability/compose.yaml b/.devcontainer/stacks/observability/compose.yaml index 1d55294..8f6bfb0 100644 --- a/.devcontainer/stacks/observability/compose.yaml +++ b/.devcontainer/stacks/observability/compose.yaml @@ -30,7 +30,8 @@ services: condition: service_healthy entrypoint: > /bin/sh -c " - mc alias set local http://minio-observability:9000 ${MINIO_OBS_ROOT_USER:-minioadmin} ${MINIO_OBS_ROOT_PASSWORD:-minioadmin} && + mc alias set local http://minio-observability:9000 + ${MINIO_OBS_ROOT_USER:-minioadmin} ${MINIO_OBS_ROOT_PASSWORD:-minioadmin} && mc mb --ignore-existing local/tempo-traces && mc mb --ignore-existing local/loki-data " diff --git a/.gitignore b/.gitignore index c080028..84c5a26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,16 @@ # Dev container local environment overrides .devcontainer/.env -# Claude local preferences +# Personal git-hook overrides (auto-merged by lefthook) +.config/lefthook-local.yml + +# Repo governance toolchain (.repo/) build artifacts +.repo/.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ + +# Claude local preferences and runtime state .claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 212cb4c..09edca8 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -1,12 +1,23 @@ # Configuration Guide -**Philosophy: One need, one place.** Every configuration concern maps to exactly one canonical location. If you're unsure where something goes, use the decision tree below. +**Philosophy: One need, one place.** Every configuration concern maps to exactly one canonical location. If you're +unsure where something goes, use the decision tree below. ## Decision Tree -``` +```text Where does my configuration go? +Is the tool's config auto-loaded only from the repo root, with no way to +point at another path (Task)? + → repo root. No alternative — these are orchestration entry points. + +Does it configure a linter, formatter, or the git hooks? + → .config/. + +Does it provision the container itself? + → .devcontainer/ (see the branches below) + Runtime, or any tool that has a devcontainer Feature? → devcontainer.json → features block (pin the version) @@ -35,19 +46,54 @@ Runs on every container start? → scripts/startup.sh ``` +## Where Configuration Lives + +Four homes, and a rule for choosing between them. Ask these in order and stop at +the first "yes". + +| # | Question | Home | Examples | +| --- | --- | --- | --- | +| 1 | Can the tool *only* load from the repo root, with no flag to point elsewhere? | Repo root | `Taskfile.yml`, `.gitattributes`, `.gitignore` | +| 2 | Does it configure a linter, formatter, or the git hooks? | `.config/` | `lefthook.yml`, `markdownlint.jsonc`, `yamllint.yaml` | +| 3 | Does it provision the container or its services? | `.devcontainer/` | `devcontainer.json`, `mise.toml`, `stacks/*/` | +| 4 | Does it enforce repo structure? | `.repo/` | The `repo` CLI and its policies | + +Why `.config/` is dotted: it is repo infrastructure, and it sits alongside the +other infrastructure directories this repo already has — `.devcontainer/`, +`.github/`, `.repo/`. What is visible at the root is content you edit; what is +dotted is machinery that operates on it. + +Two rules make the `.config/` home hold: + +- **Pass the config path explicitly.** Every caller names its config with the + tool's own flag (`--config`, `-c`, `-config-file`). The single exception is + lefthook, which searches `.config/` natively. Relying on default discovery is + what scatters dotfiles across the root to begin with. +- **Every config must have a caller.** A file nothing reads is dead weight. + `repo config check` fails on orphans. + +See [`.config/README.md`](.config/README.md) for the per-file index, and +[`.repo/README.md`](.repo/README.md) for what is mechanically enforced. + ## Quick Reference | Category | Need | Canonical Location | -|---|---|---| +| --- | --- | --- | | **Runtimes & Tools** | Anything with a Feature (Node, Python, Go, Java, Deno, bun, uv, gh, Task, ShellCheck) | `devcontainer.json` → `features` (pinned) | | | CLIs with no Feature (Codex, Lefthook) | `.devcontainer/mise.toml` | | | Self-updating CLIs (Claude Code) | `scripts/lib/base-setup.sh` | +| **Tooling** | Git hooks | `.config/lefthook.yml` | +| | Markdown lint rules | `.config/markdownlint.jsonc` | +| | YAML lint rules | `.config/yamllint.yaml` | +| | GitHub Actions lint rules | `.config/actionlint.yaml` | +| | Spelling dictionary / ignores | `.config/codespell.cfg` | +| | Task automation for the template | `Taskfile.yml` + `taskfiles/.Taskfile.yml` | +| | Repo structure policies | `.repo/src/repo_governance/policies/` | | **Editor** | VS Code settings (formatters, rulers, whitespace) | `devcontainer.json` → `customizations.vscode.settings` | | | VS Code extensions | `devcontainer.json` → `customizations.vscode.extensions` | | | Debug launch configs | `.vscode/launch.json` (in consuming project) | | **Shell & User** | Default shell, prompt, oh-my-zsh config | `devcontainer.json` → `common-utils` feature | | | Git config | Host `.gitconfig` (auto-forwarded by devcontainers) | -| | Git hooks | Project repo (`.husky/` or `.githooks/`) | | **Environment** | Runtime behavior vars (`PYTHONUNBUFFERED`, etc.) | `devcontainer.json` → `containerEnv` | | | PATH extensions | `devcontainer.json` → `remoteEnv` | | | Service credentials (dev-only) | `.devcontainer/.env` | @@ -68,7 +114,7 @@ Runs on every container start? | | Data persistence | Compose files → named volumes | | **Lifecycle** | One-time container setup | `scripts/post-create.sh` → `lib/base-setup.sh` | | | Every-start tasks | `scripts/startup.sh` | -| | Task automation | `Taskfile.yml` (in consuming project) | +| | Task automation (consuming project) | `Taskfile.yml` in the consuming repo | | **AI Tools** | Claude Code (native installer) | `lib/base-setup.sh` | | | Codex CLI (pinned) | `.devcontainer/mise.toml` | | | AI CLI config persistence | `devcontainer.json` → `mounts` (named volumes) | @@ -93,11 +139,13 @@ Runtimes and any CLI that ships a devcontainer Feature are pinned in the `featur } ``` -Comment out any tool you don't need (and its matching VS Code extension). Pin an exact version where the Feature supports it; a couple track a major line instead (`java: 17`, `postgresql-client: 16`). +Comment out any tool you don't need (and its matching VS Code extension). Pin an exact version where the Feature +supports it; a couple track a major line instead (`java: 17`, `postgresql-client: 16`). ### 2. CLIs with no Feature → `.devcontainer/mise.toml` -npm-distributed CLIs like Codex and Lefthook have no Feature, so [mise](https://mise.jdx.dev) pins and installs them. Add a line under `[tools]`: +npm-distributed CLIs like Codex and Lefthook have no Feature, so [mise](https://mise.jdx.dev) pins and installs them. +Add a line under `[tools]`: ```toml [tools] @@ -125,11 +173,29 @@ base_install_mytool() { ### VS Code Settings -All editor settings live in `devcontainer.json` → `customizations.vscode.settings`. Do not create a `.vscode/settings.json` in the template — that's for consuming projects. +All editor settings live in `devcontainer.json` → `customizations.vscode.settings`. Do not create a +`.vscode/settings.json` in the template — that's for consuming projects. ### VS Code Extensions -All extensions live in `devcontainer.json` → `customizations.vscode.extensions`. Comment out extensions for runtimes you don't use. +All extensions live in `devcontainer.json` → `customizations.vscode.extensions`. Comment out extensions for runtimes you +don't use. + +### Why there is no `.editorconfig` + +Editor intent is expressed once, in `devcontainer.json` → +`customizations.vscode.settings` (LF endings, final newline, trimmed trailing +whitespace, rulers at 80/120). Adding `.editorconfig` would create a second +place to state the same thing, and the two would drift. + +The tradeoff is deliberate and worth knowing: those settings only reach VS Code +*inside the container*. Another editor, or a host-side edit, is not covered. Two +things backstop that gap — `.gitattributes` enforces line endings for every +file Git checks out regardless of editor, and the lint gates (`task lint:all`, +and the same checks in CI) fail on violations no matter what wrote the file. + +If a consuming project has contributors who work outside the container, adding +`.editorconfig` there is the right call. It does not belong in the template. --- @@ -146,7 +212,7 @@ Git config is auto-forwarded from your host machine by the devcontainer CLI. No There are four distinct scopes for environment variables. Use the right one: | Scope | Location | When to Use | -|---|---|---| +| --- | --- | --- | | **Container-wide** | `devcontainer.json` → `containerEnv` | Runtime behavior (`PYTHONUNBUFFERED`, `UV_LINK_MODE`) | | **Remote/IDE** | `devcontainer.json` → `remoteEnv` | PATH extensions, forwarded host secrets | | **Compose services** | `.devcontainer/.env` | Service credentials, `COMPOSE_PROFILES` | @@ -164,12 +230,15 @@ Never commit secrets. Forward them from your host environment: ### Service Credentials -Dev-only credentials live in `.devcontainer/.env` (gitignored). The host-side `initializeCommand` (`scripts/initialize.sh`) copies `.env.example` → `.env` on first build, so `runArgs --env-file` has a valid file to load. The same file is also auto-discovered by Docker Compose. To reset, delete `.devcontainer/.env` and rebuild — or run `task env:reset`. +Dev-only credentials live in `.devcontainer/.env` (gitignored). The host-side `initializeCommand` +(`scripts/initialize.sh`) copies `.env.example` → `.env` on first build, so `runArgs --env-file` has a valid file to +load. The same file is also auto-discovered by Docker Compose. To reset, delete `.devcontainer/.env` and rebuild — or +run `task env:reset`. The template follows a three-state grammar: | State | Syntax | Meaning | -|---|---|---| +| --- | --- | --- | | Filled default | `VAR=value` | Safe demo value; override only if you need something different. | | Required (empty) | `VAR=` | Must be filled in; the MOTD warns at container start until set. | | Optional override | `# VAR=value` | Uncomment to enable. | @@ -183,7 +252,7 @@ The template follows a three-state grammar: All services are included in `compose.yaml`. Optional services are gated by Compose profiles: | Service | Profile | Always On? | -|---|---|---| +| --- | --- | --- | | PostgreSQL | — | Yes | | Redis | `redis` | No | | MinIO | `minio` | No | @@ -211,7 +280,7 @@ Each stack owns its config: put a stack's config files inside its own folder, next to that stack's `compose.yaml`, and bind-mount them with a path relative to the stack folder (e.g. `./init`, `./config/...`): -``` +```text stacks/ postgres/ compose.yaml @@ -230,7 +299,7 @@ stacks/ All ports are bound to `127.0.0.1` (localhost only) for security. The template uses the `154xx` range: | Port | Service | Protocol | -|---|---|---| +| --- | --- | --- | | 15432 | PostgreSQL | TCP | | 15433 | Redis | TCP | | 15434 | MinIO API | HTTP | @@ -249,7 +318,8 @@ All ports are bound to `127.0.0.1` (localhost only) for security. The template u ### Service Discovery -Services communicate via the `musher-dev` Docker network. Use the service name as the hostname (e.g., `postgres`, `redis`, `minio-observability`) with the container-internal port. +Services communicate via the `musher-dev` Docker network. Use the service name as the hostname (e.g., `postgres`, +`redis`, `minio-observability`) with the container-internal port. --- @@ -268,14 +338,16 @@ The observability stack is profile-gated (`COMPOSE_PROFILES=observability`). It ### Configuration Files | File | Purpose | -|---|---| +| --- | --- | | `stacks/observability/config/otel-collector-config.yaml` | OTel Collector pipeline configuration | | `stacks/observability/config/tempo-config.yaml` | Tempo storage and ingestion config | | `stacks/observability/config/loki-config.yaml` | Loki storage and ingestion config | | `stacks/observability/config/grafana/provisioning/datasources/` | Auto-provisioned Grafana datasources | | `stacks/observability/config/grafana/provisioning/dashboards/json/` | Auto-provisioned Grafana dashboards | -> **Note:** `tempo-config.yaml` and `loki-config.yaml` contain hardcoded MinIO credentials because they are native YAML configs that don't support environment variable interpolation. If you change `MINIO_OBS_ROOT_USER` or `MINIO_OBS_ROOT_PASSWORD` in `.env`, you must also update these files to match. +> **Note:** `tempo-config.yaml` and `loki-config.yaml` contain hardcoded MinIO credentials because they are native YAML +configs that don't support environment variable interpolation. If you change `MINIO_OBS_ROOT_USER` or +`MINIO_OBS_ROOT_PASSWORD` in `.env`, you must also update these files to match. --- @@ -283,14 +355,16 @@ The observability stack is profile-gated (`COMPOSE_PROFILES=observability`). It ### Database Initialization -SQL files in `.devcontainer/stacks/postgres/init/` are mounted into PostgreSQL's `docker-entrypoint-initdb.d/` and run in alphabetical order on first container creation: +SQL files in `.devcontainer/stacks/postgres/init/` are mounted into PostgreSQL's `docker-entrypoint-initdb.d/` and run +in alphabetical order on first container creation: - `00-init.sql` — Base schema (extensions, shared types) - `01-project.sql.example` — Project-specific schema (copy to `01-project.sql`) ### Persistence -All services use named Docker volumes (e.g., `musher-postgres-data`). Data persists across container restarts but is lost on full rebuild. For migrations, use project-level tooling (Atlas, Flyway, etc.). +All services use named Docker volumes (e.g., `musher-postgres-data`). Data persists across container restarts but is +lost on full rebuild. For migrations, use project-level tooling (Atlas, Flyway, etc.). ### Adding Volumes @@ -305,7 +379,7 @@ Follow the naming convention `musher-${devcontainerId}-`: ## Lifecycle | Hook | Runs | Use For | -|---|---|---| +| --- | --- | --- | | `initializeCommand` | Host-side, before every `docker run` | Bootstrap that must exist before the container starts (e.g., creating `.devcontainer/.env` so `--env-file` works) | | `postCreateCommand` | Once, on container creation | Tool installation, permissions, lefthook hooks | | `postStartCommand` | Every container start | `docker compose up`, health checks | @@ -331,7 +405,7 @@ main() { ### Script Layers -``` +```text post-create.sh ← Entry point (repo-specific customization) └── lib/base-setup.sh ← Reusable orchestrator (mise CLIs, Claude, nvm, config/cache dirs) └── lib/common.sh ← Shared utilities (log, retry, has_cmd, ensure_writable_dir) @@ -348,16 +422,40 @@ post-create.sh ← Entry point (repo-specific customization) ### Configuration Persistence -AI CLI configs are stored in named volumes mounted via `devcontainer.json` → `mounts`. This preserves authentication and settings across container rebuilds. +AI CLI configs are stored in named volumes mounted via `devcontainer.json` → `mounts`. This preserves authentication and +settings across container rebuilds. --- ## Directory Map -``` +```text +.config/ Tool configuration (see "Where configuration lives") + README.md Index: every file, its tool, and how it is reached + lefthook.yml Git hooks (auto-discovered by lefthook) + lefthook-local.yml Personal hook overrides (gitignored, auto-merged) + markdownlint.jsonc Markdown rules (--config) + yamllint.yaml YAML rules (--config) + actionlint.yaml Workflow rules (-config-file) + codespell.cfg Spelling (--config) +.repo/ Repo governance toolchain (the `repo` CLI) + README.md The decision rule this layout follows + pyproject.toml uv project; declares the `repo` console-script + src/repo_governance/ + cli.py `repo check` and the per-policy subcommands + policies/config/ .config/ layout + no-shadowing-root-config + policies/ports/ Port table ↔ forwardPorts ↔ compose parity + policies/hooks/ lefthook ↔ CI job parity +.github/ + dependabot.yml Weekly updates: devcontainers, actions, docker + rulesets/ Branch protection as committed JSON + workflows/ CI +taskfiles/ Task modules included by the root Taskfile.yml +Taskfile.yml Task entry point (cannot move — root-only discovery) +.gitattributes Line-ending policy (`* text=auto eol=lf`) .devcontainer/ devcontainer.json Features, extensions, settings, mounts, ports - mise.toml CLIs without a Feature (codex, lefthook) + mise.toml CLIs without a Feature (single source of tool versions) compose.yaml Stack orchestrator (includes stacks//compose.yaml) .env.example Environment template (copy to .env) .env Local overrides (gitignored) diff --git a/README.md b/README.md index 2b1c647..6740a3e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Musher Dev Container Template -Canonical dev container template for the musher-dev organization. Batteries-included configuration with AI CLIs, multiple language runtimes, Docker-in-Docker, Task runner, and consistent VS Code settings. Comment out what you don't need. +Canonical dev container template for the musher-dev organization. Batteries-included configuration with AI CLIs, +multiple language runtimes, Docker-in-Docker, Task runner, and consistent VS Code settings. Comment out what you don't +need. ## What You Get @@ -17,19 +19,23 @@ Canonical dev container template for the musher-dev organization. Batteries-incl 1. Click **Use this template** → **Create a new repository** on GitHub 2. Clone your new repo and open in VS Code 3. **Command Palette** → **Dev Containers: Reopen in Container** -4. *(Optional)* Edit `.devcontainer/.env` to set `COMPOSE_PROFILES` and override credentials — the file is created automatically from `.env.example` on first build. +4. *(Optional)* Edit `.devcontainer/.env` to set `COMPOSE_PROFILES` and override credentials — the file is created + automatically from `.env.example` on first build. ## Local Environment -All local-dev state is contained under `.devcontainer/`. On first build, `initializeCommand` copies `.env.example` → `.env` (gitignored). The same file feeds: +All local-dev state is contained under `.devcontainer/`. On first build, `initializeCommand` copies `.env.example` → +`.env` (gitignored). The same file feeds: -- **Docker Compose** — auto-discovered as the sibling `.env` next to `compose.yaml`, used to interpolate `${VAR:-default}` references. -- **The dev container itself** — loaded via `runArgs --env-file`, so shells and runtimes inside the container see the same values. +- **Docker Compose** — auto-discovered as the sibling `.env` next to `compose.yaml`, used to interpolate + `${VAR:-default}` references. +- **The dev container itself** — loaded via `runArgs --env-file`, so shells and runtimes inside the container see the + same values. To reset local env state, delete `.devcontainer/.env` and rebuild. Useful task commands: | Command | Purpose | -|---|---| +| --- | --- | | `task env:check` | Verify `.env` has every key from `.env.example`. | | `task env:required` | List required keys (declared empty in the template) that still need a value. | | `task env:diff` | Show keys present in one of `.env` / `.env.example` but not the other. | @@ -40,20 +46,23 @@ The startup MOTD also warns about drift or unfilled required keys. ## Customize - Comment out unneeded features/extensions in `devcontainer.json` -- Change a tool version → `devcontainer.json` (Features), or `.devcontainer/mise.toml` for CLIs without a Feature (codex, lefthook) +- Change a tool version → `devcontainer.json` (Features), or `.devcontainer/mise.toml` for CLIs without a Feature + (codex, lefthook) - Add project setup to `scripts/post-create.sh` (runs after `base_setup`) - Enable optional services via `COMPOSE_PROFILES` in `.devcontainer/.env` (redis, minio, registry, azimutt, observability) - Full reference → [CONFIGURATION.md](CONFIGURATION.md) ## Included CI -This template includes `.github/workflows/validate.yaml` which runs ShellCheck, Compose config validation, and a devcontainer build check. Keep or remove per your project's needs. +This template includes `.github/workflows/validate.yaml` which runs ShellCheck, Compose config validation, and a +devcontainer build check. Keep or remove per your project's needs. ## Troubleshooting ### CRLF / WSL line ending issues -The `postCreateCommand` automatically strips `\r` from all scripts before running them. If you add new scripts, ensure they're under `.devcontainer/scripts/` to be included. +The `postCreateCommand` automatically strips `\r` from all scripts before running them. If you add new scripts, ensure +they're under `.devcontainer/scripts/` to be included. ### Stale containers @@ -63,7 +72,9 @@ If settings aren't applying after changes, rebuild without cache: ### Volume permission errors -Named volumes may initialize with root ownership. The `ensure_writable_dir` function in `common.sh` and the `base_setup_config_dirs` step handle this for base volumes. For custom volumes, call `ensure_writable_dir` in your `post-create.sh`: +Named volumes may initialize with root ownership. The `ensure_writable_dir` function in `common.sh` and the +`base_setup_config_dirs` step handle this for base volumes. For custom volumes, call `ensure_writable_dir` in your +`post-create.sh`: ```bash ensure_writable_dir /home/vscode/.my-tool @@ -71,4 +82,5 @@ ensure_writable_dir /home/vscode/.my-tool ### Tool installation failures -Base setup uses `retry` with 3 attempts and 5-second delays for network operations. If a tool consistently fails to install, check network connectivity and try rebuilding the container. +Base setup uses `retry` with 3 attempts and 5-second delays for network operations. If a tool consistently fails to +install, check network connectivity and try rebuilding the container. diff --git a/Taskfile.yml b/Taskfile.yml index 5681eb5..3df122e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -76,8 +76,8 @@ tasks: cmds: - | if [[ -f "{{.ENV_FILE}}" ]]; then - read -r -p "Overwrite {{.ENV_FILE}} with the template? [y/N] " ans - case "${ans}" in + read -r -p "Overwrite {{.ENV_FILE}} with the template? [y/N] " confirm + case "${confirm}" in y|Y|yes|YES) ;; *) echo "Aborted."; exit 1 ;; esac diff --git a/lefthook.yml b/lefthook.yml deleted file mode 100644 index b09cae8..0000000 --- a/lefthook.yml +++ /dev/null @@ -1,16 +0,0 @@ -# Lefthook git hooks. Installed by post-create.sh when lefthook is on PATH. -# See https://lefthook.dev for syntax reference. - -pre-commit: - parallel: true - commands: - block-devcontainer-env: - # Defense-in-depth against committing .devcontainer/.env even if the - # .gitignore entry is accidentally removed. The file holds local dev - # secrets and should never enter version control. - run: | - if git diff --cached --name-only --diff-filter=A | grep -qx '\.devcontainer/\.env'; then - echo "Refusing to commit .devcontainer/.env (contains local secrets)." >&2 - echo "Hint: 'git restore --staged .devcontainer/.env' to unstage." >&2 - exit 1 - fi From 1977cb838206375a4b361431edbe0eec8188b072 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 15:23:38 +0700 Subject: [PATCH 3/7] feat: add lint gates wired to lefthook and CI from one version source 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) --- .config/lefthook.yml | 31 ++++++++++++++++++- .devcontainer/mise.toml | 15 +++++++++ .github/workflows/validate.yaml | 27 +++++++++++++++- Taskfile.yml | 19 +++++++++--- taskfiles/lint.Taskfile.yml | 55 +++++++++++++++++++++++++++++++++ taskfiles/repo.Taskfile.yml | 31 +++++++++++++++++++ 6 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 taskfiles/lint.Taskfile.yml create mode 100644 taskfiles/repo.Taskfile.yml diff --git a/.config/lefthook.yml b/.config/lefthook.yml index ee8b4c5..fd02c24 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -50,4 +50,33 @@ pre-commit: echo "Refusing to commit .devcontainer/.env (contains local secrets)." >&2 exit 1 fi - fail_text: "Staged .devcontainer/.env — it holds local secrets. Run 'git restore --staged .devcontainer/.env'." + fail_text: >- + Staged .devcontainer/.env — it holds local secrets. Run + 'git restore --staged .devcontainer/.env'. + + # ── Lint (configs in .config/, versions in .devcontainer/mise.toml) ── + # Each job passes its config path explicitly and runs only on the staged + # files matching its glob, so a commit touching one Markdown file does not + # relint the repo. + + - name: markdown + glob: "*.md" + run: markdownlint-cli2 --config .config/markdownlint.jsonc {staged_files} + fail_text: "Markdown lint failed. Run 'task lint:md:fix', then re-stage." + + - name: yaml + glob: "*.{yml,yaml}" + run: yamllint -c .config/yamllint.yaml {staged_files} + fail_text: "YAML lint failed. See .config/yamllint.yaml for the active rules." + + - name: actions + glob: ".github/workflows/*.{yml,yaml}" + run: actionlint -config-file .config/actionlint.yaml {staged_files} + fail_text: "Workflow lint failed. Run 'task lint:actions' for the full output." + + - name: spelling + # No glob: a typo can land in any file type. + run: codespell --config .config/codespell.cfg {staged_files} + fail_text: >- + Spelling check failed. Run 'task lint:spelling:fix' and review the + diff, or add a justified entry to .config/codespell.cfg. diff --git a/.devcontainer/mise.toml b/.devcontainer/mise.toml index 165ff4c..18732a1 100644 --- a/.devcontainer/mise.toml +++ b/.devcontainer/mise.toml @@ -9,6 +9,21 @@ # runs `mise install` against this file. Docs: https://mise.jdx.dev # See CONFIGURATION.md → "Runtimes & Tools". +# This file is the SINGLE source of tool versions. CI resolves the same pins +# from here via jdx/mise-action (MISE_GLOBAL_CONFIG_FILE), so a version is +# never stated twice and local and CI cannot drift. +# +# Backends are fully qualified (npm:, pipx:, aqua:) rather than short names so +# resolution does not depend on mise's registry. + [tools] +# --- AI + git hooks ------------------------------------------------------ "npm:@openai/codex" = "0.143.0" +# Keep in lockstep with min_version in .config/lefthook.yml. "npm:lefthook" = "2.1.10" + +# --- Linters (configs live in .config/; see .config/README.md) ----------- +"npm:markdownlint-cli2" = "0.22.1" +"pipx:yamllint" = "1.38.0" +"pipx:codespell" = "2.4.3" +"aqua:rhysd/actionlint" = "1.7.12" diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 1bbec31..dfbe6a5 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -55,13 +55,38 @@ jobs: - name: Install Task uses: arduino/setup-task@v3 with: - version: 3.x + # Keep in lockstep with the go-task Feature pin in devcontainer.json. + version: 3.52.0 repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Verify .env / .env.example stay in sync run: | cp .devcontainer/.env.example .devcontainer/.env task env:check + lint: + name: Lint + runs-on: ubuntu-latest + # Tool versions resolve from .devcontainer/mise.toml -- the same file the + # container uses -- so CI and local can never drift. + env: + MISE_GLOBAL_CONFIG_FILE: ${{ github.workspace }}/.devcontainer/mise.toml + MISE_TRUSTED_CONFIG_PATHS: ${{ github.workspace }} + steps: + - uses: actions/checkout@v7 + - name: Install pinned lint tools + uses: jdx/mise-action@v4 + with: + install: true + cache: true + - name: Install Task + uses: arduino/setup-task@v3 + with: + # Keep in lockstep with the go-task Feature pin in devcontainer.json. + version: 3.52.0 + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Run every lint gate + run: task lint:all + build: name: Devcontainer Build runs-on: ubuntu-latest diff --git a/Taskfile.yml b/Taskfile.yml index 3df122e..7a54cb7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,8 +1,19 @@ version: '3' -# Top-level tasks for the dev container itself. Project-specific tasks -# should be added in a Taskfile.yml inside the consuming repo; this file -# stays scoped to the dev environment. +# Task entry point. This file must stay at the repo root: Task only discovers +# Taskfile.* there, and --taskfile would break bare `task `. Tool config +# lives in .config/ instead -- see CONFIGURATION.md → "Where Configuration Lives". +# +# Environment tasks (tools:*, env:*) stay here because they orchestrate the dev +# container itself. Everything else is a module under taskfiles/. +# +# Project-specific tasks belong in a Taskfile.yml inside the consuming repo. + +includes: + lint: + taskfile: taskfiles/lint.Taskfile.yml + repo: + taskfile: taskfiles/repo.Taskfile.yml vars: ENV_FILE: .devcontainer/.env @@ -11,7 +22,7 @@ vars: tasks: tools:install: - desc: Install the pinned CLIs from .devcontainer/mise.toml (codex, lefthook). + desc: Install every pinned CLI from .devcontainer/mise.toml (AI CLIs, lefthook, linters). cmds: - mise install diff --git a/taskfiles/lint.Taskfile.yml b/taskfiles/lint.Taskfile.yml new file mode 100644 index 0000000..0738d34 --- /dev/null +++ b/taskfiles/lint.Taskfile.yml @@ -0,0 +1,55 @@ +version: '3' + +# Lint tasks. Every command names its config file explicitly with the tool's +# own config flag -- default discovery is deliberately never relied upon, which +# is what keeps the configs in .config/ instead of scattered at the repo root. +# +# Tool versions come from .devcontainer/mise.toml (the single source). +# Policy: CONFIGURATION.md → "Where Configuration Lives". + +vars: + MD_CONFIG: .config/markdownlint.jsonc + YAML_CONFIG: .config/yamllint.yaml + ACTIONLINT_CONFIG: .config/actionlint.yaml + CODESPELL_CONFIG: .config/codespell.cfg + # Directories that contain YAML this repo owns. + YAML_PATHS: .github .devcontainer .config taskfiles Taskfile.yml + +tasks: + all: + desc: Run every lint check (markdown, yaml, actions, spelling). + cmds: + - task: md + - task: yaml + - task: actions + - task: spelling + + md: + desc: Lint Markdown against .config/markdownlint.jsonc. + cmds: + - markdownlint-cli2 --config {{.MD_CONFIG}} "**/*.md" "#node_modules" "#.repo/.venv" + + md:fix: + desc: Auto-fix the Markdown issues markdownlint can repair. + cmds: + - markdownlint-cli2 --config {{.MD_CONFIG}} --fix "**/*.md" "#node_modules" "#.repo/.venv" + + yaml: + desc: Lint YAML against .config/yamllint.yaml. + cmds: + - yamllint -c {{.YAML_CONFIG}} {{.YAML_PATHS}} + + actions: + desc: Lint GitHub Actions workflows against .config/actionlint.yaml. + cmds: + - actionlint -config-file {{.ACTIONLINT_CONFIG}} + + spelling: + desc: Spellcheck the repo against .config/codespell.cfg. + cmds: + - codespell --config {{.CODESPELL_CONFIG}} . + + spelling:fix: + desc: Apply codespell's suggested corrections in place (review the diff). + cmds: + - codespell --config {{.CODESPELL_CONFIG}} --write-changes . diff --git a/taskfiles/repo.Taskfile.yml b/taskfiles/repo.Taskfile.yml new file mode 100644 index 0000000..f47cdf9 --- /dev/null +++ b/taskfiles/repo.Taskfile.yml @@ -0,0 +1,31 @@ +version: '3' + +# Repo-governance tasks. These wrap the `repo` CLI installed from .repo/ +# by the devcontainer bootstrap (uv tool install ./.repo). +# See .repo/README.md for what each policy enforces and why. + +tasks: + check: + desc: Run every repo-structure policy (config layout, ports, hook/CI parity). + cmds: + - repo check + + check:config: + desc: Verify .config/ layout, the README index, and no shadowing root config. + cmds: + - repo config check + + check:ports: + desc: Verify the port table, forwardPorts, and compose published ports agree. + cmds: + - repo ports check + + check:hooks: + desc: Verify every lefthook job has a CI counterpart and vice versa. + cmds: + - repo hooks check + + install: + desc: (Re)install the `repo` CLI from .repo/ into the current environment. + cmds: + - uv tool install --force ./.repo From 6c5aad740827e5af34f10993a37233e86238e4fc Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 15:36:14 +0700 Subject: [PATCH 4/7] feat: add .repo/ governance toolchain enforcing the repo's own layout 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) --- .config/lefthook.yml | 7 ++ .devcontainer/scripts/lib/base-setup.sh | 31 ++++- .github/workflows/validate.yaml | 12 ++ .repo/README.md | 90 ++++++++++++++ .repo/pyproject.toml | 16 +++ .repo/src/repo_governance/__init__.py | 10 ++ .repo/src/repo_governance/cli.py | 53 +++++++++ .../src/repo_governance/policies/__init__.py | 6 + .../policies/config/__init__.py | 5 + .../repo_governance/policies/config/check.py | 102 ++++++++++++++++ .../policies/config/violations.py | 111 ++++++++++++++++++ .../policies/hooks/__init__.py | 5 + .../repo_governance/policies/hooks/check.py | 103 ++++++++++++++++ .../policies/hooks/violations.py | 68 +++++++++++ .../policies/ports/__init__.py | 5 + .../repo_governance/policies/ports/check.py | 79 +++++++++++++ .../policies/ports/violations.py | 85 ++++++++++++++ .repo/src/repo_governance/repo.py | 87 ++++++++++++++ .repo/src/repo_governance/violations.py | 83 +++++++++++++ CONFIGURATION.md | 6 +- 20 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 .repo/README.md create mode 100644 .repo/pyproject.toml create mode 100644 .repo/src/repo_governance/__init__.py create mode 100644 .repo/src/repo_governance/cli.py create mode 100644 .repo/src/repo_governance/policies/__init__.py create mode 100644 .repo/src/repo_governance/policies/config/__init__.py create mode 100644 .repo/src/repo_governance/policies/config/check.py create mode 100644 .repo/src/repo_governance/policies/config/violations.py create mode 100644 .repo/src/repo_governance/policies/hooks/__init__.py create mode 100644 .repo/src/repo_governance/policies/hooks/check.py create mode 100644 .repo/src/repo_governance/policies/hooks/violations.py create mode 100644 .repo/src/repo_governance/policies/ports/__init__.py create mode 100644 .repo/src/repo_governance/policies/ports/check.py create mode 100644 .repo/src/repo_governance/policies/ports/violations.py create mode 100644 .repo/src/repo_governance/repo.py create mode 100644 .repo/src/repo_governance/violations.py diff --git a/.config/lefthook.yml b/.config/lefthook.yml index fd02c24..2a99f81 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -74,6 +74,13 @@ pre-commit: run: actionlint -config-file .config/actionlint.yaml {staged_files} fail_text: "Workflow lint failed. Run 'task lint:actions' for the full output." + - name: governance + # Structural policy: .config/ layout, port-declaration parity, and + # this file's own parity with CI. Cheap (pure Python, no network). + glob: "{.config/**,.devcontainer/**,.github/workflows/**,taskfiles/**,CONFIGURATION.md,Taskfile.yml}" + run: repo check + fail_text: "Repo structure policy failed. Run 'task repo:check' for the full report." + - name: spelling # No glob: a typo can land in any file type. run: codespell --config .config/codespell.cfg {staged_files} diff --git a/.devcontainer/scripts/lib/base-setup.sh b/.devcontainer/scripts/lib/base-setup.sh index 7bfe5f2..9951d15 100644 --- a/.devcontainer/scripts/lib/base-setup.sh +++ b/.devcontainer/scripts/lib/base-setup.sh @@ -135,6 +135,34 @@ base_install_claude() { retry 3 5 bash -c 'curl -fsSL https://claude.ai/install.sh | bash' } +# --- Repo governance CLI --- + +# Installs the `repo` CLI from .repo/ so structure policies run locally the +# same way they run in CI. +# +# Sequenced after base_install_tools because it needs uv on PATH, and before +# base_verify_tools because that call asserts `repo` resolves. +# +# Globals: +# _LIB_DIR — read, used to locate the repo root +# Outputs: +# Writes progress to stderr via log() +# Returns: +# 0 on success, non-zero on failure +base_install_repo_cli() { + local repo_dir="${_LIB_DIR}/../../../.repo" + if [[ ! -f "${repo_dir}/pyproject.toml" ]]; then + log "No .repo/ project found, skipping governance CLI" + return 0 + fi + if ! has_cmd uv; then + log "uv not on PATH, skipping governance CLI" + return 0 + fi + log "Installing the repo governance CLI from .repo/..." + retry 3 5 uv tool install --force "${repo_dir}" +} + # --- Verify --- # Verifies the CLIs this script installs (plus a couple of key Feature tools) @@ -145,7 +173,7 @@ base_install_claude() { # Returns: # 0 if all tools found, 1 if any are missing base_verify_tools() { - verify_tools gh task codex lefthook claude + verify_tools gh task codex lefthook claude repo } # --- Orchestrator --- @@ -163,6 +191,7 @@ base_setup() { base_install_mise base_install_tools base_install_claude + base_install_repo_cli base_verify_tools log "Base setup complete" } diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index dfbe6a5..d06ac5e 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -87,6 +87,18 @@ jobs: - name: Run every lint gate run: task lint:all + governance: + name: Repo Structure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@v9 + - name: Install the repo governance CLI + run: uv tool install ./.repo + - name: Check repo structure policies + run: repo check + build: name: Devcontainer Build runs-on: ubuntu-latest diff --git a/.repo/README.md b/.repo/README.md new file mode 100644 index 0000000..1316514 --- /dev/null +++ b/.repo/README.md @@ -0,0 +1,90 @@ +# `.repo/` — Repository Governance + +The structural policies this repo enforces on itself, and the `repo` CLI that +runs them. + +The `.repo/` prefix mirrors `.github/` and `.devcontainer/`: infrastructure that +operates on the repository rather than being part of its content. It is +deliberately not `tools/` (junk-drawer risk) or `scripts/` (names the +implementation, not the purpose). + +## Why this exists + +This repository is a template. Whatever layout it ships is replicated into every +repo scaffolded from it, so a convention that holds only while someone remembers +it will not hold. The policies here turn the layout rules into something that +fails a build instead of a code review. + +That choice has a cost worth stating: a checker enforces *what*, not *why*. So +every violation this CLI reports carries its own rationale and the action that +resolves it — the `reason` and `fix` fields are not decoration, they are the +documentation. The prose version lives in +[`CONFIGURATION.md`](../CONFIGURATION.md). + +## Use + +```bash +task repo:check # every policy (this is what CI and pre-commit run) +task repo:check:config # one policy +repo check # same thing, without Task +repo config check +``` + +The CLI is installed by the devcontainer bootstrap +(`base_install_repo_cli` in `.devcontainer/scripts/lib/base-setup.sh`, which +runs `uv tool install ./.repo`). To reinstall after editing it: +`task repo:install`. + +## Policies + +| Policy | Codes | Enforces | +| --- | --- | --- | +| `config` | `CFG-01`..`CFG-07` | Tool config lives in `.config/`, every file is indexed and has a caller, nothing at the root shadows it | +| `ports` | `PORT-01`..`PORT-05` | The port table, `forwardPorts`/`portsAttributes`, and compose published ports all agree and stay in the reserved range | +| `hooks` | `HOOK-01`..`HOOK-04` | Every lefthook job has a CI counterpart and vice versa, or a recorded reason why not | + +### The one-way-check tables + +`hooks` is the policy most likely to be argued with, so its exceptions are +explicit. `LOCAL_ONLY` and `CI_ONLY` in +[`policies/hooks/check.py`](src/repo_governance/policies/hooks/check.py) list +every check that deliberately runs in only one place, each with a reason — +`build` is minutes long, `compose` needs a Docker daemon, `block-devcontainer-env` +has nothing to assert in CI. Adding a job on either side without registering it +fails `HOOK-01`/`HOOK-03`, and an entry that outlives what it excused fails +`HOOK-04`. The allowlist cannot quietly widen. + +## Layout + +```text +.repo/ + pyproject.toml uv project; declares the `repo` console-script + src/repo_governance/ + cli.py Argument parsing and exit codes + repo.py Repo-root discovery, YAML/JSONC readers + violations.py The Violation record and its rendering + policies// + violations.py What can go wrong, and why the rule exists + check.py Whether it has gone wrong +``` + +Each policy splits declaration from detection on purpose: `violations.py` is +where the reasoning lives and is the file to read first when a check fires. + +## Adding a policy + +1. Create `policies//` with `violations.py`, `check.py`, and an + `__init__.py` re-exporting `run`. +2. `run()` returns a `Report`; give every violation a stable code, a `reason`, + and a `fix`. +3. Register it in `POLICIES` in `cli.py` — it joins `repo check` and gains a + `repo check` subcommand automatically. +4. Add a row to the table above. + +## Not yet folded in + +Env-template parity (`.env` versus `.env.example`) stays in +`.devcontainer/scripts/lib/env-check.sh`. It already has three consumers — the +`env:*` tasks, the startup MOTD, and CI — and reimplementing it here would +duplicate the logic and put the MOTD path at risk. Consolidating it is a +reasonable future change; doing it as part of introducing `.repo/` was not. diff --git a/.repo/pyproject.toml b/.repo/pyproject.toml new file mode 100644 index 0000000..86b65b9 --- /dev/null +++ b/.repo/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "repo-governance" +version = "0.1.0" +description = "Structural policy checks for the Musher dev container template." +requires-python = ">=3.11" +dependencies = ["pyyaml>=6.0"] + +[project.scripts] +repo = "repo_governance.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/repo_governance"] diff --git a/.repo/src/repo_governance/__init__.py b/.repo/src/repo_governance/__init__.py new file mode 100644 index 0000000..8350950 --- /dev/null +++ b/.repo/src/repo_governance/__init__.py @@ -0,0 +1,10 @@ +"""Structural policy checks for this repository. + +Policies live in `repo_governance.policies.` and follow one shape: +`violations.py` declares what can go wrong and why it matters, `check.py` +decides whether it has gone wrong. See `.repo/README.md`. +""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/.repo/src/repo_governance/cli.py b/.repo/src/repo_governance/cli.py new file mode 100644 index 0000000..997b4fa --- /dev/null +++ b/.repo/src/repo_governance/cli.py @@ -0,0 +1,53 @@ +"""The `repo` command. + + repo check run every policy + repo config check .config/ layout and liveness + repo ports check port declarations agree + repo hooks check local hooks and CI stay in step +""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Callable + +from repo_governance import __version__ +from repo_governance.policies import config, hooks, ports +from repo_governance.violations import Report, render_reports + +POLICIES: dict[str, Callable[[], Report]] = { + "config": config.run, + "ports": ports.run, + "hooks": hooks.run, +} + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="repo", + description="Structural policy checks for this repository.", + ) + parser.add_argument("--version", action="version", version=f"repo {__version__}") + sub = parser.add_subparsers(dest="group", required=True) + + run_all = sub.add_parser("check", help="run every policy") + run_all.set_defaults(policies=list(POLICIES)) + + for name in POLICIES: + group = sub.add_parser(name, help=f"{name} policy") + actions = group.add_subparsers(dest="action", required=True) + actions.add_parser("check", help=f"run the {name} policy") + group.set_defaults(policies=[name]) + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + reports = [POLICIES[name]() for name in args.policies] + return render_reports(reports) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.repo/src/repo_governance/policies/__init__.py b/.repo/src/repo_governance/policies/__init__.py new file mode 100644 index 0000000..5ed49ff --- /dev/null +++ b/.repo/src/repo_governance/policies/__init__.py @@ -0,0 +1,6 @@ +"""Policy modules. + +Each policy is a package with two files: `violations.py` declares the +failures it can report (including why each rule exists), and `check.py` +detects them. `run()` returns a `Report`. +""" diff --git a/.repo/src/repo_governance/policies/config/__init__.py b/.repo/src/repo_governance/policies/config/__init__.py new file mode 100644 index 0000000..7e35b0d --- /dev/null +++ b/.repo/src/repo_governance/policies/config/__init__.py @@ -0,0 +1,5 @@ +"""Policy: tool configuration lives in .config/, and every file there is live.""" + +from repo_governance.policies.config.check import run + +__all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/config/check.py b/.repo/src/repo_governance/policies/config/check.py new file mode 100644 index 0000000..7828889 --- /dev/null +++ b/.repo/src/repo_governance/policies/config/check.py @@ -0,0 +1,102 @@ +"""Detect .config/ layout drift.""" + +from __future__ import annotations + +from repo_governance import repo +from repo_governance.policies.config import violations as v +from repo_governance.violations import Report + +CONFIG_DIR = ".config" + +#: Configs the tool finds on its own. Everything else must be named by a +#: caller. Keep this list short -- each entry is a discovery dependency. +AUTO_DISCOVERED = { + "lefthook.yml": "lefthook searches .config/ natively", +} + +#: Gitignored personal overrides. Present or absent, never indexed. +LOCAL_OVERRIDES = {"lefthook-local.yml", "lefthook-local.yaml"} + +#: Root filenames that would win lefthook's first-match-wins search. +SHADOWING = ( + "lefthook.yml", "lefthook.yaml", "lefthook.json", "lefthook.jsonc", + "lefthook.toml", ".lefthook.yml", ".lefthook.yaml", ".lefthook.json", + ".lefthook.jsonc", ".lefthook.toml", +) + +#: Tool configs that belong in .config/ and must never appear at the root. +#: Git, Task and editor files are deliberately absent -- they are root-only +#: by the tools' own rules. +STRAY_ROOT_CONFIGS = ( + ".markdownlint.json", ".markdownlint.jsonc", ".markdownlint.yaml", + ".markdownlint-cli2.jsonc", ".markdownlint-cli2.yaml", + ".yamllint", ".yamllint.yml", ".yamllint.yaml", + ".codespellrc", "codespell.cfg", + "actionlint.yaml", "actionlint.yml", + ".prettierrc", ".prettierrc.json", ".prettierrc.yaml", + ".eslintrc", ".eslintrc.json", "eslint.config.js", + ".stylelintrc", ".shellcheckrc", +) + +#: Files scanned for explicit `.config/` references. +CALLER_GLOBS = ( + "Taskfile.yml", + "taskfiles/*.yml", + "taskfiles/*.yaml", + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + ".config/lefthook.yml", + ".devcontainer/scripts/**/*.sh", +) + + +def _caller_text() -> str: + chunks = [] + for pattern in CALLER_GLOBS: + for path in repo.glob(pattern): + if path.is_file(): + chunks.append(path.read_text(encoding="utf-8")) + return "\n".join(chunks) + + +def run() -> Report: + report = Report(policy="config") + root = repo.repo_root() + config_dir = root / CONFIG_DIR + + if not config_dir.is_dir(): + report.add(v.missing_config_dir()) + return report + + index_path = config_dir / "README.md" + index = index_path.read_text(encoding="utf-8") if index_path.is_file() else None + if index is None: + report.add(v.missing_index()) + + callers = _caller_text() + + for path in sorted(config_dir.iterdir()): + if not path.is_file(): + continue + name = path.name + if name == "README.md" or name in LOCAL_OVERRIDES: + continue + + if name.startswith("."): + report.add(v.dotted_filename(name)) + + if index is not None and f"`{name}`" not in index: + report.add(v.not_in_index(name)) + + if name not in AUTO_DISCOVERED and f"{CONFIG_DIR}/{name}" not in callers: + report.add(v.orphaned(name)) + + for name in SHADOWING: + if (root / name).is_file(): + report.add(v.shadowing_root_config(name)) + + for name in STRAY_ROOT_CONFIGS: + if (root / name).is_file(): + report.add(v.stray_root_config(name)) + + return report diff --git a/.repo/src/repo_governance/policies/config/violations.py b/.repo/src/repo_governance/policies/config/violations.py new file mode 100644 index 0000000..45c8e25 --- /dev/null +++ b/.repo/src/repo_governance/policies/config/violations.py @@ -0,0 +1,111 @@ +"""What can go wrong with the .config/ layout, and why each rule exists.""" + +from __future__ import annotations + +from repo_governance.violations import Violation + +DOCS = "CONFIGURATION.md#where-configuration-lives" + + +def missing_config_dir() -> Violation: + return Violation( + code="CFG-01", + summary=".config/ does not exist", + reason=( + "The repo declares one home for tool configuration. Without the " + "directory the convention is aspirational and the next config " + "lands at the root." + ), + fix="Create .config/ and move tool configs into it.", + where=".config/", + docs=DOCS, + ) + + +def missing_index() -> Violation: + return Violation( + code="CFG-02", + summary=".config/README.md is missing", + reason=( + "The index is what makes the directory self-explaining: which " + "tool reads each file and how the file is reached." + ), + fix="Add .config/README.md with a row per config file.", + where=".config/README.md", + docs=DOCS, + ) + + +def not_in_index(name: str) -> Violation: + return Violation( + code="CFG-03", + summary=f"{name} is not listed in the .config/ index", + reason=( + "A config absent from the index is invisible to the next reader, " + "who cannot tell which tool consumes it or how." + ), + fix=f"Add a row for `{name}` to the index table in .config/README.md.", + where=f".config/{name}", + docs=DOCS, + ) + + +def orphaned(name: str) -> Violation: + return Violation( + code="CFG-04", + summary=f"{name} is never referenced by any caller", + reason=( + "Configs are passed explicitly, so a file no caller names is dead " + "weight -- it looks authoritative while affecting nothing." + ), + fix=( + f"Reference .config/{name} from taskfiles/, Taskfile.yml or a " + "workflow, or delete it." + ), + where=f".config/{name}", + docs=DOCS, + ) + + +def dotted_filename(name: str) -> Violation: + return Violation( + code="CFG-05", + summary=f"{name} has a leading dot inside .config/", + reason=( + "The directory is already dotted. A second dot signals " + "auto-discovery that is not happening and adds nothing." + ), + fix=f"Rename to {name.lstrip('.')}.", + where=f".config/{name}", + docs=DOCS, + ) + + +def shadowing_root_config(name: str) -> Violation: + return Violation( + code="CFG-06", + summary=f"{name} at the repo root shadows .config/lefthook.yml", + reason=( + "Lefthook's config search is first-match-wins over " + "[lefthook.*, .lefthook.*, .config/lefthook.*], so a root file " + "silently wins and different hooks run with no warning." + ), + fix=f"Delete ./{name}; the canonical config is .config/lefthook.yml.", + where=name, + docs=DOCS, + ) + + +def stray_root_config(name: str) -> Violation: + return Violation( + code="CFG-07", + summary=f"{name} is a tool config sitting at the repo root", + reason=( + "Root dotfile accretion is the problem .config/ exists to " + "prevent, and this repo is a template -- whatever it ships is " + "replicated into every repo scaffolded from it." + ), + fix=f"Move {name} into .config/ and pass its path explicitly.", + where=name, + docs=DOCS, + ) diff --git a/.repo/src/repo_governance/policies/hooks/__init__.py b/.repo/src/repo_governance/policies/hooks/__init__.py new file mode 100644 index 0000000..2671c0c --- /dev/null +++ b/.repo/src/repo_governance/policies/hooks/__init__.py @@ -0,0 +1,5 @@ +"""Policy: local hooks and CI stay in step.""" + +from repo_governance.policies.hooks.check import run + +__all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/hooks/check.py b/.repo/src/repo_governance/policies/hooks/check.py new file mode 100644 index 0000000..a63744c --- /dev/null +++ b/.repo/src/repo_governance/policies/hooks/check.py @@ -0,0 +1,103 @@ +"""Detect drift between local git hooks and CI. + +Both tables below are deliberate: an entry is a recorded decision that a +check runs in only one place, with the reason attached. Adding a job on +either side without registering it here fails the build. +""" + +from __future__ import annotations + +from repo_governance import repo +from repo_governance.policies.hooks import violations as v +from repo_governance.violations import Report + +LEFTHOOK = ".config/lefthook.yml" +WORKFLOW = ".github/workflows/validate.yaml" + +#: lefthook job -> the CI job that runs the equivalent check. +HOOK_TO_CI = { + "markdown": "lint", + "yaml": "lint", + "actions": "lint", + "spelling": "lint", + "governance": "governance", +} + +#: lefthook jobs with no CI counterpart, and why. +LOCAL_ONLY = { + "block-devcontainer-env": ( + "Guards against staging a gitignored secrets file. CI never has a " + ".devcontainer/.env to stage, so the check has nothing to assert there." + ), +} + +#: CI jobs with no local counterpart, and why. +CI_ONLY = { + "shellcheck": ( + "Runs against the whole scripts tree via a pinned action; the " + "equivalent local run would need shellcheck installed on every host." + ), + "compose": ( + "Requires a Docker daemon to resolve `docker compose config`, which " + "is not guaranteed inside the dev container." + ), + "lockfile": ( + "Resolves every Feature digest over the network; too slow and too " + "network-dependent for a pre-commit hook." + ), + "env-check": ( + "Asserts .env matches the template. Locally the developer's .env is " + "expected to differ, and the startup MOTD already surfaces drift." + ), + "build": ( + "Builds the whole dev container image. Minutes, not seconds -- a " + "pre-commit hook cannot absorb that." + ), +} + + +def _lefthook_jobs() -> set[str]: + config = repo.read_yaml(LEFTHOOK) or {} + names: set[str] = set() + for hook, body in config.items(): + if not isinstance(body, dict): + continue + for job in body.get("jobs", []) or []: + if isinstance(job, dict) and "name" in job: + names.add(str(job["name"])) + return names + + +def _ci_jobs() -> set[str]: + workflow = repo.read_yaml(WORKFLOW) or {} + return set(workflow.get("jobs", {}) or {}) + + +def run() -> Report: + report = Report(policy="hooks") + + hooks = _lefthook_jobs() + ci = _ci_jobs() + + for name in sorted(hooks): + if name in LOCAL_ONLY: + continue + target = HOOK_TO_CI.get(name) + if target is None: + report.add(v.unregistered_hook(name)) + elif target not in ci: + report.add(v.missing_ci_job(name, target)) + + accounted = set(HOOK_TO_CI.values()) | set(CI_ONLY) + for name in sorted(ci - accounted): + report.add(v.unregistered_ci_job(name)) + + # Allowlists must not outlive what they excuse. + for name in sorted(set(LOCAL_ONLY) - hooks): + report.add(v.stale_registration("LOCAL_ONLY", name)) + for name in sorted(set(CI_ONLY) - ci): + report.add(v.stale_registration("CI_ONLY", name)) + for name in sorted(set(HOOK_TO_CI) - hooks): + report.add(v.stale_registration("HOOK_TO_CI", name)) + + return report diff --git a/.repo/src/repo_governance/policies/hooks/violations.py b/.repo/src/repo_governance/policies/hooks/violations.py new file mode 100644 index 0000000..dc88c19 --- /dev/null +++ b/.repo/src/repo_governance/policies/hooks/violations.py @@ -0,0 +1,68 @@ +"""What can go wrong with hook/CI parity, and why the rule exists.""" + +from __future__ import annotations + +from repo_governance.violations import Violation + +DOCS = "CONFIGURATION.md#where-configuration-lives" + +_WHY = ( + "A check that runs locally but not in CI is unenforced -- anyone can " + "push past it. A check that runs in CI but not locally is discovered at " + "review time instead of before the commit. Both directions have to be a " + "deliberate, recorded decision rather than an accident." +) + + +def unregistered_hook(name: str) -> Violation: + return Violation( + code="HOOK-01", + summary=f"lefthook job '{name}' has no registered CI counterpart", + reason=_WHY, + fix=( + f"Add a CI job that runs the same check and map it in " + f"HOOK_TO_CI, or record '{name}' in LOCAL_ONLY with a reason " + "(.repo/src/repo_governance/policies/hooks/check.py)." + ), + where=".config/lefthook.yml", + docs=DOCS, + ) + + +def missing_ci_job(hook: str, ci_job: str) -> Violation: + return Violation( + code="HOOK-02", + summary=f"lefthook job '{hook}' maps to CI job '{ci_job}', which does not exist", + reason=_WHY, + fix=f"Add the '{ci_job}' job to .github/workflows/validate.yaml, or fix the mapping.", + where=".github/workflows/validate.yaml", + docs=DOCS, + ) + + +def unregistered_ci_job(name: str) -> Violation: + return Violation( + code="HOOK-03", + summary=f"CI job '{name}' is not accounted for by any policy entry", + reason=_WHY, + fix=( + f"Add a lefthook job mapped to '{name}', or record it in CI_ONLY " + "with a reason (.repo/src/repo_governance/policies/hooks/check.py)." + ), + where=".github/workflows/validate.yaml", + docs=DOCS, + ) + + +def stale_registration(kind: str, name: str) -> Violation: + return Violation( + code="HOOK-04", + summary=f"{kind} entry '{name}' no longer matches anything in the repo", + reason=( + "An allowlist that outlives the thing it excused quietly widens " + "over time until it excuses something nobody chose to excuse." + ), + fix=f"Remove '{name}' from the {kind} table.", + where=".repo/src/repo_governance/policies/hooks/check.py", + docs=DOCS, + ) diff --git a/.repo/src/repo_governance/policies/ports/__init__.py b/.repo/src/repo_governance/policies/ports/__init__.py new file mode 100644 index 0000000..3974d7b --- /dev/null +++ b/.repo/src/repo_governance/policies/ports/__init__.py @@ -0,0 +1,5 @@ +"""Policy: the three places that state a port number agree.""" + +from repo_governance.policies.ports.check import run + +__all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/ports/check.py b/.repo/src/repo_governance/policies/ports/check.py new file mode 100644 index 0000000..400ebe2 --- /dev/null +++ b/.repo/src/repo_governance/policies/ports/check.py @@ -0,0 +1,79 @@ +"""Detect disagreement between the three places a port is declared.""" + +from __future__ import annotations + +import re + +from repo_governance import repo +from repo_governance.policies.ports import violations as v +from repo_governance.violations import Report + +RANGE_LOW, RANGE_HIGH = 15432, 15460 + +DEVCONTAINER = ".devcontainer/devcontainer.json" +DOC = "CONFIGURATION.md" + +#: `| 15432 | PostgreSQL | TCP |` in the Port Allocation table. +_TABLE_ROW = re.compile(r"^\|\s*(\d{4,5})\s*\|") +#: `- "127.0.0.1:15432:5432"` in a stack compose file. +_PUBLISHED = re.compile(r"^\s*-\s*[\"']?(?:[\d.]+:)?(\d{4,5}):\d+") + + +def _documented_ports() -> set[int]: + """Ports listed in the CONFIGURATION.md Port Allocation table.""" + ports: set[int] = set() + in_section = False + for line in repo.read_text(DOC).splitlines(): + if line.startswith("### Port Allocation"): + in_section = True + continue + if in_section and line.startswith("### "): + break + if in_section: + match = _TABLE_ROW.match(line) + if match: + ports.add(int(match.group(1))) + return ports + + +def _published_ports() -> dict[int, str]: + """Host ports published by the stack compose files, mapped to their file.""" + found: dict[int, str] = {} + for path in repo.glob(".devcontainer/stacks/*/compose.yaml"): + for line in path.read_text(encoding="utf-8").splitlines(): + match = _PUBLISHED.match(line) + if match: + found.setdefault(int(match.group(1)), repo.rel(path)) + return found + + +def run() -> Report: + report = Report(policy="ports") + + devcontainer = repo.read_jsonc(DEVCONTAINER) + forwarded = {int(p) for p in devcontainer.get("forwardPorts", [])} + attributes = {int(p) for p in devcontainer.get("portsAttributes", {})} + documented = _documented_ports() + published = _published_ports() + + if forwarded != attributes: + report.add(v.attributes_mismatch(forwarded - attributes, attributes - forwarded)) + + for port, source in sorted(published.items()): + if port not in forwarded: + report.add(v.not_forwarded(port, source)) + + for port in sorted(forwarded - documented): + report.add(v.undocumented(port)) + + for port in sorted(documented - forwarded): + report.add(v.documented_but_unused(port)) + + for port in sorted(forwarded): + if not RANGE_LOW <= port <= RANGE_HIGH: + report.add(v.out_of_range(port, RANGE_LOW, RANGE_HIGH, DEVCONTAINER)) + for port, source in sorted(published.items()): + if not RANGE_LOW <= port <= RANGE_HIGH: + report.add(v.out_of_range(port, RANGE_LOW, RANGE_HIGH, source)) + + return report diff --git a/.repo/src/repo_governance/policies/ports/violations.py b/.repo/src/repo_governance/policies/ports/violations.py new file mode 100644 index 0000000..1a4e2fb --- /dev/null +++ b/.repo/src/repo_governance/policies/ports/violations.py @@ -0,0 +1,85 @@ +"""What can go wrong with port declarations, and why each rule exists.""" + +from __future__ import annotations + +from repo_governance.violations import Violation + +DOCS = "CONFIGURATION.md#port-allocation" + +_WHY_DRIFT = ( + "A port is declared in three places -- the CONFIGURATION.md table, " + "devcontainer.json, and the stack's compose.yaml. Nothing links them, so " + "they drift silently and the symptom is a service that starts but is " + "unreachable from the host." +) + + +def attributes_mismatch(missing: set[int], extra: set[int]) -> Violation: + parts = [] + if missing: + parts.append(f"missing from portsAttributes: {sorted(missing)}") + if extra: + parts.append(f"in portsAttributes but not forwardPorts: {sorted(extra)}") + return Violation( + code="PORT-01", + summary="forwardPorts and portsAttributes disagree; " + "; ".join(parts), + reason=( + "A forwarded port with no attributes gets a bare number in the " + "editor's port list and defaults to a notification popup, which " + "is why every entry here sets a label and onAutoForward." + ), + fix="Add or remove the entries so both lists hold the same ports.", + where=".devcontainer/devcontainer.json", + docs=DOCS, + ) + + +def not_forwarded(port: int, source: str) -> Violation: + return Violation( + code="PORT-02", + summary=f"port {port} is published by compose but never forwarded", + reason=_WHY_DRIFT + " A published port that is not forwarded is " + "reachable inside the container and invisible outside it.", + fix=f"Add {port} to forwardPorts and portsAttributes in devcontainer.json.", + where=source, + docs=DOCS, + ) + + +def undocumented(port: int) -> Violation: + return Violation( + code="PORT-03", + summary=f"port {port} is forwarded but absent from the port table", + reason=_WHY_DRIFT + " The table is the only place a human looks to " + "find a free port before claiming one.", + fix=f"Add a row for {port} to the Port Allocation table in CONFIGURATION.md.", + where="CONFIGURATION.md", + docs=DOCS, + ) + + +def documented_but_unused(port: int) -> Violation: + return Violation( + code="PORT-04", + summary=f"port {port} is in the port table but nothing forwards it", + reason=_WHY_DRIFT + " A phantom row makes the range look more " + "crowded than it is and the next service skips a free number.", + fix=f"Remove the {port} row from CONFIGURATION.md, or forward it.", + where="CONFIGURATION.md", + docs=DOCS, + ) + + +def out_of_range(port: int, low: int, high: int, source: str) -> Violation: + return Violation( + code="PORT-05", + summary=f"port {port} is outside the reserved {low}-{high} range", + reason=( + "The template reserves one contiguous block so a consuming " + "project can allocate its own ports without checking for " + "collisions against every stack." + ), + fix=f"Move it into {low}-{high}, or widen the range in CONFIGURATION.md.", + where=source, + docs=DOCS, + ) diff --git a/.repo/src/repo_governance/repo.py b/.repo/src/repo_governance/repo.py new file mode 100644 index 0000000..8c9dca6 --- /dev/null +++ b/.repo/src/repo_governance/repo.py @@ -0,0 +1,87 @@ +"""Repository location and the file readers the policies share.""" + +from __future__ import annotations + +import json +import re +from functools import lru_cache +from pathlib import Path + +import yaml + + +@lru_cache(maxsize=1) +def repo_root() -> Path: + """The repository root. + + Walks up from the working directory looking for the marker files this + template is guaranteed to have, so `repo check` works from any + subdirectory the way git does. + """ + here = Path.cwd().resolve() + for candidate in (here, *here.parents): + if (candidate / ".devcontainer").is_dir() and (candidate / ".git").exists(): + return candidate + return here + + +def read_text(rel: str) -> str: + return (repo_root() / rel).read_text(encoding="utf-8") + + +def read_yaml(rel: str): + return yaml.safe_load(read_text(rel)) + + +_LINE_COMMENT = re.compile(r"//[^\n]*") +_TRAILING_COMMA = re.compile(r",(\s*[}\]])") + + +def read_jsonc(rel: str): + """Parse a JSON-with-comments file such as devcontainer.json. + + devcontainer.json is JSONC by specification, so `json.loads` cannot read + it directly and this repo's copy is heavily commented on purpose. + """ + raw = read_text(rel) + out: list[str] = [] + in_string = escaped = False + i = 0 + while i < len(raw): + char = raw[i] + if in_string: + out.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + i += 1 + continue + if char == '"': + in_string = True + out.append(char) + i += 1 + elif raw.startswith("//", i): + newline = raw.find("\n", i) + i = len(raw) if newline < 0 else newline + elif raw.startswith("/*", i): + end = raw.find("*/", i) + i = len(raw) if end < 0 else end + 2 + else: + out.append(char) + i += 1 + return json.loads(_TRAILING_COMMA.sub(r"\1", "".join(out))) + + +def exists(rel: str) -> bool: + return (repo_root() / rel).exists() + + +def glob(pattern: str) -> list[Path]: + return sorted(repo_root().glob(pattern)) + + +def rel(path: Path) -> str: + return path.relative_to(repo_root()).as_posix() diff --git a/.repo/src/repo_governance/violations.py b/.repo/src/repo_governance/violations.py new file mode 100644 index 0000000..c4c958a --- /dev/null +++ b/.repo/src/repo_governance/violations.py @@ -0,0 +1,83 @@ +"""The shared violation record and its rendering. + +A violation is not just "this is wrong". It carries the *reason* the rule +exists and the action that resolves it, because this repo enforces its +layout mechanically instead of in prose -- the checker has to be able to +explain itself. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Violation: + """One failed expectation.""" + + code: str + """Stable identifier, e.g. "CFG-03". Referenceable in review.""" + + summary: str + """One line: what is wrong.""" + + reason: str + """Why the rule exists. Without this the check is cargo cult.""" + + fix: str + """The concrete action that resolves it.""" + + where: str = "" + """Repo-relative path (and optional :line) the violation attaches to.""" + + docs: str = "CONFIGURATION.md#where-configuration-lives" + """Pointer to the human-facing policy.""" + + def render(self) -> str: + head = f"{self.code} {self.summary}" + if self.where: + head = f"{self.code} {self.where}: {self.summary}" + return "\n".join( + ( + head, + f" why: {self.reason}", + f" fix: {self.fix}", + f" doc: {self.docs}", + ) + ) + + +@dataclass +class Report: + """The result of running one policy.""" + + policy: str + violations: list[Violation] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.violations + + def add(self, violation: Violation) -> None: + self.violations.append(violation) + + +def render_reports(reports: list[Report], stream=sys.stdout) -> int: + """Print every report. Return a process exit code.""" + failed = [r for r in reports if not r.ok] + for report in reports: + mark = "ok " if report.ok else "FAIL" + count = "" if report.ok else f" ({len(report.violations)})" + print(f"[{mark}] {report.policy}{count}", file=stream) + if not failed: + return 0 + print("", file=stream) + for report in failed: + for violation in report.violations: + print(f" {violation.render()}", file=stream) + print("", file=stream) + total = sum(len(r.violations) for r in failed) + plural = "s" if total != 1 else "" + print(f"{total} violation{plural} across {len(failed)} policy check(s).", file=stream) + return 1 diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 09edca8..20d1f99 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -439,11 +439,13 @@ settings across container rebuilds. actionlint.yaml Workflow rules (-config-file) codespell.cfg Spelling (--config) .repo/ Repo governance toolchain (the `repo` CLI) - README.md The decision rule this layout follows + README.md What each policy enforces, and why pyproject.toml uv project; declares the `repo` console-script src/repo_governance/ cli.py `repo check` and the per-policy subcommands - policies/config/ .config/ layout + no-shadowing-root-config + repo.py Repo-root discovery, YAML/JSONC readers + violations.py The Violation record (code, reason, fix) + policies/config/ .config/ layout, index, and no shadowing root config policies/ports/ Port table ↔ forwardPorts ↔ compose parity policies/hooks/ lefthook ↔ CI job parity .github/ From a6aa10a457ca9d2acddf08d49b811557d9355244 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 15:42:24 +0700 Subject: [PATCH 5/7] feat: commit branch rulesets and enforce their agreement with CI 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) --- .github/rulesets/RULESETS.md | 67 ++++++++++++++++ .github/rulesets/main-branch.json | 50 ++++++++++++ .repo/README.md | 1 + .repo/src/repo_governance/cli.py | 3 +- .../policies/rulesets/__init__.py | 5 ++ .../policies/rulesets/check.py | 78 +++++++++++++++++++ .../policies/rulesets/violations.py | 74 ++++++++++++++++++ CONFIGURATION.md | 3 +- README.md | 20 ++++- 9 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 .github/rulesets/RULESETS.md create mode 100644 .github/rulesets/main-branch.json create mode 100644 .repo/src/repo_governance/policies/rulesets/__init__.py create mode 100644 .repo/src/repo_governance/policies/rulesets/check.py create mode 100644 .repo/src/repo_governance/policies/rulesets/violations.py diff --git a/.github/rulesets/RULESETS.md b/.github/rulesets/RULESETS.md new file mode 100644 index 0000000..6b6ade6 --- /dev/null +++ b/.github/rulesets/RULESETS.md @@ -0,0 +1,67 @@ +# Branch Rulesets + +Branch protection for this repository, committed as JSON so it is reviewable +and restorable rather than living only in the GitHub UI. + +| File | Applies to | Effect | +| --- | --- | --- | +| `main-branch.json` | The default branch | No deletion, no force-push, PR with one approval and squash merge, every CI job green and up to date | + +## Applying a ruleset + +These files are **not** applied automatically — nothing in CI has permission to +change branch protection, by design. Import one from the repository settings +(Settings → Rules → Rulesets → New ruleset → Import a ruleset), or with a token +carrying `administration:write`: + +```bash +gh api -X POST repos/musher-dev/development-container/rulesets \ + --input .github/rulesets/main-branch.json +``` + +To update an existing ruleset, `PUT` to `.../rulesets/` instead. + +## Detecting drift + +`repo rulesets check` validates the committed file's **shape** and its +agreement with CI. It deliberately does not diff against live GitHub state: +reading a repository's rulesets requires `administration:read`, which the +default `GITHUB_TOKEN` does not have, so a workflow-based drift detector would +need a long-lived personal access token or fail open. Compare manually when it +matters: + +```bash +gh api repos/musher-dev/development-container/rulesets --jq '.[] | {id, name}' +gh api repos/musher-dev/development-container/rulesets/ > /tmp/live.json +diff <(jq -S . .github/rulesets/main-branch.json) <(jq -S . /tmp/live.json) +``` + +## What is enforced automatically + +`repo rulesets check` runs in pre-commit and in the `Repo Structure` CI job: + +| Code | Fails when | +| --- | --- | +| `RS-01` | A ruleset file is not valid JSON | +| `RS-02` | A ruleset is missing a required top-level key | +| `RS-03` | A required status check names a job no CI workflow produces | +| `RS-04` | A CI job exists that no ruleset requires | + +`RS-03` is the one that matters most. A required status check is matched by the +job's **display name**, so renaming a job in `validate.yaml` without updating +this directory leaves every pull request waiting forever on a check that can +never report — and unblocking it needs admin access at exactly the moment the +repository has become unmergeable. + +`RS-04` is the mirror: a job that runs but is not required is advisory, and a +red run can still merge. If a job is genuinely meant to be non-blocking, record +it in `ADVISORY_JOBS` in +[`check.py`](../../.repo/src/repo_governance/policies/rulesets/check.py) with a +reason instead of leaving the gap silent. + +## Consuming projects + +A repository scaffolded from this template gets these files but **not** the +protection — rulesets are repository state, not repository content. Import the +ruleset once after creating the repo, then adjust the required status checks to +match whatever CI that project actually runs. diff --git a/.github/rulesets/main-branch.json b/.github/rulesets/main-branch.json new file mode 100644 index 0000000..9fe96fc --- /dev/null +++ b/.github/rulesets/main-branch.json @@ -0,0 +1,50 @@ +{ + "name": "Main Branch", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": ["~DEFAULT_BRANCH"] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": false, + "required_reviewers": [], + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "ShellCheck", "integration_id": 15368 }, + { "context": "Compose Config", "integration_id": 15368 }, + { "context": "Devcontainer Lockfile", "integration_id": 15368 }, + { "context": "Env Template Sync", "integration_id": 15368 }, + { "context": "Lint", "integration_id": 15368 }, + { "context": "Repo Structure", "integration_id": 15368 }, + { "context": "Devcontainer Build", "integration_id": 15368 } + ] + } + } + ] +} diff --git a/.repo/README.md b/.repo/README.md index 1316514..eae274d 100644 --- a/.repo/README.md +++ b/.repo/README.md @@ -42,6 +42,7 @@ runs `uv tool install ./.repo`). To reinstall after editing it: | `config` | `CFG-01`..`CFG-07` | Tool config lives in `.config/`, every file is indexed and has a caller, nothing at the root shadows it | | `ports` | `PORT-01`..`PORT-05` | The port table, `forwardPorts`/`portsAttributes`, and compose published ports all agree and stay in the reserved range | | `hooks` | `HOOK-01`..`HOOK-04` | Every lefthook job has a CI counterpart and vice versa, or a recorded reason why not | +| `rulesets` | `RS-01`..`RS-04` | Committed branch rulesets stay valid and in step with the CI jobs they require | ### The one-way-check tables diff --git a/.repo/src/repo_governance/cli.py b/.repo/src/repo_governance/cli.py index 997b4fa..77e49c7 100644 --- a/.repo/src/repo_governance/cli.py +++ b/.repo/src/repo_governance/cli.py @@ -13,13 +13,14 @@ from collections.abc import Callable from repo_governance import __version__ -from repo_governance.policies import config, hooks, ports +from repo_governance.policies import config, hooks, ports, rulesets from repo_governance.violations import Report, render_reports POLICIES: dict[str, Callable[[], Report]] = { "config": config.run, "ports": ports.run, "hooks": hooks.run, + "rulesets": rulesets.run, } diff --git a/.repo/src/repo_governance/policies/rulesets/__init__.py b/.repo/src/repo_governance/policies/rulesets/__init__.py new file mode 100644 index 0000000..a434baa --- /dev/null +++ b/.repo/src/repo_governance/policies/rulesets/__init__.py @@ -0,0 +1,5 @@ +"""Policy: committed rulesets stay consistent with the CI that satisfies them.""" + +from repo_governance.policies.rulesets.check import run + +__all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/rulesets/check.py b/.repo/src/repo_governance/policies/rulesets/check.py new file mode 100644 index 0000000..dc510b4 --- /dev/null +++ b/.repo/src/repo_governance/policies/rulesets/check.py @@ -0,0 +1,78 @@ +"""Detect drift between committed rulesets and the CI that satisfies them. + +Shape only -- this never diffs against live GitHub state. Reading a +repository's rulesets needs `administration:read`, which the default +GITHUB_TOKEN does not have, so a drift detector would require a long-lived +PAT or fail open. See .github/rulesets/RULESETS.md for the operator command. +""" + +from __future__ import annotations + +import json + +from repo_governance import repo +from repo_governance.policies.rulesets import violations as v +from repo_governance.violations import Report + +RULESET_DIR = ".github/rulesets" +WORKFLOW = ".github/workflows/validate.yaml" + +REQUIRED_KEYS = ("name", "target", "enforcement", "rules") + +#: CI jobs deliberately not required to pass before merge, and why. +ADVISORY_JOBS: dict[str, str] = {} + + +def _ci_job_names() -> list[str]: + workflow = repo.read_yaml(WORKFLOW) or {} + names = [] + for job_id, body in (workflow.get("jobs") or {}).items(): + names.append(str(body.get("name", job_id)) if isinstance(body, dict) else job_id) + return sorted(names) + + +def _required_contexts(ruleset: dict) -> set[str]: + contexts: set[str] = set() + for rule in ruleset.get("rules", []) or []: + if not isinstance(rule, dict) or rule.get("type") != "required_status_checks": + continue + params = rule.get("parameters") or {} + for check in params.get("required_status_checks", []) or []: + if isinstance(check, dict) and "context" in check: + contexts.add(str(check["context"])) + return contexts + + +def run() -> Report: + report = Report(policy="rulesets") + + paths = repo.glob(f"{RULESET_DIR}/*.json") + if not paths: + return report + + known = _ci_job_names() + + for path in paths: + name = path.name + try: + ruleset = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + report.add(v.invalid_json(name, f"line {exc.lineno}: {exc.msg}")) + continue + + for key in REQUIRED_KEYS: + if key not in ruleset: + report.add(v.missing_key(name, key)) + + contexts = _required_contexts(ruleset) + if not contexts: + continue + + for context in sorted(contexts - set(known)): + report.add(v.unknown_status_check(name, context, known)) + + for job in known: + if job not in contexts and job not in ADVISORY_JOBS: + report.add(v.unguarded_ci_job(name, job)) + + return report diff --git a/.repo/src/repo_governance/policies/rulesets/violations.py b/.repo/src/repo_governance/policies/rulesets/violations.py new file mode 100644 index 0000000..a29633d --- /dev/null +++ b/.repo/src/repo_governance/policies/rulesets/violations.py @@ -0,0 +1,74 @@ +"""What can go wrong with committed rulesets, and why each rule exists.""" + +from __future__ import annotations + +from repo_governance.violations import Violation + +DOCS = ".github/rulesets/RULESETS.md" + + +def invalid_json(name: str, detail: str) -> Violation: + return Violation( + code="RS-01", + summary=f"{name} is not valid JSON ({detail})", + reason=( + "Rulesets are applied by pasting or importing this file into " + "GitHub. A malformed file fails at the moment someone is trying " + "to restore branch protection, which is the worst time to find out." + ), + fix="Fix the JSON syntax.", + where=f".github/rulesets/{name}", + docs=DOCS, + ) + + +def missing_key(name: str, key: str) -> Violation: + return Violation( + code="RS-02", + summary=f"{name} has no '{key}' key", + reason=( + "GitHub rejects a ruleset payload missing any of the required " + "top-level keys, and the error it returns does not name them." + ), + fix=f"Add a '{key}' key to the ruleset.", + where=f".github/rulesets/{name}", + docs=DOCS, + ) + + +def unknown_status_check(name: str, context: str, known: list[str]) -> Violation: + return Violation( + code="RS-03", + summary=f"{name} requires status check '{context}', which no CI job produces", + reason=( + "A required status check that never reports leaves every pull " + "request permanently blocked on a check that cannot arrive -- and " + "the only fix is admin access, at the moment the repo is already " + "unmergeable. Renaming a CI job is the usual way to cause it." + ), + fix=( + f"Rename the job so its `name:` is '{context}', or update the " + f"ruleset to one of: {', '.join(known)}." + ), + where=f".github/rulesets/{name}", + docs=DOCS, + ) + + +def unguarded_ci_job(name: str, job: str) -> Violation: + return Violation( + code="RS-04", + summary=f"CI job '{job}' is not a required status check", + reason=( + "A gate that runs but is not required is advisory: a red run can " + "still merge. Every job in the validate workflow is meant to be " + "blocking, so a missing entry is drift rather than a decision." + ), + fix=( + f"Add a required_status_checks entry for '{job}' to {name}, or " + "record it in ADVISORY_JOBS with a reason " + "(.repo/src/repo_governance/policies/rulesets/check.py)." + ), + where=f".github/rulesets/{name}", + docs=DOCS, + ) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 20d1f99..0ecf770 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -448,9 +448,10 @@ settings across container rebuilds. policies/config/ .config/ layout, index, and no shadowing root config policies/ports/ Port table ↔ forwardPorts ↔ compose parity policies/hooks/ lefthook ↔ CI job parity + policies/rulesets/ Branch rulesets ↔ CI job-name parity .github/ dependabot.yml Weekly updates: devcontainers, actions, docker - rulesets/ Branch protection as committed JSON + rulesets/ Branch protection as committed JSON (+ RULESETS.md) workflows/ CI taskfiles/ Task modules included by the root Taskfile.yml Taskfile.yml Task entry point (cannot move — root-only discovery) diff --git a/README.md b/README.md index 6740a3e..cb673c6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ To reset local env state, delete `.devcontainer/.env` and rebuild. Useful task c | `task env:required` | List required keys (declared empty in the template) that still need a value. | | `task env:diff` | Show keys present in one of `.env` / `.env.example` but not the other. | | `task env:reset` | Re-copy the template over `.env` (prompts before overwriting). | +| `task lint:all` | Run every lint gate (Markdown, YAML, workflows, spelling). | +| `task repo:check` | Check the repo's own structure policies. | +| `task tools:install` | Install every pinned CLI from `.devcontainer/mise.toml`. | The startup MOTD also warns about drift or unfilled required keys. @@ -47,15 +50,26 @@ The startup MOTD also warns about drift or unfilled required keys. - Comment out unneeded features/extensions in `devcontainer.json` - Change a tool version → `devcontainer.json` (Features), or `.devcontainer/mise.toml` for CLIs without a Feature - (codex, lefthook) + (AI CLIs, lefthook, linters) +- Change a lint rule → the matching file in `.config/` (see [`.config/README.md`](.config/README.md)) - Add project setup to `scripts/post-create.sh` (runs after `base_setup`) - Enable optional services via `COMPOSE_PROFILES` in `.devcontainer/.env` (redis, minio, registry, azimutt, observability) - Full reference → [CONFIGURATION.md](CONFIGURATION.md) ## Included CI -This template includes `.github/workflows/validate.yaml` which runs ShellCheck, Compose config validation, and a -devcontainer build check. Keep or remove per your project's needs. +`.github/workflows/validate.yaml` runs seven jobs: ShellCheck, Compose config validation, devcontainer lockfile +freshness, `.env` template sync, the lint gates, the repo structure policies, and a devcontainer build. Lint tool +versions resolve from `.devcontainer/mise.toml` — the same file the container uses — so CI and local cannot drift. + +The same lint and structure checks run pre-commit via [`.config/lefthook.yml`](.config/lefthook.yml), and +`repo hooks check` fails the build if the two ever disagree. + +Branch protection is committed as JSON under [`.github/rulesets/`](.github/rulesets/RULESETS.md); it must be imported +once per repository, since rulesets are repository state rather than content. + +Keep or remove per your project's needs — but remove a CI job and its ruleset entry together, or `repo rulesets check` +will tell you why. ## Troubleshooting From ac2d3bd4136676c7625c270f6a26eee264ef8684 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 16:05:22 +0700 Subject: [PATCH 6/7] refactor: flatten .repo to governance/ and disambiguate reporting 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) --- .github/rulesets/RULESETS.md | 2 +- .repo/README.md | 27 +++++++++++++------ .../__init__.py | 2 +- .../repo_governance => governance}/cli.py | 16 +++-------- .repo/governance/policies/__init__.py | 26 ++++++++++++++++++ .../policies/config/__init__.py | 2 +- .../policies/config/check.py | 6 ++--- .../policies/config/violations.py | 2 +- .../policies/hooks/__init__.py | 2 +- .../policies/hooks/check.py | 6 ++--- .../policies/hooks/violations.py | 8 +++--- .../policies/ports/__init__.py | 2 +- .../policies/ports/check.py | 6 ++--- .../policies/ports/violations.py | 2 +- .../policies/rulesets/__init__.py | 2 +- .../policies/rulesets/check.py | 6 ++--- .../policies/rulesets/violations.py | 4 +-- .../repo_governance => governance}/repo.py | 0 .../violations.py => governance/reporting.py} | 0 .repo/pyproject.toml | 4 +-- .../src/repo_governance/policies/__init__.py | 6 ----- CONFIGURATION.md | 7 ++--- 22 files changed, 81 insertions(+), 57 deletions(-) rename .repo/{src/repo_governance => governance}/__init__.py (76%) rename .repo/{src/repo_governance => governance}/cli.py (77%) create mode 100644 .repo/governance/policies/__init__.py rename .repo/{src/repo_governance => governance}/policies/config/__init__.py (65%) rename .repo/{src/repo_governance => governance}/policies/config/check.py (95%) rename .repo/{src/repo_governance => governance}/policies/config/violations.py (98%) rename .repo/{src/repo_governance => governance}/policies/hooks/__init__.py (55%) rename .repo/{src/repo_governance => governance}/policies/hooks/check.py (95%) rename .repo/{src/repo_governance => governance}/policies/hooks/violations.py (88%) rename .repo/{src/repo_governance => governance}/policies/ports/__init__.py (61%) rename .repo/{src/repo_governance => governance}/policies/ports/check.py (94%) rename .repo/{src/repo_governance => governance}/policies/ports/violations.py (98%) rename .repo/{src/repo_governance => governance}/policies/rulesets/__init__.py (64%) rename .repo/{src/repo_governance => governance}/policies/rulesets/check.py (94%) rename .repo/{src/repo_governance => governance}/policies/rulesets/violations.py (95%) rename .repo/{src/repo_governance => governance}/repo.py (100%) rename .repo/{src/repo_governance/violations.py => governance/reporting.py} (100%) delete mode 100644 .repo/src/repo_governance/policies/__init__.py diff --git a/.github/rulesets/RULESETS.md b/.github/rulesets/RULESETS.md index 6b6ade6..cfaba71 100644 --- a/.github/rulesets/RULESETS.md +++ b/.github/rulesets/RULESETS.md @@ -56,7 +56,7 @@ repository has become unmergeable. `RS-04` is the mirror: a job that runs but is not required is advisory, and a red run can still merge. If a job is genuinely meant to be non-blocking, record it in `ADVISORY_JOBS` in -[`check.py`](../../.repo/src/repo_governance/policies/rulesets/check.py) with a +[`check.py`](../../.repo/governance/policies/rulesets/check.py) with a reason instead of leaving the gap silent. ## Consuming projects diff --git a/.repo/README.md b/.repo/README.md index eae274d..cdc83ef 100644 --- a/.repo/README.md +++ b/.repo/README.md @@ -48,7 +48,7 @@ runs `uv tool install ./.repo`). To reinstall after editing it: `hooks` is the policy most likely to be argued with, so its exceptions are explicit. `LOCAL_ONLY` and `CI_ONLY` in -[`policies/hooks/check.py`](src/repo_governance/policies/hooks/check.py) list +[`policies/hooks/check.py`](governance/policies/hooks/check.py) list every check that deliberately runs in only one place, each with a reason — `build` is minutes long, `compose` needs a Docker daemon, `block-devcontainer-env` has nothing to assert in CI. Adding a job on either side without registering it @@ -60,17 +60,27 @@ fails `HOOK-01`/`HOOK-03`, and an entry that outlives what it excused fails ```text .repo/ pyproject.toml uv project; declares the `repo` console-script - src/repo_governance/ + governance/ cli.py Argument parsing and exit codes + reporting.py The Violation record and its rendering repo.py Repo-root discovery, YAML/JSONC readers - violations.py The Violation record and its rendering - policies// - violations.py What can go wrong, and why the rule exists - check.py Whether it has gone wrong + policies/ + __init__.py The policy registry + / + violations.py What can go wrong, and why the rule exists + check.py Whether it has gone wrong ``` Each policy splits declaration from detection on purpose: `violations.py` is where the reasoning lives and is the file to read first when a check fires. +The shared `Violation` and `Report` primitives are in `reporting.py` -- named +so that it is never confused with a policy's own `violations.py`. + +There is no `src/` directory. Its purpose is to stop Python from importing a +local source tree in place of the installed package, which only happens when +the package sits in the working directory -- and this one sits under `.repo/`, +which is never where anyone works. `.repo/` already provides the separation, +so `src/` would only add a level to every path. ## Adding a policy @@ -78,8 +88,9 @@ where the reasoning lives and is the file to read first when a check fires. `__init__.py` re-exporting `run`. 2. `run()` returns a `Report`; give every violation a stable code, a `reason`, and a `fix`. -3. Register it in `POLICIES` in `cli.py` — it joins `repo check` and gains a - `repo check` subcommand automatically. +3. Register it in `POLICIES` in `policies/__init__.py` — it joins `repo check` + and gains a `repo check` subcommand automatically. That is the only + wiring; `cli.py` never names an individual policy. 4. Add a row to the table above. ## Not yet folded in diff --git a/.repo/src/repo_governance/__init__.py b/.repo/governance/__init__.py similarity index 76% rename from .repo/src/repo_governance/__init__.py rename to .repo/governance/__init__.py index 8350950..37ce049 100644 --- a/.repo/src/repo_governance/__init__.py +++ b/.repo/governance/__init__.py @@ -1,6 +1,6 @@ """Structural policy checks for this repository. -Policies live in `repo_governance.policies.` and follow one shape: +Policies live in `governance.policies.` and follow one shape: `violations.py` declares what can go wrong and why it matters, `check.py` decides whether it has gone wrong. See `.repo/README.md`. """ diff --git a/.repo/src/repo_governance/cli.py b/.repo/governance/cli.py similarity index 77% rename from .repo/src/repo_governance/cli.py rename to .repo/governance/cli.py index 77e49c7..0ad4676 100644 --- a/.repo/src/repo_governance/cli.py +++ b/.repo/governance/cli.py @@ -10,18 +10,10 @@ import argparse import sys -from collections.abc import Callable - -from repo_governance import __version__ -from repo_governance.policies import config, hooks, ports, rulesets -from repo_governance.violations import Report, render_reports - -POLICIES: dict[str, Callable[[], Report]] = { - "config": config.run, - "ports": ports.run, - "hooks": hooks.run, - "rulesets": rulesets.run, -} + +from governance import __version__ +from governance.policies import POLICIES +from governance.reporting import render_reports def _build_parser() -> argparse.ArgumentParser: diff --git a/.repo/governance/policies/__init__.py b/.repo/governance/policies/__init__.py new file mode 100644 index 0000000..4547fdc --- /dev/null +++ b/.repo/governance/policies/__init__.py @@ -0,0 +1,26 @@ +"""Policy modules and the registry of them. + +Each policy is a package with two files: `violations.py` declares the +failures it can report (including why each rule exists), and `check.py` +detects them. `run()` returns a `Report`. + +Registering a policy here is the only wiring it needs -- the CLI derives +`repo check` and the per-policy `repo check` subcommands from this +mapping. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from governance.policies import config, hooks, ports, rulesets +from governance.reporting import Report + +POLICIES: dict[str, Callable[[], Report]] = { + "config": config.run, + "ports": ports.run, + "hooks": hooks.run, + "rulesets": rulesets.run, +} + +__all__ = ["POLICIES"] diff --git a/.repo/src/repo_governance/policies/config/__init__.py b/.repo/governance/policies/config/__init__.py similarity index 65% rename from .repo/src/repo_governance/policies/config/__init__.py rename to .repo/governance/policies/config/__init__.py index 7e35b0d..bb8cbde 100644 --- a/.repo/src/repo_governance/policies/config/__init__.py +++ b/.repo/governance/policies/config/__init__.py @@ -1,5 +1,5 @@ """Policy: tool configuration lives in .config/, and every file there is live.""" -from repo_governance.policies.config.check import run +from governance.policies.config.check import run __all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/config/check.py b/.repo/governance/policies/config/check.py similarity index 95% rename from .repo/src/repo_governance/policies/config/check.py rename to .repo/governance/policies/config/check.py index 7828889..0a4e690 100644 --- a/.repo/src/repo_governance/policies/config/check.py +++ b/.repo/governance/policies/config/check.py @@ -2,9 +2,9 @@ from __future__ import annotations -from repo_governance import repo -from repo_governance.policies.config import violations as v -from repo_governance.violations import Report +from governance import repo +from governance.policies.config import violations as v +from governance.reporting import Report CONFIG_DIR = ".config" diff --git a/.repo/src/repo_governance/policies/config/violations.py b/.repo/governance/policies/config/violations.py similarity index 98% rename from .repo/src/repo_governance/policies/config/violations.py rename to .repo/governance/policies/config/violations.py index 45c8e25..85c3eda 100644 --- a/.repo/src/repo_governance/policies/config/violations.py +++ b/.repo/governance/policies/config/violations.py @@ -2,7 +2,7 @@ from __future__ import annotations -from repo_governance.violations import Violation +from governance.reporting import Violation DOCS = "CONFIGURATION.md#where-configuration-lives" diff --git a/.repo/src/repo_governance/policies/hooks/__init__.py b/.repo/governance/policies/hooks/__init__.py similarity index 55% rename from .repo/src/repo_governance/policies/hooks/__init__.py rename to .repo/governance/policies/hooks/__init__.py index 2671c0c..dd4ec37 100644 --- a/.repo/src/repo_governance/policies/hooks/__init__.py +++ b/.repo/governance/policies/hooks/__init__.py @@ -1,5 +1,5 @@ """Policy: local hooks and CI stay in step.""" -from repo_governance.policies.hooks.check import run +from governance.policies.hooks.check import run __all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/hooks/check.py b/.repo/governance/policies/hooks/check.py similarity index 95% rename from .repo/src/repo_governance/policies/hooks/check.py rename to .repo/governance/policies/hooks/check.py index a63744c..cf568d2 100644 --- a/.repo/src/repo_governance/policies/hooks/check.py +++ b/.repo/governance/policies/hooks/check.py @@ -7,9 +7,9 @@ from __future__ import annotations -from repo_governance import repo -from repo_governance.policies.hooks import violations as v -from repo_governance.violations import Report +from governance import repo +from governance.policies.hooks import violations as v +from governance.reporting import Report LEFTHOOK = ".config/lefthook.yml" WORKFLOW = ".github/workflows/validate.yaml" diff --git a/.repo/src/repo_governance/policies/hooks/violations.py b/.repo/governance/policies/hooks/violations.py similarity index 88% rename from .repo/src/repo_governance/policies/hooks/violations.py rename to .repo/governance/policies/hooks/violations.py index dc88c19..b7c950f 100644 --- a/.repo/src/repo_governance/policies/hooks/violations.py +++ b/.repo/governance/policies/hooks/violations.py @@ -2,7 +2,7 @@ from __future__ import annotations -from repo_governance.violations import Violation +from governance.reporting import Violation DOCS = "CONFIGURATION.md#where-configuration-lives" @@ -22,7 +22,7 @@ def unregistered_hook(name: str) -> Violation: fix=( f"Add a CI job that runs the same check and map it in " f"HOOK_TO_CI, or record '{name}' in LOCAL_ONLY with a reason " - "(.repo/src/repo_governance/policies/hooks/check.py)." + "(.repo/governance/policies/hooks/check.py)." ), where=".config/lefthook.yml", docs=DOCS, @@ -47,7 +47,7 @@ def unregistered_ci_job(name: str) -> Violation: reason=_WHY, fix=( f"Add a lefthook job mapped to '{name}', or record it in CI_ONLY " - "with a reason (.repo/src/repo_governance/policies/hooks/check.py)." + "with a reason (.repo/governance/policies/hooks/check.py)." ), where=".github/workflows/validate.yaml", docs=DOCS, @@ -63,6 +63,6 @@ def stale_registration(kind: str, name: str) -> Violation: "over time until it excuses something nobody chose to excuse." ), fix=f"Remove '{name}' from the {kind} table.", - where=".repo/src/repo_governance/policies/hooks/check.py", + where=".repo/governance/policies/hooks/check.py", docs=DOCS, ) diff --git a/.repo/src/repo_governance/policies/ports/__init__.py b/.repo/governance/policies/ports/__init__.py similarity index 61% rename from .repo/src/repo_governance/policies/ports/__init__.py rename to .repo/governance/policies/ports/__init__.py index 3974d7b..7e37ba1 100644 --- a/.repo/src/repo_governance/policies/ports/__init__.py +++ b/.repo/governance/policies/ports/__init__.py @@ -1,5 +1,5 @@ """Policy: the three places that state a port number agree.""" -from repo_governance.policies.ports.check import run +from governance.policies.ports.check import run __all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/ports/check.py b/.repo/governance/policies/ports/check.py similarity index 94% rename from .repo/src/repo_governance/policies/ports/check.py rename to .repo/governance/policies/ports/check.py index 400ebe2..15e9f6a 100644 --- a/.repo/src/repo_governance/policies/ports/check.py +++ b/.repo/governance/policies/ports/check.py @@ -4,9 +4,9 @@ import re -from repo_governance import repo -from repo_governance.policies.ports import violations as v -from repo_governance.violations import Report +from governance import repo +from governance.policies.ports import violations as v +from governance.reporting import Report RANGE_LOW, RANGE_HIGH = 15432, 15460 diff --git a/.repo/src/repo_governance/policies/ports/violations.py b/.repo/governance/policies/ports/violations.py similarity index 98% rename from .repo/src/repo_governance/policies/ports/violations.py rename to .repo/governance/policies/ports/violations.py index 1a4e2fb..387ab0d 100644 --- a/.repo/src/repo_governance/policies/ports/violations.py +++ b/.repo/governance/policies/ports/violations.py @@ -2,7 +2,7 @@ from __future__ import annotations -from repo_governance.violations import Violation +from governance.reporting import Violation DOCS = "CONFIGURATION.md#port-allocation" diff --git a/.repo/src/repo_governance/policies/rulesets/__init__.py b/.repo/governance/policies/rulesets/__init__.py similarity index 64% rename from .repo/src/repo_governance/policies/rulesets/__init__.py rename to .repo/governance/policies/rulesets/__init__.py index a434baa..9a77a06 100644 --- a/.repo/src/repo_governance/policies/rulesets/__init__.py +++ b/.repo/governance/policies/rulesets/__init__.py @@ -1,5 +1,5 @@ """Policy: committed rulesets stay consistent with the CI that satisfies them.""" -from repo_governance.policies.rulesets.check import run +from governance.policies.rulesets.check import run __all__ = ["run"] diff --git a/.repo/src/repo_governance/policies/rulesets/check.py b/.repo/governance/policies/rulesets/check.py similarity index 94% rename from .repo/src/repo_governance/policies/rulesets/check.py rename to .repo/governance/policies/rulesets/check.py index dc510b4..b565727 100644 --- a/.repo/src/repo_governance/policies/rulesets/check.py +++ b/.repo/governance/policies/rulesets/check.py @@ -10,9 +10,9 @@ import json -from repo_governance import repo -from repo_governance.policies.rulesets import violations as v -from repo_governance.violations import Report +from governance import repo +from governance.policies.rulesets import violations as v +from governance.reporting import Report RULESET_DIR = ".github/rulesets" WORKFLOW = ".github/workflows/validate.yaml" diff --git a/.repo/src/repo_governance/policies/rulesets/violations.py b/.repo/governance/policies/rulesets/violations.py similarity index 95% rename from .repo/src/repo_governance/policies/rulesets/violations.py rename to .repo/governance/policies/rulesets/violations.py index a29633d..e04cbdf 100644 --- a/.repo/src/repo_governance/policies/rulesets/violations.py +++ b/.repo/governance/policies/rulesets/violations.py @@ -2,7 +2,7 @@ from __future__ import annotations -from repo_governance.violations import Violation +from governance.reporting import Violation DOCS = ".github/rulesets/RULESETS.md" @@ -67,7 +67,7 @@ def unguarded_ci_job(name: str, job: str) -> Violation: fix=( f"Add a required_status_checks entry for '{job}' to {name}, or " "record it in ADVISORY_JOBS with a reason " - "(.repo/src/repo_governance/policies/rulesets/check.py)." + "(.repo/governance/policies/rulesets/check.py)." ), where=f".github/rulesets/{name}", docs=DOCS, diff --git a/.repo/src/repo_governance/repo.py b/.repo/governance/repo.py similarity index 100% rename from .repo/src/repo_governance/repo.py rename to .repo/governance/repo.py diff --git a/.repo/src/repo_governance/violations.py b/.repo/governance/reporting.py similarity index 100% rename from .repo/src/repo_governance/violations.py rename to .repo/governance/reporting.py diff --git a/.repo/pyproject.toml b/.repo/pyproject.toml index 86b65b9..3489bf3 100644 --- a/.repo/pyproject.toml +++ b/.repo/pyproject.toml @@ -6,11 +6,11 @@ requires-python = ">=3.11" dependencies = ["pyyaml>=6.0"] [project.scripts] -repo = "repo_governance.cli:main" +repo = "governance.cli:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/repo_governance"] +packages = ["governance"] diff --git a/.repo/src/repo_governance/policies/__init__.py b/.repo/src/repo_governance/policies/__init__.py deleted file mode 100644 index 5ed49ff..0000000 --- a/.repo/src/repo_governance/policies/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Policy modules. - -Each policy is a package with two files: `violations.py` declares the -failures it can report (including why each rule exists), and `check.py` -detects them. `run()` returns a `Report`. -""" diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0ecf770..d6dc093 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -88,7 +88,7 @@ See [`.config/README.md`](.config/README.md) for the per-file index, and | | GitHub Actions lint rules | `.config/actionlint.yaml` | | | Spelling dictionary / ignores | `.config/codespell.cfg` | | | Task automation for the template | `Taskfile.yml` + `taskfiles/.Taskfile.yml` | -| | Repo structure policies | `.repo/src/repo_governance/policies/` | +| | Repo structure policies | `.repo/governance/policies/` | | **Editor** | VS Code settings (formatters, rulers, whitespace) | `devcontainer.json` → `customizations.vscode.settings` | | | VS Code extensions | `devcontainer.json` → `customizations.vscode.extensions` | | | Debug launch configs | `.vscode/launch.json` (in consuming project) | @@ -441,10 +441,11 @@ settings across container rebuilds. .repo/ Repo governance toolchain (the `repo` CLI) README.md What each policy enforces, and why pyproject.toml uv project; declares the `repo` console-script - src/repo_governance/ + governance/ cli.py `repo check` and the per-policy subcommands + reporting.py The Violation record (code, reason, fix) repo.py Repo-root discovery, YAML/JSONC readers - violations.py The Violation record (code, reason, fix) + policies/__init__.py The policy registry -- the only wiring a policy needs policies/config/ .config/ layout, index, and no shadowing root config policies/ports/ Port table ↔ forwardPorts ↔ compose parity policies/hooks/ lefthook ↔ CI job parity From 996198880e4ceeb6c1c5ac1b5020fc460387cb3d Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 16:56:29 +0700 Subject: [PATCH 7/7] fix: pin astral-sh/setup-uv to v9.0.0 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) --- .github/workflows/validate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index d06ac5e..dff9ee6 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -93,7 +93,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v9 + uses: astral-sh/setup-uv@v9.0.0 - name: Install the repo governance CLI run: uv tool install ./.repo - name: Check repo structure policies