Skip to content

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

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

Checks

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

The same checks, one at a time:

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.

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.

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
download Fetches and verifies the Flatcar image, syslinux and the sysexts Quickstart
config Renders the Ignition configs, PXE menus and credentials from the inventory Quickstart, Ansible
artifacts download then config Quickstart
serve Runs the DHCP-proxy, TFTP and HTTP boot server 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
reinstall-cancel Disarms them again Switching back to local boot
kubeconfig Copies the admin kubeconfig to output/kubeconfig Quickstart
check-context Guard the cluster targets depend on: fails without a kubeconfig
bootstrap install-cilium, install-argo, bootstrap-apps in one go Quickstart
install-cilium Gateway API CRDs and Cilium, at the pinned versions Quickstart
install-argo The argo-cd chart, at the pinned version Quickstart
bootstrap-apps The AppProjects and the argocd Application; ArgoCD owns everything after Quickstart
storage-check Walks the Rook chain and binds a test PVC Is storage ready?
bao-init Initialises, unseals and configures OpenBao once OpenBao
bao-unseal Unseals every sealed replica from the key file (KEYFILE=<file> picks another) OpenBao
bao-policies Rewrites ESO's policies and Kubernetes auth roles OpenBao
bao-secrets Prompts for the typed secrets and writes them OpenBao
bao-rotate-root Replaces a kept root token with the operator login OpenBao
claude-session-pat Stores (SESSION=<name>) or removes (DELETE=1) one agent session's GitHub token Claude agents
untaint, taint Removes and restores the control-plane taint Single-node clusters
fonts, fonts-check Downloads the pinned webfonts, or diffs them against what is committed Fonts
clean-artifacts, clean Deletes the generated files, or everything under output/ after asking Repository layout

Branches and commits

Branch names are prefixed by area: docs/, feat/, fix/, chore/. Commit messages follow Conventional Commits:

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.

Do not hand-edit version numbers

targetRevision in the manifests and the versions in ansible/inventory.yaml are owned by Renovate — see Maintenance. 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.

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.

.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 from docs/, configured in zensical.toml, and published to GitHub Pages by .github/workflows/docs.yaml at 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); 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:

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, JetBrains Mono) and Renovate tracks the pins.

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.