Overview
Coverage Tracker is a self-hosted dashboard that tracks code coverage, cyclomatic complexity, and code duplication across your GitHub repositories — with trend charts, per-PR diff checks, and README badges.
It runs entirely on your own Cloudflare account: a single Worker serves both the dashboard SPA and the API, backed by a D1 database. Your data stays in your own database — no SaaS, no subscriptions, no third-party access to your metrics.
One instance per deployer, not multi-tenant. A single Worker and one wrangler.json on one apex domain. Everything below assumes you are deploying your own instance.
How it works
The flow is four moving parts: your CI pushes metrics, the Worker validates and stores them, D1 keeps the history, and the dashboard reads trends back.
Install the GitHub App
On the repos you want to track. The Worker registers them automatically via an
installationwebhook.Push from CI
A reporting step runs after your test suite, collects coverage / complexity / duplication numbers, and posts them using a GitHub Actions OIDC token — no static secret.
View trends
The dashboard, served as static assets by the same Worker, is protected by Cloudflare Access so only you can see it.
Embed a badge
Optionally opt a repo into the public, shields.io-compatible badge endpoint and drop the Markdown into your README.
Request routing
Only /api/* hits the Worker first; everything else is served asset-first as a single-page app. Each API route enforces its own auth in code.
POST /api/ci/coverage ← OIDC-verified, project-scoped
GET /api/projects/* ← Cloudflare Access JWT
GET /api/badge/* ← public (per-project opt-in)
POST /api/webhooks/* ← GitHub App HMAC
GET /api/health ← public
* ← dashboard SPA (static assets)Quick start
If you already run on Cloudflare, the whole setup is six moves. Each links to its full section below.
- Add your domain to Cloudflare (DNS proxied through Cloudflare).
- Create the D1 database and apply the migration.
- Create a GitHub App (webhooks + API access).
- Create a GitHub OAuth App (Cloudflare Access login).
- Configure Cloudflare Zero Trust, set secrets, and deploy the Worker.
- Install the GitHub App on your repos.
Or skip the manual route entirely — the Deploy to Cloudflare button provisions the Worker and D1 automatically. You still complete the GitHub App, Zero Trust, and secrets steps afterward.
git clone https://github.com/your-org/coverage-tracker
cd coverage-tracker
npm installPrerequisites
- A Cloudflare account (free tier is sufficient).
- A domain managed by Cloudflare — DNS must be proxied through Cloudflare.
- A GitHub account (personal or org) with admin access to the repos you want to track.
- Node.js 18+ and npm installed locally.
- Wrangler authenticated:
npx wrangler login.
If your domain’s DNS lives elsewhere, add the domain in the Cloudflare dashboard, pick the Free plan, and replace the registrar’s nameservers with the two Cloudflare provides. You do not need to create a DNS record for the Worker subdomain — the deploy step handles that.
Domain & database
Create your D1 database, then wire its id into wrangler.json and apply the schema migration.
npx wrangler d1 create coverageCopy the database_id from the output into the d1_databases entry of wrangler.json:
{
// ...
"d1_databases": [
{
"binding": "DB",
"database_name": "coverage",
"database_id": "paste-your-id-here", // ← add this line
"migrations_dir": "migrations"
}
],
// ...
}Then apply the migration to your remote database:
npm run db:migrate:remoteThe committed wrangler.json intentionally omits database_id so the Deploy to Cloudflare button can provision D1 automatically. For manual installs, add the field as shown. The custom domain is attached in the Cloudflare dashboard after first deploy — no routes entry is needed.
GitHub App
The GitHub App (this step) handles webhook events and API access. The GitHub OAuth App (next section) handles dashboard login via Cloudflare Access. Create them separately — do not conflate them.
From the account or org that will host the app, go to Settings → Developer settings → GitHub Apps → New GitHub App and fill in:
| Field | Value |
|---|---|
| GitHub App name | Globally unique, e.g. your-coverage-tracker |
| Homepage URL | https://coverage-tracker.yourdomain.com |
| Webhook → Active | checked |
| Webhook URL | …/webhooks/github |
| Webhook secret | Generate 32 random bytes — save this value:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
Leave Callback URL, OAuth during installation, and Setup URL blank. Under repository permissions set Metadata: read-only and Checks: read & write — nothing else. Subscribe to both Installation target and Installation repositories events. For a private instance, choose Only on this account.
Convert the private key
GitHub downloads a PKCS#1 .pem; the Worker requires PKCS#8. Convert it with Node — no OpenSSL needed:
node -e "const c=require('crypto'), fs=require('fs');
const key=c.createPrivateKey(fs.readFileSync(process.argv[1],'utf8'));
process.stdout.write(key.export({type:'pkcs8',format:'pem'}));" \
your-app.private-key.pem | npx wrangler secret put GITHUB_APP_PRIVATE_KEYFrom the app’s settings page, note the App ID and Client ID — you set these as secrets next.
Cloudflare Access
In Zero Trust, choose a team name (becomes myteam.cloudflareaccess.com). Then create a GitHub OAuth App for dashboard login with callback URL https://myteam.cloudflareaccess.com/cdn-cgi/access/callback, and add GitHub as an identity provider in Settings → Authentication using that OAuth App’s client id and secret.
You will create two Access applications for the same hostname. The Dashboard app redirects visitors through GitHub login and sets a CF_Authorization session cookie, which the Worker verifies on subsequent browser API calls; the API Bypass app is more specific (/api), so Cloudflare applies it to machine callers — CI OIDC, webhooks, health checks, badges — that cannot complete the browser OAuth flow.
| App | Path | Policy |
|---|---|---|
| Dashboard (Allow) | blank — whole host | Allow → your email. Copy the AUD tag → CF_ACCESS_AUD |
| API Bypass | /api | Bypass → Everyone |
Never put an Access Allow policy on /api/*. Machine callers (CI OIDC, webhooks, health) must reach the Worker unauthenticated at the edge — API auth is enforced in code. The bypass only removes the edge OAuth redirect; no /api/* route is left unprotected.
Secrets
Set every value with wrangler secret put — secrets are never committed. wrangler.json references names only.
npx wrangler secret put GITHUB_APP_ID # numeric, e.g. 1234567
npx wrangler secret put GITHUB_APP_CLIENT_ID # starts with "Iv23…"
npx wrangler secret put GITHUB_APP_PRIVATE_KEY # if not piped earlier
npx wrangler secret put GITHUB_WEBHOOK_SECRET # from the GitHub App step
npx wrangler secret put CF_ACCESS_TEAM_DOMAIN # myteam.cloudflareaccess.com
npx wrangler secret put CF_ACCESS_AUD # AUD tag UUID from Access appDEV_BYPASS_SECRET belongs only in .dev.vars for local dev. Setting it via wrangler secret put silently disables all Access JWT verification.
Deploy the Worker
Make sure dashboard dependencies are installed, then deploy. The command applies pending D1 migrations and compiles the SvelteKit dashboard before uploading.
npm --prefix dashboard install
npm run deploy
# Deployed coverage-tracker triggers
# coverage-tracker.yourdomain.com (custom domain)If Bot Fight Mode or Browser Integrity Check is enabled on your zone, add WAF skip rules for the machine-caller routes (this is separate from the Access bypass — Bot Fight Mode fires before Access):
CLOUDFLARE_API_TOKEN=<token> ZONE_DOMAIN=yourdomain.com \
node scripts/setup-waf-rules.mjsInstall & verify
From the GitHub App’s settings page → Install App → choose the account or org → select repos. This fires an installation: created webhook that populates the owners and projects tables.
Confirm the webhook landed:
npx wrangler d1 execute DB --remote \
--command "SELECT * FROM owners"
npx wrangler d1 execute DB --remote \
--command "SELECT full_slug, default_branch, badge_enabled FROM projects"One row per account in owners and one per repo in projects means the install is complete. badge_enabled is 0 by default — opt in per repo below.
If owners is empty, the webhook was not received or failed before reaching the database — check the Worker logs with npx wrangler tail. If owners has rows but projects is empty, the App was likely installed with All repositories selected and the payload contained no repo list — trigger a manual resync (the installation id is the number at the end of the app’s Configure URL):
curl -X POST https://coverage-tracker.yourdomain.com/api/admin/resync \
-H "Cf-Access-Jwt-Assertion: <your-access-token>" \
-H "Content-Type: application/json" \
-d '{"installationId": YOUR_INSTALLATION_ID}'Fix the issue shown by npx wrangler tail, clear the failed delivery from the dedup table, then redeliver from GitHub App → Advanced → Recent Deliveries → Redeliver:
npx wrangler d1 execute DB --remote \
--command "DELETE FROM webhook_deliveries WHERE delivery_id = 'THE-DELIVERY-ID'"With both tables populated, the Worker is ready to accept metrics — have CI produce a coverage report and let the reporting Action pick it up. See Generating coverage reports for the per-language commands.
Ingest from CI
Add a workflow step that runs after your test suite and posts coverage to /api/ci/coverage using a GitHub Actions OIDC token. There is no static ingest secret: the Worker verifies the token signature and checks the repository claim against your registered projects, so only your repos can push data. Re-running CI for the same commit is a safe no-op.
coverage-tracker upload ./lcov.infoThe reporting Action accepts LCOV, Cobertura XML, JaCoCo XML, or Go’s native coverage profile from any CI — Jest, Vitest, pytest-cov, go test, JaCoCo, SimpleCov. See Generating coverage reports for the per-language commands. Trend history is append-only; PR jobs read baselines but never write.
Generating coverage reports
The reporting Action does not run your tests or install coverage tools. Your CI step produces a report file (LCOV, Cobertura XML, JaCoCo XML, or Go’s native coverage profile); the Action reads it. This page shows the command for each supported language. Coverage Tracker not deployed yet? Start with the Installation Guide.
Format is detected automatically from file content — you don’t set it explicitly:
- First line
mode: set|count|atomic→ Go coverage profile - Starts with
TN:/SF:→ LCOV - XML root
<coverage>→ Cobertura - XML root
<report>→ JaCoCo
coverage-tool is only required when the format resolves to Cobertura — it’s the generator name, used to correct for known differences between Cobertura writers (see the Cobertura quirks table below).
Coverage
| Language | Tool | Format | Command | Default path |
|---|---|---|---|---|
| Go | go tool cover | native profile | go test -coverprofile=coverage.out ./... | coverage.out |
| Python | coverage.py | LCOV | coverage run -m pytest && coverage lcov -o coverage.lcov | coverage.lcov |
| JS/TS | Istanbul (nyc / vitest / jest) | LCOV | vitest run --coverage --coverage.reporter=lcov | coverage/lcov.info |
| Rust | cargo-llvm-cov | LCOV | cargo llvm-cov --lcov --output-path lcov.info | lcov.info |
| C/C++ | gcovr | LCOV | gcovr --lcov -o coverage.lcov | coverage.lcov |
| C# | coverlet | LCOV | dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov | coverage.info |
| Java | JaCoCo | JaCoCo XML | mvn test jacoco:report (Maven) or ./gradlew jacocoTestReport (Gradle) | target/site/jacoco/jacoco.xml / build/reports/jacoco/test/jacocoTestReport.xml |
| Bash | kcov | Cobertura | kcov --include-path=. coverage/ ./script.sh | dynamic — set coverage-path |
| Clojure | Cloverage | LCOV | lein cloverage --lcov | target/coverage/lcov.info |
| Dart | Flutter test / coverage pkg | LCOV | flutter test --coverage | coverage/lcov.info |
| Elixir | ExCoveralls | LCOV | mix coveralls.lcov | cover/lcov.info |
| Erlang | covertool | Cobertura | rebar3 do eunit, cover, covertool generate | dynamic — set coverage-path |
| Haskell | hpc + hpc-codecov | LCOV | cabal test --enable-coverage && hpc-codecov cabal:all -f lcov -o lcov.info | lcov.info |
| Lua | LuaCov + luacov-reporter-lcov | LCOV | luacov -r lcov | luacov.report.out |
| Perl | Devel::Cover + lcov’s perl2lcov | LCOV | cover -test && perl2lcov -o coverage.lcov | coverage.lcov |
| PHP | PHPUnit | Cobertura | XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-cobertura=coverage.xml | coverage.xml |
| Ruby | SimpleCov + simplecov-lcov | LCOV | (configure SimpleCov::Formatter::LcovFormatter in spec_helper.rb) then rspec | coverage/lcov.info |
Go is read from its native coverage profile — no LCOV/Cobertura conversion, so no accuracy loss.
Java’s JaCoCo XML is a different schema, not a dialect of Cobertura XML. It’s parsed with its own module in the reporter, not the Cobertura path.
Automatic report discovery
The “Default path” columns on this page are real probe targets, not just conventions. When coverage-path is unset, the Action probes these paths in a fixed order and uses the first hit:
coverage.outcoverage/lcov.infolcov.infocoverage.lcovcoverage.infocover/lcov.infotarget/coverage/lcov.infotarget/site/jacoco/jacoco.xmlbuild/reports/jacoco/test/jacocoTestReport.xmlcoverage.xmlluacov.report.out
If nothing is found, the Action fails with an error that lists every path it probed. An explicit coverage-path input always wins over probing.
Their default output paths contain dynamic segments (the script name, the app name) — write the file wherever the tool puts it and point coverage-path at it.
Complexity and duplication (optional)
Only coverage is required. Complexity and duplication are opt-in: set the path explicitly (complexity-path / duplication-path), or write the report to the default location below and the Action picks it up automatically.
| Metric | Tool | Command | Default path |
|---|---|---|---|
| Complexity — Go | gocyclo | gocyclo -avg ./... > gocyclo.txt | gocyclo.txt |
| Complexity — Python | Radon | radon cc -j . > radon.json | radon.json |
| Complexity — everything else | Lizard | lizard --xml > lizard-report.xml | lizard-report.xml |
| Duplication — any language | jscpd | npx jscpd . --reporters json --output ./jscpd-report | jscpd-report/jscpd-report.json |
There is no complexity-tool input — Radon (JSON), gocyclo (plain text), and Lizard (XML) are recognized by content shape. When probing finds more than one complexity file, precedence is radon.json → gocyclo.txt → lizard-report.xml; an explicit complexity-path overrides probing entirely.
If max-complexity or max-duplication is configured and no report file for that metric is found — neither at the input path nor at a default location — the Action fails with an actionable error. A metric is skipped silently only when it is both unconfigured and absent.
JaCoCo’s COMPLEXITY counter is already in the coverage report — no separate step needed for Java. An explicit complexity-path (or a probed complexity file) overrides the JaCoCo-derived value.
Cobertura quirks
Cobertura XML is a shared DTD, not an enforced spec — generators disagree on two things the reporter corrects for based on coverage-tool:
coverage-tool | Trust branch-rate? | Notes |
|---|---|---|
gocover-cobertura | No — always 0 | Go’s block-based coverage can’t map to branches |
kcov | Yes | |
covertool | Yes | |
phpunit | Yes | |
gcovr | Yes |
If your coverage-tool isn’t listed, the reporter treats branch-rate as trustworthy by default — open an issue if that’s wrong for your generator.
Status badges
Badge numbers are opt-in per repo. Find the project id, enable it, then paste the snippet into your README. Available metrics: coverage, complexity, duplication.
# find the project id
npx wrangler d1 execute DB --remote \
--command "SELECT id, full_slug FROM projects"
# enable the public badge endpoint
curl -X PATCH …/api/admin/projects/1/badge \
-H "Cf-Access-Jwt-Assertion: <token>" \
-d '{"enabled": true}'Then drop the shields.io endpoint badge into your README:
Dashboard
The SvelteKit dashboard is compiled by wrangler deploy automatically and served as static assets by the same Worker — there is no separate Pages project. After first deploy, visit https://coverage-tracker.yourdomain.com; Cloudflare Access prompts you to log in with the identity provider you configured. Once authenticated, the dashboard shows all registered repos and their per-metric, per-branch trend charts.
Check the SvelteKit build completed, that assets.directory points to ./dashboard/build, and that run_worker_first: ["/api/*"] is set so non-API paths serve the SPA.
API reference
| Method & path | Auth | Description |
|---|---|---|
POST /api/ci/coverage | OIDC | Push typed coverage metrics from CI |
GET /api/projects | Access | List all registered owners and repos |
GET /api/projects/:owner/:repo/metrics | Access | Trend data for one repo |
GET /api/baseline/:owner/:repo | OIDC | Latest value on default branch |
GET /api/badge/:owner/:repo/:metric.json | Public | shields.io endpoint (opt-in repos) |
POST /api/webhooks/github | HMAC | GitHub App installation events |
POST /api/admin/resync | Access | Reconcile projects against GitHub |
PATCH /api/admin/projects/:id/badge | Access | Toggle badge visibility |
GET /api/health | Public | Liveness check |
Ingest payload
line_coverage is required; all other fields are optional. repository, branch, and commit_sha are derived from the OIDC token claims — they are not accepted in the body.
{
"line_coverage": 82.4,
"branch_coverage": 79.1,
"cyclomatic": 4.2,
"cognitive": 2.1,
"duplication_pct": 1.8,
"maintainability": 95.0
}