Skip to content

Commit 2ecbe34

Browse files
authored
ci: retry desktop runtime staging on a transient registry failure (#179)
## Related Issue No issue — this came out of the release pull request going red on a dependency nothing in the shipped runtime uses. ## Problem The `Artifact security` job blocked the `2.0.0` release with: ``` ERR_PNPM_NO_MATCHING_VERSION No matching version found for @tanstack/query-core@5.102.3 This error happened while installing the dependencies of @pymodel/vis-web@0.1.1 The latest release of @tanstack/query-core is "5.102.2". ``` `@tanstack/react-query@5.102.3` had been published before its `query-core` peer of the same version was resolvable. `pnpm-lock.yaml` pins `5.101.4`, which was installable throughout — but `pnpm deploy --legacy` re-resolves from the registry and ignores the lockfile, and says so: ``` WARN A pnpm-lock.yaml file exists. The current configuration prohibits to read or write a lockfile ``` So a mid-publish window at npm, in a dev-only workspace package the desktop runtime never loads, fails a release gate. ## What changed The deploy now runs up to three times with a widening back-off (5s, then 20s). `withDeployRetries` takes its clock by injection, so the policy is tested without a real wait or a real registry. A deterministic failure still fails after the attempts run out, and rethrows its own error rather than a wrapper that would hide which command broke. The trade is explicit: roughly half a minute added to a genuinely broken build, against a release gate that no longer goes red because npm was mid-publish. ### What this is not This treats the symptom. The real question is why a `--filter @pymodel/pythinker-code deploy` resolves `@pymodel/vis-web`'s dependencies at all — that package is a visual debugging tool, already ignored in `.changeset/config.json`, and nothing in the packaged runtime imports it. Scoping the deploy would remove the failure class rather than retry it. That was measured before settling for a retry. Adding `--frozen-lockfile` and `--config.lockfile=true` to the legacy deploy is **inert**: the resulting closure is byte-identical (a `diff` of the full file lists is empty), and mutating a dependency to a non-existent version still exits 0. Dropping `--legacy` fails with `ERR_PNPM_DEPLOY_NONINJECTED_WORKSPACE`, which is why the flag is there. The cold-runner resolution cannot be reproduced on a warm local store, so the scoped fix needs to be developed against CI rather than guessed at here. [skip changeset] — build tooling only; nothing here reaches the published package. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved desktop runtime deployment reliability by automatically retrying failed deployment attempts. - Added increasing wait times between retries to help recover from temporary failures. - Deployment errors are clearly reported when all retry attempts fail. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 362cac1 commit 2ecbe34

2 files changed

Lines changed: 129 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: 60 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,56 @@ 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([5_000, 20_000])
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+
// Ordering alone would accept a 0ms/1ms backoff, which retries faster than a
105+
// registry propagates and turns one retry into three failures in a row.
106+
it('waits five seconds before the first retry and twenty before the second', () => {
107+
expect(deployRetryDelayMs(1)).toBe(5_000)
108+
expect(deployRetryDelayMs(2)).toBe(20_000)
109+
expect(deployRetryDelayMs(2)).toBeGreaterThan(deployRetryDelayMs(1))
110+
})
111+
})

0 commit comments

Comments
 (0)