mirror of
https://github.com/SCST-project/scst.git
synced 2026-10-22 14:12:33 +00:00
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.
164 lines
6.8 KiB
Python
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())
|