diff --git a/.github/scripts/marketplace-pre-release-history.js b/.github/scripts/marketplace-pre-release-history.js index c4dbbda0c..0645dec73 100644 --- a/.github/scripts/marketplace-pre-release-history.js +++ b/.github/scripts/marketplace-pre-release-history.js @@ -2,7 +2,8 @@ const { execFileSync } = require('node:child_process'); const { appendFileSync } = require('node:fs'); const WORKFLOW = 'marketplace-pre-release.yml'; -const PUBLISH_JOB = 'publish'; +// Include the former combined job when inspecting existing Actions history. +const PUBLISH_JOBS = ['publish', 'publish-vs-marketplace', 'publish-open-vsx']; const REGISTRY_STEPS = [ 'Publish to Visual Studio Marketplace', 'Publish to Open VSX Registry', @@ -111,20 +112,20 @@ function createGhApi(execFile = execFileSync) { }; } -function didAttemptPublication(job) { - if (job.name !== PUBLISH_JOB || !Array.isArray(job.steps)) { - return false; - } - return job.steps.some((step) => REGISTRY_STEPS.includes(step.name) +function registrySteps(jobs) { + return jobs.filter((job) => PUBLISH_JOBS.includes(job.name)) + .flatMap((job) => Array.isArray(job.steps) ? job.steps : []); +} + +function didAttemptPublication(jobs) { + return registrySteps(jobs).some((step) => REGISTRY_STEPS.includes(step.name) && (step.status === 'in_progress' || step.status === 'completed') && step.conclusion !== 'skipped'); } -function completedBothRegistries(job) { - if (job.name !== PUBLISH_JOB || !Array.isArray(job.steps)) { - return false; - } - return REGISTRY_STEPS.every((name) => job.steps.some((step) => +function completedBothRegistries(jobs) { + const steps = registrySteps(jobs); + return REGISTRY_STEPS.every((name) => steps.some((step) => step.name === name && step.status === 'completed' && step.conclusion === 'success')); } @@ -170,7 +171,7 @@ async function decidePublication({ api, repository, runId, sha }) { const isCurrentRun = String(run.id) === String(runId); if (!isCurrentRun && utcDate(run) >= runDate) { const jobs = await jobsFor(run); - if (jobs.some(didAttemptPublication)) { + if (didAttemptPublication(jobs)) { dateReserved = true; break; } @@ -192,7 +193,7 @@ async function decidePublication({ api, repository, runId, sha }) { } for (let index = end - 1; index >= start; index -= 1) { const jobs = await jobsFor(runs[index]); - if (jobs.some(completedBothRegistries)) { + if (completedBothRegistries(jobs)) { return runs[start].head_sha === sha ? { publish: false, date: runDate, reason: 'unchanged' } : { publish: true, date: runDate, reason: 'new-sha' }; diff --git a/.github/scripts/marketplace-pre-release-history.test.js b/.github/scripts/marketplace-pre-release-history.test.js index 561e07139..19e711d92 100644 --- a/.github/scripts/marketplace-pre-release-history.test.js +++ b/.github/scripts/marketplace-pre-release-history.test.js @@ -2,7 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const { join } = require('node:path'); -const { createGhApi, decidePublication, pagesToItems, completePagesToItems } = require('./marketplace-pre-release-history'); +const { createGhApi, decidePublication, pagesToItems, completePagesToItems, didAttemptPublication, completedBothRegistries } = require('./marketplace-pre-release-history'); const marketplace = 'Publish to Visual Studio Marketplace'; const openVsx = 'Publish to Open VSX Registry'; @@ -335,25 +335,60 @@ 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('workflow rechecks eligibility in the publishing job and gates both registries', () => { - const workflowPath = join(__dirname, '../workflows/marketplace-pre-release.yml'); - const workflow = readFileSync(workflowPath, 'utf8'); - const prepareStart = workflow.indexOf(' prepare:'); - const publishStart = workflow.indexOf('\n publish:\n needs:'); - const prepare = workflow.slice(prepareStart, publishStart); - const helperIndex = prepare.indexOf('.github/scripts/marketplace-pre-release-history.js'); - assert.ok(helperIndex >= 0); - assert.ok(prepare.slice(0, helperIndex).includes('uses: actions/checkout@')); - assert.match(prepare.slice(0, helperIndex), /uses: actions\/setup-node@[\s\S]*?node-version: 24/); - const publish = workflow.slice(publishStart); - const eligibilityIndex = publish.indexOf('id: eligibility'); - assert.ok(eligibilityIndex >= 0); - assert.match(publish.slice(eligibilityIndex), /run: node \.github\/scripts\/marketplace-pre-release-history\.js/); - for (const name of [marketplace, openVsx]) { - const index = publish.indexOf(`- name: ${name}`); - assert.ok(index > eligibilityIndex, `${name} must run after the eligibility check`); - const nextStep = publish.indexOf('\n - name:', index + 1); - const step = publish.slice(index, nextStep < 0 ? undefined : nextStep); - assert.match(step, /if: steps\.eligibility\.outputs\.publish == 'true'/); +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]]) { + 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 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'")); } }); + +function splitJobs(options) { + return publishJob(options).steps.map((step, index) => ({ + name: ['publish-vs-marketplace', '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 jobs = [...firstAttempt, retriedOpenVsx]; + assert.equal(completedBothRegistries(firstAttempt), false); + assert.equal(completedBothRegistries(jobs), true); + assert.deepEqual(await decide(current, [prior, current], new Map([['820', jobs]])), { + publish: false, date: '2026-10-06', reason: 'unchanged', + }); +}); + +for (const [format, jobsFor] of [['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'); + const jobs = jobsFor(); + assert.equal(didAttemptPublication(jobs), true); + assert.equal(completedBothRegistries(jobs), true); + assert.deepEqual(await decide(current, [prior, current], new Map([['800', jobs]])), { + publish: false, date: '2026-10-06', reason: 'unchanged', + }); + }); + test(`${format}: partial publication reserves date without completing publication`, async () => { + const prior = run(810, '2026-10-05', 'sha-a'); + const current = run(811, '2026-10-05', 'sha-a'); + const jobs = jobsFor({ openVsxConclusion: 'failure' }); + assert.equal(didAttemptPublication(jobs), true); + assert.equal(completedBothRegistries(jobs), false); + assert.deepEqual(await decide(current, [prior, current], new Map([['810', jobs]])), { + publish: false, date: '2026-10-05', reason: 'date-reserved', + }); + }); +} diff --git a/.github/workflows/marketplace-pre-release.yml b/.github/workflows/marketplace-pre-release.yml index 8d9e4e559..5c9394fb0 100644 --- a/.github/workflows/marketplace-pre-release.yml +++ b/.github/workflows/marketplace-pre-release.yml @@ -39,8 +39,14 @@ jobs: with: prerelease-date: ${{ needs.prepare.outputs.date }} - publish: - needs: [prepare, verify] + 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 @@ -67,8 +73,41 @@ jobs: 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 artifacts/vscode-R.vsix + 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 d14182b7e..93bd37ca3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,8 @@ on: tags: - "v*" -env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +permissions: + contents: read jobs: check-version: @@ -32,6 +32,10 @@ jobs: release: name: Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Independent of the registry jobs so the VSIX stays available as a + # fallback when a registry is down. needs: verify runs-on: ubuntu-latest permissions: @@ -47,19 +51,43 @@ jobs: - name: Create release run: | VERSION=${GITHUB_REF#refs/tags/v} - mv artifacts/vscode-R.vsix "artifacts/r-$VERSION.vsix" - gh release create "v$VERSION" "artifacts/r-$VERSION.vsix" \ - --title "v$VERSION" \ - --notes "Release v$VERSION" \ - --generate-notes + ASSET="r-$VERSION.vsix" + mv artifacts/vscode-R.vsix "artifacts/$ASSET" + if RELEASE=$(gh release view "v$VERSION" --json isDraft,assets 2>/dev/null); then + # A previous attempt may have created the release without the asset. + # Keep a completed asset: --clobber deletes it before uploading, so + # a failed upload would remove the existing download. + if jq -e --arg name "$ASSET" \ + '.assets | any(.name == $name and .state == "uploaded")' \ + <<<"$RELEASE" >/dev/null; then + echo "Release v$VERSION already has $ASSET; skipping the upload." + else + echo "Release v$VERSION already exists; uploading $ASSET." + # --clobber replaces an incomplete asset with the same name. + gh release upload "v$VERSION" "artifacts/$ASSET" --clobber + fi + # gh release create uploads assets to a draft before publishing it, + # so an interrupted attempt can leave the release as a draft. + if [ "$(jq -r .isDraft <<<"$RELEASE")" = "true" ]; then + echo "Release v$VERSION is a draft; publishing it." + gh release edit "v$VERSION" --draft=false + fi + else + gh release create "v$VERSION" "artifacts/$ASSET" \ + --title "v$VERSION" \ + --notes "Release v$VERSION" \ + --generate-notes + fi - publish: - name: Publish + 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 timeout-minutes: 30 runs-on: ubuntu-latest - needs: - - verify - - release + needs: verify steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -74,6 +102,36 @@ jobs: name: vscode-R.vsix path: artifacts/ - name: Publish to Visual Studio Marketplace - run: pnpm exec vsce publish -p ${{ secrets.VSCE_TOKEN }} --packagePath artifacts/*.vsix + env: + VSCE_PAT: ${{ secrets.VSCE_TOKEN }} + run: pnpm exec vsce publish --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 + timeout-minutes: 30 + runs-on: ubuntu-latest + needs: verify + 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 artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: vscode-R.vsix + path: artifacts/ - name: Publish to Open VSX Registry - run: pnpm exec ovsx publish -p ${{ secrets.OPEN_VSX_TOKEN }} --packagePath artifacts/*.vsix + env: + OVSX_PAT: ${{ secrets.OPEN_VSX_TOKEN }} + run: pnpm exec ovsx publish --skip-duplicate --packagePath artifacts/*.vsix