Skip to content

Commit bb73254

Browse files
authored
Merge branch 'main' into changeset-release/main
2 parents 6cad1e1 + 2ecbe34 commit bb73254

4 files changed

Lines changed: 447 additions & 7 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Changeset policy
2+
3+
# A major bump renames the release and breaks every pinned consumer, and npm
4+
# publishes are irreversible. `gen-changesets` already says a major is never an
5+
# agent's call; this makes that a merge gate rather than a convention.
6+
#
7+
# `labeled`/`unlabeled` are listed so adding the approval label re-runs the
8+
# check instead of leaving a stale red on the pull request.
9+
on:
10+
pull_request:
11+
types: [opened, synchronize, reopened, labeled, unlabeled]
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
major-bump-approval:
18+
name: Major bump needs approval
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 5
21+
22+
steps:
23+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
24+
with:
25+
# The gate diffs against the base commit, which a shallow clone of
26+
# the merge ref alone does not contain.
27+
fetch-depth: 0
28+
29+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
30+
with:
31+
node-version-file: .nvmrc
32+
33+
# The gate decides what ships, so a silent break in the gate is worse
34+
# than a red pull request. Its own cases run first.
35+
- name: Self-test the gate
36+
run: node scripts/release/check-major-changeset.mjs --self-test
37+
38+
# Both values reach the script through the environment and are never
39+
# interpolated into a shell command. `base.sha` is used rather than the
40+
# branch name so no caller-chosen text reaches git at all.
41+
- name: Check for an unapproved major changeset
42+
env:
43+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
44+
PR_LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
45+
run: node scripts/release/check-major-changeset.mjs

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)