Skip to content

CI - tests

CI - tests #2068

Workflow file for this run

name: CI - tests
on:
# Scheduled twice weekly so the test report and badge stay fresh even when
# there is no recent activity.
schedule:
- cron: "0 16 * * 1,4"
# Run post-merge on the integration branches only — pushes to a PR's feature
# branch are already covered by the pull_request event below, so scoping push
# to main/dev avoids running the suite twice for the same commit.
push:
branches: [main, dev]
paths:
- "gget/**"
- "tests/**"
- "pyproject.toml"
# Avoid recursively triggering on the bot-committed pytest report
# and tests badge JSON.
- "!tests/pytest_results.txt"
- "!.github/badges/tests.json"
# Run on every pull request into the integration branches.
pull_request:
branches: [main, dev]
paths:
- "gget/**"
- "tests/**"
- "pyproject.toml"
# Manual runs behave like scheduled runs:
# save output, upload artifact, and commit report back.
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# Derive the test matrix from pyproject.toml ([tool.hatch.envs.hatch-test]),
# so the tested environments are defined in a single place and stay identical
# locally (`hatch test`) and in CI.
get-environments:
runs-on: ubuntu-latest
outputs:
envs: ${{ steps.get-envs.outputs.envs }}
# Highest Python version in the matrix. Used to gate the "canonical"
# per-run artifacts (pytest report commit, badge, fail-job) so they
# automatically follow the newest tested interpreter without hardcoding.
latest_python: ${{ steps.get-envs.outputs.latest_python }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Get test environments from hatch
id: get-envs
run: |
ENVS_JSON=$(uvx hatch env show --json | jq -c 'to_entries
| map(select(.key | startswith("hatch-test")) | { name: .key, python: .value.python })')
# Version-aware max so 3.10 sorts after 3.9, not before.
LATEST_PY=$(echo "$ENVS_JSON" | jq -r '[.[].python] | sort_by(split(".") | map(tonumber)) | last')
echo "envs=${ENVS_JSON}" | tee "$GITHUB_OUTPUT"
echo "latest_python=${LATEST_PY}" | tee -a "$GITHUB_OUTPUT"
test:
needs: get-environments
name: ${{ matrix.env.name }}
runs-on: ubuntu-latest
permissions:
contents: write # commit pytest report back on scheduled/manual runs
id-token: write # codecov OIDC
strategy:
fail-fast: false
matrix:
env: ${{ fromJSON(needs.get-environments.outputs.envs) }}
steps:
- name: Checkout branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.env.python }}
# Builds the environment (project + test dependency-group, plus the
# cellxgene extra only where pyproject says it is available).
- name: Create hatch test environment
run: uvx hatch env create ${{ matrix.env.name }}
# Run tests on every event. The latest-Python leg additionally tees its
# output to tests/pytest_results.txt — the single canonical report that
# README/docs link to and the tests badge is generated from.
# continue-on-error + explicit exit code capture lets the badge / artifact
# / commit steps run even on test failure; the "Fail job if pytest failed"
# step at the bottom enforces the real outcome.
- name: Run tests
id: pytest_run
continue-on-error: true
shell: bash
env:
MPLBACKEND: agg
PY_VERSION: ${{ matrix.env.python }}
LATEST_PY: ${{ needs.get-environments.outputs.latest_python }}
HATCH_ENV: ${{ matrix.env.name }}
run: |
set -o pipefail
if [ "$PY_VERSION" = "$LATEST_PY" ]; then
OUT="tests/pytest_results.txt"
mkdir -p tests
{
echo "Pytest results (Python ${PY_VERSION}) - $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo ""
} > "$OUT"
set +e
uvx hatch run "${HATCH_ENV}":run-cov -ra -v --durations=10 2>&1 | tee -a "$OUT"
code=${PIPESTATUS[0]}
set -e
else
set +e
uvx hatch run "${HATCH_ENV}":run-cov -ra -v --durations=10
code=$?
set -e
fi
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
echo "pytest exit code: $code"
exit 0
# Coverage upload is best-effort: a failure here must not mask the test
# result (which is handled by the steps above/below).
- name: Generate coverage report
if: always()
continue-on-error: true
run: |
test -f .coverage || uvx hatch run ${{ matrix.env.name }}:cov-combine
uvx hatch run ${{ matrix.env.name }}:coverage xml
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v6
with:
use_oidc: true
fail_ci_if_error: false
# Parse the saved pytest summary into a shields.io endpoint JSON so the
# README "tests" badge can render "<passed>/<total> passing". Runs on
# every event for the latest-Python leg; commit-back below is what
# actually publishes the JSON to main.
- name: Generate tests badge JSON
if: always() && matrix.env.python == needs.get-environments.outputs.latest_python
shell: bash
run: |
mkdir -p .github/badges
python - <<'PY'
import json, pathlib, re
txt = pathlib.Path("tests/pytest_results.txt").read_text()
# Final pytest summary line, e.g. "==== 150 passed, 2 failed in 45.67s ===="
summary = next(
(l for l in reversed(txt.splitlines()) if re.match(r"=+ .+ in [\d.]+s.* =+", l)),
"",
)
counts = {"passed": 0, "failed": 0, "error": 0, "skipped": 0, "xfailed": 0, "xpassed": 0}
for n, k in re.findall(r"(\d+) (passed|failed|errors?|skipped|xfailed|xpassed)", summary):
counts["error" if k.startswith("error") else k] = int(n)
total = sum(counts.values())
passed = counts["passed"]
bad = counts["failed"] + counts["error"]
if not total:
msg, color = "no results", "lightgrey"
elif bad == 0:
msg, color = f"{passed}/{total} passing", "brightgreen"
else:
msg = f"{passed}/{total} passing"
color = "yellow" if passed >= 0.9 * total else "red"
pathlib.Path(".github/badges/tests.json").write_text(
json.dumps({"schemaVersion": 1, "label": "tests", "message": msg, "color": color}) + "\n"
)
print(msg, color)
PY
# Upload the saved pytest report as an artifact on every event.
# Only once (latest Python) to avoid duplicate artifacts from the matrix.
- name: Upload pytest results artifact
if: always() && matrix.env.python == needs.get-environments.outputs.latest_python
uses: actions/upload-artifact@v4
with:
name: pytest-results
path: tests/pytest_results.txt
# Commit the saved pytest report + badge JSON back to the repository.
# Safety guards:
# - skipped on pull_request: forks have a read-only token, and we don't
# want bot commits churning PR branches anyway
# - only once (latest Python) to avoid matrix push races
# - commits back to the branch that the run was triggered from
# Retry logic helps if the branch moved while this job was running.
- name: Commit and push pytest results
if: >
always() &&
matrix.env.python == needs.get-environments.outputs.latest_python &&
github.event_name != 'pull_request'
shell: bash
run: |
set -euo pipefail
BRANCH="${GITHUB_REF#refs/heads/}"
echo "Current branch: $BRANCH"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add tests/pytest_results.txt .github/badges/tests.json
if git diff --cached --quiet; then
echo "No changes to commit."
exit 0
fi
git commit -m "CI: update pytest results ($BRANCH)"
for attempt in 1 2 3 4 5; do
echo "Push attempt $attempt..."
git pull --rebase --autostash origin "$BRANCH" || true
if git push origin "$BRANCH"; then
exit 0
fi
sleep $((attempt * 5))
done
echo "Push failed after retries."
exit 1
# Explicitly fail the job if pytest failed. Separated from the pytest
# step itself so that badge / artifact / commit steps still run on failure.
- name: Fail job if pytest failed
if: always()
shell: bash
run: |
code="${{ steps.pytest_run.outputs.exit_code }}"
echo "Captured pytest exit code: ${code:-<missing>}"
if [ -z "${code:-}" ]; then
echo "pytest exit code was not captured"
exit 1
fi
if [ "$code" != "0" ]; then
exit "$code"
fi
# Single gate job so branch protection can require one stable check name
# instead of every matrix entry. See https://github.com/re-actors/alls-green.
check:
name: Tests pass
if: always()
needs:
- get-environments
- test
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}