[Fix] Count withdrawn assets and drop prereleases from the downloads badge - #447
Conversation
|
Warning Review limit reachedNext included review available in 10 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (5)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126Pv8GFR6dszndiaG58D4G
2f2a7f5 to
539438f
Compare
|
Correcting the record on one claim in this PR. The description and the merged code both say GitHub's The rule the sentence was defending is still right, for a better reason. All three assets under that tag are the v0.1.0 binaries ( No number moves: the badge still reads 240. The follow-up corrects the comment in |
#447 selects stable tags by the absence of a `-` in the tag rather than by GitHub's `prerelease` flag, and justified it by saying the flag is set on v0.1.1 by mistake. It is not. That tag is titled "v0.1.1 draft", its body calls it experimental, and it was never published as a release: this project has shipped four, v0.1.0, v0.1.2, v1.0.0 and v1.0.1. The real reason is the stronger one. All three assets under that tag are the v0.1.0 binaries, so their 42 downloads are downloads of the app as it then stood and belong to v0.1.0. Filtering on GitHub's flag would silently drop them, which is precisely why the flag is not used. No behaviour changes and no number moves: the badge still reads 240, and the tag rule gives the right answer for all eight tags. What changes is that a reader who checks the claim now finds it true. STATS.md also gains the per-release breakdown, because "four releases" beside a CSV listing five tags is the confusion this correction exists to prevent. Claude-Session: https://claude.ai/code/session_0126Pv8GFR6dszndiaG58D4G <!-- Title format: [Action] [what] [where or why] good — "Fix the patch panel's empty diff after a trunk update" bad — "Fix bug", "Update component", "Changes" A reviewer should understand this PR in five minutes. Everything above the collapsed sections is what they read first: keep it short, and put depth in the <details> blocks rather than deleting it. Delete the sections that genuinely do not apply — an empty heading is worse than no heading. --> ## Why <!-- Two or three sentences. Which of these you are writing depends on the change: FIXING SOMETHING — what breaks, for whom, and how it is triggered. The error message, the sequence that produces it, what the contributor sees instead of what they expected. BUILDING SOMETHING — what a contributor cannot do today, and what they do instead. The workaround is the argument: "starting a second ticket means another clone and another install" says more than "we should support branches". If an issue already made this case, one line and a link is enough — do not re-argue it. CHANGING HOW WE WORK — process, tooling, docs. What went wrong often enough to be worth a rule. --> ## What changes <!-- The approach, not a tour of the diff. What you changed at the level of ideas, and the one or two decisions a reviewer would otherwise have to reverse-engineer. Alternatives you rejected go in the collapsed section below. For a fix, name the root cause — not just the symptom that goes away. For a feature, say what is deliberately NOT in it. A reviewer who cannot tell a missing piece from a rejected one will ask about every one of them. --> ## How to test this <!-- Required on every PR, including ones with a green suite — this app fails in places `node --test` cannot reach. See AGENTS.md for the full shape. Platforms: any / macOS / Windows — and say why if it is not "any". Buildkite builds signed artifacts for every branch with an open PR, so this can be driven on a real machine without a local build. Check the build matches the current head commit; force-pushing invalidates earlier ones. --> **Starting state:** 1. 2. **What must not have happened:** <!-- The silent regressions. Work quietly discarded, node_modules quietly rebuilt, a patch quietly missing a file. Name what would be easy not to notice. Fixing something? Add the steps that used to reproduce the bug, so a reviewer can watch them fail to reproduce it. And say which test covers it — the standard here is that a bugfix's test fails on the old code, so name it and say you checked. Building something? Walk the path a contributor actually takes, not the shortest path to the new code. Include what happens when they do it wrong. --> ## Risks and limitations <!-- Known gaps, what you deliberately did not do, what could not be tested by hand and why. An honest limitation here is worth more than silence — it is the thing a reviewer would otherwise find and have to ask about. --> ## Related <!-- Fixes #123 / Part of #123 / Follow-up to #123. Use the GitHub keyword when this actually closes the issue, so it closes on merge. --> --- <details> <summary>Design decisions and alternatives considered</summary> <!-- Why this shape and not the obvious one. Approaches rejected, and what ruled them out. If you departed from what an issue asked for, this is where you say so and why — do not let a reviewer discover it from the diff. --> </details> <details> <summary>Review outcome (required — see AGENTS.md)</summary> <!-- Counts first, e.g. "3 [fix here] · 1 [follow-up] — all 3 fixed", then what was fixed and what was deferred with its reason. A deferral is a decision, not an omission. Put the headline count in one line up in "Risks and limitations" if it changes how the PR should be read. --> <!-- Follow "How to report" in .github/instructions/code-review.instructions.md. Add one record per reviewer. For "not run" or "partial", include the reason or remaining scope; after a push, preserve the SHA comparison and re-review outcome. --> - **Review:** completed / partial / not run — reviewer; reviewed head SHA / base SHA; evidence; outcome - **Since review:** none / `<previous SHA> → <current SHA>` checked; re-review outcome </details> <details> <summary>Implementation notes</summary> <!-- Anything a future reader would want and a reviewer does not need up front: file-by-file detail, benchmarks, upstream quirks, links to the API docs that settled a question. --> </details> <details> <summary>Screenshots or recording</summary> <!-- Required for anything with a visible surface — and a new feature almost always has one. Before and after for a change; a short recording of the flow for something new, because a still frame cannot show that a ticket switch takes seconds rather than a rebuild. Delete this block only if nothing on screen changed, and say so where it was. --> </details> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why
The README's downloads badge read shields' live GitHub total, and that number is wrong in both directions.
It undercounts, because a download counter belongs to the asset and dies with it. Four macOS
.dmgfiles 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 onmetrics, which is the whole reason those snapshots exist.It overcounts, because it includes release candidates and betas. A download of
rc.1two months after 1.0.0 shipped is not somebody adopting the app.And
badge.jsonwas already being written every week with nothing reading it, despite the workflow's own comment claiming the README consumed data we control. It did not.What changes
The badge is now computed from the full snapshot history instead of from a live query, by
scripts/download-total.cjs, and the README points atbadge.jsonthrough a shields endpoint badge.Two decisions worth stating outright:
The snapshot records
asset_id. The first version of this inferred "the file was replaced" from "the counter went down". That inference is one-directional and the snapshots are weekly: if a replacement passes the old count before the next Monday, the fall is never observed and the whole history is lost, which is exactly the case the script exists for. GitHub's asset id is unique per upload and was already in the JSON the workflow parses, so it is now a recorded fact rather than a guess. Rows taken before the column existed keep four fields and fall back to the asset name; the week an asset first gains an id its running count is handed over, or every asset alive that week would be counted twice, permanently.A counter that falls now 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. Same reasoning for an unreadable CSV row: refused, not skipped.
Stable tags are selected by the absence of a
-in the tag rather than by GitHub'sprereleaseflag, which is set onv0.1.1by mistake.Deliberately not in this PR: the platform split (
.dmg/.exe/everything else) is a catch-all that only feeds the job log, andisStableTagwould silently exclude a tag that does not follow thev-semver convention. Both are noted as follow-ups below.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).
How to test this
Platforms: any. This is a build-time script and a workflow; nothing runs inside the app.
Starting state:
npm testandnpm run lintboth clean.Then:
To watch the id handover, which is the part with no second chance: append today's counts with ids, as the workflow will, and confirm the total moves by the week's gains rather than doubling.
{ echo 'date,tag,asset,downloads,asset_id'; tail -n +2 /tmp/downloads.csv; } > /tmp/next.csv gh api --paginate repos/WordPress/contributor-toolkit/releases \ | jq -r '.[] | .tag_name as $t | .assets[] | [$t,.name,.download_count,.id] | @csv' \ | sed "s/^/$(date -u +%F),/" >> /tmp/next.csv node scripts/download-total.cjs /tmp/next.csv # 240, not 460Then run Download stats by hand from the Actions tab and check
metrics:downloads.csvhas a five-column header, today's rows carry an id, older rows still have four fields, andbadge.jsonholds the adjusted total. The README badge follows a few minutes later, once shields' cache expires.What must not have happened:
downloadTotals hands a running count over when an asset first gains an id, which fails without it.readSnapshotsthrows on an unreadable row anddownloadTotalsthrows on a falling counter, both underset -euo pipefail, so the job fails beforegit commit.grep -v "^$DATE,"handles it; verified by re-running the append locally.Risks and limitations
Review outcome: 5
[fix here]· 2[follow-up], all 5 fixed, both follow-ups deferred with reasons below.set -euo pipefail, including the orphan-branch first-run path, but CI does not exercise this job on a PR.Related
Follow-up to the
metricsbranch and the weekly snapshot job it feeds. No issue.Design decisions and alternatives considered
Keeping the live shields badge and accepting the wrong number. Rejected: the project has no other usage signal, so the one number it publishes should be defensible. It is also the number that goes into talks and P2 posts.
Inferring a re-upload from a falling counter, with no schema change. This is what the first version of this PR did, and the review killed it. Two failure modes, both silent and permanent: a replacement that overtakes the old count between two Mondays loses the whole history, and a single spurious low reading adds an asset's history a second time, forever, because the badge is recomputed from the full CSV every week. Worth recording that the heuristic had never once fired on the real data: it was dead code standing in for a case it did not actually handle.
Backfilling ids onto the existing rows. Not possible. Those snapshots were taken without the id, and the ids of the deleted assets no longer exist anywhere. The name fallback is as much as those rows can say.
Hardcoding the 89 as a constant. Simpler to write and wrong the next time an asset is withdrawn. The rule in the script covers every future case with no maintenance.
Review outcome (required — see AGENTS.md)
5
[fix here]· 2[follow-up], all 5 fixed, 2 deferred.Fixed:
asset_id(see above).assetKeytest passed for every plausible implementation, bare concatenation included, so it proved nothing. Rewritten with pairs that actually separate them, plus the missing gap-then-return and handover cases..dmgfiles are 89. Corrected in the script,STATS.mdand the commit message.Deferred:
platformOfreturnsLinuxfor anything that is not.dmgor.exe, so a.zipor alatest.ymlwould land there. It only feeds the stderr breakdown in the job log today, and the release matrix is stable; worth revisiting if a non-installer asset is ever published.isStableTagsilently excludes a tag that does not followv-semver (v1.0.0+build,toolkit-1.1.0). Anchoring on a semver regex and failing loudly on an unparseable tag is the right shape, but the repo has never tagged that way and the change is speculative until it does.Deterministic layer:
npm run lintclean,npm test1261/1261..github/instructions/code-review.instructions.md, run before the PR existed); reviewed head6b989cb/ basedd8bc22; five dimensions, with the arithmetic checked againstmetrics@066ad92.64b65af7-568d-4b50-92d2-208988ec7a84(head2f2a7f5) and5ff7af03-6e1d-421a-a28b-e604f08dde20(head539438f, after the rebase), both rate limited, no inline comments posted. The fresh-agent pass above covers the same revision against the same standard.6b989cb → 2f2a7f5: every finding above addressed in that range. Then rebased ontotrunkatac31720([Fix] Stop a build from spawning processes without bound (#275) #395), giving head539438f; that commit touchessrc/only and this one touchesscripts/, the workflow and the docs, so the diffs are disjoint and the reviewed change is unaltered. Re-verified after the rebase: lint clean, 1261 tests, and the script gives 220 on the committed snapshot and 240 with today's counts appended, stable across a further week with no new downloads.Implementation notes
scripts/download-total.cjsaccumulates per(date, asset)over the whole CSV: first sighting counts whole, a rise counts the gain, an absence keeps what the asset earned, a fall throws. The key isid:<asset id>when the row has one andJSON.stringify([tag, asset])when it does not, which is also why the key is JSON rather than a joined string. No separator character is safe in a filename.The workflow stages the script into
$RUNNER_TEMPbeforegit checkout metrics, becausemetricsis an orphan branch holding nothing but the two data files. If the script is ever moved, that step fails the job rather than skipping silently.The CSV header is migrated in place on the first run after this merges: the header line is rewritten to five columns and the existing rows are left at four.
npm testcount went 1258 → 1261 (the new file's 16 tests replace nothing; the delta is against this branch's own earlier revision).🤖 Generated with Claude Code
https://claude.ai/code/session_0126Pv8GFR6dszndiaG58D4G