BlogREADMEs
Add a chart to a GitHub README that updates itself
GitHub shows any image in a README and runs none of its JavaScript. Write the chart as an image URL, then let a weekly GitHub Action redraw it from fresh numbers.
GitHub cleans a README before anyone sees it. The sanitising step in its Markdown pipeline strips script tags and inline styles, so the code that would draw a Chart.js canvas never runs. Images get through. A chart that comes back from a URL as a PNG goes into the Markdown the way a screenshot does, and GitHub fetches it for every visitor.
Paste one line of Markdown
A RenderChart URL is your public key plus the chart’s parameters, and what comes back is the picture:

x holds the labels and y the values, in the same order. The size, w=760&h=360, is in CSS pixels; the PNG arrives at twice that, 1,520 by 720, so it stays sharp on high-density screens. Any of the ten example charts goes in the same way.
Write every space in the URL as %20 or +. We ran the line through cmarkgfm, the Python binding of the cmark-gfm parser GitHub’s Markdown is built on, once with plain spaces in the labels. The image ended at the first space: what came out was a link to half a URL followed by loose text. With %20 or + it was an <img> again.
A URL written by hand like this is unsigned. Unsigned charts carry a small renderchart.com watermark on every plan, which is why the docs suggest a short URL or a builder-signed one for a chart you paste in once, and which kind of URL goes where sets out the reasoning. A short URL also keeps your key out of a public repository, where anyone could copy it and draw watermarked charts on your allowance.
Mermaid draws charts too, on github.com
GitHub renders Mermaid code blocks, and Mermaid’s XY chart does bars and lines. If the chart will only ever be read on github.com and you are happy typing the numbers into the block by hand, it is a fair choice, and it follows the reader’s colour theme with no extra work.
The trouble starts when the README leaves GitHub. A package’s README is also its page on the registry, so we put one holding an xychart block and a chart image through readme_renderer 46.0, the library PyPI draws project descriptions with. The Mermaid block came out as a <pre lang="mermaid"> of its own source code. The image came out as an image. Anything that shows Markdown without running Mermaid treats the block the same way, while the image goes wherever the README’s other images go.
Match GitHub’s dark mode
Set GitHub to dark and a chart drawn for a white page glares. The theme=dark parameter draws the same chart with light text on a near-black ground, and since August 2022 GitHub has let a README pick an image by colour theme with the <picture> element, so each reader gets the version that suits their setting:
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://c.renderchart.com/s/YOUR_DARK_ID">
<img alt="npm downloads a week" src="https://c.renderchart.com/s/YOUR_LIGHT_ID">
</picture>
The <img> is the fallback, so the light chart goes there. That matters off GitHub too. PyPI’s renderer deleted the <source> line from our test and kept the <img>, so a reader there gets the light chart. The themes section of the URL specification says what each theme changes.
The pair is two URLs, and two renders whenever the data changes.
Redraw it every week with a GitHub Action
Numbers typed into a README go stale within the week. A scheduled job can fetch fresh ones and commit a new chart URL in their place. Ours charts weekly npm downloads, and the fetch is the only part of it that knows that, so swap it for your benchmark results or anything else a script can read.
First mark where the chart lives, so the script can find it again:
## Downloads
<!-- downloads-chart -->
<!-- /downloads-chart -->
Then the script, saved as scripts/readme-chart.mjs. It needs no packages, since Node has had fetch built in since version 18 and the Actions runner already has Node.
// Redraw the downloads chart in README.md from npm's public download counts
import { createHmac } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
const { PACKAGE, RENDERCHART_KEY, RENDERCHART_SECRET } = process.env;
const DAY = 86_400_000;
const iso = (t) => new Date(t).toISOString().slice(0, 10);
// Thirteen whole weeks, Monday to Sunday, ending on the last Sunday npm has had time to count
const today = Date.parse(iso(Date.now()));
let end = today - 3 * DAY;
end -= new Date(end).getUTCDay() * DAY;
const res = await fetch(`https://api.npmjs.org/downloads/range/${iso(end - 90 * DAY)}:${iso(end)}/${PACKAGE}`);
if (!res.ok) throw new Error(`npm answered ${res.status}`);
const { downloads } = await res.json();
// npm reports a day it has no figure for as 0: use the same weekday a week earlier instead
const counts = downloads.map((d) => d.downloads);
for (let i = 7; i < counts.length; i++) if (counts[i] === 0) counts[i] = counts[i - 7];
// One bar a week, labelled with its Monday. The first week only lends its days to the fill
const x = [];
const y = [];
for (let i = 7; i < counts.length; i += 7) {
x.push(new Date(downloads[i].day).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }));
y.push(counts.slice(i, i + 7).reduce((a, b) => a + b, 0));
}
const params = {
key: RENDERCHART_KEY,
type: 'bar',
x: x.join(','),
y: y.join(','),
title: `${PACKAGE}: npm downloads a week`,
w: '760',
h: '360',
};
// Sign: names sorted, values encoded to RFC 3986, HMAC-SHA256 with the secret
const rfc3986 = (v) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
const canonical = '/?' + Object.keys(params).sort().map((k) => `${k}=${rfc3986(params[k])}`).join('&');
const sig = createHmac('sha256', RENDERCHART_SECRET).update(canonical).digest('base64url');
const url = `https://c.renderchart.com/?${new URLSearchParams(params)}&sig=${sig}`;
// Put the new image between the two markers
const markers = /(<!-- downloads-chart -->)[\s\S]*?(<!-- \/downloads-chart -->)/;
const readme = readFileSync('README.md', 'utf8');
if (!markers.test(readme)) throw new Error('README.md has no downloads-chart markers');
writeFileSync('README.md', readme.replace(markers, `$1\n\n$2`));
The first time we ran it, it stopped with “README.md has no downloads-chart markers”. The markers were there. Our first pattern wanted a line break after the opening marker and another before the closing one, and two empty markers on consecutive lines have only one line break between them. The version above matches the markers alone and writes its own line breaks.
npm taught us the second lesson. Its download counts API reports a day it has no figure for as 0, and when we ran the script on 1 November 2026 that was true of 12 of the 91 days, including 13, 14 and 15 October in a row. Summed as they came, those weeks looked like a slump that never happened. So each missing day borrows the same weekday from the week before: an estimate, and a far better one than zero. If your package really does have days with no downloads, delete that loop.
The newest figures take a few days to land as well. On the afternoon of 1 November, 30 and 31 October still read 0, which is why the script ends its range on the last Sunday at least three days back. For a run on 1 November that was 25 October, where the chart at the top of this page stops.
The signing lines are the Node version from the signing guide, cut down because the script builds every parameter itself. A signed chart has no watermark on a paid plan. Nobody can edit its numbers either: when we changed one value in our signed URL, it came back as the 401 image with X-RC-Error: bad_signature. Set the key to accept signed URLs only, and anyone who copies it from your README cannot make charts with it. Our first request for the new chart took 3.0 seconds while it was drawn, the second 0.48 seconds from cache, and the PNG weighed 62 KB. (The labels say “Sept” because that is how Node abbreviates September in British English.)
The workflow
Save this as .github/workflows/readme-chart.yml:
name: README chart
on:
schedule:
- cron: '17 6 * * 3' # Wednesdays at 06:17 UTC, once npm has counted the weekend
workflow_dispatch:
permissions:
contents: write
jobs:
chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: node scripts/readme-chart.mjs
env:
PACKAGE: your-package
RENDERCHART_KEY: ${{ vars.RENDERCHART_KEY }}
RENDERCHART_SECRET: ${{ secrets.RENDERCHART_SECRET }}
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898299+github-actions[bot]@users.noreply.github.com"
git add README.md
git diff --cached --quiet || (git commit -m "Update downloads chart" && git push)
The public key goes in a repository variable named RENDERCHART_KEY and the secret in a repository secret named RENDERCHART_SECRET, both under Settings, then Secrets and variables, then Actions. The secret never reaches the README or the log. Only the signature it makes does.
Wednesday gives npm time to count the weekend. The odd minute is on purpose too: GitHub warns that scheduled runs are delayed at busy times and names the start of every hour as one of them. The same page says a schedule only runs on the default branch, and that in a public repository it is switched off after 60 days with no activity. If the chart stops moving, check the workflow is still enabled.
workflow_dispatch puts a “Run workflow” button on the Actions tab. Use it for the first run. The last step commits only when the README has changed; we checked that step in a local repository, and the whole file against actionlint 1.7.12, which found nothing to report. The hourly Slack chart tutorial runs the same kind of job from cron, for a chart posted to a channel every hour.
Keep the README still with a live short URL
A commit every Wednesday is noise in some histories. On Pro, a short URL’s data can change after you publish it, so the README holds one fixed URL and the job sends new numbers to it. Replace everything in the script from the signing comment down with this:
// Pro: send the new data to the saved chart. Its short URL stays the same, so README.md never changes
const { key, ...chart } = params;
const update = await fetch(`https://app.renderchart.com/api/v1/charts/${process.env.CHART_ID}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${RENDERCHART_SECRET}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ params: chart }),
});
if (!update.ok) throw new Error(`RenderChart answered ${update.status}: ${await update.text()}`);
Paste the short URL into the README once, add CHART_ID (the part after /s/) to the run step’s env, and drop the commit step. When we tried it against a test chart, the first requests after the update still got the old picture, and within two minutes the same URL served the new one. Live short URLs covers the rest, and the endpoint is in the REST API docs.
How GitHub caches the image
Visitors never request your chart URL themselves. GitHub fetches the image through its own proxy, Camo, and serves a copy from a camo.githubusercontent.com address, as its page on anonymised URLs explains. How long Camo trusts its copy depends on the Cache-Control header that came with it.
A chart URL says a year, and immutable. For a README updated by commits, that is right. New numbers make a new URL, Camo has never seen it, and so it fetches the new chart. One side effect is that a chart GitHub has already fetched can go on showing after you revoke its key.
A short URL says max-age=300, five minutes, so a live chart is checked again soon after it changes. If a chart on GitHub ever looks stuck, the same GitHub page has a last resort: send curl -X PURGE to the image’s Camo address, which you can copy from the README with a right click.
When GitHub shows the wrong picture
RenderChart answers a bad URL with a PNG that says what went wrong, so a broken chart in a README shows as an error picture rather than an empty box. Open the chart URL in a browser to read it, or ask for the header:
curl -sI "$CHART_URL" | grep -i x-rc-error
For the unsigned version of our chart it said signature_required, because keys created on a paid plan accept signed URLs only until you untick that box. For the edited one, bad_signature. You will find every error image and its header in the URL specification.
What it costs
A render is the first request for a chart. Every request after that comes from cache and costs nothing, Camo’s included, so the traffic a README gets never touches your allowance. The weekly job makes one new chart a week. That is four or five renders a month, or twice that with a dark twin.
The free plan’s 50 renders a month cover it, with the watermark on every chart, signed or not. Starter, at $29 a month, draws signed charts and short URLs without it. Live short URLs come with Pro at $79.
The script and the workflow above are the whole job, short of a key. Create a free account and put your key and secret into the repository’s settings. Then press “Run workflow”. The chart it commits uses one of your 50 free renders, and each week after that uses one more.