Skip to content

Development

go test ./...
go build ./...

The store, forge and blobstore interfaces each have test doubles (internal/store/memory, internal/forge/fake, internal/blobstore/memory), so handlers are fully testable without Postgres or a forge.

The Postgres store additionally has integration tests that run against a real server when GOCOV_TEST_DATABASE_URL is set (they are skipped otherwise). Each test creates and drops its own scratch database:

docker run --rm -d --name gocov-test-db -p 5433:5432 \
  -e POSTGRES_USER=gocov -e POSTGRES_PASSWORD=gocov -e POSTGRES_DB=gocov \
  postgres:18-alpine
GOCOV_TEST_DATABASE_URL=postgres://gocov:gocov@localhost:5433/gocov go test ./...
docker stop gocov-test-db

Web UI

The web UI is a single-page app in web/ (Vite, React, TypeScript, React Router, TanStack Query, Vitest). It is the UI: it answers the canonical URLs — /, /w/…, /repos/…, /workspace-settings/… — and there are no server-rendered pages left behind it. Go still decides every status code, redirect and access answer and injects the per-page <title> and meta tags into the shell; the app reads its data from a private JSON API under /api/ui/, authenticated by the same session cookie.

cd web
npm ci
npm test           # unit tests
npm run coverage   # the same, plus coverage/lcov.info (what CI uploads to gocov)
npm run build      # typecheck, then bundle into internal/webui/dist

The bundle is embedded into gocov-server (internal/webui), so a shipping binary needs the web build to run first — the Dockerfile and the release workflow do. Without it everything still builds and tests with the Go toolchain alone: the binary then serves a built-in placeholder shell in place of the app, so status codes, redirects and head injection stay testable without Node.

For live reload, run a backend and the Vite dev server side by side and open http://localhost:5173/:

GOCOV_PREVIEW_AUTH=1 go run ./cmd/gocov-preview   # in-memory store, synthetic history, fake sign-in
cd web && npm run dev                             # proxies /api, /oauth, /static … to the backend

GOCOV_BACKEND points the dev server at a backend other than http://localhost:8099. The component conventions and the design tokens are described in web/README.md.

The screenshots under docs/assets/ are generated, not hand-taken: with a fresh preview running (after npm run build in web/), node scripts/docs-screenshots.mjs drives headless Chrome through the pages and rewrites them all. Re-run it after a change to how a documented page looks.

Configuration

Every environment variable the binaries read is declared as a tagged struct field in internal/config:

type Server struct {
    DatabaseURL       string   `env:"DATABASE_URL,required,notEmpty"`
    Addr              string   `env:"GOCOV_ADDR" envDefault:":8080"`
    AllowedWorkspaces []string `env:"GOCOV_ALLOWED_WORKSPACES" envSeparator:","`
    GitHub            OAuthApp `envPrefix:"GOCOV_OAUTH_GITHUB_"`
    ...
}

That package is the authoritative list, and a test enforces it: TestConfigurationDocIsInSync walks the struct tags with env.GetFieldParams and fails if a variable has no row in configuration, if a row survives a variable that was removed, or if a documented default no longer matches the tag. main parses and validates once at start-up and then only reads the struct, so a new setting means a new field there, not another os.Getenv at the point of use. Presence is the tags' job — required,notEmpty, because required alone would accept a variable passed through as "". Only what the tag vocabulary cannot say is written out: validate for what must stop the process (a malformed GOCOV_SECRET_KEY, GOCOV_MODE=hosted with no sign-in provider) and Warnings for what is survivable (half a credential pair — logged, feature left off). LoadServerFrom parses an explicit environment map instead of the process environment, so the whole contract is covered by ordinary table tests.

Extending gocov

Coverage formats sit behind profile.Parser, forges behind forge.Forge, and raw profile storage behind blobstore.Store; the database schema stores a format-agnostic normalized model. GitHub and GitLab were each added this way — new formats or S3 storage slot in without rewrites.

Releasing

A gocov release lands in four repositories. The CLI is tagged here, which builds the binaries and publishes the release; gocov-action pins the CLI version its users download; upload-pipe bakes the same version into the Bitbucket pipe image; and gitlab-component pins it in the GitLab CI/CD Catalog component.

Commit subjects

main takes squash merges, so each pull request's title becomes one commit subject there, and release-please reads those subjects to choose the next version and write the CHANGELOG. Titles are therefore Conventional Commits: a type, an optional scope, then an English sentence.

Type Use it for Release effect
feat: new user-visible behaviour minor bump, listed under Features
fix: a bug fixed patch bump, listed under Bug Fixes
perf: the same behaviour, faster patch bump, listed under Performance Improvements
refactor: docs: test: ci: build: chore: style: revert: everything else none on its own, not listed

A scope narrows it (fix(web): …), and a ! before the colon (feat!: …) marks a breaking change, spelled out in a BREAKING CHANGE: footer. The pr-title check refuses any other title, because a subject without a type is silently left out of both the version and the CHANGELOG.

Cutting one

release-please keeps a release pull request open on main as soon as a feat:, fix: or perf: lands, holding the inferred version, the CHANGELOG entry and the bumped install-snippet pins, and refreshes it on every merge. Read it, then merge it: merging tags the version, and the tag's build publishes the ten binaries and checksums.txt, each with a build provenance attestation. The release body also carries GitHub's generated pull-request list underneath the CHANGELOG entry.

To override the inferred version, state it in an empty commit whose message carries a Release-As: footer — main only takes pull requests, so it goes in as one (keep the footer in the squash message when merging):

git checkout -b release-0.14.0
git commit --allow-empty -m "chore: release 0.14.0" -m "Release-As: 0.14.0"
git push -u origin release-0.14.0

The pull request is the point. Until it is merged nothing is tagged, so a wrong version or a bad note is a comment on a PR rather than a tag that has to be burned.

The wrappers follow by themselves

Once the production deploy is green, the release build opens a bump PR in each wrapper (the edits are scripts/bump-wrapper.sh, which CI runs against the real wrappers on every push), authored by the cross-repo App (installed on exactly those three repos), so no wrapper ships a CLI newer than the hosted server: gocov-action's pins the CLI its action.yml installs; upload-pipe's bakes the CLI into the image and bumps pipe.yml and the CHANGELOG; gitlab-component's bumps the version default in templates/upload.yml, the README and the CHANGELOG. Each PR carries the release label, and merging it is that wrapper's release: a tag-on-release-merge workflow tags the merge commit (the three work alike: the bump PR writes the next minor after the wrapper's newest tag as the top ## X.Y.Z heading of its CHANGELOG.md, the workflow tags that version — v-prefixed for the action, as GitHub Actions expects, bare for the pipe and the component, as Docker Hub and the CI/CD Catalog expect — and each wrapper's GitHub release carries that changelog entry as its notes) and runs that repo's release workflow — the action's release moves v1, the pipe's builds the multi-arch image for Docker Hub, the component's mirrors the tag to gitlab.com, where the project's own pipeline creates the release the CI/CD Catalog lists. The release build queues each bump PR for auto-merge, so it merges itself once the checks its repo's main requires have passed — each wrapper's checks install the new CLI and upload with it — and a wrapper whose main requires no checks is left for a human, with a warning. So a full release across all four repos is one PR merge; between the gocov release and the wrapper merges, verify-release reports the wrappers as behind, which is true.

The mirrors are the seams. The pipe's tag workflow pushes Bitbucket only when the BITBUCKET_MIRROR_USERNAME/BITBUCKET_MIRROR_APP_PASSWORD secrets are set, the component's pushes gitlab.com only when GITLAB_MIRROR_TOKEN is, and both warn instead of failing when they are not — the Atlassian catalog reads the Bitbucket repo and the GitLab catalog reads the gitlab.com project, so verify-release checks the tags landed there.

Where the version is written down

In the snippets a user copies — the CI recipes in gitlab-ci and ci-other — plus the PinnedCLIVersion constant in internal/hosted. The web UI is not one of the copies: it assembles its setup snippet in the browser from the version the API hands it, which is that constant. The Markdown cannot read a Go constant, so release-please rewrites those copies, guided by x-release-please-start-version markers (and a line marker on the constant). The markers sit outside the snippets — HTML comments in the Markdown — so they never show up in what a reader copies.

Copies drift, and in August they did: the pipe image spent ten days baking a CLI two releases older than the one the action installed, and nothing said so. Two scripts close that off from opposite ends.

scripts/check-pins.sh runs in CI and holds this repo's pins to each other. It says nothing about whether that version is the latest, and cannot: the release PR is what bumps the pins, so between a release and its bump every pin would look stale and main would be red for no reason. The wrapper repos have a script of the same name doing the same job for their own copies.

scripts/verify-release.sh is the other end — it checks the published world after a release rather than the working tree before one:

scripts/verify-release.sh            # the newest release
scripts/verify-release.sh v0.12.0    # a specific one

It confirms that the ten binaries and checksums.txt are on the release, that the checksums cover every binary and that the release and the server image carry a build provenance attestation from this repository, that this repo's snippets and all three wrappers name the released CLI, that gocov-action@v1 resolves to the newest action release, that the pipe image is on Docker Hub and the server image on GHCR for both architectures, and that both images actually report the right version when opened, with each architecture's variant holding a binary built for that architecture. It also checks the pipe's tag reached Bitbucket and the component's tag and release reached gitlab.com — those repos release through two remotes, and a tag that lands on only one publishes nothing on the other silently. For the newest release it also checks that "latest" followed the deploy: the GitHub release is marked Latest and the server image's X.Y and latest tags are the same digest as its vX.Y.Z, both of which deploy.yml moves only once app.gocov.dev runs the release — so a deploy that failed shows up here. It needs gh, curl and jq; docker is optional and only the last check needs it.

The verify-release workflow runs it weekly and on demand, and the release build runs it as its last job, after the wrapper bumps are open: it retries for up to half an hour while the bump PRs auto-merge and the wrappers publish, and a release whose wrappers have not all landed by then ends red, with the report in the job summary.

If the bot is in the way

Turn it off and cut the tag by hand — release.yml still triggers on a pushed v* tag and still does the whole job, creating the release itself when release-please has not already made one. Nothing about the release depends on the bot being alive.