Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions .github/scripts/marketplace-pre-release-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'));
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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' };
Expand Down
77 changes: 56 additions & 21 deletions .github/scripts/marketplace-pre-release-history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
});
});
}
45 changes: 42 additions & 3 deletions .github/workflows/marketplace-pre-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
86 changes: 72 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ on:
tags:
- "v*"

env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read

jobs:
check-version:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Loading