Skip to content

Commit ee60128

Browse files
authored
ci: publish to VS Marketplace and Open VSX in independent, retry-safe jobs (#1926)
Related to #1772 Related to #1914 ## Motivation During the v3.2.2 release, the Open VSX publish step failed with `503 Service Unavailable` after the VS Marketplace publish had already succeeded. Both registries were steps of a single `Publish` job, so re-running the failed job started again with `vsce publish`, which then failed with `REditorSupport.r v3.2.2 already exists.`. The Open VSX step could never be reached again. ## Changes - `release.yml` - After `verify`, VS Marketplace and Open VSX are published by two independent jobs. A failure in one registry can be retried with "Re-run failed jobs" without touching the other. - The GitHub Release is a third independent job after `verify`, so the VSIX stays available as an install fallback even when a registry is down. If the release already exists (e.g. a previous attempt created it but failed to upload the asset), the VSIX is uploaded with `--clobber` instead. - `vsce publish` / `ovsx publish` use `--skip-duplicate`. - Tokens are passed via `VSCE_PAT` / `OVSX_PAT` environment variables instead of `-p` on the command line. - Workflow-level permissions are now `contents: read`, and `GH_TOKEN` is only exposed to the GitHub Release job. - `marketplace-pre-release.yml`: the same split into two publish jobs, each with its own eligibility recheck. - `marketplace-pre-release-history.js`: publication history is evaluated per run across the split publish jobs. The old single `publish` job is still recognized, so existing Actions history keeps working. Tests updated, including a run where only the failed registry job is re-run. ## Notes - The workflows keep using the existing repository-level secrets. No repository setting changes are required. - `environment: vs-marketplace` / `environment: open-vsx` are left commented out with a TODO. They are meant for a future move to trusted publishing (OIDC, no token), which needs more than uncommenting them: a repository admin has to create the environments; VS Marketplace needs a trusted publishing policy for each publishing workflow file; and Open VSX allows only one trusted publisher per extension ([announcement](https://blogs.eclipse.org/post/tamas-cservenak/trusted-publishing-open-vsx)), so stable (`release.yml`) and pre-release (`marketplace-pre-release.yml`) Open VSX publishing would first have to be consolidated into a single workflow file (moving the publish step into a shared reusable workflow may not be enough, since the calling workflow still differs). Until then, this PR keeps using PATs. - 3.2.2 will not be published to Open VSX; the next stable release will be published there via this workflow. ## Update: draft recovery `gh release create` with assets creates a draft, uploads the assets and then publishes the draft. If an attempt is interrupted before the release is published, the draft is left behind, and `gh release view` still finds it. When the release already exists, the job now also checks `isDraft`. After uploading the VSIX, it publishes a draft with `gh release edit --draft=false`, so a rerun cannot succeed while the release is still hidden from users. ## Update: keep an existing VSIX `gh release upload --clobber` deletes the existing asset before it uploads the new one, so if the upload failed on a rerun, a VSIX that was already available would be lost. When the release already exists, the job now uploads the VSIX only if the release has no completed (`state == "uploaded"`) asset with that name. `--clobber` is still used in that case, so an incomplete asset is replaced. This updates the `--clobber` behavior described under Changes.
1 parent f00399e commit ee60128

4 files changed

Lines changed: 184 additions & 51 deletions

File tree

‎.github/scripts/marketplace-pre-release-history.js‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ const { execFileSync } = require('node:child_process');
22
const { appendFileSync } = require('node:fs');
33

44
const WORKFLOW = 'marketplace-pre-release.yml';
5-
const PUBLISH_JOB = 'publish';
5+
// Include the former combined job when inspecting existing Actions history.
6+
const PUBLISH_JOBS = ['publish', 'publish-vs-marketplace', 'publish-open-vsx'];
67
const REGISTRY_STEPS = [
78
'Publish to Visual Studio Marketplace',
89
'Publish to Open VSX Registry',
@@ -111,20 +112,20 @@ function createGhApi(execFile = execFileSync) {
111112
};
112113
}
113114

114-
function didAttemptPublication(job) {
115-
if (job.name !== PUBLISH_JOB || !Array.isArray(job.steps)) {
116-
return false;
117-
}
118-
return job.steps.some((step) => REGISTRY_STEPS.includes(step.name)
115+
function registrySteps(jobs) {
116+
return jobs.filter((job) => PUBLISH_JOBS.includes(job.name))
117+
.flatMap((job) => Array.isArray(job.steps) ? job.steps : []);
118+
}
119+
120+
function didAttemptPublication(jobs) {
121+
return registrySteps(jobs).some((step) => REGISTRY_STEPS.includes(step.name)
119122
&& (step.status === 'in_progress' || step.status === 'completed')
120123
&& step.conclusion !== 'skipped');
121124
}
122125

123-
function completedBothRegistries(job) {
124-
if (job.name !== PUBLISH_JOB || !Array.isArray(job.steps)) {
125-
return false;
126-
}
127-
return REGISTRY_STEPS.every((name) => job.steps.some((step) =>
126+
function completedBothRegistries(jobs) {
127+
const steps = registrySteps(jobs);
128+
return REGISTRY_STEPS.every((name) => steps.some((step) =>
128129
step.name === name && step.status === 'completed' && step.conclusion === 'success'));
129130
}
130131

@@ -170,7 +171,7 @@ async function decidePublication({ api, repository, runId, sha }) {
170171
const isCurrentRun = String(run.id) === String(runId);
171172
if (!isCurrentRun && utcDate(run) >= runDate) {
172173
const jobs = await jobsFor(run);
173-
if (jobs.some(didAttemptPublication)) {
174+
if (didAttemptPublication(jobs)) {
174175
dateReserved = true;
175176
break;
176177
}
@@ -192,7 +193,7 @@ async function decidePublication({ api, repository, runId, sha }) {
192193
}
193194
for (let index = end - 1; index >= start; index -= 1) {
194195
const jobs = await jobsFor(runs[index]);
195-
if (jobs.some(completedBothRegistries)) {
196+
if (completedBothRegistries(jobs)) {
196197
return runs[start].head_sha === sha
197198
? { publish: false, date: runDate, reason: 'unchanged' }
198199
: { publish: true, date: runDate, reason: 'new-sha' };

‎.github/scripts/marketplace-pre-release-history.test.js‎

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const test = require('node:test');
22
const assert = require('node:assert/strict');
33
const { readFileSync } = require('node:fs');
44
const { join } = require('node:path');
5-
const { createGhApi, decidePublication, pagesToItems, completePagesToItems } = require('./marketplace-pre-release-history');
5+
const { createGhApi, decidePublication, pagesToItems, completePagesToItems, didAttemptPublication, completedBothRegistries } = require('./marketplace-pre-release-history');
66

77
const marketplace = 'Publish to Visual Studio Marketplace';
88
const openVsx = 'Publish to Open VSX Registry';
@@ -335,25 +335,60 @@ test('run pagination fails closed on empty, inconsistent, or duplicate pages', (
335335
: { total_count: 101, workflow_runs: [item(100)] }).listRuns('owner/repo'), /duplicate workflow runs/);
336336
});
337337

338-
test('workflow rechecks eligibility in the publishing job and gates both registries', () => {
339-
const workflowPath = join(__dirname, '../workflows/marketplace-pre-release.yml');
340-
const workflow = readFileSync(workflowPath, 'utf8');
341-
const prepareStart = workflow.indexOf(' prepare:');
342-
const publishStart = workflow.indexOf('\n publish:\n needs:');
343-
const prepare = workflow.slice(prepareStart, publishStart);
344-
const helperIndex = prepare.indexOf('.github/scripts/marketplace-pre-release-history.js');
345-
assert.ok(helperIndex >= 0);
346-
assert.ok(prepare.slice(0, helperIndex).includes('uses: actions/checkout@'));
347-
assert.match(prepare.slice(0, helperIndex), /uses: actions\/setup-node@[\s\S]*?node-version: 24/);
348-
const publish = workflow.slice(publishStart);
349-
const eligibilityIndex = publish.indexOf('id: eligibility');
350-
assert.ok(eligibilityIndex >= 0);
351-
assert.match(publish.slice(eligibilityIndex), /run: node \.github\/scripts\/marketplace-pre-release-history\.js/);
352-
for (const name of [marketplace, openVsx]) {
353-
const index = publish.indexOf(`- name: ${name}`);
354-
assert.ok(index > eligibilityIndex, `${name} must run after the eligibility check`);
355-
const nextStep = publish.indexOf('\n - name:', index + 1);
356-
const step = publish.slice(index, nextStep < 0 ? undefined : nextStep);
357-
assert.match(step, /if: steps\.eligibility\.outputs\.publish == 'true'/);
338+
test('each split publishing job rechecks eligibility and gates its registry', () => {
339+
const workflow = readFileSync(join(__dirname, '../workflows/marketplace-pre-release.yml'), 'utf8');
340+
for (const [job, name] of [['publish-vs-marketplace', marketplace], ['publish-open-vsx', openVsx]]) {
341+
const start = workflow.indexOf(`\n ${job}:`);
342+
assert.ok(start >= 0);
343+
const end = workflow.indexOf('\n publish-', start + 1);
344+
const body = workflow.slice(start, end < 0 ? undefined : end);
345+
const eligibility = body.indexOf('id: eligibility');
346+
assert.ok(eligibility >= 0);
347+
assert.ok(body.indexOf('.github/scripts/marketplace-pre-release-history.js', eligibility) > eligibility);
348+
const publication = body.indexOf(`- name: ${name}`);
349+
assert.ok(publication > eligibility);
350+
assert.ok(body.slice(publication).includes("if: steps.eligibility.outputs.publish == 'true'"));
358351
}
359352
});
353+
354+
function splitJobs(options) {
355+
return publishJob(options).steps.map((step, index) => ({
356+
name: ['publish-vs-marketplace', 'publish-open-vsx'][index], steps: [step],
357+
}));
358+
}
359+
360+
test('split jobs: re-running only the failed registry job completes the publication', async () => {
361+
const prior = run(820, '2026-10-05', 'sha-a');
362+
const current = run(821, '2026-10-06', 'sha-a');
363+
const firstAttempt = splitJobs({ openVsxConclusion: 'failure' });
364+
const retriedOpenVsx = { ...splitJobs()[1], run_attempt: 2 };
365+
const jobs = [...firstAttempt, retriedOpenVsx];
366+
assert.equal(completedBothRegistries(firstAttempt), false);
367+
assert.equal(completedBothRegistries(jobs), true);
368+
assert.deepEqual(await decide(current, [prior, current], new Map([['820', jobs]])), {
369+
publish: false, date: '2026-10-06', reason: 'unchanged',
370+
});
371+
});
372+
373+
for (const [format, jobsFor] of [['split jobs', splitJobs], ['old combined job', (options) => [publishJob(options)]]]) {
374+
test(`${format}: both registries succeeded suppress unchanged SHA`, async () => {
375+
const prior = run(800, '2026-10-05', 'sha-a');
376+
const current = run(801, '2026-10-06', 'sha-a');
377+
const jobs = jobsFor();
378+
assert.equal(didAttemptPublication(jobs), true);
379+
assert.equal(completedBothRegistries(jobs), true);
380+
assert.deepEqual(await decide(current, [prior, current], new Map([['800', jobs]])), {
381+
publish: false, date: '2026-10-06', reason: 'unchanged',
382+
});
383+
});
384+
test(`${format}: partial publication reserves date without completing publication`, async () => {
385+
const prior = run(810, '2026-10-05', 'sha-a');
386+
const current = run(811, '2026-10-05', 'sha-a');
387+
const jobs = jobsFor({ openVsxConclusion: 'failure' });
388+
assert.equal(didAttemptPublication(jobs), true);
389+
assert.equal(completedBothRegistries(jobs), false);
390+
assert.deepEqual(await decide(current, [prior, current], new Map([['810', jobs]])), {
391+
publish: false, date: '2026-10-05', reason: 'date-reserved',
392+
});
393+
});
394+
}

‎.github/workflows/marketplace-pre-release.yml‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ jobs:
3939
with:
4040
prerelease-date: ${{ needs.prepare.outputs.date }}
4141

42-
publish:
43-
needs: [prepare, verify]
42+
publish-vs-marketplace:
43+
# TODO: Uncomment when switching to trusted publishing (OIDC, no token).
44+
# A repository admin must create the vs-marketplace environment, and each
45+
# publishing workflow file needs its own Marketplace trusted publishing policy.
46+
# environment: vs-marketplace
47+
needs:
48+
- prepare
49+
- verify
4450
if: github.ref == 'refs/heads/main'
4551
runs-on: ubuntu-latest
4652
timeout-minutes: 30
@@ -67,8 +73,41 @@ jobs:
6773
env:
6874
VSCE_PAT: ${{ secrets.VSCE_TOKEN }}
6975
run: pnpm exec vsce publish --pre-release --skip-duplicate --packagePath artifacts/vscode-R.vsix
76+
77+
publish-open-vsx:
78+
# TODO: Uncomment when switching to trusted publishing (OIDC, no token).
79+
# A repository admin must create the open-vsx environment. Open VSX allows
80+
# only one trusted publisher (bound to one workflow file) per extension, so
81+
# stable and pre-release publishing must first be consolidated into a single
82+
# workflow file (a shared reusable workflow may not be enough, since the
83+
# caller differs).
84+
# environment: open-vsx
85+
needs:
86+
- prepare
87+
- verify
88+
if: github.ref == 'refs/heads/main'
89+
runs-on: ubuntu-latest
90+
timeout-minutes: 30
91+
steps:
92+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
93+
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
94+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
95+
with:
96+
node-version: 24
97+
cache: 'pnpm'
98+
- run: pnpm install --frozen-lockfile
99+
- name: Download verified pre-release
100+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
101+
with:
102+
name: vscode-R.vsix
103+
path: artifacts/
104+
- name: Recheck publication eligibility
105+
id: eligibility
106+
env:
107+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
108+
run: node .github/scripts/marketplace-pre-release-history.js
70109
- name: Publish to Open VSX Registry
71110
if: steps.eligibility.outputs.publish == 'true'
72111
env:
73112
OVSX_PAT: ${{ secrets.OPEN_VSX_TOKEN }}
74-
run: pnpm exec ovsx publish --skip-duplicate artifacts/vscode-R.vsix
113+
run: pnpm exec ovsx publish --skip-duplicate --packagePath artifacts/vscode-R.vsix

‎.github/workflows/release.yml‎

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ on:
88
tags:
99
- "v*"
1010

11-
env:
12-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
11+
permissions:
12+
contents: read
1313

1414
jobs:
1515
check-version:
@@ -32,6 +32,10 @@ jobs:
3232

3333
release:
3434
name: Release
35+
env:
36+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37+
# Independent of the registry jobs so the VSIX stays available as a
38+
# fallback when a registry is down.
3539
needs: verify
3640
runs-on: ubuntu-latest
3741
permissions:
@@ -47,19 +51,43 @@ jobs:
4751
- name: Create release
4852
run: |
4953
VERSION=${GITHUB_REF#refs/tags/v}
50-
mv artifacts/vscode-R.vsix "artifacts/r-$VERSION.vsix"
51-
gh release create "v$VERSION" "artifacts/r-$VERSION.vsix" \
52-
--title "v$VERSION" \
53-
--notes "Release v$VERSION" \
54-
--generate-notes
54+
ASSET="r-$VERSION.vsix"
55+
mv artifacts/vscode-R.vsix "artifacts/$ASSET"
56+
if RELEASE=$(gh release view "v$VERSION" --json isDraft,assets 2>/dev/null); then
57+
# A previous attempt may have created the release without the asset.
58+
# Keep a completed asset: --clobber deletes it before uploading, so
59+
# a failed upload would remove the existing download.
60+
if jq -e --arg name "$ASSET" \
61+
'.assets | any(.name == $name and .state == "uploaded")' \
62+
<<<"$RELEASE" >/dev/null; then
63+
echo "Release v$VERSION already has $ASSET; skipping the upload."
64+
else
65+
echo "Release v$VERSION already exists; uploading $ASSET."
66+
# --clobber replaces an incomplete asset with the same name.
67+
gh release upload "v$VERSION" "artifacts/$ASSET" --clobber
68+
fi
69+
# gh release create uploads assets to a draft before publishing it,
70+
# so an interrupted attempt can leave the release as a draft.
71+
if [ "$(jq -r .isDraft <<<"$RELEASE")" = "true" ]; then
72+
echo "Release v$VERSION is a draft; publishing it."
73+
gh release edit "v$VERSION" --draft=false
74+
fi
75+
else
76+
gh release create "v$VERSION" "artifacts/$ASSET" \
77+
--title "v$VERSION" \
78+
--notes "Release v$VERSION" \
79+
--generate-notes
80+
fi
5581
56-
publish:
57-
name: Publish
82+
publish-vs-marketplace:
83+
name: Publish to Visual Studio Marketplace
84+
# TODO: Uncomment when switching to trusted publishing (OIDC, no token).
85+
# A repository admin must create the vs-marketplace environment, and each
86+
# publishing workflow file needs its own Marketplace trusted publishing policy.
87+
# environment: vs-marketplace
5888
timeout-minutes: 30
5989
runs-on: ubuntu-latest
60-
needs:
61-
- verify
62-
- release
90+
needs: verify
6391
steps:
6492
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6593
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
@@ -74,6 +102,36 @@ jobs:
74102
name: vscode-R.vsix
75103
path: artifacts/
76104
- name: Publish to Visual Studio Marketplace
77-
run: pnpm exec vsce publish -p ${{ secrets.VSCE_TOKEN }} --packagePath artifacts/*.vsix
105+
env:
106+
VSCE_PAT: ${{ secrets.VSCE_TOKEN }}
107+
run: pnpm exec vsce publish --skip-duplicate --packagePath artifacts/*.vsix
108+
109+
publish-open-vsx:
110+
name: Publish to Open VSX Registry
111+
# TODO: Uncomment when switching to trusted publishing (OIDC, no token).
112+
# A repository admin must create the open-vsx environment. Open VSX allows
113+
# only one trusted publisher (bound to one workflow file) per extension, so
114+
# stable and pre-release publishing must first be consolidated into a single
115+
# workflow file (a shared reusable workflow may not be enough, since the
116+
# caller differs).
117+
# environment: open-vsx
118+
timeout-minutes: 30
119+
runs-on: ubuntu-latest
120+
needs: verify
121+
steps:
122+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
123+
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
124+
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
125+
with:
126+
node-version: 24
127+
cache: 'pnpm'
128+
- run: pnpm install --frozen-lockfile
129+
- name: Download artifacts
130+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
131+
with:
132+
name: vscode-R.vsix
133+
path: artifacts/
78134
- name: Publish to Open VSX Registry
79-
run: pnpm exec ovsx publish -p ${{ secrets.OPEN_VSX_TOKEN }} --packagePath artifacts/*.vsix
135+
env:
136+
OVSX_PAT: ${{ secrets.OPEN_VSX_TOKEN }}
137+
run: pnpm exec ovsx publish --skip-duplicate --packagePath artifacts/*.vsix

0 commit comments

Comments
 (0)