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
91 changes: 58 additions & 33 deletions .github/scripts/marketplace-pre-release-history.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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 });
Expand All @@ -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([
Expand Down
93 changes: 85 additions & 8 deletions .github/scripts/marketplace-pre-release-history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down
113 changes: 0 additions & 113 deletions .github/workflows/marketplace-pre-release.yml

This file was deleted.

Loading
Loading