diff --git a/.github/scripts/detect-code-changes.sh b/.github/scripts/detect-code-changes.sh new file mode 100755 index 0000000..30a247e --- /dev/null +++ b/.github/scripts/detect-code-changes.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Decide whether a pull request touches code that the live Enterprise suite +# covers. Writes `code=true` or `code=false` to $GITHUB_OUTPUT. +# +# Plain git on purpose: no third-party action and no Node runtime, so the cost +# filter keeps the same "standard tools only" property as the rest of the +# project. tests/unit/test_ci_path_filter.py pins the pattern below. +# +# Requires: BASE_REF (the pull request's target branch), GITHUB_OUTPUT (Actions). +set -euo pipefail + +# No apostrophes in these messages: bash treats a single quote inside +# ${VAR:?word} as an opening quote even within double quotes, which makes the +# rest of the file a syntax error. +: "${BASE_REF:?BASE_REF must be set to the base branch of the pull request}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set by GitHub Actions}" + +# Paths whose change requires a live Splunk run, anchored at the repo root. +readonly COVERED='^(src/|tests/|\.github/scripts/|pyproject\.toml$|\.github/workflows/ci\.yml$)' + +# Fetch into an explicit remote-tracking ref. `git fetch origin ` only +# updates FETCH_HEAD unless the checkout happens to configure a matching +# refspec, and `origin/` would then not resolve below. +git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + +if git diff --name-only "origin/${BASE_REF}...HEAD" | grep -qE "$COVERED"; then + echo "code=true" >>"$GITHUB_OUTPUT" + echo "Code paths changed: the Enterprise suite will run." +else + echo "code=false" >>"$GITHUB_OUTPUT" + echo "No code paths changed: skipping the Enterprise suite." +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055cae8..0edb2a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,15 +65,17 @@ jobs: outputs: code: ${{ steps.filter.outputs.code }} steps: - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 - id: filter + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - filters: | - code: - - "src/**" - - "tests/**" - - "pyproject.toml" - - ".github/workflows/ci.yml" + fetch-depth: 0 + + - name: Detect code changes + id: filter + env: + BASE_REF: ${{ github.base_ref }} + # Invoked through `bash` so the job does not depend on the executable + # bit surviving every checkout. + run: bash .github/scripts/detect-code-changes.sh enterprise: name: Enterprise / Full CLI @@ -194,7 +196,7 @@ jobs: - name: Upload failure diagnostics if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: enterprise-failure-${{ github.run_id }} path: | diff --git a/.github/workflows/cloud-read.yml b/.github/workflows/cloud-read.yml index 9abea23..cc4f2e4 100644 --- a/.github/workflows/cloud-read.yml +++ b/.github/workflows/cloud-read.yml @@ -42,7 +42,7 @@ jobs: - name: Upload test report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cloud-read-${{ github.run_id }} path: cloud-read.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index fa181ed..d5a5768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Add a single **Merge Gate** check that aggregates every CI job, so branch protection needs exactly one required status check and adding a job later never needs a protection change. +- Move CI off actions that still run on the deprecated Node 20 runtime, and + replace the third-party paths-filter action with a small `git diff` script + covered by a unit test. - Add catalog-driven live coverage for all 61 Enterprise reads and 93 writes, including cleanup, global-state restoration, restart/reconnect, a container CI gate on code pull requests and `main` merges, a manual Cloud read canary, diff --git a/tests/unit/test_ci_path_filter.py b/tests/unit/test_ci_path_filter.py new file mode 100644 index 0000000..3f38ee0 --- /dev/null +++ b/tests/unit/test_ci_path_filter.py @@ -0,0 +1,77 @@ +"""Pin the CI cost filter that decides whether the live Enterprise suite runs. + +The filter lives in `.github/scripts/detect-code-changes.sh`. A silent mistake +there is expensive in both directions: too narrow and a real code change merges +without ever touching a live Splunk, too broad and every documentation typo +boots a container. The regex is read from the script itself, so there is one +source of truth and this test fails if the two ever drift apart. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / ".github/scripts/detect-code-changes.sh" + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash is not installed") +def test_script_is_valid_bash() -> None: + """The script must parse. + + A syntax error here only shows up as a failed CI job, and the reported line + can be far from the real mistake — an apostrophe inside `${VAR:?...}` opens + a quote that bash never closes, and it blames a later line. + """ + result = subprocess.run( # noqa: S603 + ["bash", "-n", str(_SCRIPT)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"detect-code-changes.sh has a syntax error:\n{result.stderr}" + + +def _covered_pattern() -> re.Pattern[str]: + """Extract the COVERED extended-regex literal from the shell script.""" + match = re.search(r"^readonly COVERED='(?P.+)'$", _SCRIPT.read_text(), re.MULTILINE) + assert match, "detect-code-changes.sh must define `readonly COVERED=''`" + return re.compile(match.group("expr")) + + +@pytest.mark.parametrize( + "path", + [ + "src/vct_splunk/cli.py", + "src/vct_splunk/core/client.py", + "tests/unit/test_client.py", + "tests/integration/enterprise/read/test_catalog.py", + "pyproject.toml", + ".github/workflows/ci.yml", + ".github/scripts/detect-code-changes.sh", + ], +) +def test_code_paths_trigger_the_live_suite(path: str) -> None: + """Anything that can change CLI behavior must run the live suite.""" + assert _covered_pattern().search(path), f"{path} should trigger the Enterprise suite" + + +@pytest.mark.parametrize( + "path", + [ + "README.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "AGENTS.md", + "LICENSE", + "docs/pyproject.toml", # only a root-level pyproject.toml counts + "notes/src/scratch.py", # only a root-level src/ counts + ".github/workflows/cloud-read.yml", # credential-gated, not this gate + ], +) +def test_docs_only_paths_skip_the_live_suite(path: str) -> None: + """Documentation and unrelated workflows must not boot a Splunk container.""" + assert not _covered_pattern().search(path), f"{path} should not trigger the Enterprise suite"