Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .config/README.md
Original file line number Diff line number Diff line change
@@ -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/<tool>.<ext>`. Create a `.config/<tool>/` 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 <name>` |
| `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.
16 changes: 16 additions & 0 deletions .config/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions .config/codespell.cfg
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions .config/lefthook.yml
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions .config/markdownlint.jsonc
Original file line number Diff line number Diff line change
@@ -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 }
}
34 changes: 34 additions & 0 deletions .config/yamllint.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions .devcontainer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 9 additions & 7 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
15 changes: 15 additions & 0 deletions .devcontainer/mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 8 additions & 0 deletions .devcontainer/scripts/initialize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
31 changes: 30 additions & 1 deletion .devcontainer/scripts/lib/base-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 ---
Expand All @@ -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"
}
3 changes: 2 additions & 1 deletion .devcontainer/stacks/observability/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
"
Expand Down
Loading