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
33 changes: 9 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/20260727_issue_40_change_detection.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 36 additions & 55 deletions scripts/detect_code_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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:
Expand All @@ -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)
]
Expand All @@ -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.")

Expand Down
34 changes: 34 additions & 0 deletions tests/test_detect_code_changes.py
Original file line number Diff line number Diff line change
@@ -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}
29 changes: 28 additions & 1 deletion tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import re
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
WORKFLOWS = ROOT / ".github" / "workflows"

Expand Down Expand Up @@ -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")
Expand Down
Loading