ref: handle fetch errors consistently (Fetcher.fetch)

This commit is contained in:
AJ ONeal
2024-12-16 00:00:22 +00:00
parent fe59a2f35c
commit f1d1027701
14 changed files with 376 additions and 182 deletions

View File

@@ -1,49 +1,56 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
/**
* Gets releases from 'brew'.
*
* @param {null} _
* @param {string} formula
* @returns {PromiseLike<any> | Promise<any>}
* @returns {Promise<any>}
*/
function getDistributables(_, formula) {
async function getDistributables(_, formula) {
if (!formula) {
return Promise.reject('missing formula for brew');
}
return fetch('https://formulae.brew.sh/api/formula/' + formula + '.json')
.then(function (resp) {
if (!resp.ok) {
throw new Error(`HTTP error! Status: ${resp.status}`);
}
return resp.json(); // Parse JSON response
})
.then(function (body) {
var ver = body.versions.stable;
var dl = (
body.bottle.stable.files.high_sierra ||
body.bottle.stable.files.catalina
).url.replace(new RegExp(ver.replace(/\./g, '\\.'), 'g'), '{{ v }}');
return [
{
version: ver,
download: dl.replace(/{{ v }}/g, ver),
},
].concat(
body.versioned_formulae.map(function (f) {
var ver = f.replace(/.*@/, '');
return {
version: ver,
download: dl,
};
}),
);
})
.catch(function (err) {
console.error('Error fetching MariaDB versions (brew)');
console.error(err);
return [];
let resp;
try {
let url = `https://formulae.brew.sh/api/formula/${formula}.json`;
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch '${formula}' release data from 'brew': ${err.response.status} ${err.response.body}`;
}
throw e;
}
let body = JSON.parse(resp.body);
var ver = body.versions.stable;
var dl = (
body.bottle.stable.files.high_sierra || body.bottle.stable.files.catalina
).url.replace(new RegExp(ver.replace(/\./g, '\\.'), 'g'), '{{ v }}');
return [
{
version: ver,
download: dl.replace(/{{ v }}/g, ver),
},
].concat(
body.versioned_formulae.map(
/** @param {String} f */
function (f) {
var ver = f.replace(/.*@/, '');
return {
version: ver,
download: dl,
};
},
),
);
}
module.exports = getDistributables;

56
_common/fetcher.js Normal file
View File

@@ -0,0 +1,56 @@
'use strict';
let Fetcher = module.exports;
/**
* @typedef ResponseSummary
* @prop {Boolean} ok
* @prop {Headers} headers
* @prop {Number} status
* @prop {String} body
*/
/**
* @param {String} url
* @param {RequestInit} opts
* @returns {Promise<ResponseSummary>}
*/
Fetcher.fetch = async function (url, opts) {
let resp = await fetch(url, opts);
let summary = Fetcher.throwIfNotOk(resp);
return summary;
};
/**
* @param {Response} resp
* @returns {Promise<ResponseSummary>}
*/
Fetcher.throwIfNotOk = async function (resp) {
let text = await resp.text();
if (!resp.ok) {
let headers = Array.from(resp.headers);
console.error('[Fetcher] error: Response Headers:', headers);
console.error('[Fetcher] error: Response Text:', text);
let err = new Error(`fetch was not ok`);
Object.assign({
status: 503,
code: 'E_FETCH_RELEASES',
response: {
status: resp.status,
headers: headers,
body: text,
},
});
throw err;
}
let summary = {
ok: resp.ok,
headers: resp.headers,
status: resp.status,
body: text,
};
return summary;
};

View File

@@ -1,5 +1,7 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
let GitHubishSource = module.exports;
/**
@@ -44,30 +46,22 @@ GitHubishSource.getDistributables = async function ({
});
}
let resp = await fetch(url, opts);
if (!resp.ok) {
let headers = Array.from(resp.headers);
console.error('Bad Resp Headers:', headers);
let text = await resp.text();
console.error('Bad Resp Body:', text);
let msg = `failed to fetch releases from '${baseurl}' with user '${username}'`;
throw new Error(msg);
}
let respText = await resp.text();
let gHubResp;
let resp;
try {
gHubResp = JSON.parse(respText);
resp = await Fetcher.fetch(url, opts);
} catch (e) {
console.error('Bad Resp JSON:', respText);
console.error(e.message);
let msg = `failed to parse releases from '${baseurl}' with user '${username}'`;
throw new Error(msg);
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch '${baseurl}' (githubish-source, user '${username}) release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let gHubResp = JSON.parse(resp.body);
let all = {
/** @type {Array<BuildInfo>} */
releases: [],
// TODO make this ':baseurl' + ':releasename'
download: '',
};
@@ -84,6 +78,29 @@ GitHubishSource.getDistributables = async function ({
return all;
};
/**
* @typedef BuildInfo
* @prop {String} [name] - name to use instead of filename for hash urls
* @prop {String} version
* @prop {String} [_version]
* @prop {String} [arch]
* @prop {String} channel
* @prop {String} date
* @prop {String} download
* @prop {String} [ext]
* @prop {String} [_filename]
* @prop {String} [hash]
* @prop {String} [libc]
* @prop {Boolean} [_musl]
* @prop {Boolean} [lts]
* @prop {String} [size]
* @prop {String} os
*/
/**
* @param {any} ghRelease - TODO
* @returns {Array<BuildInfo>}
*/
GitHubishSource.releaseToDistributables = function (ghRelease) {
let ghTag = ghRelease['tag_name']; // TODO tags aren't always semver / sensical
let lts = /(\b|_)(lts)(\b|_)/.test(ghRelease['tag_name']);
@@ -95,6 +112,7 @@ GitHubishSource.releaseToDistributables = function (ghRelease) {
date = date.replace(/T.*/, '');
let urls = [ghRelease.tarball_url, ghRelease.zipball_url];
/** @type {Array<BuildInfo>} */
let dists = [];
for (let url of urls) {
dists.push({
@@ -114,6 +132,9 @@ GitHubishSource.releaseToDistributables = function (ghRelease) {
return dists;
};
/**
* @param {BuildInfo} dist
*/
GitHubishSource.followDistributableDownloadAttachment = async function (dist) {
let abortCtrl = new AbortController();
let resp = await fetch(dist.download, {

View File

@@ -1,5 +1,7 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
/**
* @typedef DistributableRaw
* @prop {String} name
@@ -57,26 +59,18 @@ GitHubish.getDistributables = async function ({
});
}
let resp = await fetch(url, opts);
if (!resp.ok) {
let headers = Array.from(resp.headers);
console.error('Bad Resp Headers:', headers);
let text = await resp.text();
console.error('Bad Resp Body:', text);
let msg = `failed to fetch releases from '${baseurl}' with user '${username}'`;
throw new Error(msg);
}
let respText = await resp.text();
let gHubResp;
let resp;
try {
gHubResp = JSON.parse(respText);
resp = await Fetcher.fetch(url, opts);
} catch (e) {
console.error('Bad Resp JSON:', respText);
console.error(e.message);
let msg = `failed to parse releases from '${baseurl}' with user '${username}'`;
throw new Error(msg);
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch '${baseurl}' (githubish, user '${username}) release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let gHubResp = JSON.parse(resp.body);
let all = {
/** @type {Array<DistributableRaw>} */
@@ -88,13 +82,18 @@ GitHubish.getDistributables = async function ({
try {
gHubResp.forEach(transformReleases);
} catch (e) {
console.error(e.message);
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
console.error(err.message);
console.error('Error Headers:', resp.headers);
console.error('Error Body:', resp.body);
let msg = `failed to transform releases from '${baseurl}' with user '${username}'`;
throw new Error(msg);
}
/**
* @param {any} release - TODO
*/
function transformReleases(release) {
for (let asset of release['assets']) {
let name = asset['name'];

View File

@@ -1,5 +1,7 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
// See <https://googlechromelabs.github.io/chrome-for-testing/>
const releaseApiUrl =
'https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json';
@@ -41,18 +43,23 @@ const releaseApiUrl =
// }
module.exports = async function () {
let resp = await fetch(releaseApiUrl);
if (!resp.ok) {
let text = await resp.text();
let msg = `failed to fetch releases from '${releaseApiUrl}': ${resp.status} ${text}`;
throw new Error(msg);
let resp;
try {
resp = await Fetcher.fetch(releaseApiUrl, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'chromedriver' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let body = await resp.json();
let data = JSON.parse(resp.body);
let builds = [];
for (let release of body.versions) {
for (let release of data.versions) {
if (!release.downloads.chromedriver) {
continue;
}

View File

@@ -1,12 +1,14 @@
'use strict';
var FLUTTER_OSES = ['macos', 'linux', 'windows'];
let Fetcher = require('../_common/fetcher.js');
let FLUTTER_OSES = ['macos', 'linux', 'windows'];
/**
* stable, beta, dev
* @type {Object.<String, Boolean>}
*/
var channelMap = {};
let channelMap = {};
// This can be spot-checked against
// https://docs.flutter.dev/release/archive?tab=windows
@@ -77,16 +79,22 @@ module.exports = async function () {
};
for (let osname of FLUTTER_OSES) {
let response = await fetch(
`https://storage.googleapis.com/flutter_infra_release/releases/releases_${osname}.json`,
{ headers: { Accept: 'application/json' } },
);
if (!response.ok) {
throw new Error(
`Failed to fetch data for ${osname}: ${response.statusText}`,
);
let resp;
try {
let url = `https://storage.googleapis.com/flutter_infra_release/releases/releases_${osname}.json`;
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'flutter' release data for ${osname}: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let data = await response.json();
let data = JSON.parse(resp.body);
let osBaseUrl = data.base_url;
let osReleases = data.releases;

View File

@@ -1,11 +1,13 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
/** @type {Object.<String, String>} */
var osMap = {
let osMap = {
darwin: 'macos',
};
/** @type {Object.<String, String>} */
var archMap = {
let archMap = {
386: 'x86',
};
@@ -57,15 +59,23 @@ async function getDistributables() {
]
};
*/
let response = await fetch('https://golang.org/dl/?mode=json&include=all', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch Go releases: ${response.statusText}`);
}
let goReleases = await response.json();
let resp;
try {
let url = 'https://golang.org/dl/?mode=json&include=all';
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'Go' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let goReleases = JSON.parse(resp.body);
let all = {
/** @type {Array<BuildInfo>} */
releases: [],

View File

@@ -1,5 +1,7 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
let ltsRe = /GnuPG-(2\.2\.[\d\.]+)/;
function createRssMatcher() {
@@ -32,24 +34,29 @@ function createUrlMatcher() {
*/
async function getRawReleases() {
let matcher = createRssMatcher();
let resp = await fetch('https://sourceforge.net/projects/gpgosx/rss?path=/', {
headers: { Accept: 'application/rss+xml' },
});
let text = await resp.text(); // Fetch RSS feed as plain text
if (!resp.ok) {
throw new Error(`Failed to fetch RSS feed: HTTP ${resp.status}: ${text}`);
let resp;
try {
let url = 'https://sourceforge.net/projects/gpgosx/rss?path=/';
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/rss+xml' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'gpg' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let contentType = resp.headers.get('Content-Type');
if (!contentType || !contentType.includes('xml')) {
if (!contentType?.includes('xml')) {
throw new Error(`Unexpected content type: ${contentType}`);
}
let matcher = createRssMatcher();
let links = [];
for (;;) {
let m = matcher.exec(text);
let m = matcher.exec(resp.body);
if (!m) {
break;
}

View File

@@ -1,12 +1,21 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
async function getRawReleases() {
let resp = await fetch('https://iterm2.com/downloads.html', {
headers: { Accept: 'text/html' },
});
let text = await resp.text();
if (!resp.ok) {
throw new Error(`Failed to fetch releases: HTTP ${resp.status}: ${text}`);
let resp;
try {
let url = 'https://iterm2.com/downloads.html';
resp = await Fetcher.fetch(url, {
headers: { Accept: 'text/html' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'iterm2' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let contentType = resp.headers.get('Content-Type');
@@ -14,12 +23,12 @@ async function getRawReleases() {
throw new Error(`Unexpected Content-Type: ${contentType}`);
}
let lines = text.split(/[<>]+/g);
let lines = resp.body.split(/[<>]+/g);
/** @type {Array<String>} */
let links = [];
for (let str of lines) {
var m = str.match(/href="(https:\/\/iterm2\.com\/downloads\/.*\.zip)"/);
let m = str.match(/href="(https:\/\/iterm2\.com\/downloads\/.*\.zip)"/);
if (m && /iTerm2-[34]/.test(m[1])) {
if (m[1]) {
links.push(m[1]);
@@ -36,10 +45,10 @@ async function getRawReleases() {
function transformReleases(links) {
let builds = [];
for (let link of links) {
var channel = /\/stable\//.test(link) ? 'stable' : 'beta';
let channel = /\/stable\//.test(link) ? 'stable' : 'beta';
var parts = link.replace(/.*\/iTerm2[-_]v?(\d_.*)\.zip/, '$1').split('_');
var version = parts.join('.').replace(/([_-])?beta/, '-beta');
let parts = link.replace(/.*\/iTerm2[-_]v?(\d_.*)\.zip/, '$1').split('_');
let version = parts.join('.').replace(/([_-])?beta/, '-beta');
// ex: 3.5.0-beta17 => 3_5_0beta17
// ex: 3.0.2-preview => 3_0_2-preview

View File

@@ -1,31 +1,61 @@
'use strict';
var osMap = {
let Fetcher = require('../_common/fetcher.js');
/** @type {Object.<String, String>} */
let osMap = {
winnt: 'windows',
mac: 'darwin',
};
var archMap = {
/** @type {Object.<String, String>} */
let archMap = {
armv7l: 'armv7',
i686: 'x86',
powerpc64le: 'ppc64le',
};
/**
* @typedef BuildInfo
* @prop {String} version
* @prop {String} [_version]
* @prop {String} [arch]
* @prop {String} channel
* @prop {String} date
* @prop {String} download
* @prop {String} [ext]
* @prop {String} [_filename]
* @prop {String} [hash]
* @prop {String} [libc]
* @prop {Boolean} [_musl]
* @prop {Boolean} [lts]
* @prop {String} [size]
* @prop {String} os
*/
async function getDistributables() {
let all = {
/** @type {Array<BuildInfo>} */
releases: [],
download: '',
_names: ['julia', 'macaarch64'],
};
let resp = await fetch(
'https://julialang-s3.julialang.org/bin/versions.json',
{
headers: {
Accept: 'application/json',
},
},
);
let buildsByVersion = await resp.json();
let resp;
try {
let url = 'https://julialang-s3.julialang.org/bin/versions.json';
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'julia' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let buildsByVersion = JSON.parse(resp.body);
/*
{
@@ -105,6 +135,12 @@ async function getDistributables() {
return all;
}
/**
* @param {Object} a
* @param {String} a.version
* @param {Object} b
* @param {String} b.version
*/
function sortByVersion(a, b) {
let [aVer, aPre] = a.version.split('-');
let [bVer, bPre] = b.version.split('-');

View File

@@ -1,6 +1,8 @@
'use strict';
var oses = [
let Fetcher = require('../_common/fetcher.js');
let oses = [
{
name: 'macOS Sierra',
version: '10.12.6',
@@ -25,7 +27,7 @@ var oses = [
},
];
var headers = {
let headers = {
Connection: 'keep-alive',
'Cache-Control': 'max-age=0',
'Upgrade-Insecure-Requests': '1',
@@ -44,18 +46,22 @@ var headers = {
* @param {typeof oses[0]} os
*/
async function fetchReleasesForOS(os) {
let resp = await fetch(os.url, {
headers: headers,
});
let text = await resp.text();
if (!resp.ok) {
throw new Error(
`Failed to fetch URL: ${os.url}. HTTP ${resp.status}: ${text}`,
);
let resp;
try {
resp = await Fetcher.fetch(os.url, {
headers: headers,
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'macos' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
// Extract the download link
let match = text.match(/(http[^>]+Install[^>]+\.dmg)/);
let match = resp.body.match(/(http[^>]+Install[^>]+\.dmg)/);
if (match) {
return match[1];
}

View File

@@ -1,8 +1,10 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
// https://blog.risingstack.com/update-nodejs-8-end-of-life-no-support/
// 6 mos "current" + 18 mos LTS "active" + 12 mos LTS "maintenance"
//var endOfLife = 3 * 366 * 24 * 60 * 60 * 1000;
//let endOfLife = 3 * 366 * 24 * 60 * 60 * 1000;
// If there have been no updates in 12 months, it's almost certainly end-of-life
const END_OF_LIFE = 366 * 24 * 60 * 60 * 1000;
@@ -89,33 +91,43 @@ async function getDistributables() {
let baseUrl = `https://nodejs.org/download/release`;
// Fetch official builds
let resp = await fetch(`${baseUrl}/index.json`, {
headers: { Accept: 'application/json' },
});
let text = await resp.text();
if (!resp.ok) {
throw new Error(
`Failed to fetch official builds: HTTP ${resp.status}: ${text}`,
);
let resp;
try {
resp = await Fetcher.fetch(`${baseUrl}/index.json`, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'node' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let data = JSON.parse(text);
let data = JSON.parse(resp.body);
void transform(baseUrl, data);
}
{
// Fetch unofficial builds
let unofficialBaseUrl = `https://unofficial-builds.nodejs.org/download/release`;
let resp = await fetch(`${unofficialBaseUrl}/index.json`, {
headers: { Accept: 'application/json' },
});
let text = await resp.text();
if (!resp.ok) {
throw new Error(
`Failed to fetch official builds: HTTP ${resp.status}: ${text}`,
);
// Fetch unofficial builds
let resp;
try {
resp = await Fetcher.fetch(`${unofficialBaseUrl}/index.json`, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'node' (unofficial) release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let data = JSON.parse(text);
let data = JSON.parse(resp.body);
transform(unofficialBaseUrl, data);
}

View File

@@ -1,5 +1,7 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
/**
* @typedef BuildInfo
* @prop {String} version
@@ -7,16 +9,22 @@
*/
async function getDistributables() {
let resp = await fetch(
'https://releases.hashicorp.com/terraform/index.json',
{ headers: { Accept: 'application/json' } },
);
let text = await resp.text();
if (!resp.ok) {
throw new Error(`Failed to fetch releases: HTTP ${resp.status}: ${text}`);
let resp;
try {
let url = 'https://releases.hashicorp.com/terraform/index.json';
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'terraform' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let releases = JSON.parse(resp.body);
let releases = JSON.parse(text);
let all = {
/** @type {Array<BuildInfo>} */
releases: [],

View File

@@ -1,7 +1,9 @@
'use strict';
var NON_BUILDS = ['bootstrap', 'src'];
var ODDITIES = NON_BUILDS.concat(['armv6kz-linux']);
let Fetcher = require('../_common/fetcher.js');
let NON_BUILDS = ['bootstrap', 'src'];
let ODDITIES = NON_BUILDS.concat(['armv6kz-linux']);
/**
* @typedef BuildInfo
@@ -21,15 +23,21 @@ var ODDITIES = NON_BUILDS.concat(['armv6kz-linux']);
*/
module.exports = async function () {
let resp = await fetch('https://ziglang.org/download/index.json', {
method: 'GET',
headers: { Accept: 'application/json' },
});
let text = await resp.text();
if (!resp.ok) {
throw new Error(`Failed to fetch releases: HTTP ${resp.status}: ${text}`);
let resp;
try {
let url = 'https://ziglang.org/download/index.json';
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'zig' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let versions = JSON.parse(text);
let versions = JSON.parse(resp.body);
/** @type {Array<BuildInfo>} */
let releases = [];