From 9ac6762095e6f1416dabaf54ce999ea8852795cb Mon Sep 17 00:00:00 2001 From: sunmodza Date: Mon, 28 Sep 2026 20:01:15 +0700 Subject: [PATCH] Skip expensive CI jobs for documentation-only changes --- .github/workflows/tests.yml | 39 +++++++++++++++++++++++++++++---- scripts/ci_docs_only.py | 43 +++++++++++++++++++++++++++++++++++++ tests/test_ci_docs_only.py | 35 ++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 scripts/ci_docs_only.py create mode 100644 tests/test_ci_docs_only.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 915b695..6a1716f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,25 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.scope.outputs.docs_only }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Check changed files + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: python3 scripts/ci_docs_only.py + package: + needs: changes + if: ${{ needs.changes.outputs.docs_only != 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -26,6 +44,8 @@ jobs: test "$(cd /tmp && /tmp/aiython-wheel-smoke/bin/aiython /tmp/aiython-wheel-smoke/plain.py)" = 2 test: + needs: changes + if: ${{ needs.changes.outputs.docs_only != 'true' }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -62,6 +82,8 @@ jobs: if-no-files-found: warn native-hooks: + needs: changes + if: ${{ needs.changes.outputs.docs_only != 'true' }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -100,16 +122,25 @@ jobs: required: name: CI if: ${{ always() }} - needs: [package, test, native-hooks, docs] + needs: [changes, package, test, native-hooks, docs] runs-on: ubuntu-latest steps: - - name: Require every job to pass + - name: Require the right jobs to pass env: + CHANGES: ${{ needs.changes.result }} + DOCS_ONLY: ${{ needs.changes.outputs.docs_only }} PACKAGE: ${{ needs.package.result }} TEST: ${{ needs.test.result }} NATIVE_HOOKS: ${{ needs.native-hooks.result }} DOCS: ${{ needs.docs.result }} run: | - for result in "$PACKAGE" "$TEST" "$NATIVE_HOOKS" "$DOCS"; do - test "$result" = success || exit 1 + test "$CHANGES" = success + test "$DOCS" = success + if [ "$DOCS_ONLY" = true ]; then + expected=skipped + else + expected=success + fi + for result in "$PACKAGE" "$TEST" "$NATIVE_HOOKS"; do + test "$result" = "$expected" || exit 1 done diff --git a/scripts/ci_docs_only.py b/scripts/ci_docs_only.py new file mode 100644 index 0000000..7c62d3a --- /dev/null +++ b/scripts/ci_docs_only.py @@ -0,0 +1,43 @@ +"""Decide whether a CI run only changed documentation files.""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import PurePosixPath + + +DOCUMENT_ASSETS = {".gif", ".ico", ".jpeg", ".jpg", ".mp3", ".mp4", ".pdf", ".png", ".svg", ".webp"} + + +def is_docs_only(paths: list[str]) -> bool: + """Return true only for a nonempty set of documentation and static assets.""" + return bool(paths) and all( + path.endswith(".md") + or path == "zensical.toml" + or ( + path.startswith(("docs/", "assets/readme/")) + and PurePosixPath(path).suffix.lower() in DOCUMENT_ASSETS + ) + for path in paths + ) + + +def changed_paths(base: str, head: str) -> list[str]: + if not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)): + raise ValueError("CI needs two commit SHAs to inspect changed paths") + if base == "0" * 40: + return [] # A new branch has no earlier commit; run the full suite. + output = subprocess.check_output( + ["git", "diff", "--name-only", "-z", "--no-renames", base, head] + ) + return [os.fsdecode(path) for path in output.split(b"\0") if path] + + +if __name__ == "__main__": + paths = changed_paths(os.environ["BASE_SHA"], os.environ["HEAD_SHA"]) + docs_only = str(is_docs_only(paths)).lower() + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"docs_only={docs_only}\n") + print(f"Changed files: {len(paths)}; documentation only: {docs_only}") diff --git a/tests/test_ci_docs_only.py b/tests/test_ci_docs_only.py new file mode 100644 index 0000000..1eaaa4a --- /dev/null +++ b/tests/test_ci_docs_only.py @@ -0,0 +1,35 @@ +import unittest + +from scripts.ci_docs_only import is_docs_only + + +class DocsOnlyScopeTests(unittest.TestCase): + def test_docs_and_readme_assets(self): + self.assertTrue( + is_docs_only( + [ + "README.md", + "README.pypi.md", + "docs/getting-started.md", + "docs/assets/icon.png", + "assets/readme/runtime-debug.gif", + ".github/ISSUE_TEMPLATE/bug_report.md", + "zensical.toml", + ] + ) + ) + + def test_empty_or_code_changes_run_full_suite(self): + for paths in ( + [], + ["src/aiython/runtime.py"], + ["README.md", "src/aiython/runtime.py"], + ["docs/example.py"], + ["assets/readme/build_runtime_debug.py"], + ["examples/recipes/03_loop.py"], + [".github/workflows/tests.yml"], + ["pyproject.toml"], + ["uv.lock"], + ): + with self.subTest(paths=paths): + self.assertFalse(is_docs_only(paths))