diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db62f93..50321ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,98 +6,115 @@ on: pull_request: branches: [main] -env: - PYTHON_VERSION: "3.13" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - test: - name: Test + lint: + name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: ${{ env.PYTHON_VERSION }} - cache: 'pip' + version: "latest" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" + - name: Set up Python + run: uv python install 3.13 - - name: Download NLTK data - run: | - python -c "import nltk; nltk.download('averaged_perceptron_tagger_eng'); nltk.download('punkt_tab')" + - name: Install dependencies + run: uv sync --all-extras - - name: Run tests with coverage - run: | - pytest --cov=src/drover --cov-report=xml --cov-report=term-missing + - name: Run ruff check + run: uv run ruff check src/ tests/ - - name: Upload coverage report - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: coverage.xml - retention-days: 7 + - name: Run ruff format check + run: uv run ruff format --check src/ tests/ - lint: - name: Lint + typecheck: + name: Type Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: ${{ env.PYTHON_VERSION }} - cache: 'pip' + version: "latest" - - name: Install ruff - run: pip install ruff + - name: Set up Python + run: uv python install 3.13 - - name: Check formatting - run: ruff format --check src/ tests/ + - name: Install dependencies + run: uv sync --all-extras - - name: Check linting - run: ruff check src/ tests/ + - name: Run mypy + run: uv run mypy src/ - type-check: - name: Type Check + test: + name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: ${{ env.PYTHON_VERSION }} - cache: 'pip' + version: "latest" + + - name: Set up Python + run: uv python install 3.13 - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" + run: uv sync --all-extras - - name: Run mypy - run: mypy src/ + - name: Download NLTK data + run: uv run python -c "import nltk; nltk.download('averaged_perceptron_tagger_eng'); nltk.download('punkt_tab')" - security: - name: Security Scan + - name: Run tests with coverage + run: uv run pytest tests/ --cov=src --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + version-check: + name: Version Consistency runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: ${{ env.PYTHON_VERSION }} - cache: 'pip' + version: "latest" - - name: Install bandit - run: pip install bandit + - name: Set up Python + run: uv python install 3.13 + + - name: Check version consistency + run: uv run python scripts/check_version.py - - name: Run security scan + all-checks: + name: All Checks Passed + runs-on: ubuntu-latest + needs: [lint, typecheck, test, version-check] + if: always() + steps: + - name: Check all jobs passed run: | - bandit -r src/ -c pyproject.toml --severity-level medium --confidence-level medium + if [[ "${{ needs.lint.result }}" != "success" ]] || \ + [[ "${{ needs.typecheck.result }}" != "success" ]] || \ + [[ "${{ needs.test.result }}" != "success" ]] || \ + [[ "${{ needs.version-check.result }}" != "success" ]]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All checks passed!" diff --git a/scripts/check_version.py b/scripts/check_version.py new file mode 100644 index 0000000..6dff375 --- /dev/null +++ b/scripts/check_version.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Validate version consistency between __init__.py and pyproject.toml. + +This script ensures the version in src/drover/__init__.py matches +the version in pyproject.toml to prevent version drift. + +Exit codes: + 0: Versions match + 1: Version mismatch or error +""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + + +def get_init_version(init_file: Path) -> str | None: + """Extract version from __init__.py file. + + Args: + init_file: Path to __init__.py file. + + Returns: + Version string if found, None otherwise. + """ + content = init_file.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_pyproject_version(pyproject_file: Path) -> str | None: + """Extract version from pyproject.toml file. + + Args: + pyproject_file: Path to pyproject.toml file. + + Returns: + Version string if found, None otherwise. + """ + try: + content = pyproject_file.read_text(encoding="utf-8") + data = tomllib.loads(content) + return data.get("project", {}).get("version") + except (tomllib.TOMLDecodeError, KeyError): + return None + + +def main() -> int: + """Main validation logic. + + Returns: + Exit code (0 for success, 1 for failure). + """ + # Determine project root (script is in scripts/ directory) + project_root = Path(__file__).parent.parent + init_file = project_root / "src" / "drover" / "__init__.py" + pyproject_file = project_root / "pyproject.toml" + + # Check files exist + if not init_file.exists(): + print(f"❌ Error: {init_file} not found", file=sys.stderr) + return 1 + + if not pyproject_file.exists(): + print(f"❌ Error: {pyproject_file} not found", file=sys.stderr) + return 1 + + # Extract versions + init_version = get_init_version(init_file) + if init_version is None: + print(f"❌ Error: Could not find __version__ in {init_file}", file=sys.stderr) + return 1 + + pyproject_version = get_pyproject_version(pyproject_file) + if pyproject_version is None: + print(f"❌ Error: Could not find version in {pyproject_file}", file=sys.stderr) + return 1 + + # Compare versions + if init_version != pyproject_version: + print("❌ Version mismatch detected!", file=sys.stderr) + print(f" src/drover/__init__.py: {init_version}", file=sys.stderr) + print(f" pyproject.toml: {pyproject_version}", file=sys.stderr) + print(file=sys.stderr) + print("Please update both files to match.", file=sys.stderr) + print("See RELEASING.md for version management guidelines.", file=sys.stderr) + return 1 + + # Success + print(f"✅ Version consistency check passed: {init_version}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())