Cross-platform CI matrix, Windows path tests, and PyInstaller smoke t… #97
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Tests | |
| on: | |
| push: | |
| branches: [master] | |
| pull_request: | |
| # Least-privilege GITHUB_TOKEN scope. None of these jobs need write access | |
| # (no commit, no PR comment, no release publish) — read-only is enough. | |
| # A compromised action in any matrix cell can't write back to the repo. | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # ── Lock file + requirements sync (closes #47) ─────────────────────────── | |
| lockfile: | |
| name: Lock file freshness | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Python | |
| uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Verify requirements.txt matches pyproject.toml | |
| run: | | |
| python <<'PY' | |
| import sys | |
| import tomllib | |
| from pathlib import Path | |
| with open("pyproject.toml", "rb") as f: | |
| py_deps = tomllib.load(f)["project"]["dependencies"] | |
| req_deps = [ | |
| line.strip() | |
| for line in Path("requirements.txt").read_text().splitlines() | |
| if line.strip() and not line.strip().startswith("#") | |
| ] | |
| if sorted(py_deps) != sorted(req_deps): | |
| print( | |
| "requirements.txt [project.dependencies] drift from pyproject.toml", | |
| file=sys.stderr, | |
| ) | |
| print("pyproject.toml:", sorted(py_deps), file=sys.stderr) | |
| print("requirements.txt:", sorted(req_deps), file=sys.stderr) | |
| sys.exit(1) | |
| PY | |
| - name: Install pip-tools | |
| # Pin matches update-lock.yml so lock verification uses the same resolver. | |
| run: python -m pip install 'pip-tools==7.5.3' | |
| - name: Verify requirements-lock.txt is up to date | |
| # Same pip-compile flags as update-lock.yml, without --upgrade. | |
| run: | | |
| pip-compile requirements.txt \ | |
| --output-file /tmp/requirements-lock.txt \ | |
| --no-header \ | |
| --annotation-style=line \ | |
| --allow-unsafe \ | |
| --quiet | |
| diff -u \ | |
| <(grep -E '^[A-Za-z0-9_.-]+==' requirements-lock.txt | sort) \ | |
| <(grep -E '^[A-Za-z0-9_.-]+==' /tmp/requirements-lock.txt | sort) | |
| # ── Unit tests: matrix across OS and Python version ─────────────────────── | |
| # Closes #13 and #44. Multi-OS catches path-normalisation drift and | |
| # line-ending issues that a single-OS run hides; multi-Python catches API | |
| # drift across LTS / current / latest interpreters. Matrix parallelism | |
| # keeps wall-clock under ~15 min (slowest runner wins, not the sum). | |
| unittest: | |
| name: Unit tests (${{ matrix.os }} / Python ${{ matrix.python-version }}) | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest, macos-latest] | |
| python-version: ["3.10", "3.11", "3.12", "3.13"] | |
| steps: | |
| # Pinned to immutable commit SHAs (not @v4 / @v5) so a compromised tag | |
| # cannot silently swap the underlying action code on this CI runner. | |
| # When bumping, verify the new SHA via: | |
| # gh api repos/actions/<name>/git/ref/tags/<vN> --jq '.object.sha' | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Set up Python | |
| uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Install runtime + test dependencies | |
| # Install from the pinned lock file for deterministic dependency | |
| # resolution (closes #47). pytest is added on top — it is not in | |
| # requirements-lock.txt because it is a dev-only dep. pywebview is | |
| # the desktop-launcher dep and pulls GTK / Qt system libraries on | |
| # Linux — intentionally excluded from the CI unittest matrix. | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install -r requirements-lock.txt | |
| python -m pip install 'pytest>=8,<9' | |
| - name: Run unittest suite | |
| run: python -m unittest discover tests -v | |
| - name: Run pytest integration suite | |
| # Pytest fixtures (tests/conftest.py) build a temp workspaceStorage | |
| # and exercise the Flask routes via app.test_client(). Scoped to the | |
| # new endpoint file because `pytest tests/` would also re-collect the | |
| # 178 unittest.TestCase subclasses already run in the step above — | |
| # ~2× the CI minutes for zero extra signal. | |
| run: python -m pytest tests/test_api_endpoints.py -v --tb=short | |
| # ── PyInstaller desktop build (Windows only, once per workflow) ──────── | |
| # Closes #44. Builds the onedir bundle and smoke-tests --help so the | |
| # desktop entry point is verified without launching the GUI window. | |
| - name: Install PyInstaller | |
| if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' | |
| run: python -m pip install 'pyinstaller>=6,<7' | |
| - name: Build PyInstaller bundle | |
| if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' | |
| run: pyinstaller cursor-browser.spec --noconfirm | |
| - name: Smoke-test PyInstaller exe (--help) | |
| if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' | |
| run: dist\CursorChatBrowser\CursorChatBrowser.exe --help | |
| # ── Typecheck: mypy ─────────────────────────────────────────────────────── | |
| # Codebase already has type hints across most of the surface (~70+ typed | |
| # functions). Mypy runs in lenient mode (--ignore-missing-imports for | |
| # untyped third-party deps; no strict-optional) so the gate isn't a wall | |
| # of false positives. The transitional `continue-on-error: true` was | |
| # removed in #29 once mypy reached zero errors on this repo — type | |
| # failures now block merges. | |
| typecheck: | |
| name: Typecheck (mypy) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Set up Python | |
| uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install runtime deps + mypy | |
| # Install from the pinned lock file for deterministic resolution, | |
| # then add mypy (dev-only; not in requirements-lock.txt). | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install -r requirements-lock.txt | |
| python -m pip install 'mypy>=1.10,<2' | |
| - name: Run mypy | |
| # No `continue-on-error` — mypy now exits zero on this repo (closes #29), | |
| # so type errors must fail the job from here on. | |
| run: mypy --ignore-missing-imports --no-strict-optional --pretty . | |
| # ── Secret scan: gitleaks ───────────────────────────────────────────────── | |
| # Catches accidentally committed credentials. Runs over full git history | |
| # (fetch-depth: 0). No project-specific .gitleaks.toml — defaults cover | |
| # standard credential patterns (API keys, AWS, GitHub tokens, etc.). | |
| secret-scan: | |
| name: Secret scan (gitleaks) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install gitleaks | |
| run: | | |
| GITLEAKS_VERSION=8.21.2 | |
| base_url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" | |
| tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | |
| checksums="gitleaks_${GITLEAKS_VERSION}_checksums.txt" | |
| # Download tarball and checksums file to temp; retries prevent | |
| # transient 5xx failures. | |
| curl --fail --location --silent --show-error \ | |
| --retry 5 --retry-delay 2 --retry-all-errors \ | |
| -o "/tmp/${tarball}" "${base_url}/${tarball}" | |
| curl --fail --location --silent --show-error \ | |
| --retry 5 --retry-delay 2 --retry-all-errors \ | |
| -o "/tmp/${checksums}" "${base_url}/${checksums}" | |
| # Verify SHA-256 before extraction; fail and clean up on mismatch. | |
| expected=$(grep " ${tarball}$" "/tmp/${checksums}" | awk '{print $1}') | |
| if [ -z "${expected}" ]; then | |
| echo "::error::No checksum entry found for ${tarball}" >&2 | |
| rm -f "/tmp/${tarball}" "/tmp/${checksums}" | |
| exit 1 | |
| fi | |
| actual=$(sha256sum "/tmp/${tarball}" | awk '{print $1}') | |
| if [ "${expected}" != "${actual}" ]; then | |
| echo "::error::SHA-256 mismatch for ${tarball}: expected ${expected}, got ${actual}" >&2 | |
| rm -f "/tmp/${tarball}" "/tmp/${checksums}" | |
| exit 1 | |
| fi | |
| tar -xz -f "/tmp/${tarball}" gitleaks | |
| sudo mv gitleaks /usr/local/bin/gitleaks | |
| rm -f "/tmp/${tarball}" "/tmp/${checksums}" | |
| - name: Run gitleaks | |
| run: | | |
| gitleaks detect \ | |
| --source . \ | |
| --verbose \ | |
| --redact \ | |
| --exit-code 1 |