From 6aa6e650b28c79c8eccb1f429a7ed2156b2a552f Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 13 Nov 2026 14:31:20 +0000 Subject: [PATCH 1/3] ci: skip scheduled pre-releases when only development files changed Compare the files changed since the last SHA published to both registries and skip scheduled pre-releases when every change is CI, test, or other development-only. Packaging inputs under .github stay relevant, manual runs bypass the filter, and a failed fetch or diff publishes as before. Only the preparation job applies the filter so the two registry rechecks cannot diverge. --- .../marketplace-pre-release-history.js | 83 ++++++++++- .../marketplace-pre-release-history.test.js | 138 +++++++++++++++++- .github/workflows/release.yml | 3 + 3 files changed, 213 insertions(+), 11 deletions(-) diff --git a/.github/scripts/marketplace-pre-release-history.js b/.github/scripts/marketplace-pre-release-history.js index 781e7ebbf..0a92b1545 100644 --- a/.github/scripts/marketplace-pre-release-history.js +++ b/.github/scripts/marketplace-pre-release-history.js @@ -20,6 +20,36 @@ const REGISTRY_STEPS = [ 'Publish to Open VSX Registry', ]; const RUN_JQ = '{total_count, workflow_runs: [.workflow_runs[] | {id, created_at, head_sha, head_branch, event}]}'; +// Changes limited to these paths do not affect the published extension, so a +// scheduled run skips publication when nothing else changed since the last +// published SHA. Anything not matched here is treated as relevant. +const IGNORED_PATH_PATTERNS = [ + /^\.github\//, + /^src\/test\//, + /^sess\/tests\//, + /^R\/tests\//, + /^scripts\/benchmark-[^/]*$/, + /^scripts\/[^/]*\.test\.js$/, + /^\.vscode\//, + /^\.vscode-test\.mjs$/, + /^\.mocharc\.interactive\.cjs$/, + /^\.oxfmtrc\.jsonc$/, + /^\.oxlintrc\.jsonc$/, + /^\.markdownlint\.json$/, + /^\.gitignore$/, + /^cliff\.toml$/, + /^CONTRIBUTING\.md$/, + /^CHANGELOG\.old\.md$/, + /^AGENTS\.md$/, + /^\.(?:agents|claude|codex|roborev)\//, +]; +// Files under ignored directories that still determine the packaged VSIX. +const RELEVANT_PATH_EXCEPTIONS = new Set([ + '.github/scripts/extension-version.js', + '.github/workflows/main.yml', + '.github/workflows/release.yml', +]); +const GIT_TIMEOUT_MS = 120000; const JOBS_JQ = '{total_count, jobs: [.jobs[] | {name, run_attempt, steps: [(.steps // [])[] | select(.name == "Publish to Visual Studio Marketplace" or .name == "Publish to Open VSX Registry") | {name, status, conclusion}]}]}'; function pagesToItems(pages, property) { @@ -154,6 +184,28 @@ function completedBothRegistries(jobs) { step.name === name && step.status === 'completed' && step.conclusion === 'success')); } +function isRelevantPath(path) { + return RELEVANT_PATH_EXCEPTIONS.has(path) || !IGNORED_PATH_PATTERNS.some((pattern) => pattern.test(path)); +} + +function createGitChangedFiles(execFile = execFileSync) { + const git = (args) => execFile('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: GIT_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }); + return function listChangedFiles(base, head) { + // A two-endpoint tree diff needs both commits but not the history + // between them, so fetching the base into a shallow clone is enough. + git(['fetch', '--no-tags', '--depth=1', 'origin', base]); + // Without rename detection, moving a file across the ignore boundary + // still reports the relevant side. + const output = git(['diff', '--no-renames', '--name-only', '-z', base, head, '--']); + return output.split('\0').filter((path) => path !== ''); + }; +} + function utcDate(run) { if (typeof run.created_at !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(run.created_at)) { throw new Error(`Workflow run ${run.id} has an invalid created_at`); @@ -161,7 +213,7 @@ function utcDate(run) { return run.created_at.slice(0, 10); } -async function decidePublication({ api, repository, runId, sha }) { +async function decidePublication({ api, repository, runId, sha, listChangedFiles }) { const currentRun = await api.getRun(repository, runId); if (String(currentRun.id) !== String(runId) || currentRun.head_sha !== sha || currentRun.head_branch !== 'main') { throw new Error('Current run identity does not match the requested main branch run'); @@ -219,9 +271,24 @@ async function decidePublication({ api, repository, runId, sha }) { for (let index = end - 1; index >= start; index -= 1) { const jobs = await jobsFor(runs[index]); if (completedBothRegistries(jobs)) { - return runs[start].head_sha === sha - ? { publish: false, date: runDate, reason: 'unchanged' } - : { publish: true, date: runDate, reason: 'new-sha' }; + const publishedSha = runs[start].head_sha; + if (publishedSha === sha) { + return { publish: false, date: runDate, reason: 'unchanged' }; + } + // Manual runs are explicit requests and bypass the path filter. + if (!listChangedFiles || currentRun.event !== 'schedule') { + return { publish: true, date: runDate, reason: 'new-sha' }; + } + let changedFiles; + try { + changedFiles = await listChangedFiles(publishedSha, sha); + } catch (error) { + console.warn(`Could not list changes since ${publishedSha}; publishing anyway.`, error); + return { publish: true, date: runDate, reason: 'diff-unavailable' }; + } + return changedFiles.some(isRelevantPath) + ? { publish: true, date: runDate, reason: 'new-sha' } + : { publish: false, date: runDate, reason: 'no-relevant-changes' }; } } start = end; @@ -234,7 +301,11 @@ async function main() { if (!repository || !runId || !sha || !outputPath) { throw new Error('Required GitHub Actions environment variables are missing'); } - const decision = await decidePublication({ api: createGhApi(), repository, runId, sha }); + // Only the preparation job filters by path. Publication rechecks must not + // repeat it: a fetch that fails in one registry job and succeeds in the + // other would publish to only one registry. + const listChangedFiles = process.env.PRERELEASE_PATH_FILTER === 'true' ? createGitChangedFiles() : undefined; + const decision = await decidePublication({ api: createGhApi(), repository, runId, sha, listChangedFiles }); appendFileSync(outputPath, `date=${decision.date}\npublish=${decision.publish}\n`); console.log(decision.publish ? `Publish pre-release for ${decision.date} (${decision.reason}).` : `Skip pre-release (${decision.reason}).`); } @@ -246,4 +317,4 @@ if (require.main === module) { }); } -module.exports = { decidePublication, didAttemptPublication, completedBothRegistries, pagesToItems, completePagesToItems, createGhApi }; +module.exports = { decidePublication, isRelevantPath, createGitChangedFiles, didAttemptPublication, completedBothRegistries, pagesToItems, completePagesToItems, createGhApi }; diff --git a/.github/scripts/marketplace-pre-release-history.test.js b/.github/scripts/marketplace-pre-release-history.test.js index f33ba19fc..46f7f8970 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, didAttemptPublication, completedBothRegistries } = require('./marketplace-pre-release-history'); +const { createGhApi, createGitChangedFiles, decidePublication, isRelevantPath, pagesToItems, completePagesToItems, didAttemptPublication, completedBothRegistries } = require('./marketplace-pre-release-history'); const marketplace = 'Publish to Visual Studio Marketplace'; const openVsx = 'Publish to Open VSX Registry'; @@ -49,16 +49,16 @@ function harness(current, runs, jobsByRun) { }; } -async function evaluate(current, runs, jobsByRun) { +async function evaluate(current, runs, jobsByRun, listChangedFiles) { const state = harness(current, runs, jobsByRun); const { api, jobsQueried } = state; - const result = await decidePublication({ api, repository: 'owner/repo', runId: current.id, sha: current.head_sha }); + const result = await decidePublication({ api, repository: 'owner/repo', runId: current.id, sha: current.head_sha, listChangedFiles }); assert.equal(state.historyQueries, 1); return { result, jobsQueried, historyQueries: state.historyQueries }; } -async function decide(current, runs, jobsByRun) { - return (await evaluate(current, runs, jobsByRun)).result; +async function decide(current, runs, jobsByRun, listChangedFiles) { + return (await evaluate(current, runs, jobsByRun, listChangedFiles)).result; } test('manual publication consumes the date for a scheduled run, then allows the next date', async () => { @@ -469,3 +469,131 @@ for (const [format, jobsFor] of [ }); }); } + +test('path classification ignores development-only files but keeps packaging inputs', () => { + for (const path of [ + '.github/workflows/workflow-helpers.yml', + '.github/scripts/marketplace-pre-release-history.js', + 'src/test/node/plot.test.ts', + 'sess/tests/test_sess.R', + 'R/tests/attach_sess.R', + 'scripts/benchmark-journal.cjs', + 'scripts/sess-source.test.js', + '.vscode/settings.json', + '.vscode-test.mjs', + 'CONTRIBUTING.md', + 'cliff.toml', + ]) { + assert.equal(isRelevantPath(path), false, path); + } + for (const path of [ + '.github/scripts/extension-version.js', + '.github/workflows/main.yml', + '.github/workflows/release.yml', + '.gitattributes', + 'src/extension.ts', + 'src/testing.ts', + 'sess/R/session.R', + 'R/sess_source.R', + 'scripts/prepare-sess.js', + 'package.json', + 'pnpm-lock.yaml', + 'esbuild.js', + 'README.md', + 'CHANGELOG.md', + ]) { + assert.equal(isRelevantPath(path), true, path); + } +}); + +function pathFilterRuns(currentEvent = 'schedule') { + const published = run(900, '2026-10-01', 'sha-a'); + const skipped = run(901, '2026-10-02', 'sha-b'); + const current = run(902, '2026-10-03', 'sha-c', currentEvent); + const jobs = new Map([['900', [publishJob()]], ['901', [{ name: 'prerelease-prepare', steps: [] }]], ['902', []]]); + return { current, runs: [published, skipped, current], jobs }; +} + +test('scheduled runs compare against the last published SHA and skip ignored-only changes', async () => { + const { current, runs, jobs } = pathFilterRuns(); + const diffs = []; + const ignoredOnly = async (base, head) => { + diffs.push([base, head]); + return ['.github/workflows/main.yml.bak', 'src/test/node/a.test.ts', 'sess/tests/test_a.R']; + }; + assert.deepEqual(await decide(current, runs, jobs, ignoredOnly), { + publish: false, date: '2026-10-03', reason: 'no-relevant-changes', + }); + // The skipped run's SHA is not a baseline: changes accumulate from sha-a. + assert.deepEqual(diffs, [['sha-a', 'sha-c']]); + assert.deepEqual(await decide(current, runs, jobs, async () => []), { + publish: false, date: '2026-10-03', reason: 'no-relevant-changes', + }); + assert.deepEqual(await decide(current, runs, jobs, async () => ['src/test/a.ts', 'src/extension.ts']), { + publish: true, date: '2026-10-03', reason: 'new-sha', + }); + assert.deepEqual(await decide(current, runs, jobs, async () => ['.github/scripts/extension-version.js']), { + publish: true, date: '2026-10-03', reason: 'new-sha', + }); +}); + +test('diff failures publish, while date reservation and unchanged SHA take precedence', async () => { + const { current, runs, jobs } = pathFilterRuns(); + assert.deepEqual(await decide(current, runs, jobs, async () => { throw new Error('fetch failed'); }), { + publish: true, date: '2026-10-03', reason: 'diff-unavailable', + }); + + let diffCalls = 0; + const counting = async () => { diffCalls += 1; return ['src/extension.ts']; }; + const sameDay = run(903, '2026-10-03', 'sha-b', 'workflow_dispatch'); + const reserved = new Map([...jobs, ['903', [publishJob({ openVsxConclusion: 'failure' })]]]); + assert.deepEqual(await decide(current, [...runs, sameDay], reserved, counting), { + publish: false, date: '2026-10-03', reason: 'date-reserved', + }); + const unchanged = run(904, '2026-10-04', 'sha-a'); + assert.deepEqual(await decide(unchanged, [runs[0], unchanged], new Map([['900', [publishJob()]]]), counting), { + publish: false, date: '2026-10-04', reason: 'unchanged', + }); + assert.equal(diffCalls, 0); +}); + +test('manual runs and runs without a diff source bypass the path filter', async () => { + const manual = pathFilterRuns('workflow_dispatch'); + let diffCalls = 0; + const ignoredOnly = async () => { diffCalls += 1; return ['src/test/a.ts']; }; + assert.deepEqual(await decide(manual.current, manual.runs, manual.jobs, ignoredOnly), { + publish: true, date: '2026-10-03', reason: 'new-sha', + }); + assert.equal(diffCalls, 0); + const scheduled = pathFilterRuns(); + assert.deepEqual(await decide(scheduled.current, scheduled.runs, scheduled.jobs), { + publish: true, date: '2026-10-03', reason: 'new-sha', + }); +}); + +test('git adapter fetches the baseline and lists NUL-separated changes without rename detection', () => { + const calls = []; + const listChangedFiles = createGitChangedFiles((command, args, options) => { + calls.push({ command, args, options }); + return args[0] === 'diff' ? 'src/test/a b.ts\0sess/R/new\nline.R\0' : ''; + }); + assert.deepEqual(listChangedFiles('sha-a', 'sha-c'), ['src/test/a b.ts', 'sess/R/new\nline.R']); + assert.deepEqual(calls.map(({ command, args }) => [command, ...args]), [ + ['git', 'fetch', '--no-tags', '--depth=1', 'origin', 'sha-a'], + ['git', 'diff', '--no-renames', '--name-only', '-z', 'sha-a', 'sha-c', '--'], + ]); + assert.ok(calls.every(({ options }) => options.timeout > 0)); + + const failing = createGitChangedFiles(() => { throw new Error('fatal: remote error'); }); + assert.throws(() => failing('sha-a', 'sha-c'), /remote error/); +}); + +test('only the preparation job enables the pre-release path filter', () => { + const workflow = readFileSync(join(__dirname, '../workflows/release.yml'), 'utf8'); + const matches = workflow.match(/PRERELEASE_PATH_FILTER: 'true'/g) || []; + assert.equal(matches.length, 1); + const prepare = workflow.indexOf('\n prerelease-prepare:'); + const verify = workflow.indexOf('\n prerelease-verify:'); + const position = workflow.indexOf("PRERELEASE_PATH_FILTER: 'true'"); + assert.ok(prepare >= 0 && prepare < position && position < verify); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5009c41c..ffec72257 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,6 +158,9 @@ jobs: id: previous env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Scheduled runs skip publication when only CI, test, or other + # development files changed since the last published SHA. + PRERELEASE_PATH_FILTER: 'true' run: | node .github/scripts/marketplace-pre-release-history.js From 877809cfe120841312191668d81c480af5be23bb Mon Sep 17 00:00:00 2001 From: eitsupi Date: Fri, 13 Nov 2026 14:32:39 +0000 Subject: [PATCH 2/3] ci: note a TODO to simplify the pre-release path filter --- .github/scripts/marketplace-pre-release-history.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/scripts/marketplace-pre-release-history.js b/.github/scripts/marketplace-pre-release-history.js index 0a92b1545..0b7e1e72c 100644 --- a/.github/scripts/marketplace-pre-release-history.js +++ b/.github/scripts/marketplace-pre-release-history.js @@ -23,6 +23,10 @@ const RUN_JQ = '{total_count, workflow_runs: [.workflow_runs[] | {id, created_at // Changes limited to these paths do not affect the published extension, so a // scheduled run skips publication when nothing else changed since the last // published SHA. Anything not matched here is treated as relevant. +// TODO: This list mirrors the repository layout file by file and is easy to +// let drift. Moving the extension sources into a dedicated subdirectory (for +// example `extensions/vscode-R/`) would let the filter shrink to that +// directory plus a few shared build inputs. const IGNORED_PATH_PATTERNS = [ /^\.github\//, /^src\/test\//, From ffb048151a42c4d5519dd178b5e050be5e8467ac Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 00:19:20 +0000 Subject: [PATCH 3/3] ci: keep partially published pre-release runs eligible on a full rerun --- .../marketplace-pre-release-history.js | 7 +++++ .../marketplace-pre-release-history.test.js | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.github/scripts/marketplace-pre-release-history.js b/.github/scripts/marketplace-pre-release-history.js index 0b7e1e72c..01baebb31 100644 --- a/.github/scripts/marketplace-pre-release-history.js +++ b/.github/scripts/marketplace-pre-release-history.js @@ -283,6 +283,13 @@ async function decidePublication({ api, repository, runId, sha, listChangedFiles if (!listChangedFiles || currentRun.event !== 'schedule') { return { publish: true, date: runDate, reason: 'new-sha' }; } + // A full rerun repeats this preparation. Once an earlier attempt + // of this run started publishing, keep it eligible so the retry + // can complete the other registry, even if the diff now succeeds + // with a different result than the attempt that published. + if (didAttemptPublication(await jobsFor(currentRun))) { + return { publish: true, date: runDate, reason: 'retry-after-attempt' }; + } let changedFiles; try { changedFiles = await listChangedFiles(publishedSha, sha); diff --git a/.github/scripts/marketplace-pre-release-history.test.js b/.github/scripts/marketplace-pre-release-history.test.js index 46f7f8970..0c5902f9d 100644 --- a/.github/scripts/marketplace-pre-release-history.test.js +++ b/.github/scripts/marketplace-pre-release-history.test.js @@ -557,6 +557,34 @@ test('diff failures publish, while date reservation and unchanged SHA take prece assert.equal(diffCalls, 0); }); +test('a full rerun after a partial publication bypasses the path filter', async () => { + const { current, runs, jobs } = pathFilterRuns(); + const ignoredOnly = async () => ['src/test/a.ts']; + // Attempt 1: the diff fails, so the run publishes, but Open VSX fails. + assert.deepEqual(await decide(current, runs, jobs, async () => { throw new Error('fetch failed'); }), { + publish: true, date: '2026-10-03', reason: 'diff-unavailable', + }); + const partial = new Map([...jobs, ['902', prereleaseSplitJobs({ openVsxConclusion: 'failure' })]]); + // Attempt 2 (re-run all jobs): the diff now succeeds with ignored-only + // changes, but the run must stay eligible to complete Open VSX. + let diffCalls = 0; + const counting = async (...args) => { diffCalls += 1; return ignoredOnly(...args); }; + assert.deepEqual(await decide(current, runs, partial, counting), { + publish: true, date: '2026-10-03', reason: 'retry-after-attempt', + }); + assert.equal(diffCalls, 0); + // Another run on the same date stays blocked by the partial attempt. + const sameDay = run(905, '2026-10-03', 'sha-c', 'workflow_dispatch'); + assert.deepEqual(await decide(sameDay, [...runs, sameDay], partial, ignoredOnly), { + publish: false, date: '2026-10-03', reason: 'date-reserved', + }); + // Without a prior attempt, the same rerun still skips ignored-only changes. + const prepared = new Map([...jobs, ['902', [{ name: 'prerelease-prepare', steps: [] }]]]); + assert.deepEqual(await decide(current, runs, prepared, ignoredOnly), { + publish: false, date: '2026-10-03', reason: 'no-relevant-changes', + }); +}); + test('manual runs and runs without a diff source bypass the path filter', async () => { const manual = pathFilterRuns('workflow_dispatch'); let diffCalls = 0;