Skip to content

Commit e1e7bba

Browse files
committed
fix(vscode): make publishing survive a flaky registry
A timeout mid-run aborted the loop, leaving one target live, four unattempted and the release untagged. Transient failures now retry with backoff, a failed target no longer stops the others, an auth failure stops everything at once, and the summary names which targets are live so a re-run can finish the job.
1 parent a01ec40 commit e1e7bba

5 files changed

Lines changed: 247 additions & 25 deletions

File tree

apps/vscode/scripts/ovsx-publish.mjs

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
33

44
import { runLocalCli } from './local-cli.mjs';
55
import { parsePublishArguments, publishUsage } from './publish-args.mjs';
6+
import { messageOf, publishEachTarget } from './publish-retry.mjs';
67
import { extensionRoot, isMainModule } from './vsix-targets.mjs';
78
import { verifyVsix } from './vsix-verify.mjs';
89

@@ -15,22 +16,24 @@ async function main() {
1516
if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.');
1617

1718
await verifyInputs(options);
18-
for (const file of options.files) {
19-
console.log(`Publishing verified package ${file}...`);
20-
try {
21-
runLocalCli('ovsx', 'ovsx', ['publish', file], {
22-
cwd: extensionRoot,
23-
encoding: 'utf8',
24-
stdio: 'pipe',
25-
});
26-
} catch (error) {
27-
if (/already exists/i.test(error instanceof Error ? error.message : String(error))) {
28-
console.log(`Package already exists: ${file}`);
29-
continue;
19+
await publishEachTarget({
20+
targets: options.targets,
21+
files: options.files,
22+
registry: 'Open VSX',
23+
publishOne: (file) => {
24+
try {
25+
runLocalCli('ovsx', 'ovsx', ['publish', file], {
26+
cwd: extensionRoot,
27+
encoding: 'utf8',
28+
stdio: 'pipe',
29+
});
30+
return 'published';
31+
} catch (error) {
32+
if (/already exists/i.test(messageOf(error))) return 'skipped';
33+
throw error;
3034
}
31-
throw error;
32-
}
33-
}
35+
},
36+
});
3437
}
3538

3639
async function verifyInputs(options) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Publishing to a registry fails in three different ways, and they need three
2+
// different responses: a flaky network call should be retried, a bad token
3+
// should stop everything immediately, and a rejected package should fail only
4+
// its own target so the remaining ones still ship.
5+
const TRANSIENT_PATTERN =
6+
/request timeout|etimedout|econnreset|econnrefused|enotfound|eai_again|socket hang up|network|\b(?:429|500|502|503|504)\b|too many requests|service unavailable|gateway/i;
7+
const AUTH_PATTERN = /\b401\b|unauthorized|invalidaccess|access denied|not allowed|forbidden|\b403\b|authentication|invalid token|expired/i;
8+
9+
export const DEFAULT_ATTEMPTS = 3;
10+
11+
export function messageOf(error) {
12+
return error instanceof Error ? error.message : String(error);
13+
}
14+
15+
/**
16+
* `auth` aborts the whole run — every remaining target would fail identically.
17+
* `transient` is worth retrying. `fatal` fails one target and lets the rest go.
18+
*/
19+
export function classifyError(error) {
20+
const message = messageOf(error);
21+
if (AUTH_PATTERN.test(message)) return 'auth';
22+
if (TRANSIENT_PATTERN.test(message)) return 'transient';
23+
return 'fatal';
24+
}
25+
26+
function delay(ms) {
27+
return new Promise((resolve) => {
28+
setTimeout(resolve, ms);
29+
});
30+
}
31+
32+
/**
33+
* Runs `action`, retrying only transient failures with a widening backoff.
34+
* Returns the action's value; rethrows the last error once attempts run out.
35+
*/
36+
export async function withRetry(action, options = {}) {
37+
const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
38+
const label = options.label ?? 'operation';
39+
const backoffMs = options.backoffMs ?? [5000, 15000];
40+
41+
for (let attempt = 1; ; attempt += 1) {
42+
try {
43+
return await action();
44+
} catch (error) {
45+
const kind = classifyError(error);
46+
if (kind !== 'transient' || attempt >= attempts) {
47+
throw error;
48+
}
49+
const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
50+
console.warn(`${label}: ${kind} failure on attempt ${attempt}/${attempts}, retrying in ${wait / 1000}s...`);
51+
console.warn(` ${messageOf(error).split('\n')[0]}`);
52+
await delay(wait);
53+
}
54+
}
55+
}
56+
57+
/**
58+
* Publishes every target, keeping going after a per-target failure so one flaky
59+
* upload cannot strand the rest. Throws a summary naming exactly which targets
60+
* are live and which still need a re-run, because a half-published version is
61+
* the state that is hardest to reason about afterwards.
62+
*/
63+
export async function publishEachTarget({ targets, files, registry, publishOne }) {
64+
const published = [];
65+
const skipped = [];
66+
const failures = [];
67+
let abortReason = '';
68+
69+
for (let index = 0; index < targets.length; index += 1) {
70+
const target = targets[index];
71+
const file = files[index];
72+
if (abortReason) {
73+
failures.push({ target, message: `not attempted (${abortReason})` });
74+
continue;
75+
}
76+
console.log(`Publishing verified package ${file}...`);
77+
try {
78+
const outcome = await withRetry(() => publishOne(file, target), { label: `${registry} ${target}` });
79+
(outcome === 'skipped' ? skipped : published).push(target);
80+
} catch (error) {
81+
const kind = classifyError(error);
82+
failures.push({ target, message: messageOf(error).split('\n')[0] });
83+
if (kind === 'auth') {
84+
abortReason = 'aborted after an authentication failure';
85+
}
86+
}
87+
}
88+
89+
summarize({ registry, published, skipped, failures });
90+
if (failures.length > 0) {
91+
throw new Error(
92+
`${registry}: ${failures.length} of ${targets.length} target(s) failed. ` +
93+
`Published targets are live and will be skipped on a re-run — fix the cause and run the publish command again.`,
94+
);
95+
}
96+
return { published, skipped };
97+
}
98+
99+
function summarize({ registry, published, skipped, failures }) {
100+
console.log(`\n${registry} summary:`);
101+
if (published.length > 0) console.log(` published: ${published.join(', ')}`);
102+
if (skipped.length > 0) console.log(` already published: ${skipped.join(', ')}`);
103+
for (const failure of failures) {
104+
console.log(` FAILED ${failure.target}: ${failure.message}`);
105+
}
106+
}

apps/vscode/scripts/release-extension.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,20 @@ async function main() {
127127
run('git', ['add', 'apps/vscode/package.json']);
128128
run('git', ['commit', '-m', `chore(vscode): release ${version}`]);
129129

130-
run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:vsix'], { env: { ...process.env, VSCE_PAT: vscePat } });
130+
try {
131+
run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:vsix'], { env: { ...process.env, VSCE_PAT: vscePat } });
132+
} catch (error) {
133+
// The version bump is already committed and some targets may already be
134+
// live, so say exactly how to finish rather than leaving it to be worked out.
135+
throw new Error(
136+
`${error instanceof Error ? error.message : String(error)}\n\n` +
137+
`${version} is partly published and NOT tagged. The publish summary above lists which\n` +
138+
`targets are live; published ones are skipped on a re-run. Finish with:\n` +
139+
` pnpm --filter pythinker-code run publish:vsix\n` +
140+
` git tag -a ${tag} -m "Pythinker Code VS Code extension ${version}"\n` +
141+
`Do not bump the version again — ${version} is already consumed.`,
142+
);
143+
}
131144

132145
const ovsxPat = process.env.OVSX_PAT || keychainSecret(OVSX_KEYCHAIN_SERVICE);
133146
if (ovsxPat) {

apps/vscode/scripts/vsix-publish.mjs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
33

44
import { runLocalCli } from './local-cli.mjs';
55
import { parsePublishArguments, publishUsage } from './publish-args.mjs';
6+
import { publishEachTarget } from './publish-retry.mjs';
67
import { extensionRoot, isMainModule } from './vsix-targets.mjs';
78
import { verifyVsix } from './vsix-verify.mjs';
89

@@ -20,15 +21,22 @@ async function main() {
2021
}
2122

2223
await verifyInputs(options);
23-
for (const file of options.files) {
24-
console.log(`Publishing verified package ${file}...`);
25-
runLocalCli(
26-
'@vscode/vsce',
27-
'vsce',
28-
['publish', '--packagePath', file, '--skip-duplicate', ...(azureCredential ? ['--azure-credential'] : [])],
29-
{ cwd: extensionRoot },
30-
);
31-
}
24+
await publishEachTarget({
25+
targets: options.targets,
26+
files: options.files,
27+
registry: 'Marketplace',
28+
publishOne: (file) => {
29+
const result = runLocalCli(
30+
'@vscode/vsce',
31+
'vsce',
32+
['publish', '--packagePath', file, '--skip-duplicate', ...(azureCredential ? ['--azure-credential'] : [])],
33+
{ cwd: extensionRoot, encoding: 'utf8', stdio: 'pipe' },
34+
);
35+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
36+
process.stdout.write(output);
37+
return /already published/i.test(output) ? 'skipped' : 'published';
38+
},
39+
});
3240
}
3341

3442
async function verifyInputs(options) {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
// @ts-expect-error -- plain .mjs build script, no type declarations
4+
import { classifyError, publishEachTarget, withRetry } from '../scripts/publish-retry.mjs';
5+
6+
const TARGETS = ['darwin-x64', 'darwin-arm64', 'linux-x64'];
7+
const FILES = TARGETS.map((target) => `/tmp/${target}.vsix`);
8+
9+
// Backoff is real time; every retry test overrides it so the suite stays fast.
10+
const FAST = { backoffMs: [0, 0] };
11+
12+
describe('classifyError', () => {
13+
it('separates the three failure kinds that need different handling', () => {
14+
// The exact string the Marketplace returned mid-publish on 2026-08-05.
15+
expect(classifyError(new Error('Request timeout: /_apis/gallery/publishers/pythoughts'))).toBe('transient');
16+
expect(classifyError(new Error('connect ECONNRESET 13.107.42.16:443'))).toBe('transient');
17+
expect(classifyError(new Error('Response code 503 (Service Unavailable)'))).toBe('transient');
18+
19+
// The exact string the Entra credential path returned.
20+
expect(classifyError(new Error('{"message":"The requested operation is not allowed."}'))).toBe('auth');
21+
expect(classifyError(new Error('Response code 401 (Unauthorized)'))).toBe('auth');
22+
23+
expect(classifyError(new Error('Extension entrypoint(s) missing'))).toBe('fatal');
24+
});
25+
});
26+
27+
describe('withRetry', () => {
28+
it('retries a transient failure and returns the eventual success', async () => {
29+
const action = vi
30+
.fn()
31+
.mockRejectedValueOnce(new Error('Request timeout'))
32+
.mockResolvedValueOnce('published');
33+
34+
await expect(withRetry(action, { label: 'test', ...FAST })).resolves.toBe('published');
35+
expect(action).toHaveBeenCalledTimes(2);
36+
});
37+
38+
it('gives up after the attempt budget and rethrows the last error', async () => {
39+
const action = vi.fn().mockRejectedValue(new Error('Request timeout'));
40+
41+
await expect(withRetry(action, { label: 'test', attempts: 3, ...FAST })).rejects.toThrow('Request timeout');
42+
expect(action).toHaveBeenCalledTimes(3);
43+
});
44+
45+
it('does not retry a non-transient failure', async () => {
46+
const action = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)'));
47+
48+
await expect(withRetry(action, { label: 'test', ...FAST })).rejects.toThrow('401');
49+
expect(action).toHaveBeenCalledTimes(1);
50+
});
51+
});
52+
53+
describe('publishEachTarget', () => {
54+
it('keeps publishing after one target fails, so a flake cannot strand the rest', async () => {
55+
const publishOne = vi.fn(async (_file: string, target: string) => {
56+
if (target === 'darwin-arm64') throw new Error('Extension rejected');
57+
return 'published';
58+
});
59+
60+
await expect(
61+
publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }),
62+
).rejects.toThrow('1 of 3 target(s) failed');
63+
64+
// The point of the change: linux-x64 is attempted even though darwin-arm64 died.
65+
expect(publishOne.mock.calls.map((call) => call[1])).toEqual(TARGETS);
66+
});
67+
68+
it('stops immediately on an auth failure instead of hammering every target', async () => {
69+
const publishOne = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)'));
70+
71+
await expect(
72+
publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }),
73+
).rejects.toThrow('3 of 3 target(s) failed');
74+
75+
expect(publishOne).toHaveBeenCalledTimes(1);
76+
});
77+
78+
it('treats an already-published target as success, so a re-run completes', async () => {
79+
const publishOne = vi.fn(async (_file: string, target: string) =>
80+
target === 'darwin-x64' ? 'skipped' : 'published',
81+
);
82+
83+
const result = await publishEachTarget({
84+
targets: TARGETS,
85+
files: FILES,
86+
registry: 'Marketplace',
87+
publishOne,
88+
});
89+
90+
expect(result).toEqual({ published: ['darwin-arm64', 'linux-x64'], skipped: ['darwin-x64'] });
91+
});
92+
});

0 commit comments

Comments
 (0)