-
Notifications
You must be signed in to change notification settings - Fork 91
270 lines (240 loc) · 9.69 KB
/
Copy pathci.yml
File metadata and controls
270 lines (240 loc) · 9.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
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) }}