first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,235 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { matchGlob, parseFilters, evaluateFilter, runValidate } from '../ci-filter.mjs';
// --- matchGlob ---
describe('matchGlob', () => {
it('** matches dotfiles', () => {
assert.ok(matchGlob('.github/workflows/ci.yml', '**'));
});
it('** matches deeply nested paths', () => {
assert.ok(matchGlob('packages/cli/src/controllers/auth.ts', '**'));
});
it('** matches root-level files', () => {
assert.ok(matchGlob('README.md', '**'));
});
it('.github/** matches workflow files', () => {
assert.ok(matchGlob('.github/workflows/ci.yml', '.github/**'));
});
it('.github/** matches action files', () => {
assert.ok(matchGlob('.github/actions/ci-filter/action.yml', '.github/**'));
});
it('.github/** does not match non-.github paths', () => {
assert.ok(!matchGlob('packages/cli/src/index.ts', '.github/**'));
});
it('scoped package pattern matches files in that package', () => {
assert.ok(
matchGlob(
'packages/@n8n/task-runner-python/src/main.py',
'packages/@n8n/task-runner-python/**',
),
);
});
it('scoped package pattern does not match other packages', () => {
assert.ok(!matchGlob('packages/@n8n/config/src/index.ts', 'packages/@n8n/task-runner-python/**'));
});
it('* matches single-level only', () => {
assert.ok(matchGlob('README.md', '*.md'));
assert.ok(!matchGlob('docs/README.md', '*.md'));
});
it('exact path match', () => {
assert.ok(matchGlob('package.json', 'package.json'));
assert.ok(!matchGlob('packages/cli/package.json', 'package.json'));
});
it('? matches single character', () => {
assert.ok(matchGlob('file1.txt', 'file?.txt'));
assert.ok(!matchGlob('file12.txt', 'file?.txt'));
});
it('**/ at start matches zero or more path segments', () => {
assert.ok(matchGlob('src/index.ts', '**/index.ts'));
assert.ok(matchGlob('packages/cli/src/index.ts', '**/index.ts'));
assert.ok(matchGlob('index.ts', '**/index.ts'));
});
it('**/ in middle matches nested paths', () => {
assert.ok(matchGlob('packages/@n8n/db/src/deep/file.ts', 'packages/@n8n/db/**'));
});
});
// --- parseFilters ---
describe('parseFilters', () => {
it('parses single-line filter', () => {
const filters = parseFilters('workflows: .github/**');
assert.deepEqual(filters.get('workflows'), ['.github/**']);
});
it('parses single-line with multiple patterns', () => {
const filters = parseFilters('db: packages/@n8n/db/** packages/cli/**');
assert.deepEqual(filters.get('db'), ['packages/@n8n/db/**', 'packages/cli/**']);
});
it('parses multi-line filter', () => {
const input = `non-python:
**
!packages/@n8n/task-runner-python/**`;
const filters = parseFilters(input);
assert.deepEqual(filters.get('non-python'), ['**', '!packages/@n8n/task-runner-python/**']);
});
it('parses mixed single and multi-line', () => {
const input = `non-python:
**
!packages/@n8n/task-runner-python/**
workflows: .github/**`;
const filters = parseFilters(input);
assert.equal(filters.size, 2);
assert.deepEqual(filters.get('non-python'), ['**', '!packages/@n8n/task-runner-python/**']);
assert.deepEqual(filters.get('workflows'), ['.github/**']);
});
it('ignores comments and blank lines', () => {
const input = `# This is a comment
workflows: .github/**
# Another comment
db: packages/@n8n/db/**`;
const filters = parseFilters(input);
assert.equal(filters.size, 2);
});
it('throws on malformed input', () => {
assert.throws(() => parseFilters('not a valid filter line'), /Malformed/);
});
it('throws on filter with no patterns', () => {
const input = `empty:
other: .github/**`;
assert.throws(() => parseFilters(input), /no patterns/);
});
});
// --- evaluateFilter ---
describe('evaluateFilter', () => {
it('python-only files with non-python filter returns false', () => {
const files = [
'packages/@n8n/task-runner-python/src/main.py',
'packages/@n8n/task-runner-python/pyproject.toml',
];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), false);
});
it('mixed python and non-python returns true', () => {
const files = [
'packages/@n8n/task-runner-python/src/main.py',
'packages/cli/src/index.ts',
];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('non-python files with non-python filter returns true', () => {
const files = ['packages/cli/src/index.ts', 'packages/core/src/utils.ts'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('.github files with workflows filter returns true', () => {
const files = ['.github/workflows/ci.yml', '.github/actions/setup/action.yml'];
const patterns = ['.github/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
it('non-.github files with workflows filter returns false', () => {
const files = ['packages/cli/src/index.ts'];
const patterns = ['.github/**'];
assert.equal(evaluateFilter(files, patterns), false);
});
it('empty changed files returns false', () => {
assert.equal(evaluateFilter([], ['**']), false);
});
it('last matching pattern wins (gitignore semantics)', () => {
const files = ['packages/@n8n/task-runner-python/src/main.py'];
const patterns = ['**', '!packages/@n8n/task-runner-python/**', 'packages/@n8n/task-runner-python/**'];
assert.equal(evaluateFilter(files, patterns), true);
});
});
// --- runValidate ---
describe('runValidate', () => {
function runWithResults(jobResults: Record<string, { result: string }>): number | null {
const originalEnv = process.env.INPUT_JOB_RESULTS;
const originalExit = process.exit;
let exitCode: number | null = null;
process.env.INPUT_JOB_RESULTS = JSON.stringify(jobResults);
process.exit = ((code: number) => { exitCode = code; }) as never;
try {
runValidate();
} finally {
process.env.INPUT_JOB_RESULTS = originalEnv;
process.exit = originalExit;
}
return exitCode;
}
it('passes when all jobs succeed', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
typecheck: { result: 'success' },
lint: { result: 'success' },
}), null);
});
it('passes when some jobs are skipped (filtered out)', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'success' },
'security-checks': { result: 'skipped' },
}), null);
});
it('fails when a job fails', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'failure' },
typecheck: { result: 'success' },
}), 1);
});
it('fails when a job is cancelled', () => {
assert.equal(runWithResults({
'install-and-build': { result: 'success' },
'unit-test': { result: 'cancelled' },
}), 1);
});
it('fails when multiple jobs have problems', () => {
assert.equal(runWithResults({
'unit-test': { result: 'failure' },
typecheck: { result: 'cancelled' },
lint: { result: 'success' },
}), 1);
});
});
+39
View File
@@ -0,0 +1,39 @@
name: 'CI Filter'
description: |
Filter CI jobs by changed files and validate results.
Modes:
- filter: Determines which jobs to run based on changed files and a provided filter definition.
- validate: Checks the results of required jobs and fails if any of them failed or were cancelled.
inputs:
mode:
description: 'filter or validate'
required: true
filters:
description: 'Filter definitions (gitignore-style DSL)'
required: false
base-ref:
description: 'Base branch for diff. Auto-detected if not specified.'
required: false
job-results:
description: 'Job results from needs context as JSON (mode=validate)'
required: false
outputs:
results:
description: 'JSON object: { "filter-name": true/false }'
value: ${{ steps.run.outputs.results }}
runs:
using: 'composite'
steps:
- name: Run CI Filter
id: run
shell: bash
env:
INPUT_MODE: ${{ inputs.mode }}
INPUT_FILTERS: ${{ inputs.filters }}
INPUT_BASE_REF: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.merge_group.base_ref || 'master' }}
INPUT_JOB_RESULTS: ${{ inputs.job-results }}
run: node ${{ github.action_path }}/ci-filter.mjs
+216
View File
@@ -0,0 +1,216 @@
import { execSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
// --- Glob matching (dotfile-safe) ---
/**
* Match a file path against a glob pattern.
* Unlike path.matchesGlob / standard POSIX globs, `**` matches dotfiles.
*/
export function matchGlob(filePath, pattern) {
let regex = '';
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === '*' && pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') {
regex += '(?:.+/)?';
i += 3;
} else {
regex += '.*';
i += 2;
}
} else if (ch === '*') {
regex += '[^/]*';
i++;
} else if (ch === '?') {
regex += '[^/]';
i++;
} else {
regex += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
i++;
}
}
return new RegExp(`^${regex}$`).test(filePath);
}
// --- Filter DSL parser ---
/**
* Parse filter definitions from the input DSL.
*
* Supports two formats:
* Single-line: `name: pattern1 pattern2`
* Multi-line: `name:` followed by indented patterns (one per line)
*
* Lines starting with # and blank lines are ignored.
*/
export function parseFilters(input) {
const filters = new Map();
const lines = input.split('\n');
let currentFilter = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const headerMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)?$/);
if (headerMatch) {
const name = headerMatch[1];
const rest = (headerMatch[2] || '').trim();
const patterns = [];
currentFilter = name;
filters.set(name, patterns);
if (rest) {
patterns.push(...rest.split(/\s+/));
currentFilter = null;
}
continue;
}
if (currentFilter && rawLine.match(/^\s/)) {
const patterns = filters.get(currentFilter);
if (patterns) patterns.push(line);
continue;
}
throw new Error(`Malformed filter input at: "${rawLine}"`);
}
for (const [name, patterns] of filters) {
if (patterns.length === 0) {
throw new Error(`Filter "${name}" has no patterns`);
}
}
return filters;
}
// --- Git operations ---
const SAFE_REF = /^[a-zA-Z0-9_./-]+$/;
export function getChangedFiles(baseRef) {
if (!SAFE_REF.test(baseRef)) {
throw new Error(`Unsafe base ref: "${baseRef}"`);
}
execSync(`git fetch --depth=1 origin ${baseRef}`, { stdio: 'pipe' });
const output = execSync('git diff --name-only FETCH_HEAD HEAD', { encoding: 'utf-8' });
return output
.split('\n')
.map((f) => f.trim())
.filter(Boolean);
}
// --- Filter evaluation ---
/**
* Evaluate a single filter against changed files using gitignore semantics.
* Patterns evaluated in order, last match wins. ! prefix excludes.
* Filter triggers if ANY changed file passes.
*/
export function evaluateFilter(changedFiles, patterns) {
for (const file of changedFiles) {
let included = false;
for (const pattern of patterns) {
if (pattern.startsWith('!')) {
if (matchGlob(file, pattern.slice(1))) {
included = false;
}
} else {
if (matchGlob(file, pattern)) {
included = true;
}
}
}
if (included) return true;
}
return false;
}
// --- Mode: filter ---
function setOutput(name, value) {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `ghadelimiter_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
export function runFilter() {
const filtersInput = process.env.INPUT_FILTERS;
const baseRef = process.env.INPUT_BASE_REF;
if (!filtersInput) {
throw new Error('INPUT_FILTERS is required in filter mode');
}
if (!baseRef) {
throw new Error('INPUT_BASE_REF is required in filter mode');
}
const filters = parseFilters(filtersInput);
const changedFiles = getChangedFiles(baseRef);
console.log(`Changed files (${changedFiles.length}):`);
for (const f of changedFiles) {
console.log(` ${f}`);
}
const results = {};
for (const [name, patterns] of filters) {
const matched = evaluateFilter(changedFiles, patterns);
results[name] = matched;
console.log(`Filter "${name}": ${matched}`);
}
setOutput('results', JSON.stringify(results));
}
// --- Mode: validate ---
export function runValidate() {
const raw = process.env.INPUT_JOB_RESULTS;
if (!raw) {
throw new Error('INPUT_JOB_RESULTS is required in validate mode');
}
const jobResults = JSON.parse(raw);
const problems = [];
for (const [job, data] of Object.entries(jobResults)) {
if (data.result === 'failure') problems.push(`${job}: failed`);
if (data.result === 'cancelled') problems.push(`${job}: cancelled`);
}
if (problems.length > 0) {
console.error('Required checks failed:');
for (const p of problems) {
console.error(` - ${p}`);
}
process.exit(1);
}
console.log('All required checks passed:');
for (const [job, data] of Object.entries(jobResults)) {
console.log(` ${job}: ${data.result}`);
}
}
// --- Main (only when run directly, not when imported by tests) ---
if (resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1])) {
const mode = process.env.INPUT_MODE;
if (mode === 'filter') {
runFilter();
} else if (mode === 'validate') {
runValidate();
} else {
throw new Error(`Unknown mode: "${mode}". Expected "filter" or "validate".`);
}
}