From 5c3ebdb3725c3cd7f9fc65e04867d67ac64e16d1 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 27 Jul 2026 04:24:27 +0000 Subject: [PATCH 1/2] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/40 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..64fc0ae --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-07-27T04:24:27.648Z for PR creation at branch issue-40-bd08fa6be1ab for issue https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/40 \ No newline at end of file From 1bd163850251069bce85bfde436e0f1b69069111 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 27 Jul 2026 04:29:37 +0000 Subject: [PATCH 2/2] fix(ci): honor excluded paths on pushes --- .github/workflows/release.yml | 33 ++----- .gitkeep | 1 - .../20260727_issue_40_change_detection.md | 5 + pyproject.toml | 2 +- scripts/detect_code_changes.py | 91 ++++++++----------- tests/test_detect_code_changes.py | 34 +++++++ tests/test_workflows.py | 29 +++++- 7 files changed, 113 insertions(+), 82 deletions(-) delete mode 100644 .gitkeep create mode 100644 changelog.d/20260727_issue_40_change_detection.md create mode 100644 tests/test_detect_code_changes.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d69080..7c0cf03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,11 +41,6 @@ jobs: timeout-minutes: 5 if: github.event_name != 'workflow_dispatch' outputs: - py-changed: ${{ steps.changes.outputs.py-changed }} - tests-changed: ${{ steps.changes.outputs.tests-changed }} - package-changed: ${{ steps.changes.outputs.package-changed }} - docs-changed: ${{ steps.changes.outputs.docs-changed }} - workflow-changed: ${{ steps.changes.outputs.workflow-changed }} any-code-changed: ${{ steps.changes.outputs.any-code-changed }} steps: - uses: actions/checkout@v6 @@ -94,7 +89,7 @@ jobs: # These jobs ensure code quality and tests pass before any release # === LINT AND FORMAT CHECK === - # Lint runs independently of changelog check - it's a fast check that should always run + # Lint runs independently of changelog check for detected code changes. # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed lint: name: Lint and Format Check @@ -105,13 +100,8 @@ jobs: # for workflow_dispatch while still propagating workflow cancellation. if: | !cancelled() && ( - github.event_name == 'push' || github.event_name == 'workflow_dispatch' || - needs.detect-changes.outputs.py-changed == 'true' || - needs.detect-changes.outputs.tests-changed == 'true' || - needs.detect-changes.outputs.docs-changed == 'true' || - needs.detect-changes.outputs.package-changed == 'true' || - needs.detect-changes.outputs.workflow-changed == 'true' + needs.detect-changes.outputs.any-code-changed == 'true' ) steps: - uses: actions/checkout@v6 @@ -196,12 +186,8 @@ jobs: # for workflow_dispatch while still propagating workflow cancellation. if: | !cancelled() && ( - github.event_name == 'push' || github.event_name == 'workflow_dispatch' || - needs.detect-changes.outputs.py-changed == 'true' || - needs.detect-changes.outputs.tests-changed == 'true' || - needs.detect-changes.outputs.package-changed == 'true' || - needs.detect-changes.outputs.workflow-changed == 'true' + needs.detect-changes.outputs.any-code-changed == 'true' ) steps: - uses: actions/checkout@v6 @@ -261,21 +247,20 @@ jobs: fail_ci_if_error: true # === BUILD PACKAGE === - # Build package - runs if lint and test pass, or were skipped (docs-only PR) + # Build package after required checks pass for detected code changes or dispatches. build: name: Build Package runs-on: ubuntu-latest timeout-minutes: 20 needs: [detect-changes, lint, test] - # Run if: push/dispatch event, OR lint/test succeeded, OR lint/test were skipped (docs-only PR) + # Build change-bearing automatic events and all manually dispatched releases. if: | !cancelled() && ( - github.event_name == 'push' || github.event_name == 'workflow_dispatch' || - ( - (needs.lint.result == 'success' || needs.lint.result == 'skipped') && - (needs.test.result == 'success' || needs.test.result == 'skipped') - ) + needs.detect-changes.outputs.any-code-changed == 'true' + ) && ( + needs.lint.result == 'success' && + needs.test.result == 'success' ) steps: - uses: actions/checkout@v6 diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 64fc0ae..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-07-27T04:24:27.648Z for PR creation at branch issue-40-bd08fa6be1ab for issue https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/40 \ No newline at end of file diff --git a/changelog.d/20260727_issue_40_change_detection.md b/changelog.d/20260727_issue_40_change_detection.md new file mode 100644 index 0000000..008980f --- /dev/null +++ b/changelog.d/20260727_issue_40_change_detection.md @@ -0,0 +1,5 @@ +### Fixed + +- Keep excluded-only changes from activating change-gated CI jobs on pull + requests and direct pushes, and constrain Ruff to the compatible 0.8 release + line used by the template's lint configuration. diff --git a/pyproject.toml b/pyproject.toml index e1d9fa7..595125a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ [project.optional-dependencies] dev = [ - "ruff>=0.8.0", + "ruff>=0.8.0,<0.9", "mypy>=1.13.0", "pytest>=8.3.0", "pytest-asyncio>=0.24.0", diff --git a/scripts/detect_code_changes.py b/scripts/detect_code_changes.py index cd554c6..9826154 100755 --- a/scripts/detect_code_changes.py +++ b/scripts/detect_code_changes.py @@ -13,6 +13,7 @@ Excluded from code changes (don't require changelog fragments): - Markdown files (*.md) in any folder - changelog.d/ folder (changelog metadata) +- dev/log/ folder (development logs) - docs/ folder (documentation) - experiments/ folder (experimental scripts) - examples/ folder (example scripts) @@ -26,12 +27,7 @@ - GITHUB_HEAD_SHA: Head commit SHA for PR Outputs (written to GITHUB_OUTPUT): - - py-changed: 'true' if any .py files changed - - tests-changed: 'true' if any tests/ files changed - - package-changed: 'true' if pyproject.toml changed - - docs-changed: 'true' if any .md files changed - - workflow-changed: 'true' if any .github/workflows/ files changed - - any-code-changed: 'true' if any code files changed (excludes docs, changelogs, experiments, examples) + - any-code-changed: 'true' if any code files changed outside excluded paths """ from __future__ import annotations @@ -40,6 +36,15 @@ import subprocess import sys +EXCLUDED_FOLDERS = ( + "changelog.d/", + "dev/log/", + "docs/", + "examples/", + "experiments/", +) +CODE_EXTENSIONS = (".py", ".toml", ".yml", ".yaml") + def exec_command(command: str) -> str: """Execute a shell command and return trimmed output.""" @@ -97,7 +102,7 @@ def get_changed_files() -> list[str]: output = exec_command(f"git diff --name-only {base_sha} {head_sha}") if output: return [f for f in output.split("\n") if f] - except Exception as e: + except (OSError, subprocess.SubprocessError) as e: print(f"Git diff failed: {e}", file=sys.stderr) # For push events or fallback @@ -106,7 +111,7 @@ def get_changed_files() -> list[str]: output = exec_command("git diff --name-only HEAD^ HEAD") if output: return [f for f in output.split("\n") if f] - except Exception: + except (OSError, subprocess.SubprocessError): # If HEAD^ doesn't exist (first commit), list all files in HEAD print("HEAD^ not available, listing all files in HEAD") output = exec_command("git ls-tree --name-only -r HEAD") @@ -118,27 +123,30 @@ def get_changed_files() -> list[str]: def is_excluded_from_code_changes(file_path: str) -> bool: """Check if a file should be excluded from code changes detection.""" - # Exclude markdown files in any folder if file_path.endswith(".md"): return True - # Exclude specific folders from code changes - excluded_folders = [ - "changelog.d/", - "docs/", - "experiments/", - "examples/", - "python/changelog.d/", - "python/docs/", - "python/experiments/", - "python/examples/", - ] + relative_path = file_path.removeprefix("python/") + return relative_path.startswith(EXCLUDED_FOLDERS) - for folder in excluded_folders: - if file_path.startswith(folder): - return True - return False +def detect_change_types( + changed_files: list[str], *, event_name: str +) -> dict[str, bool]: + """Classify changed files for job gating on an automatic event.""" + if event_name not in {"pull_request", "push"}: + message = f"Unsupported automatic event: {event_name}" + raise ValueError(message) + + code_changed = any( + ( + file_path.endswith(CODE_EXTENSIONS) + or file_path.startswith(".github/workflows/") + ) + and not is_excluded_from_code_changes(file_path) + for file_path in changed_files + ) + return {"any-code-changed": code_changed} def detect_changes() -> None: @@ -155,31 +163,6 @@ def detect_changes() -> None: print(f" {file}") print() - # Detect .py file changes - py_changed = any(f.endswith(".py") for f in changed_files) - set_output("py-changed", "true" if py_changed else "false") - - # Detect tests/ changes - tests_changed = any( - f.startswith("tests/") or f.startswith("python/tests/") for f in changed_files - ) - set_output("tests-changed", "true" if tests_changed else "false") - - # Detect pyproject.toml changes - package_changed = any( - f in {"pyproject.toml", "python/pyproject.toml"} for f in changed_files - ) - set_output("package-changed", "true" if package_changed else "false") - - # Detect documentation changes (any .md file) - docs_changed = any(f.endswith(".md") for f in changed_files) - set_output("docs-changed", "true" if docs_changed else "false") - - # Detect workflow changes - workflow_changed = any(f.startswith(".github/workflows/") for f in changed_files) - set_output("workflow-changed", "true" if workflow_changed else "false") - - # Detect code changes (excluding docs, changelogs, experiments, examples folders, and markdown files) code_changed_files = [ f for f in changed_files if not is_excluded_from_code_changes(f) ] @@ -192,12 +175,10 @@ def detect_changes() -> None: print(f" {file}") print() - # Check if any code files changed (.py, .toml, .yml, .yaml, or workflow files) - import re - - code_pattern = re.compile(r"\.(py|toml|yml|yaml)$|\.github/workflows/") - code_changed = any(code_pattern.search(f) for f in code_changed_files) - set_output("any-code-changed", "true" if code_changed else "false") + event_name = os.environ.get("GITHUB_EVENT_NAME", "push") + outputs = detect_change_types(changed_files, event_name=event_name) + for name, value in outputs.items(): + set_output(name, "true" if value else "false") print("\nChange detection completed.") diff --git a/tests/test_detect_code_changes.py b/tests/test_detect_code_changes.py new file mode 100644 index 0000000..15a34b6 --- /dev/null +++ b/tests/test_detect_code_changes.py @@ -0,0 +1,34 @@ +"""Regression tests for CI change detection.""" + +from __future__ import annotations + +import pytest + +from scripts.detect_code_changes import detect_change_types + + +@pytest.mark.parametrize("event_name", ["pull_request", "push"]) +@pytest.mark.parametrize( + "file_path", + [ + "experiments/repro.md", + "experiments/repro.mjs", + "dev/log/trace.py", + "docs/case-studies/audit.py", + ], +) +def test_excluded_only_changes_do_not_activate_jobs( + event_name: str, file_path: str +) -> None: + """Excluded paths must stay excluded for every automatic event type.""" + assert detect_change_types([file_path], event_name=event_name) == { + "any-code-changed": False + } + + +@pytest.mark.parametrize("event_name", ["pull_request", "push"]) +def test_source_changes_activate_jobs(event_name: str) -> None: + """Source changes must activate change-gated jobs for PRs and pushes.""" + assert detect_change_types( + ["src/my_package/__init__.py"], event_name=event_name + ) == {"any-code-changed": True} diff --git a/tests/test_workflows.py b/tests/test_workflows.py index b37b947..8f55be4 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -5,7 +5,6 @@ import re from pathlib import Path - ROOT = Path(__file__).resolve().parents[1] WORKFLOWS = ROOT / ".github" / "workflows" @@ -223,6 +222,34 @@ def test_release_workflow_propagates_cancellation() -> None: assert "always()" not in condition +def test_change_gated_jobs_use_detector_for_pull_requests_and_pushes() -> None: + """Automatic events must use the same authoritative detector output.""" + workflow = read_workflow("release.yml") + + for job_name in ("lint", "test", "build"): + condition = job_condition(workflow, job_name) + assert "needs.detect-changes.outputs.any-code-changed == 'true'" in condition + assert "github.event_name == 'push'" not in condition + assert "github.event_name == 'workflow_dispatch'" in condition + + +def test_detect_changes_only_exports_consumed_outputs() -> None: + """Detector outputs should not drift from the workflow's actual job gates.""" + workflow = read_workflow("release.yml") + block = workflow_job_block(workflow, "detect-changes") + + assert "any-code-changed:" in block + for unused_output in ( + "py-changed", + "tests-changed", + "package-changed", + "docs-changed", + "workflow-changed", + ): + assert f"{unused_output}:" not in block + assert f"outputs.{unused_output}" not in workflow + + def test_release_workflow_checks_fresh_merge_and_secrets() -> None: """Pull requests must test a fresh base merge and scan for secrets.""" workflow = read_workflow("release.yml")