# Maintenance

Two things keep the repository from rotting: GitHub Actions workflows that lint everything, and Renovate, which chases dependencies.

## CI workflows

All in `.github/workflows/`.

| Workflow | Trigger | What it checks |
| --- | --- | --- |
| `lint-ansible.yaml` | `ansible/**` | `ansible-lint` |
| `lint-python.yaml` | `boot_server/*.py`, `scripts/*.py`, `tests/*.py`, `pyproject.toml`, `uv.lock` | `ruff check`, `ruff format --check` and `pytest` on the boot server and the scripts |
| `lint-yaml.yaml` | `**/*.yaml` | `yamllint` |
| `lint-markdown.yaml` | `**/*.md` | `markdownlint-cli2` |
| `docs.yaml` | push to `main` under `docs/**`, `overrides/**`, `zensical.toml`, `pyproject.toml`, `uv.lock` | Builds the site with `--strict` and publishes it — see [Contributing](<https://homelab.wlkr.ch/development/contributing/#documentation>). The build runs with a read-only token; only the deploy job, which runs no dependency code, can push |
| `preview.yaml` | pull requests on the same paths | Publishes a preview under `pr-preview/` from the build job's artifact, same split as `docs.yaml`; fork PRs skipped |
| `argo-diff-preview.yaml` | pull requests under `payload/**` | Comments the rendered ArgoCD manifest diff against `main`; fork PRs skipped |
| `image-scan.yaml` | pull requests under `payload/**` | `scripts/image-scan-diff.py`: renders every Application on `main` and on the PR, scans with Trivy only the images that changed, and fails on a fixable CRITICAL the replaced image did not carry. A finding already [waiting on a release](<https://homelab.wlkr.ch/operations/vulnerabilities/#filing-policy>) does not block the bump that gets closer to it |
| `renovate-validate.yaml` | `renovate.json` | `renovate-config-validator` |
| `secret-scan.yaml` | every pull request | `gitleaks` over the PR's commits, the same version as the pre-commit hook; a manual run scans the whole history |
| `ci-ok.yaml` | every pull request | Waits for every other check on the head commit and fails if one failed. The only check the ruleset on `main` requires, so the others keep their path filters |
| `fonts-check.yaml` | `scripts/update-fonts.sh`, `docs/assets/fonts/**` | `make fonts-check`: the committed `woff2` files match the pinned releases — see [Fonts](<https://homelab.wlkr.ch/development/contributing/#fonts>) |

## Renovate

Configured in `renovate.json`, on top of the shared preset [JanWelker/renovate-config](<https://github.com/JanWelker/renovate-config>), which holds the policy for every repository. Every component gets its own pull request so a failing update never holds up an unrelated one; the only groups are the sets below that must move together.

### Automerge policy

- **The preset's rules:** three days between a release and its PR, patch and minor automerge once `ci-ok` is green, majors open at 19:00 and wait for a human, and security fixes skip both the wait and the schedule. The preset's README says why for each.
- **One exception: a Kubernetes minor waits for a human.** A Flatcar minor lands the same way a Grafana chart patch does, but `kubernetes_version` is walked forward by hand in version-skew order, and a node provisioned from an automerged minor would run a kubelet ahead of the API server and fail its `kubeadm join` — see [Upgrades](<https://homelab.wlkr.ch/operations/upgrades/#upgrading-a-minor-version-deliberately>).
- **Pairs that must move together are grouped into one PR:** `prometheus-operator-crds` with `kube-prometheus-stack`, because the CRDs must not lag the operator; `rook-ceph` with `rook-ceph-cluster`, because Rook requires both charts on the same version; and `markdownlint-cli2` in the workflow with its pre-commit hook, because the two must lint alike; `gitleaks` likewise; the `external-snapshotter` tag with the `snapshot-controller` image it pins, because upstream's manifest at a tag names the previous release's image (the image is a `registry.k8s.io` one, so the pair waits for a human like every third-party image there); the two ARC charts, because the controller deletes a scale set whose chart version differs from its own; and every Python pin (`requires-python`, `.python-version`, the `python` images), because `uv sync` fails when they name different minors.
- **Python ranges bump.** `pyproject.toml` declares `>=` ranges, which a new release already satisfies; `rangeStrategy: bump` is what makes Renovate open a PR for the linters and the docs generator at all.
- **Flatcar and Kubernetes bumps change what a newly provisioned node installs, not what a running node runs.** kubeadm cannot skip a minor, so a cluster that sits while two Kubernetes minors merge has to be walked forward one at a time — see [Upgrades](<https://homelab.wlkr.ch/operations/upgrades/index.md>).
- **A font bump cannot automerge without its second commit:** `fonts-check.yaml` fails on it and `ci-ok` waits for it — see [Fonts](<https://homelab.wlkr.ch/development/contributing/#fonts>).
- `config:best-practices` pins GitHub Actions to commit SHAs and container images to digests, and collects every pin into one shared `renovate/pin-dependencies` branch.

### Scope

Python (`pyproject.toml`, `uv.lock`), Docker images, GitHub Actions, Kubernetes and ArgoCD manifests, Helm values files (`payload/**/values.yaml`) and pre-commit hooks, plus custom regex managers for the versions in `ansible/inventory.yaml`, the font pins in `scripts/update-fonts.sh`, any `# renovate:` annotation and every CloudNativePG `imageName` under `payload/`, and the tool versions the workflows under `.github/workflows/` install by name rather than by action (the diff-preview image, `markdownlint-cli2`, `renovate` and Trivy). The `Makefile` is deliberately not tracked: the bootstrap targets read `targetRevision` out of the owning `application.yaml` — see [Version pins](<https://homelab.wlkr.ch/architecture/gitops/#version-pins>).

### Manager rules

A misconfigured manager fails silently: the run succeeds and nothing is checked. Prove a manager works by reading what it extracts:

```bash
LOG_LEVEL=debug npx --yes renovate@latest --platform=local --dry-run=lookup
```

It needs no credentials for a public repository and prints every dependency it found and why it skipped any. The rules, each of which is written into `renovate.json`:

- **`pre-commit` must be enabled explicitly** (`"pre-commit": {"enabled": true}`); Renovate ships that manager disabled, and a `packageRules` entry for it does nothing on its own.
- **The `helm-values` manager reads only real values files.** An image tag inline in an Application's `helm.valuesObject:` is invisible to it; put a `# renovate: datasource=... depName=...` comment on the line above so the annotation manager picks it up, or move the values into a `values.yaml`.
- **A custom manager that pins a full image reference needs `autoReplaceStringTemplate`.** Without it the pin cannot be written, and one unwritable pin fails the whole shared pin branch (`Error updating branch:     update failure` on the Dependency Dashboard, the only symptom, since version bumps keep working). Capturing `currentDigest` only lets Renovate update a digest that is already there.
- **A bare Helm `tag:` value goes in the `pinDigests: false` rule.** A tag like `3.4.4-alpine` has no position for a digest; leave one out and the same banner comes back.
- **A two-part version needs a `versioningTemplate`.** The default `semver-coerced` rejects `6.03`, so the dependency is extracted and then dropped before lookup. `syslinux` and the Inter font pin carry a `regex:` versioning for this reason; Inter's also keeps `v4.0-beta9h`-style prereleases out.
- **Match the registry's spelling, not the GitHub org's.** The diff-preview image is `dagandersen/argocd-diff-preview` on Docker Hub; a regex on `dag-andersen` matches nothing and reports nothing.
- **Hold a major with a regex, not a range.** `"allowedVersions": "/^17\\./"` is matched before version parsing; a range like `<18` is graded by npm semver, where a three-part tag such as `17.11.1-trixie` parses as a prerelease and is dropped from every range that does not name one. Python is held at 3.14 this way until ansible-core supports 3.15.
- **Check that the currently pinned version passes its own rule.** A filter that excludes what is deployed excludes everything, silently.

## Language statistics

GitHub's language bar comes from [Linguist](<https://github.com/github-linguist/linguist>), which counts bytes of files it classifies as a *language*. YAML is classified as data and Markdown as prose, so both are excluded by default and the bar reported this repository as Shell and Jinja. `.gitattributes` marks YAML detectable, and marks the three vendored Grafana dashboards as vendored, because their upstream JSON blobs are more than half the repository's YAML bytes and would otherwise be most of the bar. A vendored dashboard added later needs its own line there; GitHub recalculates on the next push to `main`.
