Files
scst/scripts/ci/select_checks.py
T
Gleb Chesnokov dc506a0474 .github/workflows: Select PR checks by changed paths
Pull requests that only change development guides or independent tooling
still compile kernels and run the VM suites. Select Ginkgo, KUnit, Clang
and kernel regression through one shared path policy before expensive
work. Compare the whole PR against its merge base, retaining deleted
paths and both sides of renames. Run checks for unclassified inputs.

Keep path rules in .github/ci-checks.toml and validate their structure,
check names, literal paths and conflicting entries before selection.
Provide a validation-only command. Changes to the config or selector
exercise every check.

Select kernel regression once in the preparation job and gate the build
job before expanding its matrix. Unrelated PRs do not allocate a runner
for every kernel. Selection failures fail the workflow and successful
skips include a reason in the job summary.

Run the Packages workflow only after pushes to master, preserving its
DEB/RPM builds and publication. Keep the separate Clang DEB build on PRs.
Retain full master and manual checks and the existing Checkpatch runs.
Cover path ownership, config validation and Git histories with tests,
and shorten the CI guide around all workflows, selection and results.

Quote the CC environment key so Checkpatch does not interpret unchanged
YAML diff context as a Cc trailer.
2026-10-19 13:44:33 +03:00

164 lines
6.8 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
"""Decide whether changed paths require a CI check; filter pull requests only."""
import argparse
import os
from pathlib import Path, PurePosixPath
import subprocess
import sys
import tomllib
CHECKS = ('ginkgo', 'kunit', 'clang', 'regression')
ROOT = Path(__file__).resolve().parents[2]
CONFIG_PATH = ROOT / '.github/ci-checks.toml'
CONTROL_PATHS = {CONFIG_PATH.relative_to(ROOT).as_posix(),
Path(__file__).resolve().relative_to(ROOT).as_posix()}
def string_list(value, name):
if not isinstance(value, list) or any(not isinstance(item, str) or not item
for item in value):
raise ValueError(f'{name} must be an array of nonempty strings')
if len(value) != len(set(value)):
raise ValueError(f'{name} contains duplicate entries')
return value
def validate_paths(values, kind, name):
for value in string_list(values, name):
path = value[:-1] if kind == 'prefixes' and value.endswith('/') else value
if (not path or any(part in ('', '.', '..') for part in path.split('/'))
or any(char in value for char in '\\*?[]\x00\r\n')
or value != value.strip()
or (kind == 'prefixes' and not value.endswith('/'))
or (kind == 'basenames' and '/' in value)):
raise ValueError(f'{name}: invalid literal {kind} entry {value!r}')
def validate_config(config):
if set(config) != {'skip', 'rules'}:
raise ValueError('Config must contain exactly skip and rules')
skip = config['skip']
if not isinstance(skip, dict) or set(skip) != {'paths', 'basenames'}:
raise ValueError('skip must contain exactly paths and basenames')
for kind, values in skip.items():
validate_paths(values, kind, f'skip.{kind}')
if not isinstance(config['rules'], list):
raise ValueError('rules must be an array of tables')
seen_paths = set(skip['paths'])
seen_prefixes = set()
for index, rule in enumerate(config['rules'], 1):
name = f'rules[{index}]'
if (not isinstance(rule, dict)
or set(rule) not in ({'paths', 'checks'}, {'prefixes', 'checks'})):
raise ValueError(f'{name} must contain checks and either paths or prefixes')
checks = string_list(rule['checks'], f'{name}.checks')
if not checks or set(checks) - set(CHECKS):
raise ValueError(f'{name}.checks must select from {", ".join(CHECKS)}')
kind = 'paths' if 'paths' in rule else 'prefixes'
validate_paths(rule[kind], kind, f'{name}.{kind}')
if not rule[kind]:
raise ValueError(f'{name}.{kind} must not be empty')
for value in rule[kind]:
if kind == 'paths':
if value in seen_paths:
raise ValueError(f'{name}: duplicate path {value!r}')
seen_paths.add(value)
else:
if any(value.startswith(other) or other.startswith(value)
for other in seen_prefixes):
raise ValueError(f'{name}: overlapping prefix {value!r}')
seen_prefixes.add(value)
def load_config(path=CONFIG_PATH):
with path.open('rb') as stream:
config = tomllib.load(stream)
validate_config(config)
return config
def can_skip(check, path, config):
# Changes to the selection mechanism must exercise every check, regardless
# of the policy being edited in the same PR.
if path in CONTROL_PATHS:
return False
skip = config['skip']
if PurePosixPath(path).name in skip['basenames'] or path in skip['paths']:
return True
for rule in config['rules']:
if path in rule.get('paths', []):
return check not in rule['checks']
for rule in config['rules']:
if any(path.startswith(prefix) for prefix in rule.get('prefixes', [])):
return check not in rule['checks']
# New code, shared build inputs and unclassified paths run every check.
return False
def changed_paths(base, head):
# Resolve refs before constructing a revision range; reject missing commits.
commits = [subprocess.check_output(
['git', 'rev-parse', '--verify', '--end-of-options', f'{ref}^{{commit}}'],
text=True).strip() for ref in (base, head)]
output = subprocess.check_output([
'git', 'diff', '--no-ext-diff', '--no-renames', '--name-only', '-z',
f'{commits[0]}...{commits[1]}', '--',
])
# No rename detection: both the deleted and added path must be skip-safe.
return [os.fsdecode(path) for path in output.split(b'\0') if path]
def select_run(check, event, base, head, config):
if event != 'pull_request':
return True, f'{check}: required for push and manual runs.'
if not base or not head:
raise ValueError('Pull-request selection requires base and head commits')
paths = changed_paths(base, head)
if not paths:
return True, f'{check}: required because the PR diff is empty.'
for path in paths:
print(f'Changed path: {path!r}')
if any(not can_skip(check, path, config) for path in paths):
return True, f'{check}: required by relevant or unclassified changes.'
return False, f'{check}: skipped because all changed paths are known unrelated inputs.'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--validate-config', action='store_true',
help='validate the CI path policy without selecting a check')
parser.add_argument('--check', choices=CHECKS)
parser.add_argument('--event',
choices=('pull_request', 'push', 'workflow_dispatch'))
parser.add_argument('--base', default='')
parser.add_argument('--head', default='')
args = parser.parse_args()
if not args.validate_config and (not args.check or not args.event):
parser.error('--check and --event are required unless using --validate-config')
try:
config = load_config()
if args.validate_config:
print('CI path config is valid.')
return 0
required, reason = select_run(args.check, args.event, args.base, args.head, config)
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f'Cannot determine CI selection: {error}', file=sys.stderr)
return 1
print(reason)
result = f'run={str(required).lower()}\n'
print(result, end='')
if os.environ.get('GITHUB_OUTPUT'):
with Path(os.environ['GITHUB_OUTPUT']).open('a') as stream:
stream.write(result)
if os.environ.get('GITHUB_STEP_SUMMARY'):
with Path(os.environ['GITHUB_STEP_SUMMARY']).open('a') as stream:
stream.write(reason + '\n')
return 0
if __name__ == '__main__':
sys.exit(main())