gocov
Self-hostable coverage tracking — an open-source Coveralls/Codecov
alternative. Single binary + Postgres. Supported forges: Bitbucket Cloud
and GitHub. Supported formats: Go cover profiles, LCOV tracefiles
(JavaScript/TypeScript — Jest, Vitest, nyc, c8), JaCoCo XML
(Java/Kotlin — Maven, Gradle, Android) and Cobertura XML
(Python — coverage.py/pytest-cov; also PHPUnit, coverlet, gcovr); the
format is detected from the uploaded content.
Features (MVP)
-
Parses Go cover profiles (go test -coverprofile), LCOV tracefiles
(Jest/Vitest/nyc lcov.info), JaCoCo XML (jacoco.xml) and Cobertura
XML (coverage.xml) into total and per-file coverage
-
POST /api/v1/upload API with per-repo Bearer tokens
-
SVG coverage badge per repo (/badge/{workspace}/{repo}.svg)
-
Web UI: repo list → upload list → per-file coverage table
-
Uploader CLI that auto-detects Bitbucket Pipelines and GitHub Actions
environment variables and falls back to git
-
Pushes a coverage: X% (±Y%) build status to Bitbucket commits (or a
commit status to GitHub) when the repo has forge credentials
configured
-
Coverage gate: per-repo minimums for total and diff coverage plus a
drop tolerance; violations push a FAILED build status, so a Bitbucket
merge check can block the PR
-
Source view: any file in an upload renders line by line with coverage
overlay and hit counts, fetched from the forge at the exact commit and
cached immutably (misses are cached too); without forge credentials
the page falls back to an uncovered-line summary. When an upload has
no path_prefix, recorded paths that carry an unmapped leading
prefix (a Go module path, a CI checkout directory) are resolved by
probing trimmed variants against the forge
-
Web UI sign-in with Bitbucket and/or GitHub: configure an OAuth
consumer/app and every page requires login, allowed only for members
of the workspaces and orgs the instance tracks (see "Enable
sign-in"). Uploads, badges and health checks are unaffected; no
passwords are ever stored
-
Diff coverage for pull requests: fetches the PR diff from the forge,
intersects changed lines with coverage blocks, and posts a PR comment
listing uncovered changed lines — repeated uploads update the same
comment instead of stacking new ones. Works on Bitbucket and GitHub
alike
-
Coverage inside the PR, via Bitbucket Code Insights: every upload
attaches a report card to its commit (total coverage, delta, diff
coverage, gate verdict) that Bitbucket shows in the pull request's
Reports panel, and PR uploads annotate uncovered changed lines right
in the diff view — reviewers see untested code exactly where they are
reviewing it, with no plugin on the Bitbucket side. Changed files with
no coverage data at all get a file-level marker, and the report card
lists the worst-covered changed files while the field budget allows.
Re-uploads replace the report and annotations in place; no coverage
product on Bitbucket Cloud ships this today
[screenshot: coverage report card and inline annotations in a
Bitbucket PR]
-
On GitHub the same surface ships as a check run named gocov coverage: a summary card with the identical data, a conclusion that
mirrors the coverage gate (success/failure, neutral without a gate) so
branch protection can require it, and inline annotations on uncovered
changed lines in the Files changed view. Re-uploads replace the run in
place. Writing check runs needs a credential GitHub allows to (see
"GitHub token permissions"); with other tokens the check run is
skipped while status, comment and gate keep working
-
Coverage trend chart: the repo page graphs total coverage over the
branch's recent uploads (gate failures marked in red, every point
links to its upload) — rendered as inline SVG on the server, no
JavaScript chart library
[screenshot: coverage trend chart on a repo page]
The architecture is deliberately extensible: coverage formats sit behind
profile.Parser, forges behind forge.Forge, raw profile storage behind
blobstore.Store, and the database schema stores a format-agnostic
normalized model — so lcov/cobertura, GitHub/GitLab, diff coverage, and
S3 storage can be added without rewrites.
Quick start
docker compose up
This starts Postgres and the server on http://localhost:8080 (migrations
apply automatically).
Onboarding a whole workspace (recommended)
For many repos, register the workspace once and use a single token:
docker compose exec server gocov-server workspace add \
-prefix myworkspace -default-branch main
Set the printed token as a Bitbucket workspace variable (GOCOV_TOKEN,
secured) together with GOCOV_SERVER — every repo inherits them. Repos
register themselves on their first upload; their default branch is asked
from Bitbucket when a global bot account is configured (see
Configuration), falling back to the workspace's -default-branch.
Registering repos one by one
docker compose exec server gocov-server repo add \
-slug myworkspace/myrepo \
-default-branch main \
-bb-username myuser -bb-app-password "$APP_PASSWORD" # optional, for build statuses
For a GitHub repo, the slug is owner/repo and the credential is a
single access token (see "GitHub token permissions"):
docker compose exec server gocov-server repo add \
-slug myorg/myrepo -forge github \
-default-branch main \
-gh-token "$GITHUB_TOKEN" # optional, for statuses and PR comments
A GitHub org can also be onboarded wholesale: workspace add -prefix myorg -forge github — repos then register themselves on first upload,
exactly like a Bitbucket workspace.
Manage repos later with:
gocov-server repo list # slugs, branches, credential status
gocov-server repo rotate-token -slug myworkspace/myrepo # invalidates the old token
gocov-server repo update -slug myworkspace/myrepo \
-default-branch develop # and/or -bb-username/-bb-app-password
# or -gh-token, or -clear-credentials
gocov-server repo remove -slug myworkspace/myrepo -force # deletes uploads and raw profiles too;
# without -force only prints a summary
gocov-server workspace list|rotate-token|update|remove # workspace token management
Enable sign-in (Bitbucket and/or GitHub)
Out of the box the web UI is open and shows a banner saying so — nothing
changes on upgrade until you opt in. Configure one or both providers;
each renders as its own button on the login page.
For Bitbucket:
- Create an OAuth consumer under Workspace settings →
OAuth consumers → Add consumer with
- Callback URL:
https://your-gocov-host/oauth/bitbucket/callback
(must be exactly GOCOV_BASE_URL + /oauth/bitbucket/callback)
- Permissions: Account: Read and Email only — nothing broader
is needed
- Set the consumer's key and secret on the server:
GOCOV_OAUTH_BITBUCKET_KEY=...
GOCOV_OAUTH_BITBUCKET_SECRET=...
For GitHub:
- Create an OAuth app under Settings → Developer settings → OAuth
Apps → New OAuth App (on your account or org) with
- Authorization callback URL:
https://your-gocov-host/oauth/github/callback
- Set the app's client id and secret on the server:
GOCOV_OAUTH_GITHUB_KEY=...
GOCOV_OAUTH_GITHUB_SECRET=...
gocov requests the read-only read:org and user:email scopes at
login. Note that org members may need to grant/request the app's access
to the org once (GitHub's third-party application policy) for the org
to appear in their membership.
From then on every UI page requires signing in. Access is decided at
login time by membership: by default, members of any workspace/org the
instance tracks (registered workspaces and the workspace part of
registered repo slugs) may sign in, and everyone else gets a clear
denial page; on GitHub the account's own username also counts, so
user-namespace repos admit their owner. Set GOCOV_ALLOWED_WORKSPACES
(comma-separated workspace/org slugs) to replace the derived set with
an explicit list. Accounts are provisioned on first successful sign-in
— there is no user bookkeeping, and gocov never sees or stores
passwords (the forge tokens are discarded right after login).
CI is unaffected either way: the upload API keeps its Bearer tokens,
badges stay embeddable, /healthz stays open.
Hosted mode (self-service signup)
GOCOV_MODE=hosted turns the instance into a self-service one: any
forge account may sign in, and a user who belongs to no tracked
workspace lands on /register, which lists the workspaces their
forge account is a member of (captured at sign-in). Claiming one
creates the workspace, makes the user a member and shows the upload
token — once; afterwards it can only be rotated. Only workspaces the
forge itself reports for the account can be registered, so there is
nothing to dispute: if a colleague registered your workspace first,
signing in simply makes you a member.
Registration lands on an onboarding page: the forge-appropriate CI
snippet with the server URL and token pre-filled, and a live "waiting
for your first upload" state that flips to the repo link once coverage
arrives.
The default (GOCOV_MODE=private) keeps exactly the behavior described
above — self-hosted deployments upgrade with zero change. Hosted mode
requires at least one sign-in provider.
Workspace settings in the UI
Signed-in members manage their workspaces from the dashboard (private
and hosted mode alike): rotate the upload token (the old one dies
immediately; the new one is shown once), change the default branch and
gate defaults for auto-registered repos, and set a workspace-level bot
credential used for statuses, PR comments and insights on repos without
their own. Stored secrets are never rendered back — the page only shows
whether a credential is configured. The CLI (gocov-server workspace ...) keeps working; the UI is an addition, not a migration.
gocov-server user list # who has signed in
gocov-server user remove -email jane@example.com # revoke immediately
Removal deletes the account and its sessions; the person can sign in
again (and is re-provisioned) as long as they are still a workspace
member. Sessions last 30 days; membership is re-checked at each login,
not per request.
Access mirrors your forge workspace membership. Once sign-in is
configured, each account sees only the repos in the workspaces and orgs
the forge says it belongs to — the repo list is filtered, and a direct
link to another workspace's repo, upload or source page returns 404.
Memberships are synced from the forge on every sign-in, so there is no
separate invite or member-management step: add someone to the workspace
on Bitbucket or GitHub and they see its coverage at their next login;
remove them and it disappears. A single-team self-host where everyone
belongs to the same workspace is unaffected, as is an instance with
sign-in left open — both stay exactly as before.
GitHub App (one-click connect)
Instead of manufacturing a token, GitHub workspaces can install a
GitHub App: one click on GitHub and statuses, PR comments and check
runs work with zero credential entry, authored by the app's bot
identity (e.g. gocov[bot]). The App is also the first-class Checks
API citizen, so check runs stop being permission-fragile.
To run one on your own deployment:
- Register a GitHub App (Settings → Developer settings → GitHub
Apps → New GitHub App) with
- Setup URL:
https://your-gocov-host/github/setup, with
Redirect on update enabled
- Webhook: disabled (gocov's model is upload-driven; installs
are linked through the setup redirect and uninstalls are detected
lazily)
- Repository permissions: Checks: Read & write, Commit
statuses: Read & write, Pull requests: Read & write,
Contents: Read-only, Metadata: Read-only
- Organization permissions: Members: Read-only (org
membership for sign-in sync)
- Generate a private key on the app page and set both variables on
the server:
GOCOV_GITHUB_APP_ID=...
GOCOV_GITHUB_APP_PRIVATE_KEY=/path/to/gocov.private-key.pem # or the PEM content itself
Members then connect from the workspace settings or setup page
("Install the gocov app"); after GitHub's install screen they land back
on gocov with the workspace connected. In hosted mode the install can
even come first — an install on an account with no workspace yet
registers it on the spot (same claim rules as /register).
A connected installation sits at the top of the credential chain: it
outranks per-repo and workspace credentials for every forge surface.
Uninstalling the app on GitHub is detected on the next upload — the
affected surfaces degrade to the stored credentials, or to skipped,
never to a failed upload — and the settings page offers a reconnect.
The token paths below keep working untouched; the App is an addition,
not a migration.
Bitbucket workspace connect (one-click)
Bitbucket workspaces get the same effortless path: a member clicks
Connect workspace on the settings (or setup) page, consents once on
Bitbucket, and statuses, PR comments, reports, diffs and source fetch
work with zero manual credentials from then on. To enable it, the
deployment needs the sign-in OAuth consumer plus an encryption key:
GOCOV_SECRET_KEY=... # long random secret (e.g. `openssl rand -hex 32`); encrypts the stored grant at rest
The AES key is derived from this value with a plain SHA-256 — there is
no slow KDF to compensate for a guessable passphrase — so generate it
randomly rather than inventing one.
and the consumer's permissions extended beyond sign-in: Account:
Read, Email, Repositories: Write, Pull requests: Write.
(Bitbucket scopes live on the consumer, not the consent request, so the
sign-in consent lists them too — sign-in itself still stores no forge
tokens.)
Honest caveat, stated in the UI at connect time: Bitbucket has no app
identity, so posts appear as the account that clicked Connect. Teams
with a bot account should sign the bot in and connect with it.
The grant's refresh token is stored on the workspace, AES-GCM-encrypted
under GOCOV_SECRET_KEY; access tokens live only in memory. Bitbucket
rotates refresh tokens on every use — gocov persists each rotation
atomically. If the grant dies (the connecting account leaves the
workspace, the consent is revoked under Personal settings → Authorized
applications, or the token ages out after three unused months), the
next upload degrades to the stored credentials or to skipped, never
to a failure, and the settings page offers a reconnect. The app-password
/ API-token paths keep working untouched.
Coverage gate
gocov-server repo update -slug myworkspace/myrepo \
-min-coverage 80 -min-diff-coverage 70 -max-drop 0.5
Each rule is optional: -min-coverage is the minimum total percentage,
-min-diff-coverage applies to the changed lines of PR uploads (skipped
when no diff coverage is available), and -max-drop bounds how far
total coverage may fall below the latest gate-passing upload on the
default branch (0 forbids any drop). Gate-failing uploads are recorded
but never serve as a baseline, so re-running CI cannot launder a failure
and a PR cannot ratchet coverage down push by push. Violations mark the
pushed build status FAILED — require the gocov build in Bitbucket's
merge checks to block such PRs — and are reported in the PR comment and
the upload response (gate field). -clear-gate removes all rules. The
same flags on workspace add and workspace update set defaults
inherited by auto-registered repos. The CLI exits non-zero on a failed
gate when run with -fail-on-gate.
Uploading coverage from CI
In Bitbucket Pipelines (commit, branch, repo and PR id are auto-detected):
- step:
script:
- go test ./... -covermode=atomic -coverprofile=coverage.out
- go run github.com/bykclk/gocov/cmd/gocov@latest upload coverage.out
with GOCOV_SERVER and GOCOV_TOKEN set as repository variables.
In GitHub Actions (commit, branch, repo and PR number are auto-detected,
including the PR head SHA on pull_request runs):
- run: go test ./... -covermode=atomic -coverprofile=coverage.out
- run: go run github.com/bykclk/gocov/cmd/gocov@latest upload coverage.out
env:
GOCOV_SERVER: ${{ vars.GOCOV_SERVER }}
GOCOV_TOKEN: ${{ secrets.GOCOV_TOKEN }}
On runners without a Go toolchain, use the prebuilt binaries from
GitHub Releases instead
(linux/darwin/windows, amd64 + arm64, checksums included). Pin a version
and cache the download on self-hosted runners:
ver=v0.1.0
arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; esac
bin="$HOME/.cache/gocov/gocov-$ver-linux-$arch"
if [ ! -x "$bin" ]; then
mkdir -p "$(dirname "$bin")"
curl -fsSL "https://github.com/bykclk/gocov/releases/download/$ver/gocov-linux-$arch" -o "$bin"
chmod +x "$bin"
fi
"$bin" upload coverage.out
Outside CI, values fall back to git or can be passed explicitly:
gocov upload -server https://gocov.example -token $TOKEN \
-repo myworkspace/myrepo -commit $(git rev-parse HEAD) -branch main \
coverage.out
Other ecosystems upload their reports the same way — the format is
detected from the content, no flag needed:
npx jest --coverage # or vitest run --coverage, nyc, c8 ...
gocov upload coverage/lcov.info
mvn verify # with the jacoco-maven-plugin
gocov upload target/site/jacoco/jacoco.xml
gradle test jacocoTestReport # xml.required = true
gocov upload build/reports/jacoco/test/jacocoTestReport.xml
pytest --cov --cov-report=xml # coverage.py / pytest-cov
gocov upload coverage.xml
JaCoCo paths are package-qualified (com/example/Foo.java); diff
coverage matches them against repo paths by suffix, so source roots like
src/main/java need no configuration.
Badge

Red below 50%, yellow 50–75%, green above 75%. Shows the latest upload on
the repo's default branch.
API
POST /api/v1/upload — multipart form, Authorization: Bearer <token>
| part |
meaning |
profile |
file: the coverage profile |
repo |
optional; must match the token's repo |
commit |
required commit SHA |
branch |
defaults to the repo's default branch |
pr_id |
optional pull request id |
format |
go, lcov, jacoco or cobertura; omitted → detected from content |
path_prefix |
maps profile paths to repo paths for diff coverage, e.g. the Go module path (the CLI fills it from go.mod) |
Returns 201 with {id, total_pct, covered_stmts, total_stmts, delta_pct, build_status}. Uploads carrying a pr_id additionally get
diff_pct, diff_covered_lines, diff_total_lines, diff_status and
pr_comment when the repo has forge credentials configured.
Configuration
| variable |
default |
|
DATABASE_URL |
— |
Postgres DSN (required) |
GOCOV_ADDR |
:8080 |
listen address |
GOCOV_BASE_URL |
http://localhost:8080 |
public URL used in statuses |
GOCOV_BITBUCKET_USERNAME |
— |
global Bitbucket bot account (with an API token, the account email) |
GOCOV_BITBUCKET_APP_PASSWORD |
— |
the bot's app password or scoped API token |
GOCOV_GITHUB_TOKEN |
— |
global GitHub token for repos without their own credentials |
GOCOV_GITHUB_APP_ID |
— |
GitHub App id; with the key, enables one-click workspace connect |
GOCOV_GITHUB_APP_PRIVATE_KEY |
— |
the App's private key: PEM content, or a path to the PEM file |
GOCOV_SECRET_KEY |
— |
at-rest encryption key (long random value, e.g. openssl rand -hex 32); with the Bitbucket OAuth consumer, enables one-click workspace connect |
GOCOV_OAUTH_BITBUCKET_KEY |
— |
Bitbucket OAuth consumer key; with the secret, turns on web UI sign-in |
GOCOV_OAUTH_BITBUCKET_SECRET |
— |
Bitbucket OAuth consumer secret |
GOCOV_OAUTH_GITHUB_KEY |
— |
GitHub OAuth app client id; with the secret, turns on web UI sign-in |
GOCOV_OAUTH_GITHUB_SECRET |
— |
GitHub OAuth app client secret |
GOCOV_ALLOWED_WORKSPACES |
derived from tracked repos |
comma-separated workspace/org slugs allowed to sign in |
GOCOV_MODE |
private |
hosted opens sign-in to any forge account with self-service workspace registration |
Forge credentials resolve per repo along a precedence chain: a
one-click connection (GitHub App installation, or Bitbucket workspace
grant) beats per-repo credentials (repo update -bb-username ... /
-gh-token ...) beats workspace credentials (set on the workspace
settings page in the UI) beats the global bot credentials above.
Whichever wins is used for build statuses, PR comments, diff coverage
and default branch detection.
Bitbucket token permissions
Workspaces connected through the one-click grant need none of this.
The manual bot credential (a scoped API token; Bitbucket removed app
passwords in July 2026) needs:
| capability |
API token scopes |
app password checkboxes |
| build status, Code Insights report + annotations, source view, default branch |
read:repository:bitbucket, write:repository:bitbucket |
Repositories: Read, Write |
| PR diff coverage, PR comment |
read:pullrequest:bitbucket, write:pullrequest:bitbucket |
Pull requests: Read, Write |
| updating the PR comment in place |
read:user:bitbucket |
Account: Read |
Without the account/user scope everything still works, but gocov cannot
recognize its own earlier comment, so every upload posts a new PR
comment instead of updating the existing one. If comments stack, this
scope is the fix.
The OAuth consumer used for web UI sign-in is separate and needs the
Account: Read and Email permissions on the consumer itself.
GitHub token permissions
Workspaces connected through the GitHub App need none of this — the
App's own permissions cover every surface, check runs included. The
token path remains for repos outside a connected workspace:
The GitHub credential (GOCOV_GITHUB_TOKEN or repo add/update -gh-token) is a personal access token of a user or bot account with
access to the repos:
- Classic token: the
repo scope covers commit statuses, PR
comments, PR diffs, file content and the default branch. Public repos
get by with public_repo. GitHub does not let classic tokens
write check runs — those uploads report code_insights: skipped
while everything else keeps working.
- Fine-grained token: grant the repositories with Contents:
Read (file content, default branch, PR diffs), Commit statuses:
Write (build status), Pull requests: Write (PR comment) and
Checks: Write (the check run with inline annotations).
GitHub documents check-run writes as a GitHub App capability; a
fine-grained token with Checks: Write is the closest a plain credential
gets, and coverage has historically had gaps on some repo types. If the
check run is skipped for you, the commit status and PR comment still
carry the full verdict.
Unlike Bitbucket, updating the PR comment in place needs no extra
account scope — gocov recognizes its own comment by its **gocov**
marker.
To make the coverage gate blocking on GitHub, add a branch protection
rule under Settings → Branches → Require status checks to pass and
pick gocov (the commit status — works with every credential) or
gocov coverage (the check run). A failed gate then blocks the merge.
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:16-alpine
GOCOV_TEST_DATABASE_URL=postgres://gocov:gocov@localhost:5433/gocov go test ./...
docker stop gocov-test-db
GET /healthz reports readiness (checks database connectivity) for load
balancers and container orchestrators; the server shuts down gracefully
on SIGINT/SIGTERM.
License
AGPL-3.0 — see LICENSE.