Skip to content

Latest commit

 

History

History
334 lines (265 loc) · 20.6 KB

contributing.md

File metadata and controls

334 lines (265 loc) · 20.6 KB

Contributing

This guide covers the dev loop, house rules, CI and the review process. New here? Start with Your first PR: it tells you which folder owns your change, the one compile check to run, and how long CI takes.

Your first PR

  1. Fork the repo and create one branch for the change.
  2. Find the owning folder for your change in the table below. For where new code goes inside a module, see docs/layout.md.
  3. Run the local check for that row only. Do not run test or lint suites locally (make test, make lint, go test, npm test); CI runs them.
  4. Commit with git commit -s and a conventional prefix (feat:, fix:, docs:, chore:, …).
  5. Open the PR and check that it has one type: label and one area: label (see Submitting a change).
  6. Wait for CI (times below) and fix what it reports.
Change Owning folder Local check CI wall clock (2026-10-20)
Docs only docs/, README.md, any *.md none 1.8 min (run 2507)
Spec only specs/NNN-<slug>/ none 5.3 min (run 2525)
Web only web/ cd web && npx tsc --noEmit 18.3 min (run 2524)
One small Go service <module>/ (e.g. sentinel/, tunnel/) go build ./... in that module same as any Go change: 29–75 min, median 37.5 min
Operator / CRD operator/ (types in operator/api/v1alpha1/) make generate && make manifests, then go build ./... in operator/ same as any Go change
API route api/ (handlers in api/internal/handlers/) go build ./... in api/ same as any Go change
Game module modules/<game>/: PR in GameplanePanel/module, then a pointer bump here make module-validate MODULE=<game> 2.7 min in GameplanePanel/module (run 79, 2026-10-20); the bump PR here runs the docs gates
Website website/: PR in GameplanePanel/website, then a pointer bump here npm run build in website/ 0.6 min in GameplanePanel/website (PR 25, 2026-10-22); the bump PR here runs the docs gates

Times are GitHub Actions wall clock for one PR run, measured 2026-10-20: runs 2507, 2525 and 2524, and 11 Go PR runs for the median. Change-based test selection (specs/023-change-based-test-selection/) shortens Go runs as it lands, so treat these as upper bounds.

Gates a first PR trips on:

  • Unsigned commits: every commit needs git commit -s.
  • Missing labels: one type: and one area: label on every PR.
  • Local test or lint runs: only compile checks locally; CI is the verifier.
  • UI without design: dashboard changes start in design.pen (Pencil), see Design first for UI changes. Never hand-edit .pen files.
  • Submodule work in the wrong repo: modules/ and website/ changes go to their own repos first, then a pointer bump PR here.

Dev loop

make dev-up      # creates kind cluster, builds images, installs Helm chart
make web-dev     # starts the Vite dev server with proxy to in-cluster API

Design first for UI changes

Any change to the web dashboard's visual surface starts in design.pen (Pencil), not in code. Open design.pen, update the relevant screen, review with the maintainers, then translate to React. This keeps the 18 designed screens the source of truth and prevents the dashboard from drifting into code-led redesigns.

Backend/operator/API changes do not need a Pencil pass.

UI components

The dashboard is built on HeroUI (@heroui/react, v3) rather than hand-rolled or Radix primitives. When a screen needs a stock control (button, table, modal, form field, popover, etc.), import it directly from @heroui/react — see the HeroUI docs for the current v3 API surface. When a pattern recurs across screens (a confirm dialog, a status chip, a stat card, the authenticated shell), reuse or extend the Gameplane composition in web/src/components/ui/ rather than re-wrapping HeroUI from scratch. New reusable compositions belong there too, each with a co-located *.test.tsx. Don't add @radix-ui/* or class-variance-authority — the dashboard's HeroUI rebuild (specs/done_014-heroui-web-rebuild/) removed both from web/package.json.

Code style

  • Indentation: tabs in Go, 2 spaces elsewhere (YAML, JSON, TypeScript, Markdown). LF line endings everywhere.
  • Go: gofmt, go vet, golangci-lint. Errors are wrapped with %w to preserve the cause chain.
  • Linting: Fix the underlying issue; do not add inline suppression directives (//nolint:, // eslint-disable). A few centralized exemptions exist in .golangci.yml (e.g., _test.go files skip errcheck/gosec/unparam, and controller builders skip revive's exported rule) — contributors should not add new inline exemptions on top of these.
  • TypeScript: strict mode on. ESLint + Prettier. No any without a justification comment.
  • Comments: default to writing none — let naming carry the weight. Add one only when the why is non-obvious (hidden invariant, workaround, or constraint a reader would ask about).

CI and workflows

This repo pins every external GitHub Action to a full 40-hexadecimal commit SHA with a # vX.Y.Z trailing comment, enforced by the zizmor security scanner in the workflow-lint gate. If you're modifying a workflow or adding a new action:

  1. Resolve the action's commit SHA using:
    git ls-remote --tags --refs https://github.com/<owner>/<repo>
  2. Pin it in the workflow file:
    - uses: owner/action@<40-hex-SHA> # vX.Y.Z
  3. Dependabot's github-actions entry keeps all pins current — do not bump them by hand.

Before pushing changes to .github/workflows/ or .github/actions/, run the workflow-lint checks locally (the one exception to the "don't run suites locally" rule, since they are static checks):

go install github.com/rhysd/actionlint/cmd/actionlint@latest
actionlint .github/workflows/*.yaml

uv tool install zizmor
zizmor .github/workflows/ .github/actions/

actionlint catches workflow schema errors and runs shellcheck against run: bodies. zizmor verifies SHA pinning, least-privilege permissions, dangerous triggers, and other security concerns.

Every job in every workflow must have:

  • An explicit permissions block granting only what that job needs (top-level default is contents: read; anything more is granted only to the specific job that needs it)
  • An explicit timeout-minutes (default budget is ≤30 minutes; anything above it requires an inline justification comment)

Documented exceptions with longer timeouts are the five e2e jobs (60 minutes each) and the image build in publish-edge.yaml (35 minutes).

Security and drift gates

These jobs block merging. Each gate with a fixture has a hack/*_test.sh self-test that proves it can fail; dependency review is validated by configuration review only. Fix the cause: never add an ignore file, a suppression comment or a looser threshold.

  • vuln (go) runs hack/govulncheck-all.sh, which runs govulncheck over every module in go.work. It fails when a module reaches a symbol with a known vulnerability, and also when govulncheck itself hits a build or tool error. For a vulnerability, bump the affected dependency (or the Go toolchain) in that module's go.mod, run make tidy, and commit both. For any other error, fix the build or tool problem it reports.
  • vuln (images) runs hack/trivy-scan-images.sh over the e2e images CI builds (gameplane-test/<name>:e2e and :e2e-postgres). It fails on any HIGH or CRITICAL vulnerability that has a fix, and on a scan error. For a vulnerability, bump the base-image digest, or bump the Go or npm dependency the finding names in that image's module. For a scan error, fix what Trivy reports and re-run.
  • workflow lint also runs hadolint over every tracked Dockerfile, failing on any warning (.hadolint.yaml). Fix the Dockerfile: pin apk/apt package versions, and set a SHELL with -o pipefail before any piped RUN step.
  • dependency review runs on pull requests only. It fails when the PR adds a dependency with a HIGH or CRITICAL advisory. Pick a version without the advisory, or drop the dependency.
  • codegen drift runs make generate manifests and make tidy, then fails if git diff is not empty. Run the same commands locally and commit what they change (CLAUDE.md rule 7).
  • submodule freshness runs hack/check-submodule-freshness.sh. It fails when the modules or website pointer names a commit that is not on that repo's main, when a gitlink is missing, or when the script cannot fetch that repo's main. For a pointer that is not on main, merge the submodule PR first, then bump the pointer to the merged commit. Otherwise restore the gitlink, or fix the fetch error, and re-run.

How CI picks tests

On a pull request, CI runs only the work your change can affect (spec 023). The detect changes job decides, and its job summary has a Test selection table that lists every suite as run or skip, with the changed files that chose it or the reason everything ran.

What a partial run narrows:

  • Go unit tests run only the packages that own a changed file, plus every package that imports them, across modules. A module's own go.mod or go.sum reruns that whole module plus every package that imports it, and its .testcoverage.yml reruns the whole module. Deleting a package runs the packages that still import it. The module coverage gate then becomes a changed-lines gate: the lines you add must meet the module's own minimum (hack/check_changed_coverage.py).
  • Web unit tests run vitest related on the changed web/src files, with the same changed-lines gate at the Vitest lines threshold. If vitest related writes no coverage report, the job runs the full npm run test:cover instead. Lint and build always run whole. Shared inputs (package.json, the lockfile, the Vite/Vitest/TS/ESLint configs, src/test/**, i18n/locales/**) run the full suite.
  • Go e2e tests run only the tests that test/e2e/suite-components.json selects: tests whose components or paths changed, tests that use a changed e2e test or helper file, and every test when a universal path changes (charts, containers, CRD types, the Kind setup, signing keys). Jobs with no selected test are skipped.
  • ci.yaml edits run only the jobs whose lines changed, plus the jobs they need. An edited e2e job also runs its other-architecture twin. .golangci.yml runs only the lint legs and workflow lint. Other workflow files run only workflow lint.

Everything still runs on every push to master, and when a selection input changes (hack/ci_scope.py, hack/check_changed_coverage.py, their tests, Makefile, go.work, go.work.sum, .github/actions/**, test/e2e/buckets.sh, test/e2e/suite-components.json). It also runs everything for a ci.yaml edit outside any job or in changes/report, a path no rule maps, and any selection error.

To force a full run on your PR, add the label ci: full-run (read on the next push), or start the ci workflow by hand on your branch (workflow_dispatch).

When you add an e2e test or a component, keep test/e2e/suite-components.json in step: add the test name pattern under suites with the components and paths it exercises, or list the component under unexercised with a reason. The e2e bucket coverage job (ci_scope.py verify-suites) fails when a registered test matches no entry or a component is missing. The full rules are in specs/023-change-based-test-selection/contracts/suite-components.md.

Testing

Run the whole suite:

make test

Per-component:

cd netguard && go test ./...
cd gameaction && go test ./...
cd gameproto && go test ./...
cd svcutil  && go test ./...
cd operator && go test ./...
cd api      && go test ./...
cd agent    && go test ./...
cd audit-syslog-bridge && go test ./...
cd telemetry-receiver && go test ./...
cd sentinel && go test ./...
cd capture-sidecar && go test ./...
cd tunnel   && go test ./...
cd gp-module && go test ./...
cd mcp-server && go test ./...
cd web      && npm test

E2E testing

The e2e suite runs against a real kind cluster. CI splits it into parallel buckets defined in test/e2e/buckets.sh — the e2e bucket coverage job fails on any test that isn't in one, so add new tests to a bucket.

The two game-bot tests (TestGameServer_MinecraftBotConnects, TestGameServer_TerrariaBotConnects) boot a real game server and run a headless protocol bot as a Job inside the cluster, dialing the game Service's DNS name — the same network path a real in-cluster client uses — rather than tunnelling through kubectl port-forward.

The games namespace carries a default-deny-egress NetworkPolicy whose podSelector: {} matches every pod in it. This allows DNS resolution but blocks outbound connections, so an in-cluster helper pod placed there cannot connect to the game Service. The probe Job therefore runs in the default namespace, where no NetworkPolicy restricts it. Note that kubectl port-forward bypasses NetworkPolicy entirely (it tunnels kubelet→pod), so "it works via port-forward" is not evidence that in-cluster pod→game traffic works.

The bot is test/e2e/cmd/gameprobe, built into gameplane-test/gameprobe:e2e by the e2e-gameprobe bake target. That target sits outside the e2e bake group so only the game-bot job pays to build it, and deploy/kind/e2e.sh side-loads it into the cluster when present. The e2e game bot (kind) CI job is now blocking rather than advisory.

The ingress-nginx smoke (kind) CI job is separate from the buckets above: it boots a Kind cluster on the same node config make dev-up uses and applies the dev ingress-nginx manifest pinned in deploy/kind/up.sh (INGRESS_NGINX_VERSION), waiting for the controller Deployment's rollout to finish (its replica available). It only runs when deploy/kind/** or the CI workflow file itself changes.

The optional-components bucket (TestOptionalComponents_Lifecycle) owns its cluster because it runs helm upgrade. It checks that the MCP server, the audit-syslog bridge and the tunnel are absent by default, then enables them and checks each one. Its fixtures are test/e2e/fixtures/frps.yaml and test/e2e/fixtures/syslog-sink.yaml.

The e2e postgres / amd64 (kind) job runs the API against PostgreSQL. It boots the cluster with ./deploy/kind/e2e.sh up "$CLUSTER" e2e postgres, which applies deploy/kind/postgres.yaml and installs the chart with api.db.driver=postgres. It then runs the api-auth, api-roles and api-rbac buckets, restarting the API between them to reset the in-memory login rate limiter. The frozen 001–012 upgrade path is covered separately by TestPostgres_MigrateFromFrozenBaseline in the api (postgres) job.

Where design docs go

Two homes, nothing else:

  • A change (feature, fix, cleanup) is written up in specs/NNN-<slug>/ through speckit (spec, plan, tasks, OPEN-DECISIONS.md). Once every task is done and merged, the folder is renamed specs/done_NNN-<slug>/.
  • How a module works today is described in its <module>/specs.md, updated in the same PR as the behavior (constitution Principle IV, checked by make check-specs).

No other folder holds designs or plans. Older designs from before this rule are kept read-only in docs/archive/.

AI-assisted development

Much of this codebase is developed with AI coding assistants (Claude Code). The project was started on Claude Opus 4.8 (claude-opus-4-8); since June 2026 development continues on Claude Fable 5 (claude-fable-5). Agent-facing guidance lives in one real file, AGENTS.md; keep it current when project structure or house rules change. CLAUDE.md is a symlink to AGENTS.md for tools that read that name, and the website/ submodule uses the same pair (AGENTS.md real, CLAUDE.md link). Always edit AGENTS.md: with git config core.symlinks false (the Windows default) the link checks out as a one-line text file that only holds the name AGENTS.md. All contributions, AI-assisted or not, go through the same review, lint, and test gates below.

Submitting a change

  1. Fork + feature branch
  2. Do not run make test or make lint locally. A quick compile check (go build ./... or tsc --noEmit) is fine to avoid pushing obviously-broken code, but the full test and lint suites run only on CI (GitHub Actions) — that is where they are gated and where failures surface. The exception is workflow changes: if you modify .github/workflows/ or .github/actions/, run actionlint and zizmor locally before pushing (see CI and workflows section above).
  3. For UI work, include the Pencil node id(s) touched in the PR description
  4. Sign commits (git commit -s)
  5. PR labels: every PR must carry at least one type: and one area: label. CodeRabbit (see Code review below) applies them automatically, and the auto-label workflow (hack/auto_label.py, every 15 minutes) fills in a missing type: from the conventional-commit title prefix and a missing area: from the changed paths; type: security is never derived. You are responsible for verifying they are correct. The type: taxonomy is feature / fix / refactor / test / ci / chore / docs / security. The area: taxonomy is operator / api / agent / web / modules / chart / e2e / specs / shared / optional-components. A breaking CRD, API, or chart-value change also takes breaking. The status: labels (blocked, needs-maintainer, in-progress) are optional. A PR spanning several components takes several area: labels rather than being left unlabelled.

Game-module changes (modules/) belong in the separate GameplanePanel/module repo, which this repo vendors as a submodule. Open the module PR there; once it merges, bump the submodule pointer here (git add modules) in a follow-up PR.

.gitmodules points both submodules (modules/, website/) at the upstream GitHub URLs, so a fork of this repo clones them without forking them too. To work on your own fork of a submodule, repoint it locally: git submodule set-url modules https://github.com/<you>/module.git (don't commit that change).

Translating

Language packs live in i18n/locales/<tag>/. Adding a language or improving one does not need code changes. See docs/translating.md for the folder layout, the message rules, the 80% listing threshold, and how to check a pack. A PR that touches only i18n/locales/** and has no type prefix in its title is labelled type: docs and area: shared.

Code review

PRs receive an automated advisory review from the CodeRabbit GitHub App, configured by .coderabbit.yaml. CodeRabbit's rules encode the repo's style guide and house rules — wrapping errors with %w, no unjustified any, fix rather than silence linter warnings, etc. Its feedback does not block merges. If you think a rule is wrong, raise it with the maintainers rather than working around it — this keeps the rules canonical and consistent across the codebase.

Release process

Tags matching v* trigger the release.yaml workflow, which builds the component images, pushes the Helm chart to the gameplane OCI registry, and keyed-cosign-signs all images (component and chart) and official modules/* bundles by digest, recording all signatures in the public Sigstore Rekor transparency log.

Signing is mandatory and fail-closed: if COSIGN_PRIVATE_KEY is not configured, the release job fails before anything is pushed. A one-time key setup is required: run cosign generate-key-pair, set COSIGN_PRIVATE_KEY/COSIGN_PASSWORD as secrets of the release-signing environment, and publish signing/cosign.pub. See module-authoring.md for details.

Every job that signs (release.yaml, publish-edge.yaml, images.yaml and republish-modules.yaml) runs in the release-signing environment. Its deployment policy admits only master and v* tags, so a run on any other ref never receives the key. Images are pushed by digest, signed and verified, and only then tagged, so a published tag always names a signed image. The image matrix does not cancel sibling legs when one fails. A leg that fails before tag attachment leaves at most an untagged digest behind. A leg whose oras tag succeeded but whose later oras resolve check failed may leave published tags, so inspect the image's tags before re-running the failed jobs.

Community Visibility & Outreach

External Directory Submissions

The project maintains a tracked list of external directory submissions (AlternativeTo, Awesome-Selfhosted, Awesome-Kubernetes) to grow visibility and discoverability. See the outreach tracker for submission status and history.