# Contributing

A merge to `main` is a deployment: ArgoCD applies `payload/` to real hardware, and there is no staging cluster in between. The checks below exist to make that routine.

## Setup

```bash
uv sync                     # virtualenv and dependencies, including dev tools
uv run pre-commit install   # the same linters CI runs
```

## Checks

```bash
uv run pre-commit run --all-files       # everything below, plus gitleaks
```

The same checks, one at a time:

```bash
uv run yamllint -f github .
uv run zensical build --clean --strict        # docs site
markdownlint-cli2 '**/*.md' '!**/.venv' '!.agent'
uv run ruff check . && uv run ruff format --check .   # python only
uv run pytest -q                              # python only
cd ansible && uv run ansible-lint             # ansible only
```

Before touching `payload/`, render what Helm will actually apply; the `kubectl` line needs a kubeconfig, since even a client-side dry run asks the API server for its resource types.

```bash
helm template <name> <repo>/<chart> --version <targetRevision> -f <values>
kubectl apply --dry-run=client -f payload/platform/<component>/
```

`--strict` is what CI uses. A page not registered in `nav` in `zensical.toml` fails the build, deliberately. On a pull request, the `argo-diff-preview` workflow comments the rendered ArgoCD manifest diff between `main` and your branch; read it, since a three-line values change can render as two hundred lines of different objects. The full list of workflows is in [Maintenance](<https://homelab.wlkr.ch/development/maintenance/#ci-workflows>).

## Make targets

Every target in the `Makefile`, and the page that explains it. The ones that touch the cluster read `output/kubeconfig` (`KUBECONFIG=<file>` picks another).

| Target | Does | Page |
| --- | --- | --- |
| `setup` | Creates the virtualenv with every tool | [Setup](<#setup>) |
| `download` | Fetches and verifies the Flatcar image, syslinux and the sysexts | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>) |
| `config` | Renders the Ignition configs, PXE menus and credentials from the inventory | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>), [Ansible](<https://homelab.wlkr.ch/architecture/ansible/#playbooks>) |
| `artifacts` | `download` then `config` | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>) |
| `serve` | Runs the DHCP-proxy, TFTP and HTTP boot server | [Boot server](<https://homelab.wlkr.ch/architecture/boot-process/#boot-server>) |
| `reinstall` | Arms a node (`LIMIT=<node>`) or, after a typed confirmation, every node to wipe and install on its next network boot | [Node Lifecycle](<https://homelab.wlkr.ch/operations/nodes/#rebuilding-or-repartitioning-a-node>) |
| `reinstall-cancel` | Disarms them again | [Switching back to local boot](<https://homelab.wlkr.ch/architecture/boot-process/#switching-back-to-local-boot>) |
| `kubeconfig` | Copies the admin kubeconfig to `output/kubeconfig` | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>) |
| `check-context` | Guard the cluster targets depend on: fails without a kubeconfig |  |
| `bootstrap` | `install-cilium`, `install-argo`, `bootstrap-apps` in one go | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>) |
| `install-cilium` | Gateway API CRDs and Cilium, at the pinned versions | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>) |
| `install-argo` | The argo-cd chart, at the pinned version | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>) |
| `bootstrap-apps` | The AppProjects and the `argocd` Application; ArgoCD owns everything after | [Quickstart](<https://homelab.wlkr.ch/quickstart/#setup>) |
| `storage-check` | Walks the Rook chain and binds a test PVC | [Is storage ready?](<https://homelab.wlkr.ch/platform/rook-ceph/#is-storage-ready>) |
| `bao-init` | Initialises, unseals and configures OpenBao once | [OpenBao](<https://homelab.wlkr.ch/platform/openbao/#bootstrap>) |
| `bao-unseal` | Unseals every sealed replica from the key file (`KEYFILE=<file>` picks another) | [OpenBao](<https://homelab.wlkr.ch/platform/openbao/#unsealing-after-a-restart>) |
| `bao-policies` | Rewrites ESO's policies and Kubernetes auth roles | [OpenBao](<https://homelab.wlkr.ch/platform/openbao/#kubernetes-auth>) |
| `bao-secrets` | Prompts for the typed secrets and writes them | [OpenBao](<https://homelab.wlkr.ch/platform/openbao/#bootstrap>) |
| `bao-rotate-root` | Replaces a kept root token with the operator login | [OpenBao](<https://homelab.wlkr.ch/platform/openbao/#operator-credential>) |
| `claude-session-pat` | Stores (`SESSION=<name>`) or removes (`DELETE=1`) one agent session's GitHub token | [Claude agents](<https://homelab-apps.wlkr.ch/claude-agents/>) |
| `untaint`, `taint` | Removes and restores the control-plane taint | [Single-node clusters](<https://homelab.wlkr.ch/quickstart/#single-node-clusters>) |
| `fonts`, `fonts-check` | Downloads the pinned webfonts, or diffs them against what is committed | [Fonts](<#fonts>) |
| `clean-artifacts`, `clean` | Deletes the generated files, or everything under `output/` after asking | [Repository layout](<https://homelab.wlkr.ch/architecture/#repository-layout>) |

## Branches and commits

Branch names are prefixed by area: `docs/`, `feat/`, `fix/`, `chore/`. Commit messages follow [Conventional Commits](<https://www.conventionalcommits.org/>):

```text
docs: explain the stack choices and collect the known limitations
fix(bootstrap): repoint the install targets at the files that exist
chore(deps): update helm release kube-prometheus-stack to v89
```

The body explains *why*; the diff already says what.

> [!WARNING]
>
> **Do not hand-edit version numbers**
>
> `targetRevision` in the manifests and the versions in `ansible/inventory.yaml` are owned by Renovate — see [Maintenance](<https://homelab.wlkr.ch/development/maintenance/#renovate>). A hand bump conflicts with, or is reverted by, the next Renovate PR. The bootstrap targets in the `Makefile` read the same `targetRevision` values rather than pinning their own — see [Version pins](<https://homelab.wlkr.ch/architecture/gitops/#version-pins>).

## Review

`.github/CODEOWNERS` assigns every path to the repository owner, which requests that review; it is not required, because an author cannot approve their own pull request. What `main` requires is the `ci-ok` check; which Renovate PRs merge on it alone is in [Automerge policy](<https://homelab.wlkr.ch/development/maintenance/#automerge-policy>).

`.agent/rules/general-rules.md` holds the standing rules for AI coding agents working here: GitOps only, docs in `docs/`, no hand-edited versions. Keep it in sync when those conventions change.

## Documentation

The site is built with [Zensical](<https://zensical.org/>) from `docs/`, configured in `zensical.toml`, and published to GitHub Pages by `.github/workflows/docs.yaml` at [https://homelab.wlkr.ch/](<https://homelab.wlkr.ch/>). It is deliberately not hosted on the cluster, so it stays readable when the cluster is not. The build also writes `llms.txt`, `llms-full.txt` and a `.md` twin of every page for LLM clients ([llmstxt.org](<https://llmstxt.org/>)); the sections of `llms.txt` are listed in `zensical.toml` under `plugins.llmstxt`, so a new section in `nav` needs an entry there too.

| Stage | What happens |
| --- | --- |
| Build | On push to `main` touching `docs/**`, `overrides/**`, `zensical.toml`, `pyproject.toml` or `uv.lock`, `docs.yaml` runs `zensical build --clean --strict` |
| Deploy | The workflow drops a `.nojekyll` marker into `site/` (otherwise Pages runs Jekyll, which drops paths it considers private) and pushes to `gh-pages`, leaving `pr-preview/` untouched |
| Previews | `preview.yaml` builds every pull request matching the same paths, forks included, and publishes it under `pr-preview/` on `gh-pages`; closing the PR removes it. Its path filter must match `docs.yaml`, because this is the only `--strict` build a PR gets. Only the publish step is skipped for fork PRs: they have no write access |

Locally:

```bash
uv run zensical serve   # http://localhost:8000, rebuilds on save
uv run zensical build   # one-off build into site/ (gitignored)
```

### Conventions

- Every page is registered in `nav`; cross-references are relative Markdown paths (`platform/openbao.md`, `../quickstart.md`) so they resolve on the site and on GitHub.
- Diagrams use Mermaid fences; Zensical loads the runtime on pages that contain one.
- Markdown is linted by `lint-markdown.yaml` and the `markdownlint-cli2` pre-commit hook. `MD046`: a blank line followed by an indented line reads as a code block, so keep `!!!` admonition bodies to a single paragraph. `MD007`: nested lists indent by four spaces.
- Inline HTML is limited to `<div>` (`MD033` in `.markdownlint-cli2.yaml`), which the card grids on hub pages need.
- Theme templates are overridden by a same-named file under `overrides/` (`theme.custom_dir`). Currently only `partials/source.html`, which drops the repository-facts call that 404s because this repository publishes no releases.

### Fonts

Fonts are self-hosted so the site makes no third-party requests: `theme.font = false` in `zensical.toml`, `docs/stylesheets/fonts.css` declares Inter and JetBrains Mono from the `woff2` files in `docs/assets/fonts/`, and each SIL OFL licence sits next to them. `scripts/update-fonts.sh` pins both upstream releases ([Inter](<https://github.com/rsms/inter/releases>), [JetBrains Mono](<https://github.com/JetBrains/JetBrainsMono/releases>)) and Renovate tracks the pins.

> [!WARNING]
>
> **A font-pin PR needs a second commit**
>
> Renovate can move the pin but cannot write the binaries, so its PR changes one line and the `woff2` files are still the old release. Check out the branch, run `make fonts` to download what the pin now names, and commit the result. `make fonts-check` (run by `fonts-check.yaml` on every such PR) diffs the downloads against what is committed and fails the branch until that commit arrives, which is what holds the automerge.
