diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index 1bbc545..071a6ed 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -12,9 +12,27 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.detect.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + # Docs-only pull requests skip the work below; see + # scripts/ci_changes.py. Jobs that branch protection requires keep + # running and skip only their steps, so their check names are still + # reported. + - id: detect + run: python3 scripts/ci_changes.py + build-and-test: name: "${{ matrix.compiler }} C++${{ matrix.std }}" runs-on: ${{ matrix.os }} + needs: changes strategy: fail-fast: false @@ -89,11 +107,12 @@ jobs: steps: - name: Checkout repository + if: needs.changes.outputs.code == 'true' uses: actions/checkout@v4 # Linux: install compilers - name: Setup Compiler (Linux) - if: runner.os == 'Linux' + if: needs.changes.outputs.code == 'true' && runner.os == 'Linux' shell: bash run: | ver=$(echo "${{ matrix.compiler }}" | cut -d'-' -f2) @@ -116,7 +135,7 @@ jobs: # Windows: detect MSVC vs clang-cl - name: Configure CMake (Windows) - if: runner.os == 'Windows' + if: needs.changes.outputs.code == 'true' && runner.os == 'Windows' run: | # windows-latest moved to windows-2025-vs2026, which ships only # Visual Studio 2026 (generator "Visual Studio 18 2026"); the old @@ -142,7 +161,7 @@ jobs: # Linux: configure - name: Configure CMake (Linux) - if: runner.os == 'Linux' + if: needs.changes.outputs.code == 'true' && runner.os == 'Linux' run: | cmake -S . -B build \ -DCMAKE_CXX_STANDARD=${{ matrix.std }} \ @@ -152,12 +171,13 @@ jobs: -DPTN_SKIP_COMPILER_CHECK=ON - name: Build + if: needs.changes.outputs.code == 'true' run: cmake --build build --config Release --parallel # CMake/MSBuild initializes the compiler environment for builds, # but compile-fail tests invoke cl.exe directly from CTest. - name: Set MSVC include paths for compile-fail tests - if: matrix.compiler == 'msvc' + if: needs.changes.outputs.code == 'true' && matrix.compiler == 'msvc' shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" @@ -171,6 +191,7 @@ jobs: $include | Where-Object { $_ -match '^INCLUDE=' } | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Run tests + if: needs.changes.outputs.code == 'true' run: ctest --test-dir build -C Release --output-on-failure - name: Upload test log (on failure) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index fe2c6f9..332a8f4 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -10,14 +10,35 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.detect.outputs.code }} + perf: ${{ steps.detect.outputs.perf }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + # Docs-only pull requests skip the work below; see + # scripts/ci_changes.py. Jobs that branch protection requires keep + # running and skip only their steps, so their check names are still + # reported. + - id: detect + run: python3 scripts/ci_changes.py + pr-test: name: Ubuntu / Clang / Debug runs-on: ubuntu-24.04 + needs: changes steps: - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' - name: Set up Clang + if: needs.changes.outputs.code == 'true' run: | sudo apt-get update sudo apt-get install -y clang @@ -25,6 +46,7 @@ jobs: clang++ --version - name: Configure (Debug build) + if: needs.changes.outputs.code == 'true' run: | cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Debug \ @@ -34,9 +56,11 @@ jobs: -DPTN_SKIP_COMPILER_CHECK=ON - name: Build + if: needs.changes.outputs.code == 'true' run: cmake --build build --parallel - name: Run Tests + if: needs.changes.outputs.code == 'true' run: ctest --test-dir build --output-on-failure - name: Upload test log (on failure) @@ -49,11 +73,14 @@ jobs: ct-bench-build: name: Ubuntu / Clang / Compile-Time Bench Build runs-on: ubuntu-24.04 + needs: changes steps: - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' - name: Set up Clang + if: needs.changes.outputs.code == 'true' run: | sudo apt-get update sudo apt-get install -y clang @@ -61,6 +88,7 @@ jobs: clang++ --version - name: Configure compile-time benchmarks + if: needs.changes.outputs.code == 'true' run: | cmake -S . -B build_ct \ -DCMAKE_BUILD_TYPE=Release \ @@ -70,16 +98,19 @@ jobs: -DPTN_SKIP_COMPILER_CHECK=ON - name: Build compile-time benchmarks + if: needs.changes.outputs.code == 'true' run: cmake --build build_ct --target ptn_bench_ct --parallel - name: Report build time - if: always() + if: needs.changes.outputs.code == 'true' && always() run: | echo "Compile-time benchmarks built successfully." benchmark: name: Benchmark (informational) runs-on: ubuntu-24.04 + if: needs.changes.outputs.perf == 'true' + needs: changes # Informational only — does not block PR merge. # Benchmark failures surface as artifacts for review, not as red X. continue-on-error: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab6bdc..45a6198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,21 +13,42 @@ on: branches: [main] # NOTE: no paths-ignore here on purpose. Branch protection # requires the build-and-test/coverage checks from this - # workflow; skipping docs-only PRs would leave those checks - # unreported and block the merge (see PR #45). + # workflow; skipping the whole workflow for docs-only PRs would + # leave those checks unreported and block the merge (see PR #45). + # Instead the jobs run and the `changes` job turns their steps off. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changes: + name: changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.detect.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + # Docs-only pull requests skip the work below; see + # scripts/ci_changes.py. Jobs that branch protection requires keep + # running and skip only their steps, so their check names are still + # reported. + - id: detect + run: python3 scripts/ci_changes.py + build-and-test: runs-on: ubuntu-24.04 + needs: changes steps: - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' - name: Configure CMake + if: needs.changes.outputs.code == 'true' run: | cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ @@ -36,9 +57,11 @@ jobs: -DPTN_SKIP_COMPILER_CHECK=ON - name: Build + if: needs.changes.outputs.code == 'true' run: cmake --build build --parallel - name: Run Tests + if: needs.changes.outputs.code == 'true' run: ctest --test-dir build --output-on-failure - name: Upload test log (on failure) @@ -49,12 +72,14 @@ jobs: path: build/Testing/Temporary/LastTest.log coverage: - needs: build-and-test runs-on: ubuntu-24.04 + needs: [changes, build-and-test] steps: - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' - name: Install LLVM tools + if: needs.changes.outputs.code == 'true' run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - sudo add-apt-repository -y 'deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-18 main' @@ -62,6 +87,7 @@ jobs: sudo apt-get install -y -qq llvm-18 clang-18 - name: Configure (Clang + source-based coverage) + if: needs.changes.outputs.code == 'true' run: | cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Debug \ @@ -74,9 +100,11 @@ jobs: -DPTN_SKIP_COMPILER_CHECK=ON - name: Build + if: needs.changes.outputs.code == 'true' run: cmake --build build --parallel - name: Run Tests + if: needs.changes.outputs.code == 'true' env: # Give each test process its own raw profile file. # %p = PID, %m = binary module name → avoids overwrites across @@ -87,6 +115,7 @@ jobs: ctest --test-dir build --output-on-failure - name: Capture coverage + if: needs.changes.outputs.code == 'true' run: | # Merge raw profiles from all test processes. find build/profile -name '*.profraw' > profraw_list.txt @@ -112,6 +141,7 @@ jobs: build/tests/ptn_tests - name: Upload to Codecov + if: needs.changes.outputs.code == 'true' uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/clang-format-check.yml b/.github/workflows/clang-format-check.yml index d2d4c83..ea2429c 100644 --- a/.github/workflows/clang-format-check.yml +++ b/.github/workflows/clang-format-check.yml @@ -14,19 +14,40 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.detect.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + # Docs-only pull requests skip the work below; see + # scripts/ci_changes.py. Jobs that branch protection requires keep + # running and skip only their steps, so their check names are still + # reported. + - id: detect + run: python3 scripts/ci_changes.py + clang-format: runs-on: ubuntu-24.04 + needs: changes steps: - uses: actions/checkout@v4 + if: needs.changes.outputs.code == 'true' with: fetch-depth: 0 - uses: actions/setup-python@v5 + if: needs.changes.outputs.code == 'true' with: python-version: "3.12" - name: Check formatting of changed C/C++ files + if: needs.changes.outputs.code == 'true' shell: bash run: | if [ "${{ github.event_name }}" = "pull_request" ]; then diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 90ff825..2455a4e 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -14,8 +14,27 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.detect.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + # Docs-only pull requests skip the work below; see + # scripts/ci_changes.py. Jobs that branch protection requires keep + # running and skip only their steps, so their check names are still + # reported. + - id: detect + run: python3 scripts/ci_changes.py + clang-tidy: runs-on: ubuntu-24.04 + if: needs.changes.outputs.code == 'true' + needs: changes steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 6f8d608..2907451 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -17,8 +17,27 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: changes + runs-on: ubuntu-24.04 + outputs: + code: ${{ steps.detect.outputs.code }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + # Docs-only pull requests skip the work below; see + # scripts/ci_changes.py. Jobs that branch protection requires keep + # running and skip only their steps, so their check names are still + # reported. + - id: detect + run: python3 scripts/ci_changes.py + fuzz: runs-on: ubuntu-24.04 + if: needs.changes.outputs.code == 'true' + needs: changes steps: - uses: actions/checkout@v4 diff --git a/scripts/README.md b/scripts/README.md index 04d11c1..207b761 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -106,6 +106,27 @@ py -3 scripts/bench_compare.py \ - `--fail-if-regress-pct`: fail when any selected benchmark regresses beyond threshold. - `--fail-if-mean-regress-pct`: fail when mean regression across selected benchmarks exceeds threshold. +## CI change detection + +`scripts/ci_changes.py` decides which CI work a pull request needs. Every +workflow starts with a `changes` job that runs it and exposes two outputs: + +- `code`: false only when every changed file is documentation, agent + instructions (`ai/`, `.claude/`) or packaging metadata (`packaging/`); + see `NON_CODE_PREFIXES` and `NON_CODE_FILES` in the script. +- `perf`: true when a changed file can affect benchmark results. + +Jobs that branch protection requires keep running and skip only their +steps, so their check names are still reported (a skipped matrix job would +be reported under its unexpanded name and never satisfy the requirement). +Other jobs (clang-tidy, fuzz, the benchmark comparison) are skipped whole. +Pushes, scheduled runs and manual runs always report true, and so does any +case where the changed files cannot be listed. To check a path set locally: + +```bash +python3 scripts/ci_changes.py docs/index.md README.md +``` + ## Dependency ```bash diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py new file mode 100644 index 0000000..2d2a96e --- /dev/null +++ b/scripts/ci_changes.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Classify the files a pull request changes so CI can skip jobs. + +Writes two outputs to $GITHUB_OUTPUT (and prints them): + + code=true|false some changed file can affect a build, a test or a + CI result. False only when every changed file is + documentation, agent instructions or packaging + metadata that no CI job builds or reads. + perf=true|false some changed file can affect benchmark results. + +Only pull_request events are filtered. Pushes, scheduled runs and manual +runs always report true, and so does any case where the changed files +cannot be determined, so a mistake here costs CI minutes, never coverage. + +The checkout must be the default pull_request one (the PR merged into its +base) with fetch-depth >= 2: the first parent of HEAD is the base, so +`git diff HEAD^1 HEAD` lists exactly what the PR changes. + +Usage: + ci_changes.py # inside GitHub Actions + ci_changes.py FILE [FILE...] # classify the given paths (local check) +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +# Files and directories no CI job builds, tests or reads. +NON_CODE_PREFIXES = ( + "docs/", + "ai/", + ".claude/", + "packaging/", + ".github/ISSUE_TEMPLATE/", +) +NON_CODE_FILES = frozenset( + { + "README.md", + "CONTRIBUTING.md", + "AGENTS.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", + "LICENSE", + "mkdocs.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + } +) + +# Files that can change benchmark numbers or the benchmark job itself. +PERF_PREFIXES = ( + "include/", + "bench/", + "cmake/", + "scripts/bench_", +) +PERF_FILES = frozenset( + { + "CMakeLists.txt", + "tools/requirements-bench.txt", + ".github/workflows/ci-pr.yml", + } +) + + +def is_code(path: str) -> bool: + return path not in NON_CODE_FILES and not path.startswith(NON_CODE_PREFIXES) + + +def is_perf(path: str) -> bool: + return path in PERF_FILES or path.startswith(PERF_PREFIXES) + + +def changed_files() -> list[str] | None: + try: + out = subprocess.run( + ["git", "diff", "--no-renames", "--name-only", "HEAD^1", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout + except (subprocess.CalledProcessError, OSError): + return None + files = [line for line in out.splitlines() if line] + return files or None + + +def classify(files: list[str] | None) -> tuple[bool, bool]: + if files is None: + return True, True + return any(map(is_code, files)), any(map(is_perf, files)) + + +def main() -> int: + if len(sys.argv) > 1: + files: list[str] | None = sys.argv[1:] + elif os.environ.get("GITHUB_EVENT_NAME") == "pull_request": + files = changed_files() + if files is None: + print("could not list the changed files; running everything") + else: + files = None + + code, perf = classify(files) + + if files is not None: + print(f"{len(files)} changed file(s):") + for f in files: + print(f" {f}") + print(f"code={str(code).lower()}") + print(f"perf={str(perf).lower()}") + + out = os.environ.get("GITHUB_OUTPUT") + if out: + with open(out, "a", encoding="utf-8") as fh: + fh.write(f"code={str(code).lower()}\n") + fh.write(f"perf={str(perf).lower()}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())