Most of what I build as a freelancer lives in private client repositories. That's normal for client work, but it made my GitHub profile look half-empty: the public stats cards counted only the 20% of my work that happened to be public.

Then the public github-readme-stats instance went down (#3851), and my cards turned into broken-image icons. I took that as the push to build my own: a small Cloudflare Worker that counts private work safely and that I now run for free.

It's open source: kiri1101/github-profile-cards (MIT, with a Deploy to Cloudflare button).

The privacy problem: totals yes, names never

The goal was simple to state: show how much private work I do, without revealing what it is. No repository names, no client names, ever, not on the card and not in logs.

So the Worker only ever stores and renders totals. Each row on the card shows public, private and total, where private is simply total − public. The public part comes from counting only contributions in public repositories. The names never leave GitHub's API response; they're summed away immediately.

Why it needs two tokens

This surprised me. No single GitHub token gives you everything:

  • A classic token with only read:user returns your total commit contributions for the year, private ones included. But the PR and issue counts it can see are public-only.
  • A fine-grained token with read-only Metadata, Issues and Pull requests on all your repositories sees your private PRs and issues. It also sees your repositories' language breakdown.

Neither token can write anything. The Worker reads which account owns them (query { viewer { login } }), so there's no username to configure. If the two tokens belong to different accounts, it refuses to refresh rather than mixing two people's numbers on one card.

Cron + KV: never rate-limited, never "down"

Rendering cards on every request is what makes public instances expensive and fragile. This Worker does the opposite:

  • A cron trigger every 6 hours fetches the numbers, validates them, renders light and dark SVGs, and stores them in Workers KV.
  • Requests just read KV, which is fast and costs nothing on the free plan. If KV is ever empty or unreachable, you get a placeholder card, never a broken image.
  • New data is validated before it replaces the old: a sudden drop (say, a counter falling by more than 10%) is held back until the same drop repeats four times, so a flaky API response can never wipe your card.

A GitHub README trap worth knowing

If you wrap a <picture> (the light/dark image switcher) in a link, GitHub's README renderer moves the <img> into its own image link outside your anchor. Clicks then open the raw image, and theme switching breaks. The fix for linked cards: one SVG that themes itself with @media (prefers-color-scheme: dark) inside, used as a plain <a><img></a>.

Tests that can actually fail

There's a fair complaint going around that AI-assisted code often comes with tests that always pass, because they were written to match the code rather than the feature. I wanted proof mine weren't like that, so I used mutation testing (StrykerJS). It plants small bugs in your code (flips a < to <=, deletes a call, changes a constant) and checks whether any test notices.

The first run scored 84%: one planted bug in six went unnoticed by a suite that looked fine by coverage. After adding sharp tests whose expected values come from the spec, not from the code's own output, the critical modules (grade formula, validation, privacy split, escaping, refresh safety) score 100%. CI fails if it ever drops.

I also deleted 30 tests that caught none of the planted bugs. Fewer tests, each of which matters.

Try it

  1. Click Deploy to Cloudflare in the README.
  2. Paste two tokens (the README links straight to GitHub's token pages with the right scopes).
  3. Embed the two SVG URLs in your profile README.

It runs on the free plan, and an unedited copy shows neutral cards. You can add your own project cards and tech-stack blocks in one config file.

The grade uses the calculateRank formula from github-readme-stats, credit where it's due. Issues and ideas are welcome on the repo.