diff --git a/scripts/check_selected_ci_inputs.py b/scripts/check_selected_ci_inputs.py index d3f57b265c..b3e2d72678 100644 --- a/scripts/check_selected_ci_inputs.py +++ b/scripts/check_selected_ci_inputs.py @@ -250,7 +250,8 @@ def glob_regex(pattern): require(pattern[i] not in '?+[]{}', 'Unsupported glob syntax: ' + pattern) out.append(re.escape(pattern[i])) i += 1 - return re.compile('^' + ''.join(out) + '$') + # Paths match in full; globstars include LF, which Git permits in filenames. + return re.compile(r'\A' + ''.join(out) + r'\Z', re.DOTALL) def triggers(patterns, name): diff --git a/tests/test_selected_ci_inputs.py b/tests/test_selected_ci_inputs.py index ac5eb9dd77..101477c232 100644 --- a/tests/test_selected_ci_inputs.py +++ b/tests/test_selected_ci_inputs.py @@ -4,6 +4,7 @@ import importlib.util import io import json +import os from pathlib import Path import shutil import subprocess @@ -341,6 +342,23 @@ def test_github_path_filter_semantics(self): with self.subTest(patterns=patterns, name=name): self.assertEqual(guard.triggers(patterns, name), expected) + def test_github_path_filters_match_whole_paths_including_newlines(self): + cases = [ + (['proof.md'], 'proof.md\n', False), + (['proof.md\n'], 'proof.md\n', True), + (['supplier/**'], 'supplier/line\nbreak.md', True), + (['supplier/**/proof.md'], 'supplier/line\nbreak/proof.md', True), + (['supplier/**/proof.md'], 'supplier/proof.md', True), + (['supplier/*'], 'supplier/line\nbreak.md', True), + (['supplier/*'], 'supplier/line\nbreak/proof.md', False), + (['supplier/**', '!supplier/*'], 'supplier/line\nbreak.md', False), + (['supplier/**', '!supplier/**', 'supplier/*'], 'supplier/line\nbreak.md', True), + (['supplier/**', '!supplier/proof.md'], 'supplier/proof.md\n', True), + ] + for patterns, name, expected in cases: + with self.subTest(patterns=patterns, name=name): + self.assertEqual(guard.triggers(patterns, name), expected) + def test_unsupported_syntax_fails_closed(self): for pattern in ('research/[ab]/**', 'scripts/verify_?.py', 'research/CORE-23+.json', 'a/{b,c}'): with self.subTest(pattern=pattern), self.assertRaises(ValueError): @@ -356,7 +374,7 @@ def test_event_reader(self): self.assertIsNone(guard.event_paths(text, 'schedule')) -class BranchFilterCLI(unittest.TestCase): +class TriggerFilterCLI(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) @@ -408,6 +426,53 @@ def test_excluded_main_fails_only_when_triggers_are_required(self): else: self.assertNotIn('ADVISORY:', result.stdout) + @unittest.skipIf(os.name == 'nt', 'Windows filenames cannot contain LF') + def test_newline_paths_through_the_real_cli(self): + command = [sys.executable, '-B', str(ROOT / 'scripts/check_selected_ci_inputs.py'), + '--root', str(self.root)] + workflow = self.root / '.github/workflows/replay.yml' + ordinary = ['scripts/**', 'certificates/**', 'certificate.json', 'manifest.json', + '.github/workflows/**', 'supplier/proof.md'] + original_manifest = (self.root / 'manifest.json').read_bytes() + cases = [ + ('supplier/proof.md\n', 'supplier/proof.md', False), + ('supplier/line\nbreak.md', 'supplier/**', True), + ('supplier/line\nbreak/proof.md', 'supplier/**/proof.md', True), + ] + for name, pattern, covered in cases: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'inventoried input\n') + manifest = json.loads(original_manifest) + manifest['files'][name] = sha256(path.read_bytes()).hexdigest() + (self.root / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8') + try: + for event in guard.EVENTS: + text = 'on:\n' + for kind in guard.EVENTS: + text += ' ' + kind + ':\n' + if kind == event: + text += ' paths: ' + json.dumps(ordinary + [pattern]) + '\n' + text += 'jobs:\n replay:\n steps:\n - run: python scripts/replay.py\n' + workflow.write_text(text, encoding='utf-8') + for required in (False, True): + with self.subTest(name=name, event=event, required=required): + result = subprocess.run(command + (['--require-triggers'] if required else []), + check=False, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, int(required and not covered), + result.stdout + result.stderr) + report, _ = json.JSONDecoder().raw_decode(result.stdout) + self.assertEqual(report['record_failures'], {}) + self.assertIsNone(report['coverage_error']) + self.assertEqual(report['uncovered'], + {kind: [name] if kind == event and not covered else [] + for kind in guard.EVENTS}) + marker = 'FAIL_SELECTED_CI_INPUTS' if required and not covered else 'PASS_SELECTED_CI_INPUTS' + self.assertEqual(result.stdout.splitlines()[-1], marker) + finally: + path.unlink() + (self.root / 'manifest.json').write_bytes(original_manifest) + if __name__ == '__main__': unittest.main()