Skip to content

Commit 8451391

Browse files
committed
ci: retry desktop runtime staging on a transient registry failure
`pnpm deploy --legacy` re-resolves from the registry and ignores the lockfile, so a dependency published in a partially-propagated state fails the desktop packaging gate even though every pin in the lockfile is installable. That is what turned the release pull request red: `@tanstack/react-query@5.102.3` resolved while its `query-core` peer of the same version did not yet, in a workspace package the shipped runtime never loads. The deploy now runs up to three times with a widening back-off. A deterministic failure still fails, and still surfaces its own error rather than a wrapper — it just costs the waits first. That is the trade: about half a minute added to a genuinely broken build, against a release gate that no longer goes red because npm was mid-publish. This treats the symptom. The resolution should not reach unrelated workspace packages in the first place; that needs the deploy scoped, which cannot be verified without a cold CI runner.
1 parent 496169d commit 8451391

2 files changed

Lines changed: 125 additions & 7 deletions

File tree

apps/desktop/scripts/stage-runtime.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,60 @@ export function deployTargetArgument(workspaceRoot: string, target: string): str
5757
return relative(workspaceRoot, target)
5858
}
5959

60+
/** How many times the deploy is attempted before the build gives up. */
61+
export const DEPLOY_ATTEMPTS = 3
62+
63+
/**
64+
* Back-off before a retry, in milliseconds, indexed by the retry number.
65+
*
66+
* `pnpm deploy --legacy` re-resolves from the registry and ignores the
67+
* lockfile, so a package published in a partially-propagated state fails the
68+
* build even though every pin here is installable. That happened with
69+
* `@tanstack/react-query@5.102.3`, whose `query-core` peer of the same version
70+
* was not yet resolvable — the desktop packaging gate went red for a package
71+
* nothing in the shipped runtime uses.
72+
*
73+
* A deterministic failure still fails; it just costs the sum of these waits
74+
* first. That is the trade: about half a minute added to a genuinely broken
75+
* build, against a release gate that no longer turns red because npm was
76+
* mid-publish.
77+
* @param retry - 1 for the first retry, 2 for the second.
78+
* @returns Milliseconds to wait before that retry.
79+
*/
80+
export function deployRetryDelayMs(retry: number): number {
81+
return retry <= 1 ? 5_000 : 20_000
82+
}
83+
84+
/**
85+
* Run an operation, retrying a failure up to {@link DEPLOY_ATTEMPTS} times.
86+
*
87+
* `sleep` and `onRetry` are injected so the policy is testable without a real
88+
* wait or a real registry.
89+
* @param operation - Receives the 1-based attempt number.
90+
* @param options - Injected clock and retry reporter.
91+
* @returns Nothing; the last failure is rethrown when every attempt fails.
92+
*/
93+
export async function withDeployRetries(
94+
operation: (attempt: number) => Promise<void>,
95+
options: {
96+
readonly attempts?: number
97+
readonly sleep: (milliseconds: number) => Promise<void>
98+
readonly onRetry?: (attempt: number, error: unknown) => void
99+
},
100+
): Promise<void> {
101+
const attempts = options.attempts ?? DEPLOY_ATTEMPTS
102+
for (let attempt = 1; ; attempt += 1) {
103+
try {
104+
await operation(attempt)
105+
return
106+
} catch (error) {
107+
if (attempt >= attempts) throw error
108+
options.onRetry?.(attempt, error)
109+
await options.sleep(deployRetryDelayMs(attempt))
110+
}
111+
}
112+
}
113+
60114
async function run(command: string, args: readonly string[]): Promise<void> {
61115
const invocation = packageManagerInvocation(process.platform, command, args)
62116
await new Promise<void>((accept, reject) => {
@@ -109,12 +163,21 @@ async function materializeLinks(): Promise<void> {
109163
async function deploy(target: string): Promise<void> {
110164
const savedWorkspaceState = existsSync(workspaceState) ? await readFile(workspaceState) : undefined
111165
try {
112-
await run('pnpm', [
113-
'--config.verify-deps-before-run=false', '--filter', deployPackage, 'deploy', '--legacy', '--prod',
114-
'--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true',
115-
'--config.allow-unused-patches=true',
116-
deployTargetArgument(repositoryRoot, target),
117-
])
166+
await withDeployRetries(
167+
() => run('pnpm', [
168+
'--config.verify-deps-before-run=false', '--filter', deployPackage, 'deploy', '--legacy', '--prod',
169+
'--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true',
170+
'--config.allow-unused-patches=true',
171+
deployTargetArgument(repositoryRoot, target),
172+
]),
173+
{
174+
sleep: (milliseconds) => new Promise(resolve => { setTimeout(resolve, milliseconds) }),
175+
onRetry: (attempt, error) => {
176+
const reason = error instanceof Error ? error.message : String(error)
177+
console.warn(`desktop runtime staging attempt ${attempt} failed, retrying: ${reason}`)
178+
},
179+
},
180+
)
118181
} finally {
119182
if (savedWorkspaceState === undefined) await rm(workspaceState, { force: true })
120183
else await writeFile(workspaceState, savedWorkspaceState)

apps/desktop/tests/stage-runtime.spec.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { isAbsolute, join } from 'node:path'
22
import { describe, expect, it } from 'vitest'
3-
import { deployTargetArgument, packageManagerInvocation } from '../scripts/stage-runtime'
3+
import {
4+
DEPLOY_ATTEMPTS,
5+
deployRetryDelayMs,
6+
deployTargetArgument,
7+
packageManagerInvocation,
8+
withDeployRetries,
9+
} from '../scripts/stage-runtime'
410

511
describe('package manager invocation', () => {
612
it('leaves non-Windows invocations untouched', () => {
@@ -50,3 +56,52 @@ describe('deploy target argument', () => {
5056
expect(deployTargetArgument('/repo', target)).not.toBe(target)
5157
})
5258
})
59+
60+
describe('deploy retries', () => {
61+
function recorder() {
62+
const waits: number[] = []
63+
return { waits, sleep: async (milliseconds: number) => { waits.push(milliseconds) } }
64+
}
65+
66+
it('does not retry a first-attempt success', async () => {
67+
const { waits, sleep } = recorder()
68+
let calls = 0
69+
70+
await withDeployRetries(async () => { calls += 1 }, { sleep })
71+
72+
expect(calls).toBe(1)
73+
expect(waits).toEqual([])
74+
})
75+
76+
it('retries a transient failure and resolves', async () => {
77+
const { waits, sleep } = recorder()
78+
const retried: number[] = []
79+
80+
await withDeployRetries(
81+
async (attempt) => { if (attempt < 3) throw new Error('ERR_PNPM_NO_MATCHING_VERSION') },
82+
{ sleep, onRetry: (attempt) => retried.push(attempt) },
83+
)
84+
85+
expect(retried).toEqual([1, 2])
86+
expect(waits).toEqual([deployRetryDelayMs(1), deployRetryDelayMs(2)])
87+
})
88+
89+
// A deterministic failure must still fail the build, and must surface its own
90+
// error rather than a wrapper that hides which command broke.
91+
it('rethrows the last failure once the attempts run out', async () => {
92+
const { waits, sleep } = recorder()
93+
let calls = 0
94+
95+
await expect(withDeployRetries(
96+
async () => { calls += 1; throw new Error(`attempt ${String(calls)} failed`) },
97+
{ sleep },
98+
)).rejects.toThrow('attempt 3 failed')
99+
100+
expect(calls).toBe(DEPLOY_ATTEMPTS)
101+
expect(waits).toHaveLength(DEPLOY_ATTEMPTS - 1)
102+
})
103+
104+
it('backs off further on the second retry', () => {
105+
expect(deployRetryDelayMs(2)).toBeGreaterThan(deployRetryDelayMs(1))
106+
})
107+
})

0 commit comments

Comments
 (0)