mirror of
https://github.com/SCST-project/scst.git
synced 2026-10-22 06:02: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.
426 lines
20 KiB
Python
426 lines
20 KiB
Python
# SPDX-License-Identifier: GPL-2.0-only
|
|
"""Exercise PR selection with real Git histories; no VM or SCST access."""
|
|
|
|
from copy import deepcopy
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import select_checks
|
|
|
|
|
|
class PathPolicyTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.config = select_checks.load_config()
|
|
|
|
def test_known_guides_and_independent_tools_skip_all_builds(self):
|
|
paths = ('README.md', 'README.clang', 'tests/ginkgo/README.md',
|
|
'scst/AGENTS.md', 'doc/CI.md', 'doc/KUNIT.md',
|
|
'CONTRIBUTING.md', 'INSTALL.md', 'scripts/README.md',
|
|
'scripts/checkpatch', 'scripts/checkpatch_commits',
|
|
'scripts/checkpatch_diff', 'scripts/checkpatch_scan',
|
|
'scripts/source/list-double-blank-lines',
|
|
'scripts/source/run-shellcheck')
|
|
for check in select_checks.CHECKS:
|
|
for path in paths:
|
|
with self.subTest(check=check, path=path):
|
|
self.assertTrue(select_checks.can_skip(check, path, self.config))
|
|
|
|
def test_shared_build_inputs_and_unknown_paths_run_all_checks(self):
|
|
paths = ('scst/src/scst_lib.c', 'iscsi-scst/kernel/iscsi.c',
|
|
'scst_local/scst_local.c', 'usr/fileio/fileio.c',
|
|
'scstadmin/scstadmin', 'scst/src/kunit/select.mk',
|
|
'Makefile', 'scst/Makefile', 'doc/Makefile', '.dockerignore',
|
|
'.github/kernel-matrix.conf', '.github/ci-checks.toml',
|
|
'scripts/kernel/kernel-matrix',
|
|
'scripts/kernel/sign-modules', 'scripts/source/list-source-files',
|
|
'scripts/source/clean-source-tree', 'scripts/ci/select_checks.py',
|
|
f'scripts/ci/{Path(__file__).name}', 'scripts/ci/new-helper.py',
|
|
'scripts/checkpatch_new', 'scripts/source/new-helper',
|
|
'doc/new-helper.py', 'scst/README.c', 'new-component/config.json')
|
|
for check in select_checks.CHECKS:
|
|
for path in paths:
|
|
with self.subTest(check=check, path=path):
|
|
self.assertFalse(select_checks.can_skip(check, path, self.config))
|
|
|
|
def test_known_inputs_select_their_consumers(self):
|
|
cases = {
|
|
'tests/ginkgo/suite/suite_test.go': {'ginkgo'},
|
|
'tests/ginkgo/internal/testrun/labels.json': {'ginkgo'},
|
|
'scripts/kunit/run.py': {'ginkgo', 'kunit'},
|
|
'scripts/kunit/inventory.py': {'ginkgo', 'kunit'},
|
|
'scripts/ci/kernel.env': {'ginkgo', 'kunit'},
|
|
'scripts/ci/vm.py': {'ginkgo', 'kunit'},
|
|
'scripts/ci/build-guest': {'ginkgo', 'kunit'},
|
|
'scripts/kernel/generate-kernel-patch': {'kunit', 'regression'},
|
|
'scripts/kernel/run-regression-tests': {'kunit', 'regression'},
|
|
'debian/rules': {'clang'},
|
|
'scripts/release/build-rpm-artifacts': set(),
|
|
'scripts/release/generate-release-archive': set(),
|
|
'scripts/release/prepare-package-release': set(),
|
|
'scripts/release/update-version': set(),
|
|
'.github/workflows/ci.yml': {'clang'},
|
|
'.github/workflows/rpm.yml': set(),
|
|
'.github/workflows/kunit.yml': {'kunit'},
|
|
'.github/workflows/ginkgo.yml': {'ginkgo'},
|
|
'.github/workflows/run_regression_tests.yaml': {'regression'},
|
|
'doc/scst_pg.sgml': {'kunit', 'clang', 'regression'},
|
|
}
|
|
for path, expected in cases.items():
|
|
with self.subTest(path=path):
|
|
self.assertEqual({check for check in select_checks.CHECKS
|
|
if not select_checks.can_skip(check, path, self.config)}, expected)
|
|
|
|
def test_packaged_and_kernel_exported_documentation_is_not_a_blanket_exemption(self):
|
|
paths = ('scstadmin/scstadmin.sysfs/scst-1.0.0/README',
|
|
'iscsi-scst/doc/manpages/iscsi-scstd.8',
|
|
'fcst/README', 'scst/README_in-tree')
|
|
for check in select_checks.CHECKS:
|
|
for path in paths:
|
|
with self.subTest(check=check, path=path):
|
|
self.assertFalse(select_checks.can_skip(check, path, self.config))
|
|
|
|
def test_push_and_manual_runs_do_not_consult_paths(self):
|
|
with patch('select_checks.changed_paths') as changed:
|
|
for check in select_checks.CHECKS:
|
|
for event in ('push', 'workflow_dispatch'):
|
|
self.assertTrue(select_checks.select_run(check, event, '', '', self.config)[0])
|
|
changed.assert_not_called()
|
|
|
|
|
|
class ConfigTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.config = {'skip': {'paths': [], 'basenames': []}, 'rules': []}
|
|
|
|
def test_unknown_or_missing_fields_are_rejected(self):
|
|
cases = [
|
|
{}, {'skip': self.config['skip']},
|
|
dict(self.config, typo=[]), dict(self.config, skip=[]),
|
|
dict(self.config, skip={'paths': []}),
|
|
dict(self.config, skip={'paths': [], 'basenames': [], 'typo': []}),
|
|
dict(self.config, rules={}), dict(self.config, rules=[[]]),
|
|
]
|
|
for config in cases:
|
|
with self.subTest(config=config), self.assertRaises(ValueError):
|
|
select_checks.validate_config(config)
|
|
for rule in ({'paths': ['doc/file']},
|
|
{'paths': ['doc/file'], 'checks': ['ginkgo'], 'typo': []},
|
|
{'paths': ['doc/file'], 'prefixes': ['doc/'], 'checks': ['ginkgo']}):
|
|
with self.subTest(rule=rule), self.assertRaises(ValueError):
|
|
select_checks.validate_config(dict(self.config, rules=[rule]))
|
|
|
|
def test_check_names_and_types_are_validated(self):
|
|
for checks in ([], ['unknown'], ['ginkgo', 'ginkgo'], 'ginkgo', [True], [1], ['']):
|
|
self.config['rules'] = [{'paths': ['file'], 'checks': checks}]
|
|
with self.subTest(checks=checks), self.assertRaisesRegex(ValueError, 'checks'):
|
|
select_checks.validate_config(self.config)
|
|
|
|
def test_selectors_must_be_literal_relative_paths(self):
|
|
bad = ['', '/absolute', '../outside', './relative', 'a/../b', 'a//b',
|
|
'a\\b', 'a\nb', 'a\rb', 'a\0b', 'a*', 'a?', 'a[bc]', 'a ', ' a']
|
|
for kind in ('paths', 'prefixes'):
|
|
values = bad + (['trailing/'] if kind == 'paths' else ['missing-slash'])
|
|
for value in values:
|
|
self.config['rules'] = [{kind: [value], 'checks': ['ginkgo']}]
|
|
with self.subTest(kind=kind, value=value), self.assertRaises(ValueError):
|
|
select_checks.validate_config(self.config)
|
|
for values in ([], 'file', [False], ['file/', 'file/']):
|
|
self.config['rules'] = [{kind: values, 'checks': ['ginkgo']}]
|
|
with self.subTest(kind=kind, values=values), self.assertRaises(ValueError):
|
|
select_checks.validate_config(self.config)
|
|
self.config['rules'] = []
|
|
for values in (['doc/README'], ['.'], ['..'], ['*'], [''], ['AGENTS.md', 'AGENTS.md']):
|
|
self.config['skip']['basenames'] = values
|
|
with self.subTest(basenames=values), self.assertRaises(ValueError):
|
|
select_checks.validate_config(self.config)
|
|
|
|
def test_duplicate_paths_and_overlapping_prefixes_are_rejected(self):
|
|
for kind, first, second in (('paths', 'file', 'file'),
|
|
('prefixes', 'scripts/', 'scripts/'),
|
|
('prefixes', 'scripts/', 'scripts/kunit/'),
|
|
('prefixes', 'scripts/kunit/', 'scripts/')):
|
|
self.config['rules'] = [{kind: [first], 'checks': ['ginkgo']},
|
|
{kind: [second], 'checks': ['kunit']}]
|
|
with self.subTest(kind=kind, first=first), self.assertRaises(ValueError):
|
|
select_checks.validate_config(self.config)
|
|
self.config['skip']['paths'] = ['file']
|
|
self.config['rules'] = [{'paths': ['file'], 'checks': ['ginkgo']}]
|
|
with self.assertRaisesRegex(ValueError, 'duplicate path'):
|
|
select_checks.validate_config(self.config)
|
|
|
|
def test_exact_paths_override_prefixes_regardless_of_rule_order(self):
|
|
rules = [{'prefixes': ['tests/'], 'checks': ['ginkgo']},
|
|
{'paths': ['tests/tool.py'], 'checks': ['kunit']}]
|
|
for ordered in (rules, list(reversed(rules))):
|
|
self.config['rules'] = ordered
|
|
select_checks.validate_config(self.config)
|
|
self.assertTrue(select_checks.can_skip('ginkgo', 'tests/tool.py', self.config))
|
|
self.assertFalse(select_checks.can_skip('kunit', 'tests/tool.py', self.config))
|
|
self.assertFalse(select_checks.can_skip('ginkgo', 'tests/new.py', self.config))
|
|
self.assertFalse(select_checks.can_skip('ginkgo', 'tests-other/new.py', self.config))
|
|
self.config['skip'] = {'paths': ['tests/tool.py'], 'basenames': ['AGENTS.md']}
|
|
self.config['rules'] = rules[:1]
|
|
select_checks.validate_config(self.config)
|
|
for path in ('tests/tool.py', 'tests/AGENTS.md'):
|
|
self.assertTrue(select_checks.can_skip('ginkgo', path, self.config))
|
|
|
|
def test_control_files_cannot_be_exempted_by_config(self):
|
|
for path in select_checks.CONTROL_PATHS:
|
|
for kind in ('skip', 'paths', 'prefixes'):
|
|
config = deepcopy(self.config)
|
|
if kind == 'skip':
|
|
config['skip']['paths'] = [path]
|
|
else:
|
|
value = path if kind == 'paths' else path.rsplit('/', 1)[0] + '/'
|
|
config['rules'] = [{kind: [value], 'checks': ['ginkgo']}]
|
|
select_checks.validate_config(config)
|
|
for check in select_checks.CHECKS:
|
|
with self.subTest(path=path, kind=kind, check=check):
|
|
self.assertFalse(select_checks.can_skip(check, path, config))
|
|
|
|
|
|
class ConfigCLITests(unittest.TestCase):
|
|
def setUp(self):
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
self.root = Path(temporary.name)
|
|
self.script = self.root / 'repo/scripts/ci/select_checks.py'
|
|
self.script.parent.mkdir(parents=True)
|
|
self.script.write_text(Path(select_checks.__file__).read_text())
|
|
self.config = self.root / 'repo/.github/ci-checks.toml'
|
|
self.config.parent.mkdir(parents=True)
|
|
self.config.write_text(select_checks.CONFIG_PATH.read_text())
|
|
self.output = self.root / 'output'
|
|
self.summary = self.root / 'summary'
|
|
|
|
def run_cli(self, *args):
|
|
return subprocess.run([sys.executable, str(self.script), *args], cwd=self.root,
|
|
env=dict(os.environ, GITHUB_OUTPUT=str(self.output),
|
|
GITHUB_STEP_SUMMARY=str(self.summary)),
|
|
capture_output=True, text=True, check=False)
|
|
|
|
def test_validation_resolves_config_from_script_and_emits_no_selection(self):
|
|
result = self.run_cli('--validate-config')
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn('config is valid', result.stdout)
|
|
self.assertFalse(self.output.exists())
|
|
self.assertFalse(self.summary.exists())
|
|
|
|
def test_invalid_or_missing_config_fails_before_selection_for_every_event(self):
|
|
for content in (None, '[broken', 'skip = {}\nrules = []',
|
|
'[skip]\npaths = []\nbasenames = []\n'
|
|
'[[rules]]\npaths = ["file"]\nchecks = ["typo"]'):
|
|
if content is None:
|
|
self.config.unlink()
|
|
else:
|
|
self.config.write_text(content)
|
|
cases = [('--validate-config',)] + [
|
|
('--check', 'ginkgo', '--event', event)
|
|
for event in ('pull_request', 'push', 'workflow_dispatch')]
|
|
for args in cases:
|
|
with self.subTest(content=content, args=args):
|
|
result = self.run_cli(*args)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn('Cannot determine CI selection:', result.stderr)
|
|
self.assertNotIn('Traceback', result.stderr)
|
|
self.assertFalse(self.output.exists())
|
|
self.assertFalse(self.summary.exists())
|
|
|
|
def test_selection_still_requires_check_and_event(self):
|
|
for args in ((), ('--check', 'ginkgo'), ('--event', 'push')):
|
|
with self.subTest(args=args):
|
|
result = self.run_cli(*args)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertFalse(self.output.exists())
|
|
|
|
|
|
class GitSelectionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
self.root = Path(temporary.name)
|
|
self.repo = self.root / 'repo'
|
|
self.repo.mkdir()
|
|
self.output = self.root / 'output'
|
|
self.summary = self.root / 'summary'
|
|
self.git('init', '-q', '--initial-branch=main')
|
|
self.write('scst/core.c', 'core\n')
|
|
self.write('doc/CI.md', 'documentation\n')
|
|
self.base = self.commit()
|
|
|
|
def git(self, *args):
|
|
return subprocess.check_output([
|
|
'git', '-c', 'user.name=CI test', '-c', 'user.email=ci@example.invalid',
|
|
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', *args,
|
|
], cwd=self.repo, text=True).strip()
|
|
|
|
def write(self, name, text):
|
|
path = self.repo / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
|
|
def commit(self):
|
|
self.git('add', '-A')
|
|
self.git('commit', '-qm', 'Test change')
|
|
return self.git('rev-parse', 'HEAD')
|
|
|
|
def select(self, *, check='ginkgo', base=None, head='HEAD', event='pull_request'):
|
|
self.output.unlink(missing_ok=True)
|
|
self.summary.unlink(missing_ok=True)
|
|
return subprocess.run([
|
|
sys.executable, str(Path(select_checks.__file__).resolve()),
|
|
'--check', check, '--event', event,
|
|
'--base', self.base if base is None else base,
|
|
'--head', head,
|
|
], cwd=self.repo, env=dict(os.environ, GITHUB_OUTPUT=str(self.output),
|
|
GITHUB_STEP_SUMMARY=str(self.summary)),
|
|
capture_output=True, text=True, check=False)
|
|
|
|
def assert_selection(self, expected, **kwargs):
|
|
result = self.select(**kwargs)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(self.output.read_text(), f'run={str(expected).lower()}\n')
|
|
self.assertIn(kwargs.get('check', 'ginkgo') + ':', self.summary.read_text())
|
|
|
|
def test_docs_and_checkpatch_only(self):
|
|
self.write('doc/CI.md', 'updated documentation\n')
|
|
self.write('scripts/checkpatch', '#!/bin/bash\n')
|
|
self.commit()
|
|
for check in select_checks.CHECKS:
|
|
with self.subTest(check=check):
|
|
self.assert_selection(False, check=check)
|
|
|
|
def test_earlier_code_change_is_not_hidden_by_latest_docs_commit(self):
|
|
self.write('scst/core.c', 'changed core\n')
|
|
self.commit()
|
|
self.write('doc/CI.md', 'updated documentation\n')
|
|
self.commit()
|
|
self.assert_selection(True)
|
|
|
|
def test_kunit_tooling_change_is_not_exempted_with_documentation(self):
|
|
self.write('doc/CI.md', 'updated documentation\n')
|
|
self.write('scripts/kunit/run.py', '# changed KUnit runner\n')
|
|
self.commit()
|
|
self.assert_selection(True)
|
|
|
|
def test_changes_on_base_branch_are_excluded_from_pr_diff(self):
|
|
self.git('checkout', '-qb', 'topic')
|
|
self.write('doc/CI.md', 'updated documentation\n')
|
|
head = self.commit()
|
|
self.git('checkout', '-q', 'main')
|
|
self.write('scst/core.c', 'upstream core change\n')
|
|
base = self.commit()
|
|
self.assert_selection(False, base=base, head=head)
|
|
|
|
def test_source_renamed_to_documentation_still_requires_checks(self):
|
|
self.git('mv', 'scst/core.c', 'doc/core.md')
|
|
self.commit()
|
|
self.assert_selection(True)
|
|
|
|
def test_documentation_renamed_to_source_requires_checks(self):
|
|
self.git('mv', 'doc/CI.md', 'scst/new.c')
|
|
self.commit()
|
|
self.assert_selection(True)
|
|
|
|
def test_documentation_rename_can_skip(self):
|
|
self.git('mv', 'doc/CI.md', 'doc/KUNIT.md')
|
|
self.commit()
|
|
self.assert_selection(False)
|
|
|
|
def test_deletions_are_classified(self):
|
|
self.git('rm', '-q', 'doc/CI.md')
|
|
self.commit()
|
|
self.assert_selection(False)
|
|
self.git('rm', '-q', 'scst/core.c')
|
|
self.commit()
|
|
self.assert_selection(True)
|
|
|
|
def test_changed_file_list_is_not_truncated(self):
|
|
for index in range(350):
|
|
self.write(f'guides/{index}/AGENTS.md', 'instructions\n')
|
|
self.write('scst/core.c', 'changed core\n')
|
|
self.commit()
|
|
self.assert_selection(True)
|
|
|
|
def test_paths_with_newlines_are_not_split_into_exempt_names(self):
|
|
self.write('doc/CI.md\nREADME.md', 'unknown input\n')
|
|
self.commit()
|
|
self.assert_selection(True)
|
|
|
|
def test_empty_diff_runs_conservatively(self):
|
|
for check in select_checks.CHECKS:
|
|
self.assert_selection(True, check=check)
|
|
|
|
def test_config_change_runs_every_check(self):
|
|
self.write('.github/ci-checks.toml', select_checks.CONFIG_PATH.read_text())
|
|
self.commit()
|
|
for check in select_checks.CHECKS:
|
|
self.assert_selection(True, check=check)
|
|
|
|
def test_push_and_manual_events_run_even_for_docs_only(self):
|
|
self.write('doc/CI.md', 'updated documentation\n')
|
|
self.commit()
|
|
for check in select_checks.CHECKS:
|
|
for event in ('push', 'workflow_dispatch'):
|
|
with self.subTest(check=check, event=event):
|
|
self.assert_selection(True, check=check, event=event, base='', head='')
|
|
|
|
def test_debian_only_change_selects_only_clang(self):
|
|
self.write('debian/rules', '# packaging change\n')
|
|
self.commit()
|
|
for check in select_checks.CHECKS:
|
|
with self.subTest(check=check):
|
|
self.assert_selection(check == 'clang', check=check)
|
|
|
|
def test_release_only_change_skips_pr_checks_but_keeps_master_runs(self):
|
|
self.write('.github/workflows/rpm.yml', '# master-only workflow\n')
|
|
self.write('scripts/release/build-rpm-artifacts', '# RPM build\n')
|
|
self.commit()
|
|
for check in select_checks.CHECKS:
|
|
with self.subTest(check=check):
|
|
self.assert_selection(False, check=check)
|
|
self.assert_selection(True, check=check, event='push', base='', head='')
|
|
|
|
def test_cross_component_rename_requires_both_consumers(self):
|
|
self.write('tests/ginkgo/example.go', '// test\n')
|
|
self.base = self.commit()
|
|
(self.repo / 'debian').mkdir()
|
|
self.git('mv', 'tests/ginkgo/example.go', 'debian/example.go')
|
|
self.commit()
|
|
for check in select_checks.CHECKS:
|
|
with self.subTest(check=check):
|
|
self.assert_selection(check in ('ginkgo', 'clang'), check=check)
|
|
|
|
def test_invalid_check_fails_without_skip_output(self):
|
|
result = self.select(check='unknown')
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertFalse(self.output.exists())
|
|
|
|
def test_missing_or_invalid_revisions_fail_without_skip_output(self):
|
|
for base, head in (('', 'HEAD'), (self.base, ''),
|
|
('missing-ref', 'HEAD'), (self.base, 'missing-ref')):
|
|
with self.subTest(base=base, head=head):
|
|
result = self.select(base=base, head=head)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn('Cannot determine CI selection', result.stderr)
|
|
self.assertFalse(self.output.exists())
|
|
self.assertFalse(self.summary.exists())
|
|
|
|
def test_missing_merge_base_fails_without_skip_output(self):
|
|
self.git('checkout', '-q', '--orphan', 'unrelated')
|
|
self.write('doc/other.md', 'unrelated history\n')
|
|
self.commit()
|
|
result = self.select()
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn('Cannot determine CI selection', result.stderr)
|
|
self.assertFalse(self.output.exists())
|
|
self.assertFalse(self.summary.exists())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|