Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions .github/release-script/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
closureGaps,
classifyGap,
formatDuration,
shouldRetryResolution,
describeGap,
planPublishOrder,
type PublishGroup,
Expand Down Expand Up @@ -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<void> {
if (config.dryRun || packages.length === 0) return;
Expand All @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down
26 changes: 26 additions & 0 deletions .github/release-script/src/publish-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import {
closureGaps,
classifyGap,
formatDuration,
RESOLUTION_MISS,
describeGap,
type PlannablePackage,
planPublishOrder,
type PublishGroup,
type RegistryView,
runtimeDependencies,
stronglyConnectedComponents,
shouldRetryResolution,
sweepDeadlineExceeded,
takeIndependentRun,
} from "./publish-plan.ts";
Expand Down Expand Up @@ -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);
});
25 changes: 25 additions & 0 deletions .github/release-script/src/publish-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}