diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e289aa1..522ff5ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: pull_request: - branches: [ main ] + branches: [main] push: - branches: [ main ] + branches: [main] permissions: contents: read @@ -12,19 +12,225 @@ permissions: issues: write actions: read +# Cancel in-flight PR runs when a new commit is pushed; never cancel main runs. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: PYTHON_VERSION: '3.11' jobs: + # Single source of truth for "what mode should this run be in?". Inspects the + # PR diff and outputs: + # mode = release-please-bypass | full + # should_build = true | false + # The classification is content-based, not just branch-name-based -- that + # closes the CI-bypass hole where a contributor opens a PR from a branch + # named release-please-- and inherits sentinel passes for free. + guard: + name: Guard + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + mode: ${{ steps.classify.outputs.mode }} + should_build: ${{ steps.classify.outputs.should_build }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - id: classify + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + set -euo pipefail + + # Derive the list of release-please-managed files from + # release-please-config.json so this stays in sync automatically. + # Implicit files that release-please always touches: + # - CHANGELOG.md + # - .release-please-manifest.json + # - pyproject.toml (release-type=python bumps the version field) + # Anything declared in `.packages."."."extra-files"` is appended. + # If release-type changes (currently "python"), audit this list. + allowed_files() { + printf '%s\n' \ + 'CHANGELOG.md' \ + '.release-please-manifest.json' \ + 'pyproject.toml' + jq -r '.packages["."]["extra-files"][]?' release-please-config.json + } + + # Use fixed-string exact-line matching (`grep -Fxvf`) instead of a + # hand-built regex -- removes the need to escape filenames at all. + is_unexpected() { + # $1 = newline-separated changed files + echo "$1" | grep -Fxvf <(allowed_files) || true + } + + # Content-shape check: a metadata-only file list isn't enough on + # push:main, where actor isn't a reliable bypass signal. Verify the + # diffs themselves are version-bump-shaped: pyproject.toml only + # changes its `version = "..."` line, src/runpod_flash/__init__.py + # only changes its `__version__ = "..."` line. Defeats the + # exploitation path where a direct push touches only the metadata + # filenames but smuggles non-version edits (deps, ruff config, + # scripts) into pyproject.toml. + # $1 = git diff base (HEAD~1 for push, origin/BASE_REF for PR). + verify_version_shape() { + local base="$1" + local bad + if git diff --name-only "${base}...HEAD" -- pyproject.toml | grep -q .; then + bad="$(git diff "${base}...HEAD" -- pyproject.toml \ + | grep -E '^[-+]' \ + | grep -vE '^(---|\+\+\+)' \ + | grep -vE '^[-+]version[[:space:]]*=[[:space:]]*"[^"]+"[[:space:]]*$' || true)" + if [ -n "$bad" ]; then + echo "Bypass rejected: pyproject.toml diff touches non-version lines:" + echo "$bad" + return 1 + fi + fi + if git diff --name-only "${base}...HEAD" -- src/runpod_flash/__init__.py | grep -q .; then + bad="$(git diff "${base}...HEAD" -- src/runpod_flash/__init__.py \ + | grep -E '^[-+]' \ + | grep -vE '^(---|\+\+\+)' \ + | grep -vE '^[-+]__version__[[:space:]]*=[[:space:]]*"[^"]+"[[:space:]]*$' || true)" + if [ -n "$bad" ]; then + echo "Bypass rejected: src/runpod_flash/__init__.py diff touches non-__version__ lines:" + echo "$bad" + return 1 + fi + fi + return 0 + } + + # Build-trigger heuristic shared between push:main and PR full-mode. + # Build runs when packaging-relevant files change OR a non-Python + # file under src/ is added or modified (data files need explicit + # package-data inclusion; content edits should be smoke-tested by + # validate-wheel.sh). + # $1 = git diff base. + compute_should_build() { + local base="$1" + local changed + changed="$(git diff --name-only "${base}...HEAD")" + if echo "$changed" | grep -qE '^(pyproject\.toml|Makefile|MANIFEST\.in|scripts/validate-wheel\.sh)$'; then + echo "true" + return + fi + if git diff --name-only --diff-filter=AM "${base}...HEAD" | grep -E '^src/.+' | grep -qv '\.py$'; then + echo "true" + return + fi + echo "false" + } + + # Identity check: real release-please bot PRs are authored by the + # GitHub App. Used as defense-in-depth alongside the branch prefix + + # diff content check. + BOT_AUTHOR='runpod-release-please-bot[bot]' + + # Push to main: classify by diff against the previous commit. When + # release-please's release PR is squash-merged, the resulting commit + # on main only touches the allowed-files set with version-shaped + # diffs -- same code, just version-bumped. Re-running the full + # matrix would be pure waste, and release-please.yml's pypi-publish + # builds its own wheel before publishing. Anything else (feature + # merges, direct admin pushes) gets the full matrix. + # On push events the actor isn't a reliable signal (admins can + # merge the bot's PR by hand) -- diff content is the only check. + if [ "$EVENT_NAME" = "push" ]; then + CHANGED="$(git diff --name-only HEAD~1...HEAD)" + echo "Changed files in this push:" + echo "$CHANGED" + UNEXPECTED="$(is_unexpected "$CHANGED")" + if [ -n "$CHANGED" ] && [ -z "$UNEXPECTED" ] && verify_version_shape HEAD~1; then + echo "Classification: release-please release commit on main (version-bump shape)." + echo "mode=release-please-bypass" >> "$GITHUB_OUTPUT" + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Classification: regular push to main." + echo "mode=full" >> "$GITHUB_OUTPUT" + echo "should_build=$(compute_should_build HEAD~1)" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED="$(git diff --name-only "origin/${BASE_REF}...HEAD")" + echo "Changed files in this PR:" + echo "$CHANGED" + + # release-please bypass on PR: requires ALL FOUR: + # 1. Branch prefix matches `release-please--` + # 2. PR author is the release-please GitHub App + # 3. Diff contains only release-please-managed files + # 4. pyproject.toml/__init__.py diffs are version-bump-shaped + # Any one missing -> fall through to full CI. + if [[ "$HEAD_REF" == release-please--* ]] && [ "$PR_AUTHOR" = "$BOT_AUTHOR" ]; then + UNEXPECTED="$(is_unexpected "$CHANGED")" + if [ -z "$UNEXPECTED" ] && verify_version_shape "origin/${BASE_REF}"; then + echo "Classification: release-please bot PR (version-bump shape)." + echo "mode=release-please-bypass" >> "$GITHUB_OUTPUT" + echo "should_build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -n "$UNEXPECTED" ]; then + echo "release-please-- branch from bot contains unexpected files; falling through to full CI." + echo "Unexpected files:" + echo "$UNEXPECTED" + fi + elif [[ "$HEAD_REF" == release-please--* ]]; then + echo "release-please-- branch but author is '$PR_AUTHOR' (not '$BOT_AUTHOR'); running full CI." + fi + + # Default: full CI; build conditional on content. + echo "mode=full" >> "$GITHUB_OUTPUT" + echo "should_build=$(compute_should_build "origin/${BASE_REF}")" >> "$GITHUB_OUTPUT" + + # Fast-fail formatting/lint check. Installs only the dev dependency group + # from the lockfile (warm-cache install is ~1s), then runs ruff via + # `uv run` so we get the exact pinned ruff version that `make + # ci-quality-github` uses. Avoids `uvx ruff` -- that pulls the latest + # ruff, which can drift from the lock and produce CI verdicts that don't + # match local `make quality-check`. + pre-check: + name: Pre-check (format + lint) + runs-on: ubuntu-latest + needs: [guard] + if: ${{ needs.guard.outputs.mode == 'full' }} + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Install dev group (frozen, no project) + run: uv sync --only-group dev --frozen + # `uv run` defaults to syncing the environment before each invocation, + # which would undo the dev-only install above and pull in the full + # project. `--no-sync` runs ruff from the existing .venv directly. + - name: Ruff format + run: uv run --no-sync ruff format --check . + - name: Ruff lint + run: uv run --no-sync ruff check . --output-format=github + quality-gates: name: Quality Gates runs-on: ubuntu-latest + needs: [guard, pre-check] + if: ${{ needs.guard.outputs.mode == 'full' }} strategy: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] timeout-minutes: 15 - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -35,14 +241,14 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v2 + uses: astral-sh/setup-uv@v5 with: enable-cache: true - cache-dependency-glob: "pyproject.toml" + cache-dependency-glob: uv.lock - name: Install dependencies run: make dev - + - name: Quality checks run: make ci-quality-github @@ -51,27 +257,24 @@ jobs: if: always() with: name: test-results-${{ matrix.python-version }} - path: pytest-results.xml + path: pytest-results-*.xml build: name: Build Package runs-on: ubuntu-latest - needs: [quality-gates] + needs: [guard, quality-gates] + if: ${{ needs.guard.outputs.mode == 'full' && needs.guard.outputs.should_build == 'true' }} timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v2 + - uses: astral-sh/setup-uv@v5 with: enable-cache: true + cache-dependency-glob: uv.lock - name: Build package run: make build @@ -82,9 +285,39 @@ jobs: - name: Validate wheel packaging run: ./scripts/validate-wheel.sh - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ - + # Single aggregator. Branch protection on `main` should require ONLY this + # check ("CI / Validation"). This job runs unconditionally (`if: always()`) + # and treats "skipped" as success -- so release-please-bypass runs (where + # pre-check / quality-gates / build are all deliberately skipped by the + # guard) pass cleanly. Anything that genuinely failed or was cancelled + # upstream flips this to red. + # + # Adding or removing upstream jobs (new Python version, new security scan, + # etc.) no longer requires a branch-protection update: just include the new + # job in `needs:` and the results array. + validation: + name: Validation + runs-on: ubuntu-latest + needs: [guard, pre-check, quality-gates, build] + if: always() + timeout-minutes: 1 + steps: + - name: Aggregate upstream results + env: + GUARD: ${{ needs.guard.result }} + PRE_CHECK: ${{ needs.pre-check.result }} + QUALITY_GATES: ${{ needs.quality-gates.result }} + BUILD: ${{ needs.build.result }} + run: | + set -euo pipefail + echo "guard=$GUARD" + echo "pre-check=$PRE_CHECK" + echo "quality-gates=$QUALITY_GATES" + echo "build=$BUILD" + for r in "$GUARD" "$PRE_CHECK" "$QUALITY_GATES" "$BUILD"; do + if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then + echo "::error::Upstream job failed or was cancelled (got: $r)" + exit 1 + fi + done + echo "All upstream jobs succeeded or were intentionally skipped." diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index dda41a10..199fbd38 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,11 +1,6 @@ name: E2E Tests on: - # Uncomment to run on every push to main / pull request: - # push: - # branches: [main] - # pull_request: - # branches: [main] workflow_dispatch: inputs: tests: @@ -20,74 +15,30 @@ env: PYTHON_VERSION: '3.11' jobs: - unit-tests: - name: Unit + Integration - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: "pyproject.toml" - - - name: Install dependencies - run: uv sync --all-groups - - - name: Run unit + integration tests - run: | - uv run pytest tests/ \ - -n auto \ - --timeout=60 \ - --junitxml=unit-results.xml \ - --cov-report=xml:coverage.xml \ - --cov-fail-under=0 - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: unit-results - path: unit-results.xml - - - name: Upload coverage - uses: actions/upload-artifact@v4 - if: always() - with: - name: coverage - path: coverage.xml - + # Note: there's intentionally no unit-tests job here. ci.yml already runs the + # full quality matrix on every PR and push:main; duplicating it under + # workflow_dispatch would just be a third copy of the same install + pytest + # setup. If you need unit results outside of CI, run ci.yml manually or + # rerun a previous run. e2e: name: E2E runs-on: ubuntu-latest timeout-minutes: 45 steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - - name: Install uv - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v5 with: enable-cache: true - cache-dependency-glob: "pyproject.toml" + cache-dependency-glob: uv.lock - name: Install dependencies - run: uv sync --all-groups + run: make dev - name: Run E2E tests env: @@ -118,10 +69,10 @@ jobs: tests = int(root.attrib.get("tests", 0)) print(f"Tests run: {tests}") if tests == 0: - print("ERROR: 0 tests ran — check test filter or test collection") + print("ERROR: 0 tests ran -- check test filter or test collection") sys.exit(1) except FileNotFoundError: - print("ERROR: e2e-results.xml not found — pytest did not run") + print("ERROR: e2e-results.xml not found -- pytest did not run") sys.exit(1) EOF @@ -132,36 +83,8 @@ jobs: name: e2e-results path: e2e-results.xml - summary: - name: Summary - needs: [unit-tests, e2e] - if: always() - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Download unit results - uses: actions/download-artifact@v4 - with: - name: unit-results - continue-on-error: true - - - name: Download coverage - uses: actions/download-artifact@v4 - with: - name: coverage - continue-on-error: true - - - name: Download E2E results - uses: actions/download-artifact@v4 - with: - name: e2e-results - continue-on-error: true - - name: Write summary - env: - UNIT_RESULT: ${{ needs.unit-tests.result }} - E2E_RESULT: ${{ needs.e2e.result }} + if: always() run: | python - <<'EOF' import xml.etree.ElementTree as ET, os, sys @@ -169,79 +92,48 @@ jobs: summary_file = os.environ.get("GITHUB_STEP_SUMMARY") out = open(summary_file, "a") if summary_file else sys.stdout - def parse_junit(path): - """Return (total, failures, duration) from a JUnit XML file.""" - try: - root = ET.parse(path).getroot() - suites = root.findall("testsuite") if root.tag == "testsuites" else [root] - total = sum(int(s.attrib.get("tests", 0)) for s in suites) - failures = sum(int(s.attrib.get("failures", 0)) + int(s.attrib.get("errors", 0)) for s in suites) - duration = sum(float(s.attrib.get("time", 0)) for s in suites) - failed_names = [ - tc.get("classname", "") + "::" + tc.get("name", "") - for s in suites - for tc in s.findall("testcase") - if tc.find("failure") is not None or tc.find("error") is not None - ] - return total, failures, duration, failed_names - except FileNotFoundError: - return None, None, None, [] - - def status_icon(result, total, failures): - if total is None: return ":x: Did not run" - if total == 0: return ":warning: No tests ran" - if failures == 0: return ":white_check_mark: Passed" - return ":x: Failed" - - unit_total, unit_fail, unit_dur, unit_failed_names = parse_junit("unit-results.xml") - e2e_total, e2e_fail, e2e_dur, e2e_failed_names = parse_junit("e2e-results.xml") - - unit_pass = (unit_total - unit_fail) if unit_total is not None else None - e2e_pass = (e2e_total - e2e_fail) if e2e_total is not None else None - - unit_result = os.environ.get("UNIT_RESULT", "") - e2e_result = os.environ.get("E2E_RESULT", "") - - print("# Test Results\n", file=out) - print("| Suite | Status | Passed | Failed | Total | Duration |", file=out) - print("|---|---|---|---|---|---|", file=out) - print(f"| Unit + Integration | {status_icon(unit_result, unit_total, unit_fail)} | " - f"{unit_pass if unit_pass is not None else '-'} | " - f"{unit_fail if unit_fail is not None else '-'} | " - f"{unit_total if unit_total is not None else '-'} | " - f"{unit_dur:.1f}s |" if unit_dur is not None else "- |", file=out) - print(f"| E2E | {status_icon(e2e_result, e2e_total, e2e_fail)} | " - f"{e2e_pass if e2e_pass is not None else '-'} | " - f"{e2e_fail if e2e_fail is not None else '-'} | " - f"{e2e_total if e2e_total is not None else '-'} | " - f"{e2e_dur:.1f}s |" if e2e_dur is not None else "- |", file=out) + try: + root = ET.parse("e2e-results.xml").getroot() + suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + total = sum(int(s.attrib.get("tests", 0)) for s in suites) + failures = sum(int(s.attrib.get("failures", 0)) + int(s.attrib.get("errors", 0)) for s in suites) + duration = sum(float(s.attrib.get("time", 0)) for s in suites) + failed_names = [ + tc.get("classname", "") + "::" + tc.get("name", "") + for s in suites + for tc in s.findall("testcase") + if tc.find("failure") is not None or tc.find("error") is not None + ] + except FileNotFoundError: + total, failures, duration, failed_names = None, None, None, [] + + if total is None: + status = ":x: Did not run" + elif total == 0: + status = ":warning: No tests ran" + elif failures == 0: + status = ":white_check_mark: Passed" + else: + status = ":x: Failed" + + passed = (total - failures) if total is not None else None + + def _num(v): + return str(v) if v is not None else "-" + def _dur(v): + return f"{v:.1f}s" if v is not None else "-" + + print("# E2E Results\n", file=out) + print("| Status | Passed | Failed | Total | Duration |", file=out) + print("|---|---|---|---|---|", file=out) + print(f"| {status} | {_num(passed)} | {_num(failures)} | {_num(total)} | {_dur(duration)} |", + file=out) print("", file=out) - all_failed = [("Unit", n) for n in unit_failed_names] + [("E2E", n) for n in e2e_failed_names] - if all_failed: + if failed_names: print("## Failed Tests\n", file=out) - print("| Suite | Test |", file=out) - print("|---|---|", file=out) - for suite, name in all_failed: - print(f"| {suite} | `{name}` |", file=out) - print("", file=out) - - # Coverage - print("## Coverage\n", file=out) - try: - cov_root = ET.parse("coverage.xml").getroot() - line_rate = float(cov_root.attrib.get("line-rate", 0)) - total_cov = f"{line_rate * 100:.1f}%" - print(f"**Total: {total_cov}**\n", file=out) - print("
", file=out) - print("Per-package breakdown\n", file=out) - print("| Package | Coverage |", file=out) - print("|---|---|", file=out) - for pkg in cov_root.iter("package"): - name = pkg.attrib.get("name", "") - rate = float(pkg.attrib.get("line-rate", 0)) - print(f"| `{name}` | {rate * 100:.1f}% |", file=out) - print("
", file=out) - except FileNotFoundError: - print("> Coverage data not available.", file=out) + print("| Test |", file=out) + print("|---|", file=out) + for name in failed_names: + print(f"| `{name}` |", file=out) EOF diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index e81543c5..b9ab1277 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -9,50 +9,22 @@ permissions: pull-requests: write issues: write id-token: write + actions: read env: PYTHON_VERSION: '3.11' jobs: - # Run quality checks - quality-gates: - name: Quality Gates - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ['3.10', '3.11', '3.12', '3.13'] - timeout-minutes: 15 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install uv - uses: astral-sh/setup-uv@v2 - with: - enable-cache: true - - - name: Install dependencies - run: make dev - - - name: Quality checks - run: make quality-check - - # Release orchestration + # Quality is enforced by CI on PRs via branch protection -- the code that + # reached main has already passed the full matrix. Re-running it here would + # duplicate ~24 min of compute per merge for zero added signal. release-please: name: Release Please runs-on: ubuntu-latest - needs: [quality-gates] - + outputs: release_created: ${{ steps.release.outputs.release_created }} - + steps: - name: Generate GitHub App Token id: app-token @@ -67,30 +39,25 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - # PyPI publishing pypi-publish: name: PyPI Publish runs-on: ubuntu-latest needs: [release-please] if: ${{ needs.release-please.outputs.release_created == 'true' }} - + environment: name: pypi-production url: https://pypi.org/project/runpod-flash/ - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v2 + - uses: astral-sh/setup-uv@v5 with: enable-cache: true + cache-dependency-glob: uv.lock - name: Build package run: make build @@ -101,4 +68,4 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - verbose: true \ No newline at end of file + verbose: true diff --git a/Makefile b/Makefile index ff9cb101..c764d4d4 100644 --- a/Makefile +++ b/Makefile @@ -12,9 +12,8 @@ help: # Show this help menu @awk 'BEGIN {FS = ":.*# "; printf "%-20s %s\n", "Target", "Description"} /^[a-zA-Z_-]+:.*# / {printf "%-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @echo "" -dev: # Install development dependencies and package in editable mode +dev: # Install development dependencies (editable install handled by uv sync) uv sync --all-groups - uv pip install -e . update: uv sync --upgrade --all-groups @@ -72,7 +71,7 @@ test-integration: # Run integration tests in parallel (auto-detect cores) test-integration-serial: # Run integration tests serially (for debugging) uv run pytest tests/integration/ -v -m integration -test-coverage: # Run tests with coverage report (parallel by default) +test-coverage: # Run tests with coverage report (parallel non-serial, then serial pass for state isolation) uv run pytest tests/ -v -n auto -m "not serial" --cov=runpod_flash --cov-report=xml uv run pytest tests/ -v -m "serial" --cov=runpod_flash --cov-append --cov-report=term-missing @@ -102,12 +101,21 @@ format-check: # Check code formatting typecheck: # Check types with mypy uv run mypy . -# Quality gates (used in CI) -quality-check: format-check lint test-coverage # Essential quality gate for CI (parallel by default) -quality-check-strict: format-check lint typecheck test-coverage # Strict quality gate with type checking (parallel by default) -quality-check-serial: format-check lint test-coverage-serial # Serial quality gate for debugging - -# GitHub Actions specific targets +# Quality gates. +# +# - quality-check / quality-check-serial: aliases for ci-quality-github / +# ci-quality-github-serial. These are the canonical CI gates. When invoked +# locally you'll see GitHub Actions annotation markers (::group::, +# --output-format=github) in the output -- inert outside Actions but +# cosmetically noisy. The alias guarantees local and CI run the same recipe. +# - quality-check-strict: NOT an alias. Adds mypy typecheck on top of the +# plain format/lint/test-coverage targets, with plain output (no annotation +# markers, no JUnit XML). Use when you want stricter local feedback. +quality-check: ci-quality-github # Essential quality gate (parallel by default) +quality-check-strict: format-check lint typecheck test-coverage # Strict quality gate with type checking +quality-check-serial: ci-quality-github-serial # Serial quality gate for debugging + +# GitHub Actions specific target -- canonical CI quality gate. ci-quality-github: # Quality checks with GitHub Actions formatting (parallel by default) @echo "::group::Code formatting check" uv run ruff format --check . @@ -115,9 +123,11 @@ ci-quality-github: # Quality checks with GitHub Actions formatting (parallel by @echo "::group::Linting" uv run ruff check . --output-format=github @echo "::endgroup::" - @echo "::group::Test suite with coverage" - uv run pytest tests/ --junitxml=pytest-results.xml -v -n auto -m "not serial" --cov=runpod_flash --cov-report=xml --cov-fail-under=0 - uv run pytest tests/ --junitxml=pytest-results.xml -v -m "serial" --cov=runpod_flash --cov-append --cov-report=term-missing + @echo "::group::Test suite with coverage (parallel non-serial)" + uv run pytest tests/ --junitxml=pytest-results-parallel.xml -v -n auto -m "not serial" --cov=runpod_flash --cov-report=xml --cov-fail-under=0 + @echo "::endgroup::" + @echo "::group::Test suite with coverage (serial pass)" + uv run pytest tests/ --junitxml=pytest-results-serial.xml -v -m "serial" --cov=runpod_flash --cov-append --cov-report=term-missing @echo "::endgroup::" ci-quality-github-serial: # Serial quality checks for GitHub Actions (for debugging)