diff --git a/.github/release-script/src/index.ts b/.github/release-script/src/index.ts index e278c57dbf..97205eea31 100644 --- a/.github/release-script/src/index.ts +++ b/.github/release-script/src/index.ts @@ -9,6 +9,7 @@ import { closureGaps, classifyGap, formatDuration, + shouldRetryResolution, describeGap, planPublishOrder, type PublishGroup, @@ -1204,10 +1205,13 @@ async function publishPendingPackages( * Measured cost for the full 716-package set on a cold cache: 5.5 minutes, 225 MB unpacked. * Against a two-hour sweep that is the cheapest proof available that the release is installable. */ -/** npm's vocabulary for "that version is not there (yet)", as opposed to a broken artifact. */ -const RESOLUTION_MISS = /ETARGET|E404|notarget|No matching version/i; -const INSTALL_ATTEMPTS = Math.max(1, getEnvInt("NPM_INSTALL_ATTEMPTS", 4)); -const INSTALL_RETRY_MS = Math.max(1_000, getEnvInt("NPM_INSTALL_RETRY_MS", 60_000)); +/** + * Wall-clock budget for re-asking after a resolution miss, NOT a number of attempts: one attempt + * costs 5.5 minutes on the 716-package set and seconds on a single SDK channel, so the same + * attempt count means wildly different patience. See `shouldRetryResolution`. + */ +const INSTALL_LAG_BUDGET_MS = Math.max(0, getEnvInt("NPM_INSTALL_LAG_MS", 10 * 60_000)); +const INSTALL_RETRY_MS = Math.max(1_000, getEnvInt("NPM_INSTALL_RETRY_MS", 30_000)); async function verifyInstallableClosure(packages: Package[], config: Config): Promise { if (config.dryRun || packages.length === 0) return; @@ -1216,7 +1220,8 @@ async function verifyInstallableClosure(packages: Package[], config: Config): Pr console.log(`\nšŸ”Ž Phase 3: resolving the published set from an empty directory (${packages.length} roots)…`); try { - for (let attempt = 1; attempt <= INSTALL_ATTEMPTS; attempt++) { + const probeStartedAt = Date.now(); + for (let attempt = 1; ; attempt++) { await writeFile( join(dir, "package.json"), JSON.stringify( @@ -1247,10 +1252,9 @@ async function verifyInstallableClosure(packages: Package[], config: Config): Pr // and three SDK channels were published cleanly and then reported uninstallable by this // very probe, seconds later. Retry a bounded number of times before believing it — // anything else, and any miss that outlives the budget, still fails. - if (RESOLUTION_MISS.test(output) && attempt < INSTALL_ATTEMPTS) { - console.log( - `ā³ not resolvable yet (attempt ${attempt}/${INSTALL_ATTEMPTS}), waiting ${INSTALL_RETRY_MS / 1000}s…`, - ); + if (shouldRetryResolution(output, probeStartedAt, Date.now(), INSTALL_LAG_BUDGET_MS)) { + const waited = formatDuration((Date.now() - probeStartedAt) / 1000); + console.log(`ā³ not resolvable yet (attempt ${attempt}, ${waited} of ${INSTALL_LAG_BUDGET_MS / 60_000}m)…`); await sleep(INSTALL_RETRY_MS); continue; } diff --git a/.github/release-script/src/publish-plan.test.ts b/.github/release-script/src/publish-plan.test.ts index 92a4dd866f..4c3a5f6d69 100644 --- a/.github/release-script/src/publish-plan.test.ts +++ b/.github/release-script/src/publish-plan.test.ts @@ -29,6 +29,7 @@ import { closureGaps, classifyGap, formatDuration, + RESOLUTION_MISS, describeGap, type PlannablePackage, planPublishOrder, @@ -36,6 +37,7 @@ import { type RegistryView, runtimeDependencies, stronglyConnectedComponents, + shouldRetryResolution, sweepDeadlineExceeded, takeIndependentRun, } from "./publish-plan.ts"; @@ -372,3 +374,27 @@ test("durations read at a glance across the three scales", () => { ["0s", "1m33s", "7m12s", "2h14m"], ); }); + + +// --- asking the registry again, or not -------------------------------------------------- + +test("a version that is not visible yet is worth asking again, inside the budget", () => { + const miss = "npm error code ETARGET\nnpm error notarget No matching version found for @girs/x@5.0.0."; + assert.equal(shouldRetryResolution(miss, 0, 5 * 60_000, 10 * 60_000), true); + assert.equal(shouldRetryResolution(miss, 0, 11 * 60_000, 10 * 60_000), false); +}); + +test("a failure that waiting cannot fix is never retried", () => { + // The arm that matters: retrying these turns one clear red into a slow one. + for (const hard of ["npm error code E403 Forbidden", "npm error code EINTEGRITY", "tarball is not a gzip"]) { + assert.equal(shouldRetryResolution(hard, 0, 1, 10 * 60_000), false, hard); + } +}); + +test("the miss pattern covers the shapes npm actually prints", () => { + assert.deepEqual( + ["ETARGET", "E404 Not Found", "notarget", "No matching version"].map((s) => RESOLUTION_MISS.test(s)), + [true, true, true, true], + ); + assert.equal(RESOLUTION_MISS.test("EPUBLISHCONFLICT"), false); +}); diff --git a/.github/release-script/src/publish-plan.ts b/.github/release-script/src/publish-plan.ts index 51efddbbe8..09e4319626 100644 --- a/.github/release-script/src/publish-plan.ts +++ b/.github/release-script/src/publish-plan.ts @@ -395,3 +395,28 @@ export function formatDuration(seconds: number): string { if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`; return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`; } + + +/** npm's vocabulary for "that version is not there (yet)", as opposed to a broken artifact. */ +export const RESOLUTION_MISS = /ETARGET|E404|notarget|No matching version/i; + +/** + * Whether a failed install probe is worth asking again. + * + * Two things have to be true, and the first matters more than the budget: the failure must LOOK + * like a version that is not visible yet. A broken tarball, a 403, a network error — none of + * those get better by waiting, and retrying them for ten minutes turns one clear red into a slow + * one. + * + * The budget is wall-clock rather than a number of attempts, because an attempt is not a fixed + * cost: probing the 716-package set takes 5.5 minutes on a cold cache, probing one SDK channel + * takes seconds. `NPM_INSTALL_ATTEMPTS` Ɨ 60 s read the same on paper and meant three minutes for + * the channel — which is how `@girs/sdk-gnome-51@5.0.0` was published successfully at 20:12:33 + * and reported uninstallable at 20:15:35. Measured readability lag on the same registry that day + * reached 4m12s (`@girs/matekbd-1.0`), and the packument's own `time` entry is no guide: it said + * 20:12:32.972 for a version no install could resolve three minutes later. + */ +export function shouldRetryResolution(output: string, startedAt: number, now: number, budgetMs: number): boolean { + if (!RESOLUTION_MISS.test(output)) return false; + return now - startedAt < budgetMs; +}