-
Notifications
You must be signed in to change notification settings - Fork 0
477 lines (431 loc) · 22.9 KB
/
Copy pathci.yml
File metadata and controls
477 lines (431 loc) · 22.9 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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
permissions:
contents: read
pull-requests: read
checks: write
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
REDIS_DISABLE_HIREDIS: true
DEFAULT_PYTHON_VERSION: "3.12"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Lint, format, type check (runs on all events)
quick-check:
name: Format & Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec
# `uv sync` builds the Rust extension via maturin into ./target, and clippy
# below compiles the same workspace — cache the dependency artifacts and the
# cargo registry across runs.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Install dependencies
run: uv sync --python ${{ env.DEFAULT_PYTHON_VERSION }} --group dev
- name: Check Python formatting
run: uv run ruff format --check .
- name: Lint Python
if: success() || failure()
run: uv run ruff check .
- name: Type check Python
if: success() || failure()
run: uv run basedpyright --level error
- name: Check Rust formatting
if: success() || failure()
run: cd rust && cargo fmt --check
# A leftover path dep or [patch.crates-io] redirect would make maturin build
# release wheels from unpublished local source instead of the published crate,
# producing a non-reproducible public wheel. Assert the POSITIVE invariant on
# the lockfile: cachekit-core must resolve to the crates.io registry. A path or
# patch dep has no `source` line, so this one check covers every Cargo.toml form
# (inline table, [dependencies.cachekit-core], [patch.crates-io]) and the
# workspace root — unlike a grep for `path =`, which only caught the inline form.
- name: Guard cachekit-core resolves to crates.io
if: success() || failure()
run: |
src=$(awk '
/^\[\[package\]\]/ { name=""; source="" }
/^name = / { name=$3 }
/^source = / { source=$0 }
name == "\"cachekit-core\"" && source != "" { print source; exit }
' Cargo.lock)
echo "cachekit-core resolved: ${src:-<no source line>}"
if [ "$src" != 'source = "registry+https://github.com/rust-lang/crates.io-index"' ]; then
echo "::error::cachekit-core must resolve to the crates.io registry, not a local path/patch dep"
exit 1
fi
# --locked: fail on a stale Cargo.lock so the build provably resolves
# cachekit-core from crates.io as committed
- name: Lint Rust
if: success() || failure()
run: cd rust && cargo clippy --locked -- -D warnings
# PR: 3.12 only, unit + critical + executable docs
# Push: full matrix, full test suite
test:
name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write # Required for Codecov OIDC
strategy:
fail-fast: false
matrix:
python-version: ${{ github.event_name == 'push' && fromJSON('["3.10", "3.11", "3.12", "3.13", "3.14"]') || fromJSON('["3.12"]') }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Start Redis (no persistence)
run: |
docker run -d --name redis -p 6379:6379 \
redis:7-alpine redis-server --save "" --appendonly no
until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec
cache-suffix: py${{ matrix.python-version }} # setup-uv keys on lockfile hash only, not job/matrix
# pyo3 is built per interpreter (no abi3), so key the Rust cache per matrix
# leg — with one shared key the first leg to save wins and every other leg
# restores foreign pyo3 artifacts and rebuilds anyway (cargo fingerprints
# catch the mismatch), so the cache would thrash instead of hit.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: py${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --python ${{ matrix.python-version }} --group dev
- name: Run tests (PRs)
if: github.event_name == 'pull_request'
env:
REDIS_URL: redis://localhost:6379
run: |
uv run pytest tests/unit/ -m "not slow" \
-n auto \
--cov=src/cachekit \
--cov-report=xml \
--cov-report=term \
--junitxml=junit-unit.xml \
-o junit_family=legacy
# --cov-fail-under evaluates the *combined* unit+critical total (the
# --cov-append accumulation), so it gates this final invocation only, and
# reddens the job in pytest independent of the Codecov upload (rationale:
# residual-risk note below). 82 = lowest same-repo PR cumulative TOTAL
# across the PR matrix minus codecov.yml's declared 2% tolerance, floored;
# keep it in step with the Makefile test-cov floor.
uv run pytest tests/critical/ -m "not slow" \
--cov=src/cachekit \
--cov-append \
--cov-report=xml \
--cov-report=term \
--cov-fail-under=82 \
--junitxml=junit.xml \
-o junit_family=legacy
- name: Run full test suite (main)
if: github.event_name == 'push'
env:
REDIS_URL: redis://localhost:6379
run: |
uv run pytest tests/unit/ -m "not slow" \
-n auto \
--cov=src/cachekit \
--cov-report=xml \
--cov-report=term \
--junitxml=junit-unit.xml \
-o junit_family=legacy
uv run pytest tests/critical/ tests/integration/ -m "not slow" \
--ignore=tests/integration/saas \
--cov=src/cachekit \
--cov-append \
--cov-report=xml \
--cov-report=term \
--junitxml=junit.xml \
-o junit_family=legacy
# Executable docs are tests: docstring examples, tests/docs/ and the docs/
# markdown blocks. None of them sit under tests/unit or tests/critical, so
# the runs above never collect them.
#
# No REDIS_URL here on purpose: the RedisBackendConfig docstring example
# fails `extra_forbidden` when it is exported (#225).
- name: Run docstring examples
if: ${{ !cancelled() && matrix.python-version == '3.12' }}
run: uv run pytest --doctest-modules src/cachekit --ignore=src/cachekit/_rust_serializer.py
- name: Run tests/docs suite
if: ${{ !cancelled() && matrix.python-version == '3.12' }}
env:
REDIS_URL: redis://localhost:6379 # autouse redis-isolation fixture uses external Redis when set (else spawns a binary the runner lacks)
run: uv run pytest tests/docs/
- name: Run markdown documentation examples
if: ${{ !cancelled() && matrix.python-version == '3.12' }}
run: uv run pytest --markdown-docs docs/
# Deterministic memory invariants (RSS/allocation bounds), not wall-clock benchmarks. These
# are `performance and slow` — the mmap zero-copy read (#171) and the large-object allocation
# caps (#152). Flaky timing/throughput benchmarks are `performance` WITHOUT `slow` and stay out
# of CI. Version-independent, so run once on 3.12 (~5s) rather than across the push matrix.
- name: Run memory-invariant tests
if: ${{ !cancelled() && matrix.python-version == '3.12' }}
env:
REDIS_URL: redis://localhost:6379 # autouse redis-isolation fixture uses external Redis when set (else spawns a binary the runner lacks)
run: uv run pytest tests/performance/ -m "performance and slow" -q
# The two uploads below are deliberately treated differently, not defaulted.
# coverage.xml is the ONLY input to the
# project/patch statuses codecov.yml declares, and every flag there sets
# `carryforward: true` — so a silently-dropped upload does not remove the
# status, it answers "is this PR's new code 80% covered?" with a previous
# run's numbers.
#
# Scoped to same-repo events rather than a bare `true`, because of what the
# action actually does on a fork (read at the pinned SHA, not assumed): its
# `Get OIDC token` step is guarded `CC_USE_OIDC == 'true' && CC_FORK != 'true'`,
# so on a fork it never attempts OIDC, `CC_TOKEN` stays empty, and the upload
# proceeds TOKENLESS — Codecov's rate-limited path. A bare `true` would let a
# 429 nobody controls redden an outside contribution, and `CI Success` is a
# required check on `main`, so that 429 would block the merge. The flag
# therefore applies exactly where OIDC really authenticates.
#
# RESIDUAL, now narrowed to the PATCH status: the TOTAL-coverage floor no
# longer depends on the upload — the final PR pytest run enforces it with
# `--cov-fail-under` (above), so a dropped or degraded upload can no longer
# answer the total-coverage question with an earlier commit's carryforward;
# the job reddens inside pytest first, on same-repo and fork PRs alike. What
# Codecov still solely owns is the new-code PATCH status: it is diff-level,
# stays `carryforward: true`, and is not enforced locally, so on a fork PR
# whose tokenless upload is silently dropped it can still answer "is this
# PR's new code 80% covered?" with a previous run's numbers. Accepted: that
# status is a reporting signal, not a merge floor, and the local total floor
# already catches a gross coverage regression on the same run.
# The wrapper's CLI signature check is likewise
# unenforced there (fail_ci_if_error is its switch; see the pin note below).
# Accepted because a fork PR runs under `pull_request` on an ephemeral
# GitHub-hosted runner with no secrets and no OIDC token. The one credential
# an unverified binary there can reach is the job's GITHUB_TOKEN, read-only
# on fork PRs and expired when the job ends, and the runner is discarded
# afterwards, so nothing persists. Fork runs also need maintainer approval,
# and same-repo runs, which verify the binary and fail closed, far outnumber
# them.
#
# Do not re-pin below v7.0.0 (fb8b3582): releases published before Codecov's
# June 2026 keybase migration fetch the CLI signing key from a deleted account,
# so `gpg --verify` can never pass. The wrapper stops there only when
# fail_ci_if_error is true; otherwise it prints "CLI integrity verified" and runs
# the unverified binary. Accepted flip side: a keybase.io outage now fails
# same-repo CI closed, not open.
- name: Upload coverage to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
with:
files: ./coverage.xml
use_oidc: true
fail_ci_if_error: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
flags: ${{ github.event_name == 'push' && 'full' || 'critical' }}-python-${{ matrix.python-version }}
# fail_ci_if_error is true here too, for a different reason: it is also the
# wrapper's signature-enforcement switch (see the pin note above), so false
# would let an unverified binary run. The "don't redden CI" intent lives in
# continue-on-error instead: junit.xml feeds Codecov Test Analytics (flaky-test
# history) only and nothing gates on it, so a Codecov-side outage marks this
# step failed-and-continued and the job stays green.
# That downgrade is visibility only: the wrapper exits before it chmods or runs
# the binary, so a bad signature here still runs no downloaded code. Nor does it hide
# an integrity failure on same-repo events: the coverage step above verifies the
# same CLI, key and checksum hard-fail first, so a signature that cannot pass
# reddens the job there before this step runs.
#
# `handle_no_reports_found` is left at its default (false) on both uploads on
# purpose: it would also swallow "the report was never written", which is the
# silent degradation these settings exist to prevent. On coverage.xml a green job
# that uploaded nothing is a trust bug; on junit.xml continue-on-error already
# accepts a green job, and false there keeps the failed step visible as an
# annotation instead of hiding it.
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
with:
files: ./junit.xml
report_type: test_results
use_oidc: true
flags: ${{ github.event_name == 'push' && 'full' || 'critical' }}-python-${{ matrix.python-version }}
fail_ci_if_error: true
# Free-threaded CPython safety net. Runs the core suites on a
# 3.14 free-threaded build and fails if the GIL gets re-enabled, so code
# whose correctness silently depended on the GIL's memory ordering cannot
# regress unnoticed. Optional native deps without free-threaded wheels
# (orjson, numpy, pandas, pyarrow) are dev-group-only and stay out
# (--group test); hiredis is skipped because it does not declare
# free-threaded support — importing it re-enables the GIL — and redis-py
# transparently falls back to its pure-Python parser.
test-freethreaded:
name: Tests (Python 3.14t, GIL disabled)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Start Redis (no persistence)
run: |
docker run -d --name redis -p 6379:6379 \
redis:7-alpine redis-server --save "" --appendonly no
until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec
cache-suffix: py3.14t
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Install dependencies (test group only)
run: uv sync --python 3.14t --no-default-groups --group test --no-install-package hiredis
- name: Verify the GIL is actually disabled
run: |
uv run --no-sync python -c "
import sys, sysconfig
assert sysconfig.get_config_var('Py_GIL_DISABLED') == 1, 'not a free-threaded build'
import cachekit
import cachekit._rust_serializer
import redis
assert not sys._is_gil_enabled(), 'an import re-enabled the GIL'
print('free-threaded build, GIL still disabled after imports')
"
- name: Run unit + critical suites (GIL-free)
env:
REDIS_URL: redis://localhost:6379
run: |
uv run --no-sync pytest tests/unit/ -m "not slow" -n auto
uv run --no-sync pytest tests/critical/ -m "not slow"
# Version sync + dependency CVE scan (push to main only)
post-merge:
name: Post-Merge Checks
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec
# `uv sync` below builds the Rust extension via maturin — cache its dependencies.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Check Python ↔ Rust version consistency
run: |
PYTHON_VERSION=$(grep -E "^version = " pyproject.toml | head -1 | cut -d'"' -f2)
RUST_VERSION=$(grep -E "^version = " rust/Cargo.toml | head -1 | cut -d'"' -f2)
if [ "$PYTHON_VERSION" != "$RUST_VERSION" ]; then
echo "Version mismatch: Python=$PYTHON_VERSION Rust=$RUST_VERSION"
exit 1
fi
- name: Install dependencies
run: uv sync --python ${{ env.DEFAULT_PYTHON_VERSION }} --group dev
- name: Scan Python dependencies for CVEs
run: |
# No suppressions: every prior CVE is resolved at source on the py3.10+
# resolution. urllib3>=2.7.0 and pip>=26.2 are pinned via
# [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared
# by their py3.10+ fix versions. Keep this list IDENTICAL to
# security-fast.yml's pip-audit so the two cannot drift.
uv run pip-audit --desc
# This repo is public and forkable, so every job runs on
# GitHub-hosted runners, never on self-hosted ones. Allow-list, not
# deny-list: a deny-list fails open on a new label, a casing variant,
# `${{ vars.X }}` indirection or the mapping form `runs-on: {group:, labels:}`.
# The program is the heredoc below; its regression fixtures are
# .github/scripts/runner-drift-guard-selftest.sh, run first so a broken
# guard cannot pass anything. This is drift protection for maintainers, NOT
# a fork-PR control; that lives in repository and org runner settings.
runner-drift-guard:
name: Runner Drift Guard
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# The program stays in this workflow file on purpose: GITHUB_TOKEN cannot
# write .github/workflows/, so a `contents: write` job (release-please) or
# a compromised action could rewrite a script under .github/scripts/ but
# not this heredoc. It is written to a temp file so the self-test and the
# scan run the same bytes.
- name: Write the guard program
run: |
cat > "$RUNNER_TEMP/runner-drift-guard.awk" <<'AWK'
# Allow-list, fail closed: every runs-on value, and every os value
# inside a matrix block, must be one of the exact hosted labels in
# HOSTED or the matrix.os expression. This is a line scanner, not a
# YAML parser: a line that could hide a runner label from it is an
# error, never a skip. Exact labels, not a hosted-prefix pattern (a
# prefix admits any self-hosted label named ubuntu-<anything>) and not
# a copy of GitHub's image table (it rots): adopting another hosted
# image is a deliberate edit to HOSTED.
BEGIN { HOSTED = "^(ubuntu|macos|windows)-latest$" }
FNR == 1 { matrix_indent = -1 }
{ sub(/#.*/, ""); $0 = tolower($0) }
/^[[:space:]]*$/ { next }
# Track the matrix block by indentation: a bare os: is validated only
# there, because the same key names a workflow_dispatch input or an
# action with: parameter, which select no runner. The tracker arms
# only on a bare `matrix:` line — anything else on that line (an
# expression, anchor, alias, tag or flow map) leaves the block unread,
# so the rule below rejects the line instead of guessing what is on it.
{
match($0, /^[[:space:]]*(-[[:space:]]*)?/)
indent = RLENGTH
if (matrix_indent >= 0 && indent <= matrix_indent) matrix_indent = -1
}
/^[[:space:]]*(-[[:space:]]*)?["']?matrix["']?[[:space:]]*:[[:space:]]*$/ { matrix_indent = indent }
# Lines that may hide a runner label: a flow mapping carrying a
# runner/strategy/matrix key; strategy: or matrix: with any inline
# value, include: with one inside a matrix; a YAML merge key or a bare
# alias item; a job calling a reusable workflow in another repo (its
# runs-on is outside this scan; ./.github/workflows/ callees are
# scanned). Accepted false positives, all fail-closed: an env/with key
# named matrix or strategy with an inline value; a flow map with an
# os key.
/[{,][[:space:]]*["']?(runs-on|os|strategy|matrix|include)["']?[[:space:]]*:/ ||
/^[[:space:]]*(-[[:space:]]*)?["']?(strategy|matrix)["']?[[:space:]]*:[[:space:]]*[^[:space:]]/ ||
(/^[[:space:]]*(-[[:space:]]*)?["']?include["']?[[:space:]]*:[[:space:]]*[^[:space:]]/ && matrix_indent >= 0) ||
/^[[:space:]]*(-[[:space:]]*)?(<<[[:space:]]*:|\*[^*[:space:]])/ ||
(/^[[:space:]]*uses[[:space:]]*:/ && /\/\.github\/workflows\// && !/uses[[:space:]]*:[[:space:]]*["']?\.\//) {
printf "::error file=%s,line=%d::line may hide a runner label from the drift guard — write `runs-on: <label>` / `os: [<labels>]` inline; keep `strategy:`, `matrix:`, `include:` as bare keys over a static block (no expressions, anchors, aliases or flow maps); call reusable workflows only from ./.github/workflows/\n", FILENAME, FNR
bad = 1
}
/^[[:space:]]*(-[[:space:]]*)?["']?runs-on["']?[[:space:]]*:/ ||
(/^[[:space:]]*(-[[:space:]]*)?["']?os["']?[[:space:]]*:/ && matrix_indent >= 0) {
v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v)
if (v ~ /^["']?\$\{\{[[:space:]]*matrix\.os[[:space:]]*\}\}["']?$/) next
if (v ~ /^\[/ && v !~ /\]$/) v = ""
gsub(/[][ "']/, "", v)
n = split(v, items, ",")
ok = (v != "")
for (i = 1; i <= n; i++) if (items[i] !~ HOSTED) ok = 0
if (!ok) {
printf "::error file=%s,line=%d::runner value \"%s\" is not an allow-listed GitHub-hosted label — this public repo runs only on GitHub-hosted runners; adopting a new hosted image means extending HOSTED in the runner-drift-guard job of ci.yml\n", FILENAME, FNR, (v == "" ? "<value not inline on this line>" : v)
bad = 1
}
}
END { exit bad }
AWK
- name: Guard self-test (regression fixtures)
run: bash .github/scripts/runner-drift-guard-selftest.sh "$RUNNER_TEMP/runner-drift-guard.awk"
# One awk pass, no shell pipeline: a program error exits non-zero and fails
# the step (fail closed) instead of reading as "no match".
- name: Every runs-on / matrix os value is an allow-listed GitHub-hosted label
run: awk -f "$RUNNER_TEMP/runner-drift-guard.awk" .github/workflows/*.y*ml
# Summary job (required for branch protection)
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [quick-check, test, test-freethreaded, runner-drift-guard]
if: always()
steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.quick-check.result }}" != "success" ]] || \
[[ "${{ needs.test.result }}" != "success" ]] || \
[[ "${{ needs.test-freethreaded.result }}" != "success" ]] || \
[[ "${{ needs.runner-drift-guard.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
echo "All CI checks passed"