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' + }); +});