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..2a99f81 --- /dev/null +++ b/.config/lefthook.yml @@ -0,0 +1,89 @@ +# 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'. + + # ── 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: 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} + 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/.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/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/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/.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/.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/.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/.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 diff --git a/.github/rulesets/RULESETS.md b/.github/rulesets/RULESETS.md new file mode 100644 index 0000000..cfaba71 --- /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/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/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 1bbec31..dff9ee6 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -55,13 +55,50 @@ 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 + + governance: + name: Repo Structure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + - 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/.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/.repo/README.md b/.repo/README.md new file mode 100644 index 0000000..cdc83ef --- /dev/null +++ b/.repo/README.md @@ -0,0 +1,102 @@ +# `.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 | +| `rulesets` | `RS-01`..`RS-04` | Committed branch rulesets stay valid and in step with the CI jobs they require | + +### 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`](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 + governance/ + cli.py Argument parsing and exit codes + reporting.py The Violation record and its rendering + repo.py Repo-root discovery, YAML/JSONC readers + 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 + +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 `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 + +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/governance/__init__.py b/.repo/governance/__init__.py new file mode 100644 index 0000000..37ce049 --- /dev/null +++ b/.repo/governance/__init__.py @@ -0,0 +1,10 @@ +"""Structural policy checks for this repository. + +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`. +""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/.repo/governance/cli.py b/.repo/governance/cli.py new file mode 100644 index 0000000..0ad4676 --- /dev/null +++ b/.repo/governance/cli.py @@ -0,0 +1,46 @@ +"""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 governance import __version__ +from governance.policies import POLICIES +from governance.reporting import render_reports + + +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/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/governance/policies/config/__init__.py b/.repo/governance/policies/config/__init__.py new file mode 100644 index 0000000..bb8cbde --- /dev/null +++ b/.repo/governance/policies/config/__init__.py @@ -0,0 +1,5 @@ +"""Policy: tool configuration lives in .config/, and every file there is live.""" + +from governance.policies.config.check import run + +__all__ = ["run"] diff --git a/.repo/governance/policies/config/check.py b/.repo/governance/policies/config/check.py new file mode 100644 index 0000000..0a4e690 --- /dev/null +++ b/.repo/governance/policies/config/check.py @@ -0,0 +1,102 @@ +"""Detect .config/ layout drift.""" + +from __future__ import annotations + +from governance import repo +from governance.policies.config import violations as v +from governance.reporting 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/governance/policies/config/violations.py b/.repo/governance/policies/config/violations.py new file mode 100644 index 0000000..85c3eda --- /dev/null +++ b/.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 governance.reporting 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/governance/policies/hooks/__init__.py b/.repo/governance/policies/hooks/__init__.py new file mode 100644 index 0000000..dd4ec37 --- /dev/null +++ b/.repo/governance/policies/hooks/__init__.py @@ -0,0 +1,5 @@ +"""Policy: local hooks and CI stay in step.""" + +from governance.policies.hooks.check import run + +__all__ = ["run"] diff --git a/.repo/governance/policies/hooks/check.py b/.repo/governance/policies/hooks/check.py new file mode 100644 index 0000000..cf568d2 --- /dev/null +++ b/.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 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" + +#: 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/governance/policies/hooks/violations.py b/.repo/governance/policies/hooks/violations.py new file mode 100644 index 0000000..b7c950f --- /dev/null +++ b/.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 governance.reporting 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/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/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/governance/policies/hooks/check.py", + docs=DOCS, + ) diff --git a/.repo/governance/policies/ports/__init__.py b/.repo/governance/policies/ports/__init__.py new file mode 100644 index 0000000..7e37ba1 --- /dev/null +++ b/.repo/governance/policies/ports/__init__.py @@ -0,0 +1,5 @@ +"""Policy: the three places that state a port number agree.""" + +from governance.policies.ports.check import run + +__all__ = ["run"] diff --git a/.repo/governance/policies/ports/check.py b/.repo/governance/policies/ports/check.py new file mode 100644 index 0000000..15e9f6a --- /dev/null +++ b/.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 governance import repo +from governance.policies.ports import violations as v +from governance.reporting 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/governance/policies/ports/violations.py b/.repo/governance/policies/ports/violations.py new file mode 100644 index 0000000..387ab0d --- /dev/null +++ b/.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 governance.reporting 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/governance/policies/rulesets/__init__.py b/.repo/governance/policies/rulesets/__init__.py new file mode 100644 index 0000000..9a77a06 --- /dev/null +++ b/.repo/governance/policies/rulesets/__init__.py @@ -0,0 +1,5 @@ +"""Policy: committed rulesets stay consistent with the CI that satisfies them.""" + +from governance.policies.rulesets.check import run + +__all__ = ["run"] diff --git a/.repo/governance/policies/rulesets/check.py b/.repo/governance/policies/rulesets/check.py new file mode 100644 index 0000000..b565727 --- /dev/null +++ b/.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 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" + +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/governance/policies/rulesets/violations.py b/.repo/governance/policies/rulesets/violations.py new file mode 100644 index 0000000..e04cbdf --- /dev/null +++ b/.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 governance.reporting 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/governance/policies/rulesets/check.py)." + ), + where=f".github/rulesets/{name}", + docs=DOCS, + ) diff --git a/.repo/governance/repo.py b/.repo/governance/repo.py new file mode 100644 index 0000000..8c9dca6 --- /dev/null +++ b/.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/governance/reporting.py b/.repo/governance/reporting.py new file mode 100644 index 0000000..c4c958a --- /dev/null +++ b/.repo/governance/reporting.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/.repo/pyproject.toml b/.repo/pyproject.toml new file mode 100644 index 0000000..3489bf3 --- /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 = "governance.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["governance"] diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 212cb4c..d6dc093 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/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,44 @@ 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 What each policy enforces, and why + pyproject.toml uv project; declares the `repo` console-script + 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 + 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 + policies/rulesets/ Branch rulesets ↔ CI job-name parity +.github/ + dependabot.yml Weekly updates: devcontainers, actions, docker + 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) +.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..cb673c6 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,43 +19,64 @@ 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. | | `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. ## 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 + (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 ### 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 +86,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 +96,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..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 @@ -76,8 +87,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 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