Skip to content

Commit acf57d1

Browse files
authored
fix(vscode): create the Open VSX namespace before publishing (#39)
## Related Issue No issue — found while verifying the 0.12.0 release. The extension reached the Visual Studio Marketplace but never reached Open VSX, and the release job reported success anyway. ## Problem `Publish VS Code extension` concluded **success** in the 0.12.0 release, but Open VSX has no version of the extension at all: ``` $ curl https://open-vsx.org/api/pythoughts/pythinker-code {"error":"Extension not found: pythoughts.pythinker-code"} ``` Two separate defects: 1. **Open VSX rejects a publish into a namespace that does not exist.** `pythoughts` was never created there — `https://open-vsx.org/api/pythoughts` answers `{"error":"Namespace not found: pythoughts"}` — so all six targets failed. The Marketplace has no namespace concept, so a publisher that works there fails here and nothing upstream catches it. The step is `continue-on-error: true`, which is why the job still went green. 2. **The failure reported no cause.** `runLocalCli` does capture the CLI output, but the per-target summary kept only the first line of the error — the `Local ovsx exited with code 1:` wrapper — and dropped the registry's message that followed. The release log shows six of these: ``` FAILED darwin-x64: Local ovsx exited with code 1: FAILED darwin-arm64: Local ovsx exited with code 1: ... Open VSX: 6 of 6 target(s) failed. ``` ## What changed - `ovsx-publish.mjs` creates the namespace before publishing, taking the name from the extension manifest's `publisher` rather than hardcoding it. An already-existing namespace is the success case, so re-runs stay safe. - `publish-retry.mjs` gains `summaryLine`, which keeps the registry's own words instead of the CLI wrapper line. Used by both the per-target summary and the retry warning. Both changes are covered, and the summary fix was mutation-tested: reverting it reproduces the exact production line `FAILED darwin-x64: Local ovsx exited with code 1:`. Verification: `apps/vscode` suite 334 passed (18 files), both vscode tsconfigs typecheck clean, oxlint back to its baseline count for the touched file. Note: this fixes the publish path. Getting 0.8.6 onto Open VSX still needs the publish to run again with `OVSX_PAT` — nothing is published there yet, so no target will be skipped. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [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 * **New Features** * Open VSX publishing now automatically creates the required publisher namespace. * Publishing errors include clearer registry-provided details. * Existing namespaces are handled safely without interrupting publication. * **Bug Fixes** * Unauthorized and other unexpected publishing failures are now surfaced instead of being ignored. * Retry and failure messages now provide concise, informative error summaries. * **Tests** * Added coverage for namespace creation, existing namespaces, authorization failures, and detailed retry error reporting. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5fe0536 commit acf57d1

5 files changed

Lines changed: 159 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"pythinker-code": patch
3+
---
4+
5+
Publish the VS Code extension to Open VSX by creating the publisher namespace first, so Cursor, VSCodium and Windsurf can install it, and report the registry's own error when a publish fails instead of only the CLI exit line.

apps/vscode/scripts/ovsx-publish.mjs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,43 @@
11
#!/usr/bin/env node
2-
import { existsSync } from 'node:fs';
2+
import { existsSync, readFileSync } from 'node:fs';
3+
import { join } from 'node:path';
34

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

11+
/**
12+
* Open VSX refuses every publish into a namespace that does not exist yet, and
13+
* the Marketplace has no such concept — so a publisher that works there fails
14+
* here on all six targets at once. Creating it is idempotent from our side: the
15+
* namespace already existing is the success case, not an error.
16+
*
17+
* This is why 0.8.6 reached the Marketplace but no version ever reached Open VSX.
18+
*/
19+
export function ensureNamespace(namespace, run = runLocalCli) {
20+
try {
21+
run('ovsx', 'ovsx', ['create-namespace', namespace], {
22+
cwd: extensionRoot,
23+
encoding: 'utf8',
24+
stdio: 'pipe',
25+
});
26+
console.log(`Created Open VSX namespace ${namespace}.`);
27+
} catch (error) {
28+
if (/already exists|already owned/i.test(messageOf(error))) return;
29+
throw error;
30+
}
31+
}
32+
33+
function publisherName() {
34+
const manifest = JSON.parse(readFileSync(join(extensionRoot, 'package.json'), 'utf8'));
35+
if (typeof manifest.publisher !== 'string' || manifest.publisher === '') {
36+
throw new Error('apps/vscode/package.json has no publisher to use as the Open VSX namespace.');
37+
}
38+
return manifest.publisher;
39+
}
40+
1041
async function main() {
1142
const options = parsePublishArguments(process.argv.slice(2));
1243
if (options.help) {
@@ -16,6 +47,7 @@ async function main() {
1647
if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.');
1748

1849
await verifyInputs(options);
50+
ensureNamespace(publisherName());
1951
await publishEachTarget({
2052
targets: options.targets,
2153
files: options.files,

apps/vscode/scripts/publish-retry.mjs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,33 @@ const AUTH_PATTERN = /\b401\b|unauthorized|invalidaccess|access denied|not allow
88

99
export const DEFAULT_ATTEMPTS = 3;
1010

11+
/** Longest summary line worth printing; registry errors can be one huge JSON blob. */
12+
export const SUMMARY_LIMIT = 400;
13+
1114
export function messageOf(error) {
1215
return error instanceof Error ? error.message : String(error);
1316
}
1417

18+
/**
19+
* One line for a summary, chosen so it carries the registry's own words.
20+
*
21+
* `runLocalCli` wraps a CLI failure as `Local ovsx exited with code 1:` followed
22+
* by the captured output, so reporting only the first line printed six identical
23+
* `FAILED <target>: Local ovsx exited with code 1:` entries with the actual cause
24+
* — a missing Open VSX namespace — cut off right after the colon.
25+
*/
26+
export function summaryLine(error) {
27+
const lines = messageOf(error)
28+
.split('\n')
29+
.map((line) => line.trim())
30+
.filter((line) => line !== '');
31+
if (lines.length === 0) return '';
32+
const [wrapper, ...rest] = lines;
33+
// Cap the whole result, not just the joined branch: a registry that answers
34+
// with one long JSON line would otherwise print unbounded.
35+
return (rest.length === 0 ? wrapper : `${wrapper} ${rest.join(' ')}`).slice(0, SUMMARY_LIMIT);
36+
}
37+
1538
/**
1639
* `auth` aborts the whole run — every remaining target would fail identically.
1740
* `transient` is worth retrying. `fatal` fails one target and lets the rest go.
@@ -48,7 +71,7 @@ export async function withRetry(action, options = {}) {
4871
}
4972
const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
5073
console.warn(`${label}: ${kind} failure on attempt ${attempt}/${attempts}, retrying in ${wait / 1000}s...`);
51-
console.warn(` ${messageOf(error).split('\n')[0]}`);
74+
console.warn(` ${summaryLine(error)}`);
5275
await delay(wait);
5376
}
5477
}
@@ -79,7 +102,7 @@ export async function publishEachTarget({ targets, files, registry, publishOne }
79102
(outcome === 'skipped' ? skipped : published).push(target);
80103
} catch (error) {
81104
const kind = classifyError(error);
82-
failures.push({ target, message: messageOf(error).split('\n')[0] });
105+
failures.push({ target, message: summaryLine(error) });
83106
if (kind === 'auth') {
84107
abortReason = 'aborted after an authentication failure';
85108
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
// @ts-expect-error -- plain .mjs build script, no type declarations
4+
import { ensureNamespace } from '../scripts/ovsx-publish.mjs';
5+
6+
/**
7+
* Open VSX rejects a publish into a namespace that does not exist, which is why
8+
* the 0.12.0 release put 0.8.6 on the Marketplace but left Open VSX with no
9+
* version at all. The Marketplace has no namespace concept, so nothing upstream
10+
* of this catches it.
11+
*/
12+
describe('ensureNamespace', () => {
13+
it('creates the namespace before any publish is attempted', () => {
14+
const run = vi.fn();
15+
16+
ensureNamespace('pythoughts', run);
17+
18+
expect(run).toHaveBeenCalledTimes(1);
19+
const [pkg, bin, args] = run.mock.calls[0] as [string, string, string[]];
20+
expect([pkg, bin]).toEqual(['ovsx', 'ovsx']);
21+
expect(args).toEqual(['create-namespace', 'pythoughts']);
22+
});
23+
24+
it('treats an existing namespace as success, so a re-run is safe', () => {
25+
const run = vi.fn(() => {
26+
throw new Error('Local ovsx exited with code 1:\nERROR Namespace already exists: pythoughts');
27+
});
28+
29+
expect(() => ensureNamespace('pythoughts', run)).not.toThrow();
30+
});
31+
32+
it('propagates a real failure instead of publishing into a broken namespace', () => {
33+
const run = vi.fn(() => {
34+
throw new Error('Local ovsx exited with code 1:\nERROR Response code 401 (Unauthorized)');
35+
});
36+
37+
expect(() => ensureNamespace('pythoughts', run)).toThrow(/401/u);
38+
});
39+
});

apps/vscode/test/publish-retry.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it, vi } from 'vitest';
22

33
// @ts-expect-error -- plain .mjs build script, no type declarations
4-
import { classifyError, publishEachTarget, withRetry } from '../scripts/publish-retry.mjs';
4+
import { classifyError, publishEachTarget, SUMMARY_LIMIT, summaryLine, withRetry } from '../scripts/publish-retry.mjs';
55

66
const TARGETS = ['darwin-x64', 'darwin-arm64', 'linux-x64'];
77
const FILES = TARGETS.map((target) => `/tmp/${target}.vsix`);
@@ -50,7 +50,63 @@ describe('withRetry', () => {
5050
});
5151
});
5252

53+
describe('summaryLine', () => {
54+
/**
55+
* The exact shape `runLocalCli` throws, and the exact reason the 0.12.0 release
56+
* printed six `FAILED <target>: Local ovsx exited with code 1:` lines with no
57+
* cause: the summary kept only the wrapper line and dropped the output after it.
58+
*/
59+
it('keeps the registry error that follows the CLI wrapper line', () => {
60+
const error = new Error(
61+
'Local ovsx exited with code 1:\nERROR Unknown namespace: pythoughts\n',
62+
);
63+
64+
const line = summaryLine(error);
65+
66+
expect(line).toContain('Unknown namespace: pythoughts');
67+
expect(line).toContain('exited with code 1');
68+
});
69+
70+
it('caps a long error whether or not it has a second line', () => {
71+
// The cap used to bind only to the joined branch, so a registry answering
72+
// with one long JSON line printed in full.
73+
expect(summaryLine(new Error('x'.repeat(500)))).toHaveLength(SUMMARY_LIMIT);
74+
expect(summaryLine(new Error(`wrapper:\n${'y'.repeat(500)}`))).toHaveLength(SUMMARY_LIMIT);
75+
});
76+
77+
it('leaves a single-line error alone and survives a blank one', () => {
78+
expect(summaryLine(new Error('Response code 401 (Unauthorized)')))
79+
.toBe('Response code 401 (Unauthorized)');
80+
// A CLI that failed without writing anything: every line is blank.
81+
expect(summaryLine(' \n \n')).toBe('');
82+
});
83+
});
84+
5385
describe('publishEachTarget', () => {
86+
it('reports the underlying cause for a failed target, not just the wrapper', async () => {
87+
const publishOne = vi.fn().mockRejectedValue(
88+
new Error('Local ovsx exited with code 1:\nERROR Unknown namespace: pythoughts'),
89+
);
90+
const logged: string[] = [];
91+
const log = vi.spyOn(console, 'log').mockImplementation((...args) => {
92+
logged.push(args.join(' '));
93+
});
94+
95+
try {
96+
await expect(
97+
publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Open VSX', publishOne }),
98+
).rejects.toThrow('3 of 3 target(s) failed');
99+
} finally {
100+
log.mockRestore();
101+
}
102+
103+
const failures = logged.filter((line) => line.includes('FAILED'));
104+
expect(failures).toHaveLength(3);
105+
for (const failure of failures) {
106+
expect(failure).toContain('Unknown namespace: pythoughts');
107+
}
108+
});
109+
54110
it('keeps publishing after one target fails, so a flake cannot strand the rest', async () => {
55111
const publishOne = vi.fn(async (_file: string, target: string) => {
56112
if (target === 'darwin-arm64') throw new Error('Extension rejected');

0 commit comments

Comments
 (0)