diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3ee7797 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,135 @@ +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: + # ── Unit tests: matrix across OS and Python version ─────────────────────── + # Closes #13. The unittest suite is the merge gate. Multi-OS catches the + # rare path / line-ending issue that a single-OS run hides; multi-Python + # catches API drift across LTS / current / latest interpreters. + unittest: + name: Unit tests (${{ matrix.os }} / Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["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//git/ref/tags/ --jq '.object.sha' + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install runtime + test dependencies + # Only what the tests actually exercise. `pywebview` from + # requirements.txt is the desktop-launcher dep and pulls GTK / Qt + # system packages on Linux — out of scope for the unittest suite. + run: | + python -m pip install --upgrade pip + python -m pip install 'flask>=3.0' 'fpdf2>=2.7' + + - name: Run unittest suite + run: python -m unittest discover tests -v + + # ── 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 on first run. continue-on-error keeps findings as + # warnings during the surface-cleanup phase; flip to required by removing + # continue-on-error once the surface is clean. + typecheck: + name: Typecheck (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install runtime deps + mypy + run: | + python -m pip install --upgrade pip + python -m pip install 'flask>=3.0' 'fpdf2>=2.7' 'mypy>=1.10' + + - name: Run mypy + # Transitional only (maintainer consensus): keeps CI green until `mypy` exits + # zero on this repo — then delete this line so type errors fail the job. + continue-on-error: true + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + 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