Contributions are welcome: code, docs, bug reports, and ideas.
For security-sensitive reports, please use the private reporting path in SECURITY.md instead of opening a public issue.
Use the DevContainer. It includes Go, kubectl, k3d, Helm, and all required tools. Everything below assumes you are running inside it.
See .devcontainer/README.md for setup instructions.
An optional repo-root .env file can provide read-only gh CLI access inside the devcontainer for
checking PRs, Actions runs, and CI logs. Keep it local-only; the existing .gitignore already
covers .env.
You can access common services as part of the e2e cluster:
- gitea: http://localhost:13000/ (giteaadmin/giteapassword123)
- redis://default:e2e-valkey-password@127.0.0.1:16379/0
For small fixes, docs improvements, and obvious bug fixes, feel free to open a PR directly.
For larger changes, new features, architectural shifts, or anything likely to take significant effort, please open an issue or discussion first so direction can be aligned before you spend a lot of time on implementation.
As a rough rule: if the change is likely to take more than about 30-60 minutes of focused work, or more than a few thousand AI tokens / a substantial coding session to prepare, check first.
task lint # must pass
task test # must pass
task test-e2e # must pass (requires Docker running)Editing markdown? Follow docs/style-guide.md. A docs-only change runs
task lint-docs instead of the full suite; git add new files first so their links resolve.
See Documentation checks for what that runs.
task testThis regenerates manifests and codegen, runs go fmt and go vet, downloads envtest
assets, and runs all packages except test/e2e. Coverage is written to cover.out:
go tool cover -html=cover.outtask test also enforces a coverage ratchet (task cover-check): it fails if total coverage drops
more than ~0.5% below .coverage-baseline, a committed high-water mark. When your change improves
coverage the baseline auto-raises, so commit the updated .coverage-baseline alongside your
change so the floor advances. On PRs, Codecov
reports the merged unit + e2e coverage.
Run a single package or test:
go test ./internal/controller -v
go test ./internal/git -run TestName -vThe project fuzzes the code that parses untrusted or semi-structured input (manifest
editing in internal/git/manifestedit, and audit/admission request decoding in
internal/webhook) so hostile input can never crash the controller. task test
already replays every fuzz seed and committed crash reproducer as ordinary regression
cases, so you get that coverage for free on every run.
Before a major production release, run a longer discovery pass:
task fuzz # release-time discovery run, per target, under the race detector
task fuzz-smoke # short active-fuzz smoke of each targetIf fuzzing finds a crash, Go writes the reproducer under
<package>/testdata/fuzz/<Target>/. Commit it: it fixes the input as a permanent
regression case, replayed by task test thereafter. See
docs/finished/dynamic-analysis-fuzzing-plan.md.
task test-e2eE2E runs against a real k3d cluster with Gitea and Prometheus. The cluster is intentionally not torn down on failure, which makes debugging easier and reruns faster. Tests are written to append to existing state rather than require a clean slate.
For deeper debugging, see test/e2e/E2E_DEBUGGING.md.
To measure how much of the controller the e2e suite exercises, build an instrumented image and flush coverage from the running pod after the suite:
E2E_COVERAGE=1 task test-e2e
task e2e-coverage-collect # writes e2e-cover.outCI does this automatically on the full matrix and uploads it to Codecov under the e2e flag.
For controller iteration against the shared e2e cluster:
tilt upThe Tilt UI keeps build/deploy work on the existing task prepare-e2e path, then adds a small
playground group of manual resources:
playground-bootstrapcreates a reusable example repo, thetilt-playgroundnamespace, the Git credentials, and thesops-age-keySecret- the starter
GitProvider,GitTarget, andWatchRulelive intest/playground/and are auto-applied by Tilt via kustomize once the bootstrap has run playground-cleanupremoves the fixed playground namespace, repo, and local artifactsplayground-upsert-*/playground-delete-*mutate watched resources so you can quickly confirm create, update, delete, and Secret encryption flowsplayground-statusprints the current starter resources and the recent repo commit log
The design note for the playground flow lives in
docs/finished/tilt-playground-plan.md.
Troubleshooting:
- envtest errors: run
task setup-envtestthen retry - Docker errors: ensure the Docker daemon is running
- Permission issues: see
docs/ci/go-module-permissions.md - Windows: see
docs/ci/windows-devcontainer.md
task lint-docs runs all three, and task lint includes it. Each owns one thing and none of them
overlaps with the others:
| Task | Tool | Checks |
|---|---|---|
task lint-doc-links |
hack/doccheck |
Every reference resolves, including repo-relative doc paths cited inside Go comments, YAML, and shell. No off-the-shelf tool reads those. |
task lint-markdown |
markdownlint-cli2 | Structure: fences, headings, lists, blank lines, bullet style. |
task lint-prose |
Vale | English against docs/style-guide.md: em dashes, American spelling, product names, words to cut. |
task lint-docs # links and structure everywhere; prose on the gated files
task lint-markdown-fix # apply the safe mechanical fixes
task lint-prose DOCS_SCOPE=all # the whole tree, to see the prose backlogLinks and structure are checked on every tracked file; only prose is gated on the files
.docs-lint-scope lists. Structure was staged the same way until the residue
was cleared, so markdownlint now gates the whole tree and a new document cannot regress it. Vale
still cannot: 145 of the 167 linted files carry at least one error, almost all of them em dashes,
and docs/style-guide.md says that cleanup must not land as one sweeping
commit. That gate stays small and the list grows.
To add a file to the prose gate, clean it and add its path to that file:
vale docs/some-file.md # what it would costEditing a doc that is not on the list still passes prose, so an unlisted file can drift. Run vale
on anything you touch even when nothing forces you to.
Reference checking is not staged this way. task lint-doc-links always covers every tracked
markdown, Go, YAML, and shell file, so a moved document breaks the build wherever it is cited.
Only Vale errors fail the build. Warnings and suggestions print and do not block, because
.vale.ini reserves the error level for rules a machine can decide alone. There is no
autofix for prose, on purpose. task lint-markdown-fix does not clear everything either: over the
whole tree it fixes about two thirds and leaves the rest for a human, led by long lines, missing
fence languages, and bold text used as a heading.
To add a word, a preferred term, or an exception, edit the rule under .vale/styles/HouseStyle/;
each file explains what it costs, measured against the real corpus. To suppress one genuinely
exceptional finding, use <!-- vale HouseStyle.RuleName = NO --> around the smallest span that
needs it, never a repository-wide exclusion.
This project uses Conventional Commits. The type determines whether a release is triggered and what version bump applies:
| Type | Effect |
|---|---|
feat |
minor bump |
fix, perf, revert |
patch bump |
docs, test, chore, ci, refactor, style, build |
no bump |
feat! or BREAKING CHANGE: footer |
major bump |
Examples: feat(controller): add SSH key rotation, fix(webhook): prevent queue race
See .github/RELEASES.md for how releases and changelogs are automated.
Repo-specific guidance for AI coding assistants lives in AGENTS.md.
- Branch from
main - Make changes, follow the validation steps above
- Open a PR against
main; CI runs automatically
External contributions come from a fork, and GitHub gives fork PRs a read-only
GITHUB_TOKEN: it cannot push to the org's packages and has no access to repository
secrets. CI adapts automatically so that fork PRs still run the full pipeline
(lint, unit, e2e, image scan) without needing any write access:
- Images are built but never pushed. The CI base container and the project image
are built from your PR's own code in the PR run, so a PR that changes
.devcontainer/Dockerfileis validated against its own toolchain. - Images move between jobs as artifacts, not via the registry. They are
docker saved, uploaded, and later jobsdocker loadthem; the e2e job imports the project image straight into k3d (IMAGE_DELIVERY_MODE=load) instead of pulling it. - Same checks as maintainers. PRs and pushes to
mainrun the same jobs from the same workflow file (ci.yml); only the image delivery differs.
Nothing is published from a PR regardless of origin: image and chart publishing only
happen on push to main via release.yml, after the
full pipeline passed. If this is your first contribution, a maintainer needs to
approve the workflow run before it starts. That is a GitHub safety default rather than a
distrust of your patch.
The full design (trust zones, signing, verification) is described in docs/ci-overview.md.
Thank you for contributing!