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
94 changes: 88 additions & 6 deletions .github/scripts/marketplace-pre-release-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,40 @@ 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.
// 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\//,
/^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) {
Expand Down Expand Up @@ -154,14 +188,36 @@ 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`);
}
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');
Expand Down Expand Up @@ -219,9 +275,31 @@ 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' };
}
// 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);
} 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' };
Comment thread
renkun-ken marked this conversation as resolved.
Resolved
}
}
start = end;
Expand All @@ -234,7 +312,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}).`);
}
Expand All @@ -246,4 +328,4 @@ if (require.main === module) {
});
}

module.exports = { decidePublication, didAttemptPublication, completedBothRegistries, pagesToItems, completePagesToItems, createGhApi };
module.exports = { decidePublication, isRelevantPath, createGitChangedFiles, didAttemptPublication, completedBothRegistries, pagesToItems, completePagesToItems, createGhApi };
166 changes: 161 additions & 5 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, 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';
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -469,3 +469,159 @@ 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('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;
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);
});
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading