CI workflow (scverse/scanpy)
The CI workflow from scverse/scanpy, explained and optimized by Latchkey.
CI health: F - at risk
Point runs-on at Latchkey and get caching, run de-duplication, job timeouts, SHA-pinned actions, self-healing for flaky steps, and up to 58% lower cost, applied automatically.
What it does
This is the CI workflow from the scverse/scanpy repository, a real project running GitHub Actions. It is shown here with attribution under its BSD-3-Clause license.
Below, Latchkey shows a faster, safer version produced by its optimization engine.
The workflow
name: CI
on:
push:
branches:
- main
- "[0-9]+.[0-9]+.x"
pull_request:
env:
PYTEST_ADDOPTS: "-v --color=yes -n auto --internet-tests --junitxml=test-data/test-results.xml"
FORCE_COLOR: "1"
MPLBACKEND: agg
# It’s impossible to ignore SyntaxWarnings for a single module,
# so because leidenalg 0.10.0 has them, we pre-compile things: https://github.com/vtraag/leidenalg/issues/173
UV_COMPILE_BYTECODE: "1"
jobs:
get-environments:
runs-on: ubuntu-latest
outputs:
envs: ${{ steps.get-envs.outputs.envs }}
steps:
- uses: actions/checkout@v7
with: { filter: 'blob:none', fetch-depth: 0 }
- uses: astral-sh/setup-uv@v8.3.2
with: { enable-cache: false }
- id: get-envs
run: |
ENVS_JSON=$(NO_COLOR=1 uvx hatch env show --json | jq -c 'to_entries
| map(
select(.key | startswith("hatch-test"))
| {
name: .key,
"test-type": (if (.key | test("pre|low-vers")) then "coverage" else null end),
python: .value.python
}
)')
echo "envs=${ENVS_JSON}" | tee $GITHUB_OUTPUT
test:
needs: get-environments
runs-on: ubuntu-latest
strategy:
matrix:
env: ${{ fromJSON(needs.get-environments.outputs.envs) }}
permissions:
id-token: write # for codecov OIDC
steps:
- uses: actions/checkout@v7
with: { filter: 'blob:none', fetch-depth: 0 }
- uses: astral-sh/setup-uv@v8.3.2
with:
python-version: ${{ matrix.env.python }}
- name: Cache downloaded data
uses: actions/cache@v6
with:
path: .pytest_cache/d/scanpy-data
key: pytest
- name: Install dependencies
run: |
echo "::group::Install hatch"
uv tool install hatch
echo "::endgroup::"
echo "::group::Create environment"
hatch -v env create ${{ matrix.env.name }}
echo "::endgroup::"
hatch run ${{ matrix.env.name }}:session-info scanpy anndata
- name: Run tests
if: matrix.env.test-type == null
run: hatch run ${{ matrix.env.name }}:run
- name: Run tests (coverage)
if: matrix.env.test-type == 'coverage'
run: |
hatch run ${{ matrix.env.name }}:run-cov
hatch run ${{ matrix.env.name }}:cov-combine
hatch run ${{ matrix.env.name }}:coverage xml
hatch run ${{ matrix.env.name }}:cov-report
- name: Upload coverage data
if: ${{ !cancelled() && matrix.env.test-type == 'coverage' }}
uses: codecov/codecov-action@v6
with:
flags: ${{ matrix.env.name }}
files: test-data/coverage.xml
use_oidc: true
fail_ci_if_error: true
- name: Upload test results
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v6
with:
report_type: test_results
flags: ${{ matrix.env.name }}
files: test-data/test-results.xml
use_oidc: true
fail_ci_if_error: true
- name: Publish debug artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
with:
name: debug-data-${{ matrix.env.name }}
path: .pytest_cache/d/debug
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { filter: 'blob:none', fetch-depth: 0 }
- uses: actions/setup-python@v6
with:
python-version: "3.x"
- uses: astral-sh/setup-uv@v8.3.2
with:
enable-cache: true
- run: uvx --from build pyproject-build --sdist --wheel .
- run: uvx twine check dist/*
check:
if: always()
needs:
- get-environments
- test
- build
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
The same workflow, on Latchkey
Estimated ~20% faster on cache hits, plus fewer wasted runs and a safer supply chain. Added and changed lines are highlighted.
name: CI on: push: branches: - main - "[0-9]+.[0-9]+.x" pull_request: env: PYTEST_ADDOPTS: "-v --color=yes -n auto --internet-tests --junitxml=test-data/test-results.xml" FORCE_COLOR: "1" MPLBACKEND: agg # It’s impossible to ignore SyntaxWarnings for a single module, # so because leidenalg 0.10.0 has them, we pre-compile things: https://github.com/vtraag/leidenalg/issues/173 UV_COMPILE_BYTECODE: "1" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: get-environments: timeout-minutes: 30 runs-on: latchkey-small outputs: envs: ${{ steps.get-envs.outputs.envs }} steps: - uses: actions/checkout@v7 with: { filter: 'blob:none', fetch-depth: 0 } - uses: astral-sh/setup-uv@v8.3.2 with: { enable-cache: false } - id: get-envs run: | ENVS_JSON=$(NO_COLOR=1 uvx hatch env show --json | jq -c 'to_entries | map( select(.key | startswith("hatch-test")) | { name: .key, "test-type": (if (.key | test("pre|low-vers")) then "coverage" else null end), python: .value.python } )') echo "envs=${ENVS_JSON}" | tee $GITHUB_OUTPUT test: timeout-minutes: 30 needs: get-environments runs-on: latchkey-small strategy: matrix: env: ${{ fromJSON(needs.get-environments.outputs.envs) }} permissions: id-token: write # for codecov OIDC steps: - uses: actions/checkout@v7 with: { filter: 'blob:none', fetch-depth: 0 } - uses: astral-sh/setup-uv@v8.3.2 with: python-version: ${{ matrix.env.python }} - name: Cache downloaded data uses: actions/cache@v6 with: path: .pytest_cache/d/scanpy-data key: pytest - name: Install dependencies run: | echo "::group::Install hatch" uv tool install hatch echo "::endgroup::" echo "::group::Create environment" hatch -v env create ${{ matrix.env.name }} echo "::endgroup::" hatch run ${{ matrix.env.name }}:session-info scanpy anndata - name: Run tests if: matrix.env.test-type == null run: hatch run ${{ matrix.env.name }}:run - name: Run tests (coverage) if: matrix.env.test-type == 'coverage' run: | hatch run ${{ matrix.env.name }}:run-cov hatch run ${{ matrix.env.name }}:cov-combine hatch run ${{ matrix.env.name }}:coverage xml hatch run ${{ matrix.env.name }}:cov-report - name: Upload coverage data if: ${{ !cancelled() && matrix.env.test-type == 'coverage' }} uses: codecov/codecov-action@v6 with: flags: ${{ matrix.env.name }} files: test-data/coverage.xml use_oidc: true fail_ci_if_error: true - name: Upload test results if: ${{ !cancelled() }} uses: codecov/codecov-action@v6 with: report_type: test_results flags: ${{ matrix.env.name }} files: test-data/test-results.xml use_oidc: true fail_ci_if_error: true - name: Publish debug artifacts if: ${{ !cancelled() }} uses: actions/upload-artifact@v5 with: name: debug-data-${{ matrix.env.name }} path: .pytest_cache/d/debug build: timeout-minutes: 30 runs-on: latchkey-small steps: - uses: actions/checkout@v7 with: { filter: 'blob:none', fetch-depth: 0 } - uses: actions/setup-python@v6 with: cache: 'pip' python-version: "3.x" - uses: astral-sh/setup-uv@v8.3.2 with: enable-cache: true - run: uvx --from build pyproject-build --sdist --wheel . - run: uvx twine check dist/* check: timeout-minutes: 30 if: always() needs: - get-environments - test - build runs-on: latchkey-small steps: - uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }}
What changed
- Run on Latchkey managed runners with one line (
runs-on), which apply the fixes below automatically and self-heal transient failures. This example useslatchkey-small; pick the runner size that fits the job. - Cancel superseded runs when a branch or PR gets a newer push.
- Cache dependency installs on the setup step so they are served from cache.
- Add a job timeout so a hung step cannot burn hours of runner time.
3 third-party actions are referenced by a movable tag. Pin them to the commit SHA (Latchkey resolves and applies this automatically) so a repointed tag cannot change what runs.
This workflow runs 4 jobs per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.