diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 04a08ac..1160eb5 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -163,10 +163,14 @@ def detect(event_name, event, ref_name): if not git("tag", "--list", f"v{version}"): # A docs-only fix after failed release CI must not bypass that CI. return set(GROUPS) - if any(path in CI_ROUTING_PATHS for path in paths) and all( - path in CI_ROUTING_PATHS or path.endswith(".md") for path in paths - ): - return {"python_quality"} + if any(path in CI_ROUTING_PATHS for path in paths): + companions = classify( + [path for path in paths if path not in CI_ROUTING_PATHS] + ) + # Reuse normal path rules: accompanying lint/support edits must keep + # their checks, while build-related or unknown inputs remain broad. + if companions <= {"python_quality", "shell"}: + return companions | {"python_quality"} return selected diff --git a/.github/scripts/test_ci_aggregates.py b/.github/scripts/test_ci_aggregates.py new file mode 100644 index 0000000..9cd0cde --- /dev/null +++ b/.github/scripts/test_ci_aggregates.py @@ -0,0 +1,61 @@ +"""Execute the matrix verdict commands with success, skip and failure inputs.""" + +import os +import re +import subprocess +import textwrap +import unittest +from pathlib import Path + +WORKFLOW = Path(__file__).resolve().parents[1] / "workflows" / "test.yaml" + + +class MatrixSummaryTest(unittest.TestCase): + def test_matrix_verdicts_and_dependencies(self): + workflow = WORKFLOW.read_text() + for job, dependency in ( + ("clang-result", "clang"), + ("core-consumer-result", "core-consumer"), + ): + block = re.search( + rf"^ {job}:\n(.*?)(?=^ [\w-]+:|\Z)", + workflow, + re.MULTILINE | re.DOTALL, + ).group(1) + self.assertIn(f"needs: [changes, {dependency}]", block) + self.assertIn("if: ${{ always() }}", block) + self.assertIn(f"MATRIX_RESULT: ${{{{ needs.{dependency}.result }}}}", block) + self.assertIn("CHANGES_RESULT: ${{ needs.changes.result }}", block) + self.assertIn("CPP_SELECTED: ${{ needs.changes.outputs.cpp }}", block) + command = textwrap.dedent(block.split(" run: |\n", 1)[1]) + for detection in ("success", "failure", "cancelled", "skipped"): + for selected in ("true", "false", ""): + for matrix in ("success", "failure", "cancelled", "skipped", ""): + expected = matrix == "success" or ( + detection == "success" + and selected == "false" + and matrix == "skipped" + ) + with self.subTest( + job=job, + detection=detection, + selected=selected, + matrix=matrix, + ): + result = subprocess.run( + ["bash", "-e", "-c", command], + env=dict( + os.environ, + CHANGES_RESULT=detection, + CPP_SELECTED=selected, + MATRIX_RESULT=matrix, + ), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode == 0, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 0266cb6..3e7506e 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -219,6 +219,59 @@ def test_ci_routing_changes_run_full_pr_ci_but_not_repeat_on_tagged_main(self): detect("push", {"before": "base"}, "main"), expected ) + def test_tagged_main_routing_changes_preserve_lightweight_companion_checks(self): + # Complete changed-file set from PR #148, including its lint helper. + routing = [ + ".github/scripts/ci_changes.py", + ".github/scripts/test_ci_changes.py", + ".github/workflows/test.yaml", + "docs/development.md", + "docs/maintaining.md", + ] + for companions, expected in ( + (["scripts/check-python.sh"], {"python_quality", "shell"}), + (["sample/run_cpp.sh"], {"python_quality", "shell"}), + (["benchmarks/plot_homepage.py"], {"python_quality"}), + ([".github/ISSUE_TEMPLATE/config.yml"], {"python_quality"}), + (["src/core/api.cpp"], set(GROUPS)), + (["python_bindings/__init__.py"], set(GROUPS)), + (["scripts/build-macos-openmp.sh"], set(GROUPS)), + (["scripts/check-tidy.sh"], set(GROUPS)), + (["CMakeLists.txt"], set(GROUPS)), + (["pyproject.toml"], set(GROUPS)), + (["unknown-build-input"], set(GROUPS)), + (["scripts/check-python.sh", "src/core/api.cpp"], set(GROUPS)), + (["python/ivf.py", "sample/python/clustering_cli/ivf.py"], set(GROUPS)), + ): + changed = "\0".join(routing + companions) + "\0" + for event, ref, tags, result in ( + ("push", "main", "v0.7.0", expected), + ("push", "main", "", set(GROUPS)), + ("push", "master", None, set(GROUPS)), + ("pull_request", "148/merge", None, set(GROUPS)), + ): + replies = ( + ["merge-base", changed] if event == "pull_request" else [changed] + ) + if tags is not None: + replies.append(tags) + payload = ( + {"pull_request": {"base": {"sha": "base"}}} + if event == "pull_request" + else {"before": "base"} + ) + with self.subTest( + companions=companions, event=event, ref=ref, tags=tags + ): + with ( + patch("ci_changes.git", side_effect=replies), + patch( + "ci_changes.Path.read_text", + return_value='[project]\nversion = "0.7.0"', + ), + ): + self.assertEqual(detect(event, payload, ref), result) + def test_release_events_and_new_branches_run_everything(self): for event, payload, ref in ( ("workflow_dispatch", {}, "main"), diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 27d8c5d..118f0a9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -184,6 +184,25 @@ jobs: -DRABITQ_USE_OPENMP=${{ matrix.openmp }} -P tests/core_consumer/run.cmake + clang-result: + name: Clang validation + needs: [changes, clang] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require selected matrix checks + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CPP_SELECTED: ${{ needs.changes.outputs.cpp }} + MATRIX_RESULT: ${{ needs.clang.result }} + run: | + if [ "$CHANGES_RESULT" = success ] && [ "$CPP_SELECTED" = false ]; then + test "$MATRIX_RESULT" = skipped || test "$MATRIX_RESULT" = success + else + test "$MATRIX_RESULT" = success + fi + windows: needs: changes if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.cpp == 'true') }} @@ -346,6 +365,25 @@ jobs: -DRABITQ_USE_OPENMP=${{ matrix.openmp }} -P tests/core_consumer/run.cmake + core-consumer-result: + name: Core and FastScan validation + needs: [changes, core-consumer] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require selected matrix checks + env: + CHANGES_RESULT: ${{ needs.changes.result }} + CPP_SELECTED: ${{ needs.changes.outputs.cpp }} + MATRIX_RESULT: ${{ needs.core-consumer.result }} + run: | + if [ "$CHANGES_RESULT" = success ] && [ "$CPP_SELECTED" = false ]; then + test "$MATRIX_RESULT" = skipped || test "$MATRIX_RESULT" = success + else + test "$MATRIX_RESULT" = success + fi + linux-arm64: name: Native Linux ARM64 C++ needs: changes diff --git a/docs/maintaining.md b/docs/maintaining.md index d2b4244..f18d00b 100644 --- a/docs/maintaining.md +++ b/docs/maintaining.md @@ -43,11 +43,29 @@ change detection fails, so a detection error cannot silently waive a check. Release tags and manual wheel builds always run the full wheel build. CI routing edits run the full relevant matrix on pull requests. After an already -released version reaches `main`, a push changing only the routing files and -Markdown reruns Python lint and selector tests without repeating platform -builds. An untagged version on `main` still forces full C++ and Python CI, +released version reaches `main`, a push containing routing edits avoids repeated +platform builds only when all accompanying files select at most Python quality +checks and ShellCheck under the normal path rules. Python lint and selector tests +always run in this case, and any accompanying shell checks are retained. For +example, routing plus `scripts/check-python.sh` runs Python quality and ShellCheck; +routing plus library code, build configuration, wheel-related inputs or unknown +paths keeps the full selection. PR routing checks remain broad. An untagged version on `main` still forces full C++ and Python CI, even for a subsequent docs-only commit, before automatic publishing can proceed. +## Matrix summary checks + +`Clang validation` and `Core and FastScan validation` provide stable check names +for the corresponding matrices. They run even when a dependency fails or is +skipped. A successful matrix passes. A skipped matrix passes only when change +detection succeeded and explicitly selected no C++ checks; failure, cancellation, +or an unexpected skip fails the summary. If detection fails, the existing +fallback runs the matrix and its success is required. + +After these checks have appeared and been validated on GitHub, add their exact +names to main's required status checks. Workflow definitions do not update branch +protection automatically. Keep existing protections until that transition is +complete; these summaries do not cover unrelated jobs. + ## Publishing a release Update the stable `X.Y.Z` version in both `pyproject.toml` and `CMakeLists.txt`,