Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions .github/workflows/download-stats.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }}
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
31 changes: 24 additions & 7 deletions STATS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,50 @@ 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 }'

# One asset over time
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

**They count HTTP requests, not people.** A re-download, a `curl` in a CI script, a retry after a dropped connection and a bot crawling the releases page each add one. Treat them as an interest signal, not an install count.

**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.
178 changes: 178 additions & 0 deletions scripts/download-total.cjs
Original file line number Diff line number Diff line change
@@ -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 <downloads.csv> [--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}`
);
}
}
Loading
Loading