From 539438fe6956299722939bdd89c4342c2dacab0e Mon Sep 17 00:00:00 2001 From: JuanMa Date: Fri, 11 Sep 2026 12:01:58 +0200 Subject: [PATCH] [Fix] Count withdrawn assets and drop prereleases from the downloads badge The README badge read shields' live GitHub total, which gets the number wrong twice over. It undercounts, because a download counter belongs to the asset and dies with it. Four macOS .dmg files were deleted and re-uploaded when the signing key was rotated, so 89 downloads that really happened are gone from the API. They survive only in the weekly snapshots on `metrics`, which is the whole reason those snapshots exist. And it overcounts, because it includes release candidates and betas. A download of rc.1 two months after 1.0.0 shipped is not somebody adopting the app. `badge.json` was already being written every week and nothing read it, despite the workflow comment claiming the README consumed data we control. Now it does. The snapshot gains an `asset_id` column, because the counter belongs to the upload rather than to the filename: a file replaced under its own name restarts at zero, and the id is the only thing that says so. scripts/download-total.cjs walks the whole of downloads.csv and accumulates per id, falling back to the name for the rows taken before the column existed and handing a running count over the week an asset first gains an id, so nothing is counted twice. A counter that falls is not interpreted, it fails the run: GitHub cannot produce one, so it is bad data, and a badge that is quietly wrong is worse than a workflow that goes red. Stable tags are selected by the absence of a `-` in the tag rather than by GitHub's `prerelease` flag, which is set on v0.1.1 by mistake. Against the snapshots as they stand the badge reads 220 rather than 149. Once this week's run adds today's counts it is 240 against the live total's 180 (macOS 106, Windows 90, Linux 44). It moves once a week instead of live, which is the price of being able to adjust it at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0126Pv8GFR6dszndiaG58D4G --- .github/workflows/download-stats.yml | 38 ++++-- README.md | 2 +- STATS.md | 31 +++-- scripts/download-total.cjs | 178 +++++++++++++++++++++++++++ tests/unit/download-total.test.cjs | 160 ++++++++++++++++++++++++ 5 files changed, 391 insertions(+), 18 deletions(-) create mode 100644 scripts/download-total.cjs create mode 100644 tests/unit/download-total.test.cjs diff --git a/.github/workflows/download-stats.yml b/.github/workflows/download-stats.yml index 50efd039..7a2d9c91 100644 --- a/.github/workflows/download-stats.yml +++ b/.github/workflows/download-stats.yml @@ -2,8 +2,9 @@ # # GitHub exposes only a running total per asset (`download_count`) and no history endpoint, so a # trend can only exist if we write it down ourselves. The counter is also destroyed when an asset -# is deleted and re-uploaded — renaming a published file restarts it at zero — which makes these -# snapshots the only durable record. +# is deleted and re-uploaded (renaming a published file restarts it at zero), which makes these +# snapshots the only durable record: the README badge is computed from them, by +# `scripts/download-total.cjs`, precisely because the live API can no longer produce the number. # # The data lives on `metrics`, an orphan branch sharing no history with trunk: the commit stays # out of trunk's log, and the branch can be deleted without touching anything else. @@ -44,6 +45,11 @@ jobs: # token that can push to any branch, trunk included. A SHA cannot be repointed. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + # The job checks out `metrics` below, an orphan branch that holds only the data files, so + # the script has to be taken off trunk before the working tree stops containing it. + - name: Stage the badge script outside the working tree + run: cp scripts/download-total.cjs "$RUNNER_TEMP/download-total.cjs" + - name: Record today's counts env: GH_TOKEN: ${{ github.token }} @@ -67,22 +73,34 @@ jobs: DATE=$(date -u +%F) gh api --paginate "repos/$GITHUB_REPOSITORY/releases" > releases.json - [ -f downloads.csv ] || echo 'date,tag,asset,downloads' > downloads.csv + HEADER='date,tag,asset,downloads,asset_id' + [ -f downloads.csv ] || echo "$HEADER" > downloads.csv + + # The asset id column was added later. Older rows have four fields and stay that way: + # the id they were taken without cannot be recovered, and the reader falls back to the + # asset name for them. + if [ "$(head -n 1 downloads.csv)" != "$HEADER" ]; then + { echo "$HEADER"; tail -n +2 downloads.csv; } > downloads.next + mv -f downloads.next downloads.csv + fi # A manual re-run replaces the day's rows instead of duplicating them. grep -v "^$DATE," downloads.csv > downloads.next mv -f downloads.next downloads.csv # One row per asset per day: which platform people take is the interesting part, and a - # single total cannot be broken back down later. - jq -r '.[] | .tag_name as $tag | .assets[] | [$tag, .name, .download_count] | @csv' \ + # single total cannot be broken back down later. The asset id goes in because the + # counter belongs to the upload, not to the filename. Re-uploading a file under the + # same name restarts it, and the id is the only thing that says so. + jq -r '.[] | .tag_name as $tag | .assets[] | [$tag, .name, .download_count, .id] | @csv' \ releases.json | sed "s/^/$DATE,/" >> downloads.csv - # A shields.io endpoint badge can read this, so the README's number comes from data we - # control rather than a live third-party query. - TOTAL=$(jq '[.[].assets[].download_count] | add // 0' releases.json) - printf '{"schemaVersion":1,"label":"downloads","message":"%s","color":"blue"}\n' \ - "$TOTAL" > badge.json + # The README's badge reads this file, so the number it shows is computed from the full + # history rather than from whatever the API happens to still be counting today. It + # covers stable tags only and includes assets that have since been withdrawn, which + # the live GitHub total can do neither of. See scripts/download-total.cjs. + TOTAL=$(node "$RUNNER_TEMP/download-total.cjs" downloads.csv) + node "$RUNNER_TEMP/download-total.cjs" downloads.csv --badge > badge.json rm releases.json diff --git a/README.md b/README.md index 884fa2a8..eaee21de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## WordPress Contributor Toolkit (Electron) -[![Unit tests](https://github.com/WordPress/contributor-toolkit/actions/workflows/unit-tests.yml/badge.svg?branch=trunk)](https://github.com/WordPress/contributor-toolkit/actions/workflows/unit-tests.yml) [![Latest release](https://img.shields.io/github/v/release/WordPress/contributor-toolkit)](https://github.com/WordPress/contributor-toolkit/releases/latest) [![Downloads](https://img.shields.io/github/downloads/WordPress/contributor-toolkit/total)](https://github.com/WordPress/contributor-toolkit/releases) +[![Unit tests](https://github.com/WordPress/contributor-toolkit/actions/workflows/unit-tests.yml/badge.svg?branch=trunk)](https://github.com/WordPress/contributor-toolkit/actions/workflows/unit-tests.yml) [![Latest release](https://img.shields.io/github/v/release/WordPress/contributor-toolkit)](https://github.com/WordPress/contributor-toolkit/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FWordPress%2Fcontributor-toolkit%2Fmetrics%2Fbadge.json)](https://github.com/WordPress/contributor-toolkit/blob/trunk/STATS.md) Make WordPress posts: - [WordPress Core Dev Environment Toolkit: A Faster Path to Your First Core Contribution](https://make.wordpress.org/core/2026/04/16/wordpress-core-dev-environment-toolkit-a-faster-path-to-your-first-core-contribution/) (April 2026) diff --git a/STATS.md b/STATS.md index 1488673d..8a593199 100644 --- a/STATS.md +++ b/STATS.md @@ -18,18 +18,20 @@ git show metrics:downloads.csv One row per asset per snapshot, so any release can be broken down by platform: ```csv -date,tag,asset,downloads -2026-07-31,"v0.1.2","wordpress-contributor-toolkit-0.1.2-mac-arm64.dmg",1 -2026-07-31,"v0.1.1","WordPress.Contributor.Toolkit-0.1.0.AppImage",20 -2026-07-31,"v0.1.0","mac-release-arm64.dmg",47 +date,tag,asset,downloads,asset_id +2026-09-07,"v1.0.1","wordpress-contributor-toolkit-1.0.1-mac-arm64.dmg",12,293445711 +2026-09-07,"v1.0.0","wordpress-contributor-toolkit-1.0.0-win-x64.exe",31,281129055 ``` +`asset_id` is GitHub's own id for the uploaded file, and it is what makes a replaced file legible: the counter belongs to the upload, not to the name, so the same filename re-uploaded is a new id starting again at zero. Snapshots taken before that column existed have four fields and keep them. + **The number is cumulative per asset, not per week.** Two consecutive rows for the same asset are running totals; subtract them to get the change between those dates. A few things the data answers directly: ```bash -# Total across every release, on the most recent snapshot +# Raw total across every release on the most recent snapshot, prereleases included and +# withdrawn assets forgotten, that is, the number the badge deliberately does not show git show metrics:downloads.csv | awk -F, -v d="$(git show metrics:downloads.csv | tail -1 | cut -d, -f1)" \ '$1 == d { gsub(/"/, "", $4); sum += $4 } END { print sum }' @@ -37,7 +39,22 @@ git show metrics:downloads.csv | awk -F, -v d="$(git show metrics:downloads.csv git show metrics:downloads.csv | grep 'mac-arm64.dmg' ``` -`badge.json`, on the same branch, holds the current total across every release in the format a [shields.io endpoint badge](https://shields.io/badges/endpoint-badge) reads. +### The badge + +`badge.json`, on the same branch, holds the number the README badge shows, in the format a [shields.io endpoint badge](https://shields.io/badges/endpoint-badge) reads. It is **not** the total GitHub reports, and it is deliberately neither the larger nor the smaller number: + +- **Stable tags only.** Release candidates and betas are excluded, by the `-` in the tag rather than by GitHub's `prerelease` flag, which is set on `v0.1.1` by mistake. A download of `rc.1` two months after 1.0.0 shipped is not somebody adopting the app. They hold 18 downloads between them, frozen since the snapshot of 2026-08-24. +- **Withdrawn assets still count.** An asset that disappears from the snapshots keeps whatever it had earned. Four macOS `.dmg` files were replaced when the signing key was rotated, and the live API now reports nothing for the 89 downloads that preceded that. +- **A re-upload is a new asset, not a correction.** The replacement is counted on top of what the old file had, matched by `asset_id` rather than by guessing from a counter that went down. A counter that does go down is bad data and fails the run, because GitHub cannot produce one. + +[`scripts/download-total.cjs`](scripts/download-total.cjs) does this, over the whole of `downloads.csv`, every time the workflow runs. To reproduce it: + +```bash +git show metrics:downloads.csv > downloads.csv +node scripts/download-total.cjs downloads.csv +``` + +The consequence worth knowing: the badge moves once a week, when the workflow runs, not the moment someone downloads something. ### What the numbers are not @@ -45,6 +62,6 @@ git show metrics:downloads.csv | grep 'mac-arm64.dmg' **Source archives and clones are not included.** Only uploaded release assets are counted — the auto-generated `.zip`/`.tar.gz` and `git clone` are not. -**The counter belongs to the asset, not the release.** Deleting a release file and re-uploading it restarts that file at zero, which is part of why these snapshots exist: they are the only record that survives a rename. It has already happened once, to the three artifacts replaced on the v0.1.2 draft. +**The counter belongs to the asset, not the release.** Deleting a release file and re-uploading it restarts that file at zero, which is part of why these snapshots exist: they are the only record that survives a rename. It has already happened once, to the three artifacts replaced on the v0.1.2 draft, and again to the macOS `.dmg` files when the signing key was rotated. Since the snapshots record `asset_id`, a future one is visible rather than merely suspected. **History starts when the workflow did.** Everything before the first snapshot is unrecoverable — GitHub never stored it. diff --git a/scripts/download-total.cjs b/scripts/download-total.cjs new file mode 100644 index 00000000..68781105 --- /dev/null +++ b/scripts/download-total.cjs @@ -0,0 +1,178 @@ +// The download total behind the README badge, computed from `downloads.csv` on the `metrics` +// branch rather than from a live query to GitHub. +// +// Summing today's `download_count` across today's assets, which is what a live shields badge +// does, gets the number wrong twice over. +// +// It undercounts, because the counter belongs to the asset and dies with it. Four macOS `.dmg` +// files were deleted and re-uploaded when the signing key was rotated, so 89 downloads that +// really happened are gone from the API. They survive only in the snapshots, which is the whole +// reason the snapshots exist. +// +// And it overcounts, because it includes release candidates and betas. A download of `rc.1` two +// months after 1.0.0 shipped is not somebody adopting the app, and counting it as adoption is +// the kind of flattery that makes the number worth nothing. +// +// So the badge counts stable tags only, and counts every download an asset ever recorded, +// including after the asset itself is gone. + +const fs = require('node:fs'); + +// GitHub's asset id is unique per upload, so a replaced file is a different id and its fresh +// counter cannot be confused with the old one's. Snapshots taken before the id was recorded fall +// back to the name, which is as much as they can say. +function assetKey({ id, tag, asset }) { + return id ? `id:${id}` : JSON.stringify([tag, asset]); +} + +// A prerelease is the semver suffix, not GitHub's `prerelease` flag: v0.1.1 is flagged +// prerelease on the API and is a real release with real users. +function isStableTag(tag) { + return !tag.replace(/^v/, '').includes('-'); +} + +function platformOf(assetName) { + const name = assetName.toLowerCase(); + if (name.endsWith('.dmg')) return 'macOS'; + if (name.endsWith('.exe')) return 'Windows'; + return 'Linux'; +} + +// Minimal RFC 4180 reader. The file is written by `jq @csv`, which quotes every string field +// and doubles any quote inside it. +function parseCsv(text) { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + + if (quoted) { + if (char !== '"') { + field += char; + } else if (text[i + 1] === '"') { + field += '"'; + i += 1; + } else { + quoted = false; + } + continue; + } + + if (char === '"') { + quoted = true; + } else if (char === ',') { + row.push(field); + field = ''; + } else if (char === '\n' || char === '\r') { + if (char === '\r' && text[i + 1] === '\n') i += 1; + row.push(field); + if (row.length > 1 || row[0] !== '') rows.push(row); + row = []; + field = ''; + } else { + field += char; + } + } + + row.push(field); + if (row.length > 1 || row[0] !== '') rows.push(row); + + return rows; +} + +// A row this cannot read is a corrupt snapshot, and a corrupt snapshot silently dropped is a +// badge that is quietly too low with nothing to show for it. Refuse instead: the workflow fails +// the run rather than committing a wrong number that nobody can tell is wrong. +function readSnapshots(csvText) { + const rows = parseCsv(csvText); + const body = rows[0] && rows[0][0] === 'date' ? rows.slice(1) : rows; + + return body.map((row, index) => { + const [date, tag, asset, downloads, id] = row; + const count = Number(downloads); + + if (row.length < 4 || !date || !tag || !asset || !Number.isInteger(count) || count < 0) { + throw new Error(`downloads.csv: unreadable row ${index + 2}: ${JSON.stringify(row)}`); + } + + return { date, tag, asset, downloads: count, id: id || '' }; + }); +} + +// Every download each asset ever recorded, whether or not the asset is still published. +// An asset missing from a snapshot is not zero: it was withdrawn, and what it earned stands. +function downloadTotals(csvText, { stableOnly = true } = {}) { + const entries = readSnapshots(csvText).filter((entry) => !stableOnly || isStableTag(entry.tag)); + const dates = [...new Set(entries.map((entry) => entry.date))].sort(); + const byDate = new Map(dates.map((date) => [date, new Map()])); + + for (const entry of entries) { + byDate.get(entry.date).set(assetKey(entry), entry); + } + + const previous = new Map(); + const byPlatform = { macOS: 0, Windows: 0, Linux: 0 }; + let total = 0; + + for (const date of dates) { + for (const [key, entry] of byDate.get(date)) { + // The week the snapshot started recording ids, an asset already in the history + // changes key. Hand its running count over rather than start it again, or every + // asset alive that week is counted twice, for good. + const nameKey = JSON.stringify([entry.tag, entry.asset]); + if (key !== nameKey && !previous.has(key) && previous.has(nameKey)) { + previous.set(key, previous.get(nameKey)); + previous.delete(nameKey); + } + + const before = previous.get(key) ?? 0; + + // GitHub's counters only go up, and a replaced asset gets a new id rather than a + // reset one, so there is no reading this as anything but bad data. + if (entry.downloads < before) { + throw new Error( + `downloads.csv: ${entry.tag} ${entry.asset} fell from ${before} to ${entry.downloads} on ${date}` + ); + } + + previous.set(key, entry.downloads); + byPlatform[platformOf(entry.asset)] += entry.downloads - before; + total += entry.downloads - before; + } + } + + return { total, byPlatform }; +} + +function badge(total) { + return { schemaVersion: 1, label: 'downloads', message: String(total), color: 'blue' }; +} + +module.exports = { assetKey, isStableTag, platformOf, parseCsv, readSnapshots, downloadTotals, badge }; + +// `node scripts/download-total.cjs downloads.csv` prints the total; `--badge` prints the +// shields endpoint document the workflow commits. +if (require.main === module) { + const args = process.argv.slice(2); + const wantsBadge = args.includes('--badge'); + const path = args.find((arg) => !arg.startsWith('--')); + + if (!path) { + console.error('usage: download-total.cjs [--badge]'); + process.exit(1); + } + + const { total, byPlatform } = downloadTotals(fs.readFileSync(path, 'utf8')); + + if (wantsBadge) { + console.log(JSON.stringify(badge(total))); + } else { + console.log(String(total)); + console.error( + `macOS ${byPlatform.macOS} Windows ${byPlatform.Windows} Linux ${byPlatform.Linux}` + ); + } +} diff --git a/tests/unit/download-total.test.cjs b/tests/unit/download-total.test.cjs new file mode 100644 index 00000000..911c59e5 --- /dev/null +++ b/tests/unit/download-total.test.cjs @@ -0,0 +1,160 @@ +// The download total behind the README badge. The cases worth protecting are the ones the live +// GitHub number gets wrong: an asset that was withdrawn, and an asset whose counter was +// restarted by a re-upload. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + assetKey, + isStableTag, + platformOf, + parseCsv, + readSnapshots, + downloadTotals, + badge +} = require('../../scripts/download-total.cjs'); + +const HEADER = 'date,tag,asset,downloads,asset_id\n'; + +// `id` omitted writes a four-field row, which is what every snapshot taken before the id column +// existed looks like. +function csv(...rows) { + const body = rows + .map(([date, tag, asset, downloads, id]) => + id === undefined + ? `${date},"${tag}","${asset}",${downloads}` + : `${date},"${tag}","${asset}",${downloads},${id}` + ) + .join('\n'); + return `${HEADER + body}\n`; +} + +test('assetKey separates two uploads of the same filename', () => { + // The point of the id: a file deleted and re-uploaded under its own name is a different + // counter, and nothing in the name says so. + const name = 'app-mac-arm64.dmg'; + assert.notEqual( + assetKey({ id: '11', tag: 'v1.0.0', asset: name }), + assetKey({ id: '22', tag: 'v1.0.0', asset: name }) + ); +}); + +test('assetKey falls back to the name when a snapshot predates the id column', () => { + assert.equal( + assetKey({ id: '', tag: 'v1.0.0', asset: 'app.dmg' }), + assetKey({ tag: 'v1.0.0', asset: 'app.dmg' }) + ); +}); + +test('assetKey does not let one asset collide with another through its key', () => { + // Concatenating the two fields, with or without a separator a filename could contain, + // would make these two the same asset. + assert.notEqual(assetKey({ tag: 'v1.0.0', asset: 'a' }), assetKey({ tag: 'v1.0.0a', asset: '' })); + assert.notEqual(assetKey({ tag: 'v1', asset: 'a,b' }), assetKey({ tag: 'v1,a', asset: 'b' })); +}); + +test('isStableTag rejects the semver prerelease suffix', () => { + assert.equal(isStableTag('v1.0.0-rc.2'), false); + assert.equal(isStableTag('v1.0.0-beta.1'), false); + assert.equal(isStableTag('v1.0.0'), true); + assert.equal(isStableTag('v1.1.0'), true); +}); + +test('isStableTag keeps v0.1.1, which GitHub flags as a prerelease by mistake', () => { + assert.equal(isStableTag('v0.1.1'), true); +}); + +test('platformOf reads the platform off the file extension', () => { + assert.equal(platformOf('wordpress-contributor-toolkit-1.0.1-mac-arm64.dmg'), 'macOS'); + assert.equal(platformOf('wordpress-contributor-toolkit-1.0.1-win-x64.exe'), 'Windows'); + assert.equal(platformOf('wordpress-contributor-toolkit-1.0.1-linux-x86_64.AppImage'), 'Linux'); +}); + +test('parseCsv keeps a comma that lives inside a quoted field', () => { + const rows = parseCsv('date,tag,asset,downloads\n2026-09-07,"v1.0.0","a,b.dmg",3\n'); + assert.deepEqual(rows[1], ['2026-09-07', 'v1.0.0', 'a,b.dmg', '3']); +}); + +test('readSnapshots refuses a corrupt row rather than dropping it', () => { + // A dropped row is a badge that is quietly too low. Failing the run is the only way anyone + // finds out. + assert.throws(() => readSnapshots(`${HEADER}2026-09-07,"v1.0.0","app.exe",notanumber\n`), /row 2/); + assert.throws(() => readSnapshots(`${HEADER}2026-09-07,"v1.0.0"\n`), /row 2/); +}); + +test('downloadTotals counts the gain between snapshots, not the snapshots', () => { + const text = csv( + ['2026-08-31', 'v1.0.1', 'app-win-x64.exe', 10, '7'], + ['2026-09-07', 'v1.0.1', 'app-win-x64.exe', 11, '7'] + ); + assert.equal(downloadTotals(text).total, 11); +}); + +test('downloadTotals keeps the count of an asset that was withdrawn', () => { + // The .dmg files deleted during the signing-key rotation. They stop appearing in the + // snapshots; the people who downloaded them did not stop existing. + const text = csv( + ['2026-08-17', 'v1.0.0', 'app-mac-arm64.dmg', 21, '1'], + ['2026-08-17', 'v1.0.0', 'app-win-x64.exe', 18, '2'], + ['2026-08-24', 'v1.0.0', 'app-win-x64.exe', 31, '2'] + ); + const { total, byPlatform } = downloadTotals(text); + assert.equal(byPlatform.macOS, 21); + assert.equal(total, 52); +}); + +test('downloadTotals counts a re-upload on top of what the file had under its old id', () => { + // The case a falling counter used to stand in for, and the reason the id is recorded: here + // the replacement passes the old count before the next Monday, so nothing ever falls. + const text = csv( + ['2026-08-17', 'v1.0.0', 'app-mac-arm64.dmg', 47, '1'], + ['2026-08-31', 'v1.0.0', 'app-mac-arm64.dmg', 50, '2'] + ); + assert.equal(downloadTotals(text).total, 97); +}); + +test('downloadTotals hands a running count over when an asset first gains an id', () => { + // The week the snapshot started recording ids. Without the handover every asset alive that + // week is counted twice, permanently. + const text = csv( + ['2026-08-31', 'v1.0.1', 'app-win-x64.exe', 10], + ['2026-09-07', 'v1.0.1', 'app-win-x64.exe', 11, '7'], + ['2026-09-14', 'v1.0.1', 'app-win-x64.exe', 13, '7'] + ); + assert.equal(downloadTotals(text).total, 13); +}); + +test('downloadTotals refuses a counter that fell, which GitHub cannot produce', () => { + const text = csv( + ['2026-08-31', 'v1.0.1', 'app-win-x64.exe', 10, '7'], + ['2026-09-07', 'v1.0.1', 'app-win-x64.exe', 4, '7'] + ); + assert.throws(() => downloadTotals(text), /fell from 10 to 4/); +}); + +test('downloadTotals leaves release candidates and betas out', () => { + const text = csv( + ['2026-09-07', 'v1.0.1', 'app-win-x64.exe', 11, '1'], + ['2026-09-07', 'v1.0.0-rc.2', 'app-rc-win-x64.exe', 5, '2'], + ['2026-09-07', 'v1.0.0-beta.1', 'app-beta-win-x64.exe', 2, '3'] + ); + assert.equal(downloadTotals(text).total, 11); + assert.equal(downloadTotals(text, { stableOnly: false }).total, 18); +}); + +test('downloadTotals reads the snapshots in date order, whatever order the rows arrive in', () => { + const text = csv( + ['2026-09-07', 'v1.0.1', 'app-win-x64.exe', 11, '7'], + ['2026-08-31', 'v1.0.1', 'app-win-x64.exe', 10, '7'] + ); + assert.equal(downloadTotals(text).total, 11); +}); + +test('badge emits the shields endpoint document the workflow commits', () => { + assert.deepEqual(badge(220), { + schemaVersion: 1, + label: 'downloads', + message: '220', + color: 'blue' + }); +});