Contributing to WaWarden¶
This guide covers building, testing and checking a change the way CI does,
and how to write commits. Report a vulnerability privately, as
SECURITY.md describes, never in a public issue or pull
request.
Contents¶
- Toolchain
- Build
- Test
- Format and vet
- Lint
- Modules
- Vulnerability check
- Fuzzing
- Architecture tests
- Golden files
- Reproducible build
- Documentation
- Documentation site
- Commits and history
- What CI runs
Toolchain¶
The build uses the Go version on the toolchain line of go.mod. With its
default settings, the go command (1.21 or later) downloads that version when
the installed one is older. CI checks that go env GOVERSION matches it:
Other tools, at the versions CI uses:
| Tool | Version | For |
|---|---|---|
| golangci-lint | v2.14.0 |
Lint |
| govulncheck | v1.8.0 |
Vulnerability check |
| Docker | any recent | hack/offline-test.sh and the reproducible build |
Build¶
The dev build tag makes a development build, which adds the fake engine
(see the fake engine). Release builds
never carry it.
Test¶
The second run adds an end-to-end test that runs serve with the fake engine
and pairs it through the admin CLI.
No test contacts WhatsApp:
- The protocol adapter is tested with hand-built events, protobuf fixtures and injected transports.
- Its dialer, and every transport it builds, refuses every connection inside a test binary.
hack/offline-test.sh proves it. It fetches the modules that go.sum pins,
then runs both suites in a container with no network. It needs Docker.
CI also runs two longer checks:
go test -count=1 -run 'Property|AnyGrant' ./internal/store/scoped/ -rapid.checks=1000 -rapid.shrinktime=10s
go test -count=1 -run '^TestMigratingALargeArchive$' ./internal/store/ingest/
The first runs the scoped store's property checks 1,000 times. The second
migrates an archive of 500,000 messages. A property failure writes its case
under internal/store/scoped/testdata/rapid.
Format and vet¶
gofmt -l must list nothing.
Lint¶
CI runs golangci-lint v2.14.0 with the repository's configuration, once
without and once with the dev tag:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0
golangci-lint run --config=.golangci.yml
golangci-lint run --config=.golangci.yml --build-tags=dev
.golangci.yml enables govet, staticcheck, errcheck,
ineffassign, unused, gosec, bodyclose, noctx, depguard and
forbidigo.
depguardkeeps packages behind their fences. For example,internal/apireaches the archive only throughinternal/store/scoped, and onlyinternal/store/internal/dbimports the SQLite driver.forbidigobanshttp.ListenAndServe,http.DefaultServeMux,http.Getand similar calls, and opening sockets outsideinternal/listeners.
A //nolint directive must name its linters, and may not silence depguard
or forbidigo. The architecture tests enforce this.
Modules¶
Modules are pinned by go.sum and never vendored. CI refuses a vendor
directory and checks:
go mod tidy -diff must print nothing. A new dependency is a security
decision: the architecture tests hold the list of modules that code may
import, and a module missing from it fails TestThirdPartyAllowList and the
third-party-imports rule. Dependency updates
describes how updates are reviewed.
Vulnerability check¶
CI runs it on every change, and a separate workflow runs it daily.
Fuzzing¶
The fuzz targets guard parsers and normalisers: the chat identifier normaliser, the sanitisers, the request decoders, cursors and message references, tokens, history inflation and crafted protocol messages. List them:
CI runs each target for 60 seconds. Run one the same way:
A failure writes its input under the package's testdata/fuzz/. Fix the bug
and commit that file, so the case runs in every later go test.
Architecture tests¶
internal/archtest parses every Go file of the module and checks it against
a list of rules. It also builds fixture packages that must fail to compile
with a specific error. The tests run in go test ./... like any other.
Secure by construction explains
the invariant behind each mechanism.
What the rules enforce, in groups:
- Fences and confinement.
internal/apireaches the archive only through the scoped store. Onlyinternal/store/internal/dbholds a database handle. Onlyinternal/engine/waimports the protocol library. Onlyinternal/backupimports age, for encryption only. Only the listed modules may be imported, and some packages may use the standard library only. - Network and goroutines. Only
internal/listenersopens sockets and builds HTTP servers. Every goroutine starts throughsafego.Go. Tests bind no wildcard address. Protocol client calls are limited to a reviewed list. - Routes and grants. Routes and MCP tools are registered only through the helpers that take a policy class. Every scoped read and every send takes a grant first. Only the decision functions mint grants.
- SQL. Queries are constants, chat sets go through the one
INbuilder, and nothing runsANALYZE. Only the retention code deletes audit rows or moves the audit anchor. - Secrets and files. No credential literals, constant-time comparisons
only, and only
internal/keystoucheskeys/. Onlyinternal/backupnamesbackups/. One log writer carries every line. - Development code. The fake engine compiles only with the
devtag, and nothing a release build compiles imports it. - Lint and workflows.
//nolintrules as above. Workflowrunscripts hold no${{ }}expressions, and workflows build with read-only modules. - The v1 surface. See Golden files.
Fixing a failure¶
A rule failure appears as a subtest, TestArchitecture/<rule name>, with the
file, the line and what the rule expects. Run one rule:
To fix it:
- Move the code to the package the rule allows, or use the sanctioned
helper, such as
safego.Gofor a goroutine or the scoped store for a read. - If the change is deliberate, such as a new dependency or a new protocol
client call, update the rule's list in
internal/archtestin the same change. Say why in the commit message, since reviewers treat it as a security change. - Never silence a rule with
//nolint; it is refused fordepguardandforbidigo, and archtest is not a linter.
Each rule carries its own test cases (TestRuleCases). A change to a rule
adds a case that it must catch.
Golden files¶
Some tests compare output with committed files. Regenerate one only after reviewing the change, and commit the regenerated file with it:
| Test | Regenerate with | Holds |
|---|---|---|
TestV1SurfaceMatchesTheGolden |
go test ./internal/archtest -run TestV1SurfaceMatchesTheGolden -update-v1-surface |
internal/archtest/testdata/v1-surface.txt: routes, MCP tools, variables, subcommands and flags, exit codes, events and metric names |
TestMCPToolListMatchesTheGoldenFiles |
go test ./internal/api -run TestMCPToolListMatchesTheGoldenFiles -update-mcp-tools |
internal/api/testdata/mcp: the MCP tools/list answers |
| scoped store query plans | go test ./internal/store/scoped -update-plans |
internal/store/scoped/testdata/plans: SQLite query plans |
The v1 surface file is the compatibility promise
in testable form. A line that regeneration adds is a new feature. A line it
drops or changes is a breaking change, which needs v2.0.0. Each category
also has a floor, so a net removal fails even after regeneration.
Reproducible build¶
It needs a clean working tree and a commit fetched without tags, so that
go build stamps the same version information as a release. CI builds the
binaries and the OCI image (oci mode, which also needs Docker) on two
runners and compares their checksums and image digests. Reproducing a release
describes the full procedure.
Documentation¶
The documentation lives in docs/, with
README.md, RELEASING.md,
SECURITY.md and CHANGELOG.md at the root.
A change that alters behaviour updates the document that describes it, in the
same commit.
- Use British spelling: "licence", "behaviour", "normaliser".
- Write short sentences in the present tense. Describe what is, not what is planned.
- Keep version history in
CHANGELOG.md, not in the reference documents. - Use the terms of the glossary.
- Make anything an operator may link to a heading.
- Link between documents with relative links, so they work on GitHub and on the site.
Documentation site¶
The site at https://wawarden.dortort.dev/ is built by MkDocs with the
Material theme. Its sources are mkdocs.yml, docs/ and the
root Markdown files. The Python packages are pinned with hashes in
website/requirements.txt.
To preview it, from the repository root, with Python 3.12 or later:
python3 -m venv .venv
.venv/bin/pip install --no-deps --require-hashes -r website/requirements.txt
.venv/bin/mkdocs serve --strict
Then open http://127.0.0.1:8000/wawarden/. .venv/ and the build output
site/ are ignored by Git.
CI runs the strict build on every pull request
(.github/workflows/pages.yml), and publishes
the site on every push to main. Any warning fails it, such as a broken link
or a missing anchor. Run the same build before pushing a documentation change:
Write links as GitHub resolves them:
- Between
docs/files, use relative.mdpaths, such asrunbook.md#disconnect-reasons. - To the root files, use paths such as
../README.md.
website/hooks.py maps the root files to project/
pages on the site, and points links to other repository files at GitHub. To
update the pins, edit website/requirements.in and
run the pip-compile command at its top.
Commits and history¶
Commit messages follow type(scope): summary, in the imperative and in lower
case, as git log --oneline shows:
fix(api): give the send route a body cap that fits an escaped text
test(archtest): refuse protocol client calls outside the reviewed list
docs(runbook): say what to do on audit_prune_refused and alarm on it
| Type | For |
|---|---|
feat |
A new feature or behaviour |
fix |
A bug fix |
test |
Tests only |
docs |
Documentation only |
refactor |
A change that keeps behaviour |
The scope names the package or document, such as api, store, engine,
archtest, runbook or threat-model. Leave it out when a change spans
many. Keep each commit to one change, with its tests and documentation.
main requires a linear history: a merge commit cannot be pushed to it.
Rebase onto main before pushing (git pull --rebase). See
Repository settings.
What CI runs¶
.github/workflows/ci.yml runs on every pull
request and every push to main:
| Job | Runs |
|---|---|
| Build and test | Go version check, gofmt, build, vet and go test -race, each without and with -tags dev |
| Property checks and a large archive | The two longer tests under Test |
| Lint | golangci-lint v2.14.0, without and with --build-tags=dev |
| Modules | No vendor, go mod tidy -diff, go mod verify, read-only builds |
| govulncheck | govulncheck ./... |
| Fuzz | Each fuzz target for 60 seconds |
| Reproducible build | Two builds, on amd64 and arm64 runners, compared bit for bit |
CodeQL and OpenSSF Scorecard run in their own workflows, and so does the documentation site's strict build.