From 621b7ad061adc2185de8cee170286e111a3657b5 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 15 Sep 2026 19:52:23 -0400 Subject: [PATCH 1/4] fix(release): wait for the npm tarball before bumping Homebrew changeset publish can succeed several minutes before the public tarball GET returns 200. The brew job fetched immediately, got HTTP 404, and left the tap on the previous version. Poll until the tarball is downloadable. --- .changeset/brew-tap-wait-for-npm.md | 5 + scripts/release/update-brew-formula.mjs | 69 ++++++++++-- scripts/release/update-brew-formula.test.mjs | 106 +++++++++++++++++++ 3 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 .changeset/brew-tap-wait-for-npm.md create mode 100644 scripts/release/update-brew-formula.test.mjs diff --git a/.changeset/brew-tap-wait-for-npm.md b/.changeset/brew-tap-wait-for-npm.md new file mode 100644 index 000000000..059a24c51 --- /dev/null +++ b/.changeset/brew-tap-wait-for-npm.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Wait for the npm tarball to become downloadable before updating the Homebrew formula. diff --git a/scripts/release/update-brew-formula.mjs b/scripts/release/update-brew-formula.mjs index 0db0f9576..6f183ad60 100644 --- a/scripts/release/update-brew-formula.mjs +++ b/scripts/release/update-brew-formula.mjs @@ -1,23 +1,72 @@ +/** + * Bump Formula/pythinker-code.rb in PyModel/homebrew-tap to the published npm + * tarball. `changeset publish` can succeed several minutes before the public + * GET of `/-/pythinker-code-.tgz` returns 200, so the download polls + * until the tarball is fetchable (fetch, sleep, and clock are injected so the + * poll is unit-testable without a network or a real wait). + */ import { createHash } from 'node:crypto'; import { execFileSync, spawnSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +export const NPM_TARBALL_POLL_BUDGET_MS = 600_000; +export const NPM_TARBALL_POLL_INTERVAL_MS = 15_000; + function redactGitOutput(value, token) { const redacted = String(value ?? '').replaceAll(/\/\/x-access-token:[^@\s]*@/gu, '//***@'); return token.length >= 8 ? redacted.replaceAll(token, '***') : redacted; } +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +export async function downloadNpmTarball(options) { + const { url, fetchImpl, sleep, now, budgetMs, intervalMs, log = console.log } = options; + const deadline = now() + budgetMs; + let attempts = 0; + let lastError = new Error('Failed to download npm tarball'); + + for (;;) { + attempts += 1; + try { + const response = await fetchImpl(url); + if (response.status === 200) { + const tarball = Buffer.from(await response.arrayBuffer()); + if (tarball.length > 0) return { tarball, attempts }; + lastError = new Error('Failed to download npm tarball: empty body'); + } else { + lastError = new Error(`Failed to download npm tarball: HTTP ${response.status}`); + } + } catch (error) { + lastError = new Error(`Failed to download npm tarball: ${errorMessage(error)}`, { cause: error }); + } + + if (now() + intervalMs >= deadline) { + throw new Error(`${lastError.message} after ${attempts} attempt(s)`); + } + log(`${lastError.message} (attempt ${attempts}); retrying in ${intervalMs / 1000}s`); + await sleep(intervalMs); + } +} + async function main() { const packageJson = JSON.parse(readFileSync(new URL('../../apps/pythinker-code/package.json', import.meta.url), 'utf8')); const version = packageJson.version; const tarballUrl = `https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${version}.tgz`; - const response = await fetch(tarballUrl); - if (response.status !== 200) throw new Error(`Failed to download npm tarball: HTTP ${response.status}`); - - const tarball = Buffer.from(await response.arrayBuffer()); - if (tarball.length === 0) throw new Error('Failed to download npm tarball: empty body'); + const { tarball } = await downloadNpmTarball({ + url: tarballUrl, + fetchImpl: (url) => fetch(url, { signal: AbortSignal.timeout(15_000) }), + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + now: () => Date.now(), + budgetMs: NPM_TARBALL_POLL_BUDGET_MS, + intervalMs: NPM_TARBALL_POLL_INTERVAL_MS, + }); const sha256 = createHash('sha256').update(tarball).digest('hex'); const token = process.env.TAP_GITHUB_TOKEN; @@ -82,7 +131,9 @@ async function main() { } } -main().catch((error) => { - console.error(error.message); - process.exitCode = 1; -}); +if (process.argv[1] === import.meta.filename) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/release/update-brew-formula.test.mjs b/scripts/release/update-brew-formula.test.mjs new file mode 100644 index 000000000..1ea1421ff --- /dev/null +++ b/scripts/release/update-brew-formula.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { downloadNpmTarball } from './update-brew-formula.mjs'; + +const TARBALL_URL = 'https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-2.0.0.tgz'; +const BODY = Buffer.from('pythinker-tarball'); + +function response(status, body = BODY) { + return new Response(body, { status }); +} + +function pollClock() { + let now = 0; + const sleeps = []; + return { + now: () => now, + sleeps, + sleep: async (ms) => { + sleeps.push(ms); + now += ms; + }, + log() {}, + }; +} + +void test('returns the tarball when the first fetch is HTTP 200', async () => { + const clock = pollClock(); + let fetches = 0; + const result = await downloadNpmTarball({ + url: TARBALL_URL, + fetchImpl: async () => { + fetches += 1; + return response(200); + }, + sleep: clock.sleep, + now: clock.now, + log: clock.log, + budgetMs: 45_000, + intervalMs: 15_000, + }); + + assert.equal(fetches, 1); + assert.equal(result.attempts, 1); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, []); +}); + +void test('retries after HTTP 404 and returns the tarball once npm serves it', async () => { + const clock = pollClock(); + const statuses = [404, 404, 200]; + const result = await downloadNpmTarball({ + url: TARBALL_URL, + fetchImpl: async () => response(statuses.shift() ?? 500), + sleep: clock.sleep, + now: clock.now, + log: clock.log, + budgetMs: 45_000, + intervalMs: 15_000, + }); + + assert.equal(result.attempts, 3); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, [15_000, 15_000]); +}); + +void test('retries an empty 200 body until a non-empty tarball arrives', async () => { + const clock = pollClock(); + const bodies = [Buffer.alloc(0), BODY]; + const result = await downloadNpmTarball({ + url: TARBALL_URL, + fetchImpl: async () => response(200, bodies.shift() ?? BODY), + sleep: clock.sleep, + now: clock.now, + log: clock.log, + budgetMs: 45_000, + intervalMs: 15_000, + }); + + assert.equal(result.attempts, 2); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, [15_000]); +}); + +void test('throws after the budget when every fetch is HTTP 404', async () => { + const clock = pollClock(); + let fetches = 0; + await assert.rejects( + () => + downloadNpmTarball({ + url: TARBALL_URL, + fetchImpl: async () => { + fetches += 1; + return response(404); + }, + sleep: clock.sleep, + now: clock.now, + log: clock.log, + budgetMs: 45_000, + intervalMs: 15_000, + }), + { message: 'Failed to download npm tarball: HTTP 404 after 3 attempt(s)' }, + ); + assert.equal(fetches, 3); + assert.deepEqual(clock.sleeps, [15_000, 15_000]); +}); From 92c15951aba78a300e262529c0ce3313cdbb9ad0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 15 Sep 2026 19:58:53 -0400 Subject: [PATCH 2/4] fix(release): keep nightly snapshots and brew drift from failing CI Nightly `changeset version --snapshot` needs GITHUB_TOKEN to write changelog entries; without it the job fails and opens a false drift issue even when every lane matches. Pass github.token and pull-requests:read. The brew job now has 20 minutes so the npm tarball poll can finish. release-status also reads the tap formula so a missed brew bump is visible on the next nightly instead of only after a user install. --- .github/workflows/nightly.yml | 3 + .github/workflows/release.yml | 2 +- scripts/release/release-status.mjs | 36 ++++++++++ scripts/release/release-status.test.mjs | 22 ++++++ scripts/release/release-workflows.test.mjs | 4 ++ scripts/release/update-brew-formula.test.mjs | 74 ++++++++------------ 6 files changed, 97 insertions(+), 44 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 91b845032..1332a1762 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,6 +12,7 @@ concurrency: permissions: contents: read id-token: write + pull-requests: read jobs: publish: @@ -52,6 +53,8 @@ jobs: run: pnpm build - name: Publish dev snapshot + env: + GITHUB_TOKEN: ${{ github.token }} run: | pnpm changeset version --snapshot dev VERSION=$(node -p "require('./apps/pythinker-code/package.json').version") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd0beb385..1766550fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -486,7 +486,7 @@ jobs: run: node scripts/release/verify-release-consistency.mjs update-brew-tap: - timeout-minutes: 15 + timeout-minutes: 20 # Checkout only; the tap push uses a minted app token, not this one. permissions: contents: read diff --git a/scripts/release/release-status.mjs b/scripts/release/release-status.mjs index a79aff544..f95102849 100644 --- a/scripts/release/release-status.mjs +++ b/scripts/release/release-status.mjs @@ -69,6 +69,24 @@ async function fetchJson(fetchImpl, url, label, init = {}) { } } +async function fetchText(fetchImpl, url, label, init = {}) { + const response = await fetchImpl(url, { + ...init, + headers: { + 'user-agent': 'pythinker-release-status', + ...init.headers, + }, + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}.`); + return await response.text(); +} + +function brewFormulaVersion(formula) { + const match = typeof formula === 'string' ? /pythinker-code-(\d+\.\d+\.\d+)\.tgz/u.exec(formula) : null; + return match === null ? undefined : validVersion(match[1]); +} + function validVersion(value) { return typeof value === 'string' && semver.exec(value)?.[0] === value ? value : undefined; } @@ -162,6 +180,7 @@ export async function collectReleaseStatus({ npmResult, cdnResult, cliReleaseResult, + brewResult, desktopStableResult, desktopReleasesResult, marketplaceResult, @@ -184,6 +203,11 @@ export async function collectReleaseStatus({ 'CLI GitHub release', { headers: github }, ), + fetchText( + fetchImpl, + 'https://raw.githubusercontent.com/PyModel/homebrew-tap/main/Formula/pythinker-code.rb', + 'Homebrew formula', + ), fetchJson( fetchImpl, 'https://api.github.com/repos/PyModel/pythinker-desktop-releases/releases/latest', @@ -260,6 +284,11 @@ export async function collectReleaseStatus({ coverage: targetCoverage(Object.keys(value.downloads ?? {})), })); + const brew = settledValue(brewResult, (value) => { + const version = brewFormulaVersion(value); + if (version === undefined) throw new Error('Homebrew formula has no pythinker-code tarball version.'); + return { version }; + }); const cliMissing = cliRelease.missing ?? expectedCliAssets; const cliOk = npm.version === cliVersion && cliRelease.tag === cliTag @@ -283,6 +312,13 @@ export async function collectReleaseStatus({ details: cdn.error ?? `platforms ${cdn.coverage?.present ?? 0}/${nativeTargets.length}${missingDetail(cdn.coverage?.missing ?? nativeTargets)}`, }, + { + lane: 'Homebrew', + expected: cliVersion, + observed: brew.version ?? 'unavailable', + ok: brew.version === cliVersion && brew.error === undefined, + details: brew.error ?? `Formula/pythinker-code.rb ${brew.version}`, + }, { lane: 'Desktop Stable', expected: desktopVersion, diff --git a/scripts/release/release-status.test.mjs b/scripts/release/release-status.test.mjs index 0d3549182..79d481d23 100644 --- a/scripts/release/release-status.test.mjs +++ b/scripts/release/release-status.test.mjs @@ -50,10 +50,17 @@ function json(body, status = 200) { function fixtureFetch({ cliAssets = expectedCliAssets, nightlyAssets = desktopAssets(desktopNightlyVersion, 'nightly'), + brewVersion = '1.3.0', } = {}) { return async (input) => { const url = new URL(String(input)); if (url.hostname === 'registry.npmjs.org') return json({ latest: '1.3.0' }); + if (url.hostname === 'raw.githubusercontent.com') { + return new Response( + `url "https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${brewVersion}.tgz"\n`, + { status: 200, headers: { 'content-type': 'text/plain' } }, + ); + } if (url.hostname === 'code.pythinker.com') { return json({ version: '1.3.0', @@ -126,6 +133,7 @@ void test('reports all live release lanes aligned', async (t) => { assert.equal(result.ok, true); assert.equal(result.rows.every((row) => row.ok), true); assert.match(renderReleaseStatus(result), /\| npm CLI \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u); + assert.match(renderReleaseStatus(result), /\| Homebrew \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u); assert.match(renderReleaseStatus(result), /\| Desktop Nightly \| 0\.2\.2-nightly\.4102 \| 0\.2\.2-nightly\.4102 \| PASS \|/u); }); @@ -143,6 +151,20 @@ void test('fails when a published CLI release is missing one required asset', as assert.match(cli?.details ?? '', /missing manifest\.json/u); }); +void test('fails when the Homebrew formula lags the published CLI version', async (t) => { + const rootDir = await fixtureRoot(t); + const result = await collectReleaseStatus({ + rootDir, + desktopCommitCount, + fetchImpl: fixtureFetch({ brewVersion: '1.2.0' }), + }); + + assert.equal(result.ok, false); + const brew = result.rows.find((row) => row.lane === 'Homebrew'); + assert.equal(brew?.ok, false); + assert.equal(brew?.observed, '1.2.0'); +}); + void test('fails when the current desktop Nightly release is incomplete', async (t) => { const rootDir = await fixtureRoot(t); const result = await collectReleaseStatus({ diff --git a/scripts/release/release-workflows.test.mjs b/scripts/release/release-workflows.test.mjs index 381755a42..82e2e0fe2 100644 --- a/scripts/release/release-workflows.test.mjs +++ b/scripts/release/release-workflows.test.mjs @@ -30,6 +30,7 @@ void test('release workflow uses full push-boundary lane signals and isolated jo assert.match(workflow, /pythinker_release_tag: \$\{\{ steps\.pythinker-release\.outputs\.tag \|\|/u); assert.match(workflow, /APPLE_CERTIFICATE_P12: \$\{\{ secrets\.MAC_CSC_LINK \}\}/u); assert.match(workflow, /APPLE_NOTARIZATION_KEY_P8: \$\{\{ secrets\.APPLE_API_KEY_P8 \}\}/u); + assert.match(workflow, /^ update-brew-tap:\n timeout-minutes: 20$/mu); }); void test('VS Code release supports isolated recovery and attests verified VSIX files', () => { @@ -56,6 +57,7 @@ void test('native releases fail without requested signing and attest each zip', void test('nightly reconciliation maintains one release drift issue', () => { const workflow = read('.github/workflows/nightly.yml'); + const publishJob = workflow.slice(workflow.indexOf('\n publish:'), workflow.indexOf('\n desktop-nightly:')); assert.match(workflow, /uses: \.\/\.github\/workflows\/desktop-release\.yml/u); assert.match(workflow, /^ desktop-nightly:/mu); assert.match(workflow, /needs: \[publish, desktop-nightly\]/u); @@ -63,6 +65,8 @@ void test('nightly reconciliation maintains one release drift issue', () => { assert.match(workflow, /scripts\/release\/release-status\.mjs/u); assert.match(workflow, /Release lane drift detected/u); assert.match(workflow, /issues: write/u); + assert.match(workflow, /pull-requests: read/u); + assert.equal(publishJob.includes('GITHUB_TOKEN: ${{ github.token }}'), true); assert.doesNotMatch(workflow, /uses: actions\/(checkout|setup-node)@v\d+/u); assert.equal(workflow.match(/persist-credentials: false/gu)?.length, 2); }); diff --git a/scripts/release/update-brew-formula.test.mjs b/scripts/release/update-brew-formula.test.mjs index 1ea1421ff..4c4df5b1d 100644 --- a/scripts/release/update-brew-formula.test.mjs +++ b/scripts/release/update-brew-formula.test.mjs @@ -13,32 +13,38 @@ function response(status, body = BODY) { function pollClock() { let now = 0; const sleeps = []; + const sleep = async (ms) => { + sleeps.push(ms); + now += ms; + }; return { now: () => now, sleeps, - sleep: async (ms) => { - sleeps.push(ms); - now += ms; - }, - log() {}, + sleep, + }; +} + +function pollOptions(clock, fetchImpl) { + return { + url: TARBALL_URL, + fetchImpl, + sleep: (ms) => clock.sleep(ms), + now: () => clock.now(), + log: () => {}, + budgetMs: 45_000, + intervalMs: 15_000, }; } void test('returns the tarball when the first fetch is HTTP 200', async () => { const clock = pollClock(); let fetches = 0; - const result = await downloadNpmTarball({ - url: TARBALL_URL, - fetchImpl: async () => { + const result = await downloadNpmTarball( + pollOptions(clock, async () => { fetches += 1; return response(200); - }, - sleep: clock.sleep, - now: clock.now, - log: clock.log, - budgetMs: 45_000, - intervalMs: 15_000, - }); + }), + ); assert.equal(fetches, 1); assert.equal(result.attempts, 1); @@ -49,15 +55,9 @@ void test('returns the tarball when the first fetch is HTTP 200', async () => { void test('retries after HTTP 404 and returns the tarball once npm serves it', async () => { const clock = pollClock(); const statuses = [404, 404, 200]; - const result = await downloadNpmTarball({ - url: TARBALL_URL, - fetchImpl: async () => response(statuses.shift() ?? 500), - sleep: clock.sleep, - now: clock.now, - log: clock.log, - budgetMs: 45_000, - intervalMs: 15_000, - }); + const result = await downloadNpmTarball( + pollOptions(clock, async () => response(statuses.shift() ?? 500)), + ); assert.equal(result.attempts, 3); assert.deepEqual(result.tarball, BODY); @@ -67,15 +67,9 @@ void test('retries after HTTP 404 and returns the tarball once npm serves it', a void test('retries an empty 200 body until a non-empty tarball arrives', async () => { const clock = pollClock(); const bodies = [Buffer.alloc(0), BODY]; - const result = await downloadNpmTarball({ - url: TARBALL_URL, - fetchImpl: async () => response(200, bodies.shift() ?? BODY), - sleep: clock.sleep, - now: clock.now, - log: clock.log, - budgetMs: 45_000, - intervalMs: 15_000, - }); + const result = await downloadNpmTarball( + pollOptions(clock, async () => response(200, bodies.shift() ?? BODY)), + ); assert.equal(result.attempts, 2); assert.deepEqual(result.tarball, BODY); @@ -87,18 +81,12 @@ void test('throws after the budget when every fetch is HTTP 404', async () => { let fetches = 0; await assert.rejects( () => - downloadNpmTarball({ - url: TARBALL_URL, - fetchImpl: async () => { + downloadNpmTarball( + pollOptions(clock, async () => { fetches += 1; return response(404); - }, - sleep: clock.sleep, - now: clock.now, - log: clock.log, - budgetMs: 45_000, - intervalMs: 15_000, - }), + }), + ), { message: 'Failed to download npm tarball: HTTP 404 after 3 attempt(s)' }, ); assert.equal(fetches, 3); From ee07a655a64909117feb1128940b1eda510bd454 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 15 Sep 2026 20:15:38 -0400 Subject: [PATCH 3/4] fix(release): drop unused lastError initializer in brew tarball poll CodeQL flagged the dummy Error assigned before the fetch loop: every path overwrites it, so the initial object was dead. Keep lastError unset until a real fetch failure, and fall back only if the loop exits without one. --- scripts/release/update-brew-formula.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/release/update-brew-formula.mjs b/scripts/release/update-brew-formula.mjs index 6f183ad60..d377d1fd6 100644 --- a/scripts/release/update-brew-formula.mjs +++ b/scripts/release/update-brew-formula.mjs @@ -27,7 +27,7 @@ export async function downloadNpmTarball(options) { const { url, fetchImpl, sleep, now, budgetMs, intervalMs, log = console.log } = options; const deadline = now() + budgetMs; let attempts = 0; - let lastError = new Error('Failed to download npm tarball'); + let lastError; for (;;) { attempts += 1; @@ -44,10 +44,11 @@ export async function downloadNpmTarball(options) { lastError = new Error(`Failed to download npm tarball: ${errorMessage(error)}`, { cause: error }); } + const message = lastError?.message ?? 'Failed to download npm tarball'; if (now() + intervalMs >= deadline) { - throw new Error(`${lastError.message} after ${attempts} attempt(s)`); + throw new Error(`${message} after ${attempts} attempt(s)`); } - log(`${lastError.message} (attempt ${attempts}); retrying in ${intervalMs / 1000}s`); + log(`${message} (attempt ${attempts}); retrying in ${intervalMs / 1000}s`); await sleep(intervalMs); } } From 546b0fd7baddfe05c81cc7c95c741d77e1b1534e Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 15 Sep 2026 20:32:48 -0400 Subject: [PATCH 4/4] fix(release): use the leftover brew poll budget for a last fetch Stopping when one full interval no longer fits skipped the final window. A tarball that appeared in that leftover time was treated as missing. Sleep the remaining budget and fetch again before failing. --- scripts/release/update-brew-formula.mjs | 8 +++++--- scripts/release/update-brew-formula.test.mjs | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/scripts/release/update-brew-formula.mjs b/scripts/release/update-brew-formula.mjs index d377d1fd6..b1dcd63e3 100644 --- a/scripts/release/update-brew-formula.mjs +++ b/scripts/release/update-brew-formula.mjs @@ -45,11 +45,13 @@ export async function downloadNpmTarball(options) { } const message = lastError?.message ?? 'Failed to download npm tarball'; - if (now() + intervalMs >= deadline) { + const remainingMs = deadline - now(); + if (remainingMs <= 0) { throw new Error(`${message} after ${attempts} attempt(s)`); } - log(`${message} (attempt ${attempts}); retrying in ${intervalMs / 1000}s`); - await sleep(intervalMs); + const waitMs = remainingMs < intervalMs ? remainingMs : intervalMs; + log(`${message} (attempt ${attempts}); retrying in ${waitMs / 1000}s`); + await sleep(waitMs); } } diff --git a/scripts/release/update-brew-formula.test.mjs b/scripts/release/update-brew-formula.test.mjs index 4c4df5b1d..cf8bd7485 100644 --- a/scripts/release/update-brew-formula.test.mjs +++ b/scripts/release/update-brew-formula.test.mjs @@ -87,8 +87,20 @@ void test('throws after the budget when every fetch is HTTP 404', async () => { return response(404); }), ), - { message: 'Failed to download npm tarball: HTTP 404 after 3 attempt(s)' }, + { message: 'Failed to download npm tarball: HTTP 404 after 4 attempt(s)' }, ); - assert.equal(fetches, 3); - assert.deepEqual(clock.sleeps, [15_000, 15_000]); + assert.equal(fetches, 4); + assert.deepEqual(clock.sleeps, [15_000, 15_000, 15_000]); +}); + +void test('sleeps the leftover budget then fetches again before giving up', async () => { + const clock = pollClock(); + const statuses = [404, 404, 404, 200]; + const result = await downloadNpmTarball({ + ...pollOptions(clock, async () => response(statuses.shift() ?? 500)), + budgetMs: 40_000, + }); + assert.equal(result.attempts, 4); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, [15_000, 15_000, 10_000]); });