Skip to content

Commit 7b5720c

Browse files
authored
Merge pull request #1930 from REditorSupport/ci/trusted-publishing
2 parents 0282581 + 17a430b commit 7b5720c

6 files changed

Lines changed: 266 additions & 176 deletions

File tree

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

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
const { execFileSync } = require('node:child_process');
22
const { appendFileSync } = require('node:fs');
33

4-
const WORKFLOW = 'marketplace-pre-release.yml';
5-
// Include the former combined job when inspecting existing Actions history.
6-
const PUBLISH_JOBS = ['publish', 'publish-vs-marketplace', 'publish-open-vsx'];
4+
const WORKFLOW = 'release.yml';
5+
// Retain the retired marketplace-pre-release.yml workflow ID on the canonical
6+
// repository so runs from before the workflow consolidation stay visible.
7+
const LEGACY_WORKFLOW_IDS = {
8+
'REditorSupport/vscode-R': ['376461048'],
9+
};
10+
// Include former job names when inspecting existing Actions history.
11+
const PUBLISH_JOBS = [
12+
'publish',
13+
'publish-vs-marketplace',
14+
'publish-open-vsx',
15+
'prerelease-publish-vs-marketplace',
16+
'prerelease-publish-open-vsx',
17+
];
718
const REGISTRY_STEPS = [
819
'Publish to Visual Studio Marketplace',
920
'Publish to Open VSX Registry',
@@ -59,6 +70,40 @@ function createGhApi(execFile = execFileSync) {
5970
}
6071
return trimmed.split(/\r?\n/).map((line) => JSON.parse(line));
6172
}
73+
function listWorkflowRuns(repository, workflow) {
74+
const path = `repos/${repository}/actions/workflows/${workflow}/runs`;
75+
const allRuns = [];
76+
let totalCount;
77+
let page = 1;
78+
do {
79+
const pages = call([
80+
path,
81+
'-f', 'per_page=100',
82+
'-f', `page=${page}`,
83+
], { jq: RUN_JQ, paginate: false });
84+
if (pages.length !== 1) {
85+
throw new Error(`GitHub API returned an invalid workflow runs page ${page}`);
86+
}
87+
const pageTotal = pages[0].total_count;
88+
if (!Number.isSafeInteger(pageTotal) || pageTotal < 0 || (totalCount !== undefined && pageTotal !== totalCount)) {
89+
throw new Error('GitHub API returned an invalid or changing workflow run total_count');
90+
}
91+
totalCount = pageTotal;
92+
const pageRuns = pagesToItems(pages, 'workflow_runs');
93+
if (pageRuns.length === 0 && allRuns.length < totalCount) {
94+
throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`);
95+
}
96+
allRuns.push(...pageRuns);
97+
page += 1;
98+
} while (allRuns.length < totalCount);
99+
if (allRuns.length !== totalCount) {
100+
throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`);
101+
}
102+
if (new Set(allRuns.map((run) => String(run.id))).size !== allRuns.length) {
103+
throw new Error('GitHub API returned duplicate workflow runs across pages');
104+
}
105+
return allRuns;
106+
}
62107
return {
63108
getRun(repository, runId) {
64109
const run = call([`repos/${repository}/actions/runs/${runId}`], { paginate: false });
@@ -68,38 +113,18 @@ function createGhApi(execFile = execFileSync) {
68113
return run;
69114
},
70115
listRuns(repository) {
71-
const path = `repos/${repository}/actions/workflows/${WORKFLOW}/runs`;
72-
const allRuns = [];
73-
let totalCount;
74-
let page = 1;
75-
do {
76-
const pages = call([
77-
path,
78-
'-f', 'per_page=100',
79-
'-f', `page=${page}`,
80-
], { jq: RUN_JQ, paginate: false });
81-
if (pages.length !== 1) {
82-
throw new Error(`GitHub API returned an invalid workflow runs page ${page}`);
83-
}
84-
const pageTotal = pages[0].total_count;
85-
if (!Number.isSafeInteger(pageTotal) || pageTotal < 0 || (totalCount !== undefined && pageTotal !== totalCount)) {
86-
throw new Error('GitHub API returned an invalid or changing workflow run total_count');
116+
const runs = listWorkflowRuns(repository, WORKFLOW);
117+
for (const legacyId of LEGACY_WORKFLOW_IDS[repository] || []) {
118+
try {
119+
runs.push(...listWorkflowRuns(repository, legacyId));
120+
} catch (error) {
121+
const detail = `${error?.stderr || ''}\n${error?.message || ''}`;
122+
if (!/HTTP 404\b/.test(detail)) {
123+
throw error;
124+
}
87125
}
88-
totalCount = pageTotal;
89-
const pageRuns = pagesToItems(pages, 'workflow_runs');
90-
if (pageRuns.length === 0 && allRuns.length < totalCount) {
91-
throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`);
92-
}
93-
allRuns.push(...pageRuns);
94-
page += 1;
95-
} while (allRuns.length < totalCount);
96-
if (allRuns.length !== totalCount) {
97-
throw new Error(`GitHub API returned incomplete workflow run history (${allRuns.length} of ${totalCount})`);
98-
}
99-
if (new Set(allRuns.map((run) => String(run.id))).size !== allRuns.length) {
100-
throw new Error('GitHub API returned duplicate workflow runs across pages');
101126
}
102-
return allRuns;
127+
return runs;
103128
},
104129
listJobs(repository, runId) {
105130
const pages = call([

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

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -335,33 +335,106 @@ 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('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]]) {
338+
test('consolidated release workflow configures trusted publishing and rechecks pre-release eligibility', () => {
339+
const workflow = readFileSync(join(__dirname, '../workflows/release.yml'), 'utf8');
340+
assert.ok(!workflow.includes('VSCE_PAT'));
341+
assert.ok(!workflow.includes('OVSX_PAT'));
342+
assert.ok(!workflow.includes('VSCE_TOKEN'));
343+
assert.ok(!workflow.includes('OPEN_VSX_TOKEN'));
344+
assert.ok(workflow.includes("format('stable-publishing-{0}', github.ref)"));
345+
assert.ok(!workflow.includes("format('release-{0}', github.ref)"));
346+
347+
for (const [job, envName, command] of [
348+
['publish-vs-marketplace', 'vs-marketplace', 'vsce publish --oidc'],
349+
['publish-open-vsx', 'open-vsx', 'ovsx publish --trusted-publishing'],
350+
]) {
341351
const start = workflow.indexOf(`\n ${job}:`);
342352
assert.ok(start >= 0);
343-
const end = workflow.indexOf('\n publish-', start + 1);
344-
const body = workflow.slice(start, end < 0 ? undefined : end);
353+
const nextJob = workflow.slice(start + 1).search(/\n [a-z-]+:/);
354+
const body = nextJob < 0 ? workflow.slice(start) : workflow.slice(start, start + 1 + nextJob);
355+
assert.ok(body.includes(`environment: ${envName}`));
356+
assert.ok(body.includes('id-token: write'));
357+
assert.ok(body.includes(command));
358+
}
359+
360+
for (const [job, name, envName, command] of [
361+
['prerelease-publish-vs-marketplace', marketplace, 'vs-marketplace', 'vsce publish --oidc --pre-release'],
362+
['prerelease-publish-open-vsx', openVsx, 'open-vsx', 'ovsx publish --trusted-publishing'],
363+
]) {
364+
const start = workflow.indexOf(`\n ${job}:`);
365+
assert.ok(start >= 0);
366+
const nextJob = workflow.slice(start + 1).search(/\n [a-z-]+:/);
367+
const body = nextJob < 0 ? workflow.slice(start) : workflow.slice(start, start + 1 + nextJob);
368+
assert.ok(body.includes(`environment: ${envName}`));
369+
assert.ok(body.includes('id-token: write'));
370+
assert.ok(body.includes('actions: read'));
345371
const eligibility = body.indexOf('id: eligibility');
346372
assert.ok(eligibility >= 0);
347373
assert.ok(body.indexOf('.github/scripts/marketplace-pre-release-history.js', eligibility) > eligibility);
348374
const publication = body.indexOf(`- name: ${name}`);
349375
assert.ok(publication > eligibility);
350376
assert.ok(body.slice(publication).includes("if: steps.eligibility.outputs.publish == 'true'"));
377+
assert.ok(body.slice(publication).includes(command));
351378
}
352379
});
353380

381+
test('canonical repository run lookup includes retired marketplace-pre-release workflow ID', () => {
382+
const queriedPaths = [];
383+
const api = createGhApi((_command, args) => {
384+
const path = args.find((arg) => arg.startsWith('repos/'));
385+
queriedPaths.push(path);
386+
const id = path.includes('/376461048/') ? 10 : 20;
387+
return `${JSON.stringify({
388+
total_count: 1,
389+
workflow_runs: [run(id, '2026-11-10', 'sha-a')],
390+
})}\n`;
391+
});
392+
const runs = api.listRuns('REditorSupport/vscode-R');
393+
assert.deepEqual(queriedPaths, [
394+
'repos/REditorSupport/vscode-R/actions/workflows/release.yml/runs',
395+
'repos/REditorSupport/vscode-R/actions/workflows/376461048/runs',
396+
]);
397+
assert.deepEqual(runs.map((item) => item.id), [20, 10]);
398+
399+
const purgedLegacyApi = createGhApi((_command, args) => {
400+
const path = args.find((arg) => arg.startsWith('repos/'));
401+
if (path.includes('/376461048/')) {
402+
throw new Error('gh: Not Found (HTTP 404)');
403+
}
404+
return `${JSON.stringify({
405+
total_count: 1,
406+
workflow_runs: [run(20, '2026-11-10', 'sha-a')],
407+
})}\n`;
408+
});
409+
assert.deepEqual(purgedLegacyApi.listRuns('REditorSupport/vscode-R').map((item) => item.id), [20]);
410+
411+
const transientFailureApi = createGhApi((_command, args) => {
412+
const path = args.find((arg) => arg.startsWith('repos/'));
413+
if (path.includes('/376461048/')) {
414+
throw new Error('gh: Bad Gateway (HTTP 502)');
415+
}
416+
return `${JSON.stringify({ total_count: 0, workflow_runs: [] })}\n`;
417+
});
418+
assert.throws(() => transientFailureApi.listRuns('REditorSupport/vscode-R'), /HTTP 502/);
419+
});
420+
354421
function splitJobs(options) {
355422
return publishJob(options).steps.map((step, index) => ({
356423
name: ['publish-vs-marketplace', 'publish-open-vsx'][index], steps: [step],
357424
}));
358425
}
359426

427+
function prereleaseSplitJobs(options) {
428+
return publishJob(options).steps.map((step, index) => ({
429+
name: ['prerelease-publish-vs-marketplace', 'prerelease-publish-open-vsx'][index], steps: [step],
430+
}));
431+
}
432+
360433
test('split jobs: re-running only the failed registry job completes the publication', async () => {
361434
const prior = run(820, '2026-10-05', 'sha-a');
362435
const current = run(821, '2026-10-06', 'sha-a');
363-
const firstAttempt = splitJobs({ openVsxConclusion: 'failure' });
364-
const retriedOpenVsx = { ...splitJobs()[1], run_attempt: 2 };
436+
const firstAttempt = prereleaseSplitJobs({ openVsxConclusion: 'failure' });
437+
const retriedOpenVsx = { ...prereleaseSplitJobs()[1], run_attempt: 2 };
365438
const jobs = [...firstAttempt, retriedOpenVsx];
366439
assert.equal(completedBothRegistries(firstAttempt), false);
367440
assert.equal(completedBothRegistries(jobs), true);
@@ -370,7 +443,11 @@ test('split jobs: re-running only the failed registry job completes the publicat
370443
});
371444
});
372445

373-
for (const [format, jobsFor] of [['split jobs', splitJobs], ['old combined job', (options) => [publishJob(options)]]]) {
446+
for (const [format, jobsFor] of [
447+
['prerelease split jobs', prereleaseSplitJobs],
448+
['legacy split jobs', splitJobs],
449+
['old combined job', (options) => [publishJob(options)]],
450+
]) {
374451
test(`${format}: both registries succeeded suppress unchanged SHA`, async () => {
375452
const prior = run(800, '2026-10-05', 'sha-a');
376453
const current = run(801, '2026-10-06', 'sha-a');

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

Lines changed: 0 additions & 113 deletions
This file was deleted.

0 commit comments

Comments
 (0)