Skip to content

Commit ebb51b2

Browse files
fix(vscode): retry publish on marketplace version conflict (tldraw#8297)
When two pushes to `main` happen in quick succession, the `publish-editor-extensions` workflow fails with "already exists". The concurrency group serializes job execution, but the marketplace API has propagation delay — both runs fetch the same stale version and compute the same next version. This PR wraps the version-bump → package → publish cycle in a retry loop. If `vsce publish` fails with "already exists", the script waits 60s, re-fetches the marketplace version, re-computes the next version, re-packages, and retries (up to 5 attempts). Also moves the `vsce show` marketplace fetch (previously a separate workflow step) into the script so it can be re-run on retry. ### Change type - [x] `bugfix` ### Test plan 1. Review the retry logic in `publish-editor-extensions.ts` 2. Verify the "already exists" error string matches what vsce actually outputs (confirmed from CI logs: `::error::tldraw-org.tldraw-vscode v2.216.1 already exists.`) - [ ] Unit tests - [ ] End to end tests ### Code changes | Section | LOC change | | -------------- | ---------- | | Config/tooling | +52 / -27 |
1 parent 29bedeb commit ebb51b2

2 files changed

Lines changed: 52 additions & 27 deletions

File tree

.github/workflows/publish-editor-extensions.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,6 @@ jobs:
4646
- name: Build types
4747
run: yarn build-types
4848

49-
- name: Get extension info from the marketplace
50-
working-directory: 'apps/vscode/extension'
51-
run: yarn get-info
52-
5349
- name: Publish extension
5450
run: yarn tsx ./internal/scripts/publish-editor-extensions.ts
5551
env:

internal/scripts/publish-editor-extensions.ts

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,17 @@ const env = makeEnv(['VSCE_PAT', 'OVSX_PAT', 'TLDRAW_ENV'])
1111

1212
const EXTENSION_DIR = 'apps/vscode/extension'
1313
const DISTRIBUTION_DIR = 'apps/vscode/extension/release'
14+
const MAX_RETRIES = 5
15+
const RETRY_DELAY_MS = 60_000
1416

15-
async function updateExtensionVersion() {
17+
function isVersionConflictError(err: unknown): boolean {
18+
const message = err instanceof Error ? err.message : ''
19+
const stderr = typeof (err as any)?.stderr === 'string' ? (err as any).stderr : ''
20+
return message.includes('already exists') || stderr.includes('already exists')
21+
}
22+
23+
async function fetchMarketplaceVersion(): Promise<string> {
24+
await exec('yarn', ['get-info'], { pwd: EXTENSION_DIR })
1625
const extensionInfoJsonPath = path.join(EXTENSION_DIR, 'extension.json')
1726
if (!existsSync(extensionInfoJsonPath)) {
1827
throw new Error('Published extension info not found.')
@@ -22,21 +31,25 @@ async function updateExtensionVersion() {
2231
if (!version) {
2332
throw new Error('Could not get the version of the published extension.')
2433
}
25-
const semVer = parse(version)
34+
return version
35+
}
36+
37+
async function bumpVersion(): Promise<string> {
38+
const currentVersion = await fetchMarketplaceVersion()
39+
const semVer = parse(currentVersion)
2640
if (!semVer) {
27-
throw new Error('Could not parse the published version.')
41+
throw new Error(`Could not parse the published version: ${currentVersion}`)
2842
}
2943
const release = env.TLDRAW_ENV === 'production' ? 'minor' : 'patch'
3044
const nextVersion = semVer.inc(release).version
31-
nicelog(`Updating extension version from ${version} to ${nextVersion}`)
45+
nicelog(`Bumping extension version from ${currentVersion} to ${nextVersion}`)
3246

3347
const packageJsonPath = path.join(EXTENSION_DIR, 'package.json')
3448
if (!existsSync(packageJsonPath)) {
3549
throw new Error("Could not find the extension's package.json file.")
3650
}
3751
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
3852
packageJson.version = nextVersion
39-
4053
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, '\t') + '\n')
4154
return nextVersion
4255
}
@@ -76,28 +89,44 @@ async function copyExtensionToReleaseFolder(version: string) {
7689
await exec('git', ['push'])
7790
}
7891

79-
async function packageAndPublish(version: string) {
92+
async function main() {
93+
if (env.TLDRAW_ENV !== 'production' && env.TLDRAW_ENV !== 'staging') {
94+
throw new Error('Workflow triggered from a branch other than main or production.')
95+
}
96+
8097
await exec('yarn', ['lazy', 'run', 'build', '--filter=packages/*'])
81-
switch (env.TLDRAW_ENV) {
82-
case 'production':
83-
await exec('yarn', ['package'], { pwd: EXTENSION_DIR })
84-
await exec('yarn', ['publish'], { pwd: EXTENSION_DIR })
85-
// commit vsix AFTER successful publish to avoid partial state on failure
86-
await copyExtensionToReleaseFolder(version)
87-
return
88-
case 'staging':
89-
await exec('yarn', ['package', '--pre-release'], { pwd: EXTENSION_DIR })
90-
await exec('yarn', ['publish', '--pre-release'], { pwd: EXTENSION_DIR })
91-
return
92-
default:
93-
throw new Error('Workflow triggered from a branch other than main or production.')
98+
99+
// When two pushes to main happen in quick succession, the concurrency group serializes
100+
// the runs but the marketplace API has propagation delay — both runs can compute the same
101+
// next version. If publish fails with "already exists", re-fetch and retry.
102+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
103+
const version = await bumpVersion()
104+
105+
try {
106+
switch (env.TLDRAW_ENV) {
107+
case 'production':
108+
await exec('yarn', ['package'], { pwd: EXTENSION_DIR })
109+
await exec('yarn', ['publish'], { pwd: EXTENSION_DIR })
110+
await copyExtensionToReleaseFolder(version)
111+
return
112+
case 'staging':
113+
await exec('yarn', ['package', '--pre-release'], { pwd: EXTENSION_DIR })
114+
await exec('yarn', ['publish', '--pre-release'], { pwd: EXTENSION_DIR })
115+
return
116+
}
117+
} catch (err) {
118+
if (isVersionConflictError(err) && attempt < MAX_RETRIES) {
119+
nicelog(
120+
`Version conflict detected (attempt ${attempt}/${MAX_RETRIES}), retrying in ${RETRY_DELAY_MS / 1000}s...`
121+
)
122+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
123+
continue
124+
}
125+
throw err
126+
}
94127
}
95128
}
96129

97-
async function main() {
98-
const version = await updateExtensionVersion()
99-
await packageAndPublish(version)
100-
}
101130
main().catch(async (err) => {
102131
console.error(err)
103132
process.exit(1)

0 commit comments

Comments
 (0)