Skip to content

Commit e156e78

Browse files
aboseclaude
andcommitted
fix: validate GitHub release list and guard against download-count collapse
On 2026-08-17 13:35 UTC the GitHub releases API returned an empty list. getReleaseDetails() trusted it, so the build published totalDownloads=0 and an empty prodReleaseHistory to gh-pages, after which every hourly run failed on the existing history guard. Live data was restored from the last good deploy (53257d7) on gh-pages. - index.js: check HTTP status, require a non-empty array containing at least one prod-app-v release with assets; retry 3x, then exit(1). - downloadCounts.js: refuse to publish if the computed total drops >10% below the live value (override with ALLOW_HISTORY_RESET=true). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6e4be84 commit e156e78

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

build/downloadCounts.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ async function updateDownloadStats(releases) {
5959
const existingData = await getCurrentDownloadData();
6060
console.log(`Current data from ${DOWNLOAD_COUNTS_URL}`, existingData);
6161
if (existingData && existingData.timestamp && existingData.totalDownloads) {
62+
// Download counts are cumulative and only ever go up (barring a release being deleted from GitHub).
63+
// A big drop means we got a bad/partial release list; don't publish it, as that would also feed the
64+
// negative rate and (via download_history) wipe the history graphs.
65+
const MAX_ALLOWED_DROP_FRACTION = 0.1;
66+
const drop = existingData.totalDownloads - data.totalDownloads;
67+
if (drop > existingData.totalDownloads * MAX_ALLOWED_DROP_FRACTION
68+
&& process.env.ALLOW_HISTORY_RESET !== 'true') {
69+
console.error(`[download_counts] computed totalDownloads ${data.totalDownloads} is ${drop} lower than`
70+
+ ` the live value ${existingData.totalDownloads} (>${MAX_ALLOWED_DROP_FRACTION * 100}% drop).`);
71+
console.error(`[download_counts] Refusing to publish. Re-run with ALLOW_HISTORY_RESET=true if this drop is genuine.`);
72+
process.exit(1);
73+
}
6274
const lastTimeStamp = new Date(existingData.timestamp).getTime();
6375
const currentTimeStamp = new Date(data.timestamp).getTime();
6476
const timeDifferenceMinutes = (currentTimeStamp - lastTimeStamp) / (1000 * 60);

build/index.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const fs = require('fs');
22
const downloadCounts = require("./downloadCounts");
33
const downloadHistory = require("./downloadHistory");
44

5-
async function getReleaseDetails() {
5+
async function fetchAllReleasePages() {
66
const headers = {
77
'Accept': 'application/vnd.github.v3+json'
88
};
@@ -18,7 +18,13 @@ async function getReleaseDetails() {
1818

1919
while (nextPage) {
2020
const response = await fetch(nextPage, { headers });
21+
if (!response.ok) {
22+
throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${nextPage}`);
23+
}
2124
const data = await response.json();
25+
if (!Array.isArray(data)) {
26+
throw new Error(`unexpected payload: releases page is not an array (${JSON.stringify(data).slice(0, 200)})`);
27+
}
2228
releases = releases.concat(data);
2329

2430
const linkHeader = response.headers.get('link');
@@ -34,6 +40,43 @@ async function getReleaseDetails() {
3440
return releases;
3541
}
3642

43+
// The GitHub API occasionally returns an empty/partial release list (transient glitches, rate limits, etc).
44+
// If we blindly trust that, we publish "0 downloads" and wipe download_history.json (this happened on
45+
// 2026-08-17 13:35 UTC). So we validate the payload and retry, and never proceed with an unusable list.
46+
function validateReleases(releases) {
47+
if (!Array.isArray(releases)) return 'releases is not an array';
48+
if (releases.length === 0) return 'GitHub returned an empty release list';
49+
const prodReleases = releases.filter(r => !r.prerelease && (r.tag_name || "").startsWith("prod-app-v"));
50+
if (prodReleases.length === 0) return 'no prod-app-v releases in payload';
51+
if (!prodReleases.some(r => Array.isArray(r.assets) && r.assets.length > 0)) {
52+
return 'no prod release has any assets';
53+
}
54+
return null;
55+
}
56+
57+
async function getReleaseDetails(attempts = 3) {
58+
let lastError;
59+
for (let attempt = 1; attempt <= attempts; attempt++) {
60+
try {
61+
const releases = await fetchAllReleasePages();
62+
const problem = validateReleases(releases);
63+
if (problem) {
64+
throw new Error(`unexpected payload: ${problem}`);
65+
}
66+
return releases;
67+
} catch (error) {
68+
lastError = error;
69+
console.warn(`[github_releases] attempt ${attempt}/${attempts} failed: ${error.message}`);
70+
if (attempt < attempts) {
71+
await new Promise(resolve => setTimeout(resolve, 5000 * attempt));
72+
}
73+
}
74+
}
75+
console.error(`[github_releases] fetch failed ${attempts} times. Last error: ${lastError && lastError.message}`);
76+
console.error(`[github_releases] Refusing to publish stats computed from an invalid release list.`);
77+
process.exit(1);
78+
}
79+
3780
function ensureDirectoryExists(dirPath) {
3881
try {
3982
// The 'recursive' option ensures all parent directories are created if not existing

0 commit comments

Comments
 (0)