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 build/dashboard --extra dev # deps + tooling into build/dashboard/.venv, from the lock
uv run --project build/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
build/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 — a new tracked file has a hard ceiling of 800 lines, target 400; an existing offender's current line count is its personal ceiling indocs/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 the gate runs locally and inmake lintbut is not yet wired into CI (issue #1257), so two same-wave merges can each pass alone and fail together: re-runmake lintafter merging onto the tip. Generated code, vendored files, data/config, and prose docs are exempt by glob — seeis_exempt()in the script — and so is the shippedpitheadartifact itself, for now: its futurelib/*.shsources are what the gate will govern once Phase 2 splits it),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 vsorigin/develop, 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 stack ships two ways from one repo. develop is the integration branch for the Docker Compose
product and is the repo's default branch. develop-v2 is its twin: everything on develop,
plus the appliance OS tree under os/. Appliance work targets develop-v2; everything else
targets develop, and develop is merged into develop-v2 to keep the twins level.
Automation that GitHub reads from a fixed location must live on develop, and must name the
appliance branch explicitly when it needs the appliance tree. 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 on
develop-v2 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 (#1146, #1162, #1163). #1048 is its sibling
and 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.
Living on develop is only half of it. A workflow on develop still checks out develop, which
has no os/, so the appliance lane is reached by an explicit ref
(.github/workflows/os-rootfs.yml) or by target-branch: (.github/dependabot.yml). Both files
carry a comment saying why they are deliberately asymmetric; do not "tidy" either onto develop-v2.
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 inbuild/dashboard/pyproject.toml). Runmake lint-py, orcd build/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.