Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/compare-changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: compare-changes
on:
pull_request:
paths:
- ".github/workflows/compare-changes.yml"
- "compare-changes/**/*"
- "!compare-changes/README.md"

jobs:
test:
name: Test
runs-on: [ubuntu-24.04]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Run tests
shell: sh
run: |
python3 ./compare-changes/test.py
1 change: 1 addition & 0 deletions compare-changes/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
31 changes: 20 additions & 11 deletions compare-changes/action.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
import os
import json
import yaml
import fnmatch


Expand All @@ -12,6 +11,7 @@ def convert_github_patterns_to_fnmatch(github_patterns):
Handles common GitHub Actions path wildcard patterns:
- dir/** -> dir/*
- dir/**/* -> dir/*
- **/*.file -> *.file

Returns a list of converted patterns.
"""
Expand All @@ -26,6 +26,10 @@ def convert_github_patterns_to_fnmatch(github_patterns):
elif pattern.endswith("**/*"):
converted = pattern[:-4] + "*"
fnmatch_patterns.append(converted)
# Handle **/*.file pattern (matches any .file at any depth)
elif pattern.startswith("**/*."):
converted = pattern[3:]
fnmatch_patterns.append(converted)
# Keep other patterns as is
else:
fnmatch_patterns.append(pattern)
Expand Down Expand Up @@ -64,18 +68,21 @@ def extract_path_patterns(config):
)


def check_for_matches(path_patterns, changed_files):
"""Check if any changed file matches any path pattern."""
def check_for_matches(original_patterns, fnmatch_patterns, changed_files):
"""Check if any changed file matches any path pattern, using original pattern intent."""
for changed_file in changed_files:
for pattern in path_patterns:
# For patterns without slashes that aren't directory patterns,
# ensure they only match at the root level
if "/" not in pattern and "*" in pattern and "/" in changed_file:
for orig_pattern, fn_pattern in zip(original_patterns, fnmatch_patterns):
# Only restrict to root-level if the original pattern does NOT contain a slash or '**/'
is_root_only = "/" not in orig_pattern and not orig_pattern.startswith(
"**/"
)
if is_root_only and "*" in fn_pattern and "/" in changed_file:
# Skip files in subdirectories for root-level patterns
continue

if fnmatch.fnmatch(changed_file, pattern):
print(f"File '{changed_file}' matches pattern '{pattern}'")
if fnmatch.fnmatch(changed_file, fn_pattern):
print(
f"File '{changed_file}' matches pattern '{fn_pattern}' (original: '{orig_pattern}')"
)
print(
"Returning early as an optimisation, there may be more matches not listed here."
)
Expand All @@ -84,6 +91,8 @@ def check_for_matches(path_patterns, changed_files):


def main():
import yaml # Only import yaml when main() is run

# Get inputs from environment variables
changes_json = os.environ.get("INPUT_CHANGES", "[]")
wildcard_name = os.environ.get("INPUT_WILDCARD")
Expand Down Expand Up @@ -125,7 +134,7 @@ def main():
print(f"- '{before}' -> '{after}'")

# Check for matches (using converted patterns)
matched = check_for_matches(fnmatch_patterns, changed_files)
matched = check_for_matches(path_patterns, fnmatch_patterns, changed_files)

# Output the result to GitHub Actions
appending_mode = "a"
Expand Down
102 changes: 102 additions & 0 deletions compare-changes/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import sys
import os
import importlib.util

# Dynamically import action.py as a module
script_dir = os.path.dirname(os.path.abspath(__file__))
action_path = os.path.join(script_dir, "action.py")
spec = importlib.util.spec_from_file_location("action", action_path)
action = importlib.util.module_from_spec(spec)
sys.modules["action"] = action
spec.loader.exec_module(action)


def test_check_for_matches():
"""Table-driven tests for pattern matching logic."""
test_cases = [
# pattern, changeset, expected_result
{
"patterns": ["*.yml"],
"changes": ["foo.yml", "bar/test.yml"],
"expected": True,
},
{
"patterns": ["*.yml"],
"changes": ["bar/test.yml"],
"expected": False
},
{
"patterns": ["**/*.yml"],
"changes": ["foo.yml", "bar/test.yml"],
"expected": True,
},
{
"patterns": ["dir/**"],
"changes": ["dir/file.txt", "dir/sub/file.txt"],
"expected": True,
},
{
"patterns": ["dir/**"],
"changes": ["dir/sub/file.txt"],
"expected": True,
},
{
"patterns": ["dir/**/*"],
"changes": ["dir/file.txt", "dir/sub/file.txt"],
"expected": True,
},
{
"patterns": ["dir/**/*"],
"changes": ["dir/sub/file.txt"],
"expected": True,
},
{
"patterns": ["**/*.py"],
"changes": ["main.py", "src/app.py", "docs/readme.md"],
"expected": True,
},
{
"patterns": ["**/*.py"],
"changes": ["src/foo/app.py"],
"expected": True,
},
{
"patterns": ["*.md"],
"changes": ["docs/readme.md"],
"expected": False
},
{
"patterns": ["*.md"],
"changes": ["readme.md"],
"expected": True
},
{
"patterns": ["**/*.md"],
"changes": ["docs/readme.md"],
"expected": True
},
{
"patterns": ["foo/bar.txt"],
"changes": ["foo/bar.txt", "bar/foo.txt"],
"expected": True,
},
{
"patterns": ["foo/bar.txt"],
"changes": ["bar/foo.txt"],
"expected": False
},
]
for i, case in enumerate(test_cases):
fnmatch_patterns = action.convert_github_patterns_to_fnmatch(case["patterns"])
result = action.check_for_matches(
case["patterns"], fnmatch_patterns, case["changes"]
)
print(
f"Test case {i + 1}: patterns={case['patterns']} changes={case['changes']}\nExpected: {case['expected']} Got: {result}"
)
assert result == case["expected"], f"Failed test case {i + 1}"
print("All tests passed.")


if __name__ == "__main__":
test_check_for_matches()