The workflow for contributing bug fixes, docs changes, and features.
- Open an issue before writing code for anything beyond a small fix. Discuss the approach there first.
- Check the open issues for existing work on the same thing.
- This project follows a Code of Conduct; by participating you agree to uphold it.
The dashboard uses uv for dependency management; a hashed
uv.lock pins every transitive dependency for reproducible installs. Its Python tooling
(ruff lint + format, pre-commit)
lives in the dev extra. Install uv, then from the repo root:
uv sync --project dashboard --extra dev # deps + tooling into dashboard/.venv, from the lock
uv run --project dashboard pre-commit installmake test and make lint-py run through uv automatically (no venv to activate); pre-commit
runs ruff (plus a few hygiene hooks) on your changed files. If you change dependencies in
dashboard/pyproject.toml, run uv lock and commit the updated uv.lock.
-
Fork the repo and create a branch off
develop(the integration branch;mainholds released commits only — it fast-forwards to each release's tagged commit, see Releasing › Branch mechanics). -
Make your change. Keep it focused: one logical change per PR.
-
Run the full test suite locally:
make testThis runs everything CI does that doesn't need a live test server:
- lint — every file surface gets a linter/formatter check (
make lintruns them all; run one withmake lint-<surface>):lint-sh(shellcheck + shfmt),lint-py(ruff),lint-js(Biome),lint-yaml(yamllint),lint-md(markdownlint),lint-docs-voice(banned-word check),lint-operator-strings(no issue/PR numbers in operator-facingpithead/dashboard text, and no baredocs/paths inpitheadoperator text — release bundles ship nodocs/, so point at$DOCS_URL/docs/<file>.md#anchorinstead; comments keep the plain path),lint-topology(no real-looking IPv6/IPv4 literal,/home/<name>path,.lan/.internal/.localhostname, oruser@hoststring — a public repo, so every one of those has to stay a generic class, not a trace of whoever's actual box;tests/anddocs/are an accepted exemption boundary for illustrative/fixture content, and each class also carries a small, explicit value-level allowlist — see the script's own header — never a per-file exemption comment),lint-file-budget(the file-budget ratchet, issue #1105 Phase 0 — see File budget gate),lint-pithead-parity(the shippedpitheadmust be exactly whatlib/pithead/*.shconcatenate to: edit a slice, runscripts/build-pithead.sh, and commit both — issue #1105 Phase 2),lint-trivy-parity(the CVE gate's two trivy-action steps andscripts/trivyignore-watch.shmust name one trivy engine version — issue #1290),lint-proto(buf),lint-toml(taplo). The non-Python tools run vianpx/uvx/docker, so a contributor needs Node, uv, and Docker on PATH (plusshfmt);pre-commitruns the same checks on changed files. Link-checking (lychee) runs on a weekly schedule, not per-PR. - test-dashboard — the dashboard
pytestsuite (must stay ≥ the 80% total coverage gate). CI also runsmake test-patch-coverage(diff-cover): new/changed lines must be ≥ 90% covered against the PR's own base branch, the ratchet that stops coverage rotting at the margin. The gate says so explicitly when a diff has nothing it measures (shell/docs-only PRs pass loudly), and fails if a changed dashboard Python file is missing fromcoverage.xmlentirely — the silent no-op it used to be. Run it right aftermake test-dashboard, socoverage.xmlis fresh. - test-frontend — the frontend logic tests (
node --test); uses the same Node that the lint surfaces already require. - test-stack — the
pitheadshell test suite. - test-compose —
docker-compose.ymlinterpolation validation. - test-integration-selftest — the integration harness's own pure logic.
- test-fakes — the tier-2 contract test (real dashboard clients vs controllable fakes).
Bigger, infra-dependent suites run separately:
make test-mini-stack(tier-3 docker) andmake test-integration(tier-4 live, against a real box; start with--check). - lint — every file surface gets a linter/formatter check (
-
Add or update tests for your change. Cover the intent (a behavior/contract), not just the line. The Testing Guide has per-change recipes; the Testing Strategy explains the tiers.
-
Update the docs in
docs/(and the README, if relevant) for any user-facing change. To see what the suites cover,make test-inventorywrites a generated (git-ignored) inventory you can read locally.
The file-budget ratchet (issue #1105 Phase 0). A new tracked file has a hard ceiling of 800 lines, target
400; an existing offender's current line count is its personal ceiling in docs/dev/file-budget.tsv, and a
PR may not grow it past that — ceilings only ever move down, and the gate rejects a budget edit that raises
one.
A deliberate, justified addition to a budgeted file therefore has exactly one legal path: split or shrink
the file so the addition fits under a ceiling that stays put or drops — see issue #1258 for the worked
example, where a security test that outgrew its file moved into its own — and note that two same-wave merges
can each pass alone and fail together, so re-run make lint after merging onto the tip.
The ratchet half is fatal in CI when it cannot run at all: a job that resolves none of the base-ref
candidates has lost its fetch-depth: 0, which is a misconfigured job rather than a clean tree, so the gate
fails there instead of printing a note into a log nobody reads (issue #1739). A local checkout without those
refs still gets the note and still passes.
Generated code, vendored files, data/config, and prose docs are exempt by glob — see is_exempt() in the
script — and so is the shipped pithead artifact itself: it is generated, and the gate governs its
lib/pithead/*.sh sources instead, now that Phase 2 has begun splitting it.
One row was exempt from the ceilings-only-move-down half, and only that half: lib/pithead/99-remainder.sh
measured how much of that generated artifact Phase 2 had not split out yet, not the size of a file anyone
writes. Without that, pithead's own exemption became a freeze — the CLI could not gain a line, because the
row refused to rise and the file refused to grow past it (issue #1464). That row has retired, and not by
reaching the 400 target: Phase 2 split the remainder out completely, so the file was deleted at 949 lines
and nothing in docs/dev/file-budget.tsv names it now. monotonic_exempt() in the script still carries the
arm and the reasoning behind it.
develop is the integration branch for everything the repo ships — the Docker Compose product and
the appliance OS tree under os/ — and it is the repo's default branch. Until 2026-09-06 the
appliance lived on a twin branch, develop-v2; that branch was fast-forwarded into develop and
retired, so a reference to it anywhere in this tree is stale and should be fixed, not followed.
Automation that GitHub reads from a fixed location must live on the default branch. GitHub
fires a workflow's schedule: trigger from the default branch only, and Dependabot reads
.github/dependabot.yml from the default branch only. A scheduled workflow or a Dependabot entry
that lives anywhere else never runs — and a job that never runs looks exactly like a job that ran
and found nothing, which is why this went unnoticed three times while the twin existed
(#1146, #1162, #1163). Its sibling is #1048, worth knowing next to it: there the schedule did fire,
and the job skipped itself behind an unset repository variable, so main showed green for a gate
that had never run. Nothing in the tree needs an explicit checkout ref: or a Dependabot
target-branch: any more; if you find one, it is left over from the twin.
Check this from run history, never from the file — the file always looks fine:
gh run list --workflow=<name>.yml --limit 200 --json event \
--jq '[.[].event] | group_by(.) | map({event: .[0], n: length})'No schedule key in that breakdown means the schedule has never fired. A schedule key is
necessary but not sufficient — #1048's shape passes that test — so open the newest scheduled run and
check its steps actually ran rather than skipping:
gh run view <run-id> --json jobs \
--jq '.jobs[].steps[] | "\(.conclusion) \(.name)"'The Dependabot equivalent of the first check is to group its PRs by baseRefName.
- Target the
developbranch and fill out the PR template. - Link the issue your PR addresses (e.g.
Closes #123). - Make sure
make testpasses; CI runs the same checks. - PRs require review before merging; reviewers are requested automatically via CODEOWNERS.
- Match the surrounding code. Shell scripts should pass
shellcheck --severity=warning; Python is linted and formatted byruff(config indashboard/pyproject.toml). Runmake lint-py, orcd dashboard && ruff formatto apply it. - Keep commits tidy and messages descriptive.
By contributing, you agree that your contributions are licensed under the project's MIT License.