Development¶
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:
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.