diff --git a/.github/scripts/marketplace-pre-release-history.js b/.github/scripts/marketplace-pre-release-history.js index 0645dec73..781e7ebbf 100644 --- a/.github/scripts/marketplace-pre-release-history.js +++ b/.github/scripts/marketplace-pre-release-history.js @@ -1,9 +1,20 @@ const { execFileSync } = require('node:child_process'); const { appendFileSync } = require('node:fs'); -const WORKFLOW = 'marketplace-pre-release.yml'; -// Include the former combined job when inspecting existing Actions history. -const PUBLISH_JOBS = ['publish', 'publish-vs-marketplace', 'publish-open-vsx']; +const WORKFLOW = 'release.yml'; +// Retain the retired marketplace-pre-release.yml workflow ID on the canonical +// repository so runs from before the workflow consolidation stay visible. +const LEGACY_WORKFLOW_IDS = { + 'REditorSupport/vscode-R': ['376461048'], +}; +// Include former job names when inspecting existing Actions history. +const PUBLISH_JOBS = [ + 'publish', + 'publish-vs-marketplace', + 'publish-open-vsx', + 'prerelease-publish-vs-marketplace', + 'prerelease-publish-open-vsx', +]; const REGISTRY_STEPS = [ 'Publish to Visual Studio Marketplace', 'Publish to Open VSX Registry', @@ -59,6 +70,40 @@ function createGhApi(execFile = execFileSync) { } return trimmed.split(/\r?\n/).map((line) => JSON.parse(line)); } + function listWorkflowRuns(repository, workflow) { + const path = `repos/${repository}/actions/workflows/${workflow}/runs`; + const allRuns = []; + let totalCount; + let page = 1; + do { + const pages = call([ + path, + '-f', 'per_page=100', + '-f', `page=${page}`, + ], { jq: RUN_JQ, paginate: false }); + if (pages.length !== 1) { + throw new Error(`GitHub API returned an invalid workflow runs page ${page}`); + } + const pageTotal = pages[0].total_count; + if (!Number.isSafeInteger(pageTotal) || pageTotal < 0 || (totalCount !== undefined && pageTotal !== totalCount)) { + throw new Error('GitHub API returned an invalid or changing workflow run total_count'); + } + totalCount = pageTotal; + const pageRuns = pagesToItems(pages, 'workflow_runs'); + if (pageRuns.length === 0 && allRuns.length < totalCount) { + throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`); + } + allRuns.push(...pageRuns); + page += 1; + } while (allRuns.length < totalCount); + if (allRuns.length !== totalCount) { + throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`); + } + if (new Set(allRuns.map((run) => String(run.id))).size !== allRuns.length) { + throw new Error('GitHub API returned duplicate workflow runs across pages'); + } + return allRuns; + } return { getRun(repository, runId) { const run = call([`repos/${repository}/actions/runs/${runId}`], { paginate: false }); @@ -68,38 +113,18 @@ function createGhApi(execFile = execFileSync) { return run; }, listRuns(repository) { - const path = `repos/${repository}/actions/workflows/${WORKFLOW}/runs`; - const allRuns = []; - let totalCount; - let page = 1; - do { - const pages = call([ - path, - '-f', 'per_page=100', - '-f', `page=${page}`, - ], { jq: RUN_JQ, paginate: false }); - if (pages.length !== 1) { - throw new Error(`GitHub API returned an invalid workflow runs page ${page}`); - } - const pageTotal = pages[0].total_count; - if (!Number.isSafeInteger(pageTotal) || pageTotal < 0 || (totalCount !== undefined && pageTotal !== totalCount)) { - throw new Error('GitHub API returned an invalid or changing workflow run total_count'); + const runs = listWorkflowRuns(repository, WORKFLOW); + for (const legacyId of LEGACY_WORKFLOW_IDS[repository] || []) { + try { + runs.push(...listWorkflowRuns(repository, legacyId)); + } catch (error) { + const detail = `${error?.stderr || ''}\n${error?.message || ''}`; + if (!/HTTP 404\b/.test(detail)) { + throw error; + } } - totalCount = pageTotal; - const pageRuns = pagesToItems(pages, 'workflow_runs'); - if (pageRuns.length === 0 && allRuns.length < totalCount) { - throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`); - } - allRuns.push(...pageRuns); - page += 1; - } while (allRuns.length < totalCount); - if (allRuns.length !== totalCount) { - throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`); - } - if (new Set(allRuns.map((run) => String(run.id))).size !== allRuns.length) { - throw new Error('GitHub API returned duplicate workflow runs across pages'); } - return allRuns; + return runs; }, listJobs(repository, runId) { const pages = call([ diff --git a/.github/scripts/marketplace-pre-release-history.test.js b/.github/scripts/marketplace-pre-release-history.test.js index 19e711d92..f33ba19fc 100644 --- a/.github/scripts/marketplace-pre-release-history.test.js +++ b/.github/scripts/marketplace-pre-release-history.test.js @@ -335,33 +335,106 @@ test('run pagination fails closed on empty, inconsistent, or duplicate pages', ( : { total_count: 101, workflow_runs: [item(100)] }).listRuns('owner/repo'), /duplicate workflow runs/); }); -test('each split publishing job rechecks eligibility and gates its registry', () => { - const workflow = readFileSync(join(__dirname, '../workflows/marketplace-pre-release.yml'), 'utf8'); - for (const [job, name] of [['publish-vs-marketplace', marketplace], ['publish-open-vsx', openVsx]]) { +test('consolidated release workflow configures trusted publishing and rechecks pre-release eligibility', () => { + const workflow = readFileSync(join(__dirname, '../workflows/release.yml'), 'utf8'); + assert.ok(!workflow.includes('VSCE_PAT')); + assert.ok(!workflow.includes('OVSX_PAT')); + assert.ok(!workflow.includes('VSCE_TOKEN')); + assert.ok(!workflow.includes('OPEN_VSX_TOKEN')); + assert.ok(workflow.includes("format('stable-publishing-{0}', github.ref)")); + assert.ok(!workflow.includes("format('release-{0}', github.ref)")); + + for (const [job, envName, command] of [ + ['publish-vs-marketplace', 'vs-marketplace', 'vsce publish --oidc'], + ['publish-open-vsx', 'open-vsx', 'ovsx publish --trusted-publishing'], + ]) { const start = workflow.indexOf(`\n ${job}:`); assert.ok(start >= 0); - const end = workflow.indexOf('\n publish-', start + 1); - const body = workflow.slice(start, end < 0 ? undefined : end); + const nextJob = workflow.slice(start + 1).search(/\n [a-z-]+:/); + const body = nextJob < 0 ? workflow.slice(start) : workflow.slice(start, start + 1 + nextJob); + assert.ok(body.includes(`environment: ${envName}`)); + assert.ok(body.includes('id-token: write')); + assert.ok(body.includes(command)); + } + + for (const [job, name, envName, command] of [ + ['prerelease-publish-vs-marketplace', marketplace, 'vs-marketplace', 'vsce publish --oidc --pre-release'], + ['prerelease-publish-open-vsx', openVsx, 'open-vsx', 'ovsx publish --trusted-publishing'], + ]) { + const start = workflow.indexOf(`\n ${job}:`); + assert.ok(start >= 0); + const nextJob = workflow.slice(start + 1).search(/\n [a-z-]+:/); + const body = nextJob < 0 ? workflow.slice(start) : workflow.slice(start, start + 1 + nextJob); + assert.ok(body.includes(`environment: ${envName}`)); + assert.ok(body.includes('id-token: write')); + assert.ok(body.includes('actions: read')); const eligibility = body.indexOf('id: eligibility'); assert.ok(eligibility >= 0); assert.ok(body.indexOf('.github/scripts/marketplace-pre-release-history.js', eligibility) > eligibility); const publication = body.indexOf(`- name: ${name}`); assert.ok(publication > eligibility); assert.ok(body.slice(publication).includes("if: steps.eligibility.outputs.publish == 'true'")); + assert.ok(body.slice(publication).includes(command)); } }); +test('canonical repository run lookup includes retired marketplace-pre-release workflow ID', () => { + const queriedPaths = []; + const api = createGhApi((_command, args) => { + const path = args.find((arg) => arg.startsWith('repos/')); + queriedPaths.push(path); + const id = path.includes('/376461048/') ? 10 : 20; + return `${JSON.stringify({ + total_count: 1, + workflow_runs: [run(id, '2026-11-10', 'sha-a')], + })}\n`; + }); + const runs = api.listRuns('REditorSupport/vscode-R'); + assert.deepEqual(queriedPaths, [ + 'repos/REditorSupport/vscode-R/actions/workflows/release.yml/runs', + 'repos/REditorSupport/vscode-R/actions/workflows/376461048/runs', + ]); + assert.deepEqual(runs.map((item) => item.id), [20, 10]); + + const purgedLegacyApi = createGhApi((_command, args) => { + const path = args.find((arg) => arg.startsWith('repos/')); + if (path.includes('/376461048/')) { + throw new Error('gh: Not Found (HTTP 404)'); + } + return `${JSON.stringify({ + total_count: 1, + workflow_runs: [run(20, '2026-11-10', 'sha-a')], + })}\n`; + }); + assert.deepEqual(purgedLegacyApi.listRuns('REditorSupport/vscode-R').map((item) => item.id), [20]); + + const transientFailureApi = createGhApi((_command, args) => { + const path = args.find((arg) => arg.startsWith('repos/')); + if (path.includes('/376461048/')) { + throw new Error('gh: Bad Gateway (HTTP 502)'); + } + return `${JSON.stringify({ total_count: 0, workflow_runs: [] })}\n`; + }); + assert.throws(() => transientFailureApi.listRuns('REditorSupport/vscode-R'), /HTTP 502/); +}); + function splitJobs(options) { return publishJob(options).steps.map((step, index) => ({ name: ['publish-vs-marketplace', 'publish-open-vsx'][index], steps: [step], })); } +function prereleaseSplitJobs(options) { + return publishJob(options).steps.map((step, index) => ({ + name: ['prerelease-publish-vs-marketplace', 'prerelease-publish-open-vsx'][index], steps: [step], + })); +} + test('split jobs: re-running only the failed registry job completes the publication', async () => { const prior = run(820, '2026-10-05', 'sha-a'); const current = run(821, '2026-10-06', 'sha-a'); - const firstAttempt = splitJobs({ openVsxConclusion: 'failure' }); - const retriedOpenVsx = { ...splitJobs()[1], run_attempt: 2 }; + const firstAttempt = prereleaseSplitJobs({ openVsxConclusion: 'failure' }); + const retriedOpenVsx = { ...prereleaseSplitJobs()[1], run_attempt: 2 }; const jobs = [...firstAttempt, retriedOpenVsx]; assert.equal(completedBothRegistries(firstAttempt), false); assert.equal(completedBothRegistries(jobs), true); @@ -370,7 +443,11 @@ test('split jobs: re-running only the failed registry job completes the publicat }); }); -for (const [format, jobsFor] of [['split jobs', splitJobs], ['old combined job', (options) => [publishJob(options)]]]) { +for (const [format, jobsFor] of [ + ['prerelease split jobs', prereleaseSplitJobs], + ['legacy split jobs', splitJobs], + ['old combined job', (options) => [publishJob(options)]], +]) { test(`${format}: both registries succeeded suppress unchanged SHA`, async () => { const prior = run(800, '2026-10-05', 'sha-a'); const current = run(801, '2026-10-06', 'sha-a'); diff --git a/.github/workflows/marketplace-pre-release.yml b/.github/workflows/marketplace-pre-release.yml deleted file mode 100644 index 5c9394fb0..000000000 --- a/.github/workflows/marketplace-pre-release.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: marketplace-pre-release - -on: - schedule: - - cron: '23 3 * * *' - workflow_dispatch: - -concurrency: - group: marketplace-pre-release - cancel-in-progress: false - -permissions: - contents: read - actions: read - -jobs: - prepare: - if: github.repository == 'REditorSupport/vscode-R' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - outputs: - publish: ${{ steps.previous.outputs.publish }} - date: ${{ steps.previous.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - - name: Check publication history - id: previous - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node .github/scripts/marketplace-pre-release-history.js - - verify: - needs: prepare - if: needs.prepare.outputs.publish == 'true' - uses: ./.github/workflows/main.yml - with: - prerelease-date: ${{ needs.prepare.outputs.date }} - - publish-vs-marketplace: - # TODO: Uncomment when switching to trusted publishing (OIDC, no token). - # A repository admin must create the vs-marketplace environment, and each - # publishing workflow file needs its own Marketplace trusted publishing policy. - # environment: vs-marketplace - needs: - - prepare - - verify - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - name: Download verified pre-release - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: vscode-R.vsix - path: artifacts/ - - name: Recheck publication eligibility - id: eligibility - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node .github/scripts/marketplace-pre-release-history.js - - name: Publish to Visual Studio Marketplace - if: steps.eligibility.outputs.publish == 'true' - env: - VSCE_PAT: ${{ secrets.VSCE_TOKEN }} - run: pnpm exec vsce publish --pre-release --skip-duplicate --packagePath artifacts/vscode-R.vsix - - publish-open-vsx: - # TODO: Uncomment when switching to trusted publishing (OIDC, no token). - # A repository admin must create the open-vsx environment. Open VSX allows - # only one trusted publisher (bound to one workflow file) per extension, so - # stable and pre-release publishing must first be consolidated into a single - # workflow file (a shared reusable workflow may not be enough, since the - # caller differs). - # environment: open-vsx - needs: - - prepare - - verify - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - name: Download verified pre-release - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: vscode-R.vsix - path: artifacts/ - - name: Recheck publication eligibility - id: eligibility - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node .github/scripts/marketplace-pre-release-history.js - - name: Publish to Open VSX Registry - if: steps.eligibility.outputs.publish == 'true' - env: - OVSX_PAT: ${{ secrets.OPEN_VSX_TOKEN }} - run: pnpm exec ovsx publish --skip-duplicate --packagePath artifacts/vscode-R.vsix diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93bd37ca3..c5009c41c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,29 @@ name: release -# runs when a tag v* is pushed -# releases the extension to GitHub and the vscode marketplace +# Runs when a tag v* is pushed (stable release) or on the daily schedule / +# manual dispatch on main (Marketplace and Open VSX pre-release). +# Stable and pre-release publishing live in a single workflow file so Open VSX +# Trusted Publishing (which allows one workflow file per extension) covers both. on: push: tags: - "v*" + schedule: + - cron: '23 3 * * *' + workflow_dispatch: + +concurrency: + group: ${{ github.event_name == 'push' && format('stable-publishing-{0}', github.ref) || 'marketplace-pre-release' }} + cancel-in-progress: false permissions: contents: read + actions: read jobs: check-version: + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read @@ -32,6 +43,7 @@ jobs: release: name: Release + if: github.event_name == 'push' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Independent of the registry jobs so the VSIX stays available as a @@ -81,10 +93,11 @@ jobs: publish-vs-marketplace: name: Publish to Visual Studio Marketplace - # TODO: Uncomment when switching to trusted publishing (OIDC, no token). - # A repository admin must create the vs-marketplace environment, and each - # publishing workflow file needs its own Marketplace trusted publishing policy. - # environment: vs-marketplace + if: github.event_name == 'push' + environment: vs-marketplace + permissions: + contents: read + id-token: write timeout-minutes: 30 runs-on: ubuntu-latest needs: verify @@ -102,19 +115,15 @@ jobs: name: vscode-R.vsix path: artifacts/ - name: Publish to Visual Studio Marketplace - env: - VSCE_PAT: ${{ secrets.VSCE_TOKEN }} - run: pnpm exec vsce publish --skip-duplicate --packagePath artifacts/*.vsix + run: pnpm exec vsce publish --oidc --skip-duplicate --packagePath artifacts/*.vsix publish-open-vsx: name: Publish to Open VSX Registry - # TODO: Uncomment when switching to trusted publishing (OIDC, no token). - # A repository admin must create the open-vsx environment. Open VSX allows - # only one trusted publisher (bound to one workflow file) per extension, so - # stable and pre-release publishing must first be consolidated into a single - # workflow file (a shared reusable workflow may not be enough, since the - # caller differs). - # environment: open-vsx + if: github.event_name == 'push' + environment: open-vsx + permissions: + contents: read + id-token: write timeout-minutes: 30 runs-on: ubuntu-latest needs: verify @@ -132,6 +141,97 @@ jobs: name: vscode-R.vsix path: artifacts/ - name: Publish to Open VSX Registry + run: pnpm exec ovsx publish --trusted-publishing --skip-duplicate --packagePath artifacts/*.vsix + + prerelease-prepare: + if: github.event_name != 'push' && github.repository == 'REditorSupport/vscode-R' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.previous.outputs.publish }} + date: ${{ steps.previous.outputs.date }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Check publication history + id: previous + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node .github/scripts/marketplace-pre-release-history.js + + prerelease-verify: + needs: prerelease-prepare + if: needs.prerelease-prepare.outputs.publish == 'true' + uses: ./.github/workflows/main.yml + with: + prerelease-date: ${{ needs.prerelease-prepare.outputs.date }} + + prerelease-publish-vs-marketplace: + environment: vs-marketplace + permissions: + actions: read + contents: read + id-token: write + needs: + - prerelease-prepare + - prerelease-verify + if: github.event_name != 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - name: Download verified pre-release + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: vscode-R.vsix + path: artifacts/ + - name: Recheck publication eligibility + id: eligibility env: - OVSX_PAT: ${{ secrets.OPEN_VSX_TOKEN }} - run: pnpm exec ovsx publish --skip-duplicate --packagePath artifacts/*.vsix + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/marketplace-pre-release-history.js + - name: Publish to Visual Studio Marketplace + if: steps.eligibility.outputs.publish == 'true' + run: pnpm exec vsce publish --oidc --pre-release --skip-duplicate --packagePath artifacts/vscode-R.vsix + + prerelease-publish-open-vsx: + environment: open-vsx + permissions: + actions: read + contents: read + id-token: write + needs: + - prerelease-prepare + - prerelease-verify + if: github.event_name != 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - name: Download verified pre-release + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: vscode-R.vsix + path: artifacts/ + - name: Recheck publication eligibility + id: eligibility + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/marketplace-pre-release-history.js + - name: Publish to Open VSX Registry + if: steps.eligibility.outputs.publish == 'true' + run: pnpm exec ovsx publish --trusted-publishing --skip-duplicate --packagePath artifacts/vscode-R.vsix diff --git a/.github/workflows/workflow-helpers.yml b/.github/workflows/workflow-helpers.yml index 40a6d35ff..299ac1756 100644 --- a/.github/workflows/workflow-helpers.yml +++ b/.github/workflows/workflow-helpers.yml @@ -5,13 +5,13 @@ on: paths: - '.github/scripts/marketplace-pre-release-history.js' - '.github/scripts/marketplace-pre-release-history.test.js' - - '.github/workflows/marketplace-pre-release.yml' + - '.github/workflows/release.yml' - '.github/workflows/workflow-helpers.yml' pull_request: paths: - '.github/scripts/marketplace-pre-release-history.js' - '.github/scripts/marketplace-pre-release-history.test.js' - - '.github/workflows/marketplace-pre-release.yml' + - '.github/workflows/release.yml' - '.github/workflows/workflow-helpers.yml' workflow_dispatch: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b7131bbc..740079c2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,7 +162,7 @@ The release workflow rejects odd minors, non-numeric versions, and tags that do not match `package.json`. Stable patch releases remain independent of daily pre-releases. -The Marketplace pre-release workflow runs daily at 03:23 UTC. It skips commits +Daily pre-release publishing in `.github/workflows/release.yml` runs at 03:23 UTC. It skips commits already covered by a successful daily run, runs the existing build, lint and all-OS tests, and publishes only the verified pre-release VSIX. The GitHub `latest` development VSIX continues to update on every verified push. @@ -175,7 +175,8 @@ Keep the base version in `package.json`; do not commit generated daily versions. For development toward `4.0.0`, maintainers can explicitly set the base version to `3.999.0`, then change it to `4.0.0` for the stable release. -The workflow reuses `VSCE_TOKEN` and `OPEN_VSX_TOKEN` to publish the same VSIX +The workflow uses Trusted Publishing (OIDC) via the `vs-marketplace` and `open-vsx` +GitHub environments to publish the same VSIX to the VS Code Marketplace and Open VSX Registry. Both publications must succeed for the daily run to count as successful. Retries retain the original run's UTC date and tolerate an already-published