-
Notifications
You must be signed in to change notification settings - Fork 0
437 lines (421 loc) · 19.5 KB
/
Copy pathci.yml
File metadata and controls
437 lines (421 loc) · 19.5 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
name: CI
# COM09 quality gates for the backtest-engine repository.
#
# One job per gate, each named `gate<N>-...`, so a red check names the gate that
# failed without opening the log. No job uses `continue-on-error`: a gate that
# cannot be fully satisfied yet is scoped down *visibly*, in a comment here and
# with a `::warning` annotation in the run, never silenced.
#
# This mirrors the gate structure already in place in the data-pipeline
# repository. The two repositories are a producer/consumer pair and the audit
# found their contract fixtures had silently drifted apart, so the gates are
# deliberately kept symmetrical rather than each repo inventing its own.
#
# gate1 lint ruff
# gate2 types mypy
# gate3 unit tests + coverage pytest (Docker-free suite)
# gate4 contract validation JSON Schema round-trip over the fixtures
# gate5 app boot smoke API and worker import without a database
# gate6 migration contribution COM07 contribution-root contract
# gate7 dependency + secret scan pip-audit + TruffleHog
# gate8 DB integration Testcontainers PostgreSQL 16
# gate9 object-store integration LocalStack S3
on:
pull_request:
branches: [develop]
push:
branches: [develop, main]
permissions:
contents: read
concurrency:
group: backtest-ci-${{ github.ref }}
cancel-in-progress: true
env:
# COM02 pins the runtime to CPython 3.12.13. Do not relax this to "3.12":
# the data-pipeline README drifted to 3.11 unnoticed for months.
PYTHON_VERSION: "3.12.13"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
jobs:
# ---------------------------------------------------------------- gate 1
gate1-lint:
name: gate1-lint (ruff)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: ruff check
run: python -m ruff check src tests --output-format=github
# Format enforcement is scoped to the code this rebuild owns. The
# pre-canonical modules predate any formatter config; reformatting them
# wholesale would bury the real diffs. Widen this list as each card lands.
- name: ruff format (rebuilt code)
run: >-
python -m ruff format --check
src/backtest_engine/persistence tests/conftest.py tests/persistence
# ---------------------------------------------------------------- gate 2
gate2-types:
name: gate2-types (mypy)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: mypy
run: python -m mypy
# ---------------------------------------------------------------- gate 3
gate3-unit-tests:
name: gate3-unit-tests (pytest + coverage)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[test]"
# PYTHONUNBUFFERED and `--timeout` exist because this gate once died at the
# 20-minute job limit having printed *nothing*: pytest block-buffers when
# stdout is a pipe, so the kill discarded every line and the log named no
# test. The same command finishes locally in ~16s. Unbuffered output plus
# a per-test timeout turn a hang into a named test with a stack trace.
- name: pytest (Docker-free suite)
env:
PYTHONUNBUFFERED: "1"
run: >-
python -m pytest -p no:cacheprovider -m "not docker"
--timeout=120 --timeout-method=thread
--cov=backtest_engine --cov-report=term-missing --cov-report=xml
- name: Upload coverage
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-backtest-engine
path: coverage.xml
if-no-files-found: warn
# ---------------------------------------------------------------- gate 4
gate4-contracts:
name: gate4-contracts (JSON Schema)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: Contract tests
run: python -m pytest tests/test_contracts.py -p no:cacheprovider -q
# The audit found two copies of the COM06 fixture that had already drifted:
# the producer's own fixture was rejected by the consumer's validator.
# A consumer-side duplicate of a producer-owned schema is therefore banned
# outright rather than kept in sync by hand.
- name: No consumer-side duplicate of a producer-owned schema
run: |
set -euo pipefail
if find tests/fixtures/contracts -name 'com06*' -print | grep -q .; then
echo "::error title=gate4::A com06.* fixture survives; that flat snake_case shape is not published by anyone"
exit 1
fi
echo "no resurrected com06 fixture"
# ---------------------------------------------------------------- gate 5
gate5-app-smoke:
name: gate5-app-smoke (entry points load without a database)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install project (validates packaging and console entry points)
run: python -m pip install -e .
# Import-only: it must be possible to load the API app, the worker and the
# persistence layer without a database, because the runtime must not touch
# one at import time.
- name: Import the entry points
run: |
python -c "import backtest_engine.api"
python -c "import backtest_engine.worker"
python -c "import backtest_engine.orchestrator"
python -c "from backtest_engine.persistence import METADATA; print(sorted(METADATA.tables))"
# The audit's headline finding was a 26-line worker whose docstring admitted
# "Domain execution is intentionally absent", and an orchestrator that existed
# only inside a test file. Neither may come back.
- name: The worker is not a stub
run: |
set -euo pipefail
if grep -qi 'intentionally absent' src/backtest_engine/worker.py; then
echo "::error title=gate5::worker.py still declares its domain execution absent"
exit 1
fi
lines=$(grep -cve '^\s*$' src/backtest_engine/worker.py)
echo "worker.py non-blank lines: $lines"
test "$lines" -gt 60
- name: The orchestrator lives in src, not in a test file
run: |
set -euo pipefail
test -f src/backtest_engine/orchestrator.py
echo "orchestrator present in src"
# ---------------------------------------------------------------- gate 6
gate6-migration-contribution:
name: gate6-migration-contribution (COM07)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[dev]"
- name: Contribution contract tests
run: >-
python -m pytest tests/persistence/test_migration_contribution.py
tests/persistence/test_central_migration_fixture.py -p no:cacheprovider -q
- name: No repository-local Flyway execution
# COM07: owner repositories contribute SQL; only backend/db-migration
# assembles and applies it. A local Flyway config or a `V001__` file
# means someone reintroduced a private migration path.
run: |
set -euo pipefail
if find . -path ./.git -prune -o \
\( -name 'flyway.conf' -o -name 'flyway.toml' \) -print | grep -q .; then
echo "::error title=gate6::A Flyway configuration exists in this repository"
exit 1
fi
if find . -path ./.git -prune -o -name 'V[0-9][0-9][0-9]__*.sql' -print | grep -q .; then
echo "::error title=gate6::Legacy V001-style migration filenames are rejected centrally"
exit 1
fi
echo "no repository-local migration execution found"
# D writes storage, market_data and backtest only. `storage` is registered
# SHARED in DatabaseAccessPolicy, so this repository contributes no DDL for
# it: the ownership contradiction is tracked as a separate issue, not
# resolved by quietly shipping a table.
- name: Contributed DDL touches only the backtest schema
run: |
set -euo pipefail
shopt -s nullglob
files=(db/migration-contributions/migrations/*.sql)
if [ ${#files[@]} -eq 0 ]; then
echo "::warning title=gate6::No contributed migration yet; nothing to scope-check"
exit 0
fi
if grep -Ein '\b(identity|strategy|bot|competition|performance|trading|operations|storage)\.' "${files[@]}"; then
echo "::error title=gate6::Contributed DDL references a schema this repository does not own"
exit 1
fi
echo "contributed DDL is scoped to backtest"
# ---------------------------------------------------------------- gate 7
gate7-dependency-and-secret-scan:
name: gate7-dependency-and-secret-scan
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# TruffleHog needs history to scan every commit on the branch.
fetch-depth: 0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install project and audit tooling
run: |
python -m pip install -e .
python -m pip install pip-audit
- name: pip-audit
# Fails when any installed dependency has a known advisory. `--strict`
# is deliberately omitted: the editable local project is not on PyPI and
# would be reported as unauditable, which is noise, not a finding.
run: pip-audit --progress-spinner off --desc
- name: Secret scan (TruffleHog, verified findings only)
uses: trufflesecurity/trufflehog@6f3c981e7b77f235fd2702dd74af25fc4b72bf11 # v3.96.0
with:
path: ./
# PRs scan only commits introduced by the head branch; develop pushes
# scan only the pushed range. Scanning from an empty base traverses
# unrelated historical fixtures and repeatedly reports old secrets.
base: "${{ github.event.pull_request.base.sha || github.event.before }}"
head: "${{ github.event.pull_request.head.sha || github.sha }}"
# No `--fail` here: the action already passes it, and trufflehog
# rejects the repeated flag with
# "flag 'fail' cannot be repeated", which fails the gate on argument
# parsing before it scans anything.
extra_args: --results=verified
- name: No committed .env or credential files
run: |
set -euo pipefail
if git ls-files | grep -E '(^|/)\.env($|\.)' | grep -v '\.env\.example$'; then
echo "::error title=gate7::A .env file is tracked in git"
exit 1
fi
if git ls-files | grep -E '\.(pem|p12|pfx|keystore)$'; then
echo "::error title=gate7::A key material file is tracked in git"
exit 1
fi
echo "no credential files tracked"
# ---------------------------------------------------------------- gate 8
gate8-db-integration:
name: gate8-db-integration (PostgreSQL 16 + LocalStack)
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 30
# LocalStack as well as PostgreSQL, because `-m docker` is a single set that
# spans both: the worker's SQS tests and the end-to-end test need a queue and
# an object store, not just a database. Without this the queue tests skipped
# here - and their default endpoint is a developer's local container port, so
# they passed on that machine and covered nothing in CI.
services:
localstack:
image: localstack/localstack:4.7.0
env:
SERVICES: s3,sqs
EAGER_SERVICE_LOADING: "1"
AWS_DEFAULT_REGION: us-east-1
ports:
- 4566:4566
options: >-
--health-cmd "curl -sf http://localhost:4566/_localstack/health || exit 1"
--health-interval 10s --health-timeout 5s --health-retries 12
# `tests/conftest.py` starts its own PostgreSQL 16 container through
# Testcontainers and applies the CENTRAL Flyway bundle to it, so there is no
# `services:` block here: a second, differently-migrated server would only
# invite tests to point at the wrong one.
#
# That harness *skips* when Docker is unavailable, and a skip is a silent
# pass. The last step therefore asserts, from the JUnit report, that the
# integration tests actually executed and that none of them skipped.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[test]"
- name: Docker is available
run: docker version
- name: Pre-pull the database image
run: docker pull postgres:16-alpine
- name: Vendored central bundle matches its recorded digests
run: |
set -euo pipefail
cd db/migration-contributions/fixtures/central-migration
sha256sum -c ../central-migration.sha256
# The container is migrated with the vendored byte-for-byte copy of the
# applied central Flyway bundle, so this proves the persistence layer
# against the canonical schema and not against a restatement of it.
- name: Integration tests (PostgreSQL 16 via Testcontainers, queue via LocalStack)
env:
# tests/test_worker.py defaults to a developer's local LocalStack port
# and skips when it is absent. Point it at the service container so the
# real SQS coverage actually executes here.
BACKTEST_TEST_SQS_ENDPOINT: http://localhost:4566
LOCALSTACK_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
run: >-
python -m pytest -m docker -p no:cacheprovider -rs
--junitxml=integration-results.xml
- name: Integration tests actually ran (a skip is not a pass)
if: always()
run: |
python - <<'PY'
import sys
import xml.etree.ElementTree as ET
root = ET.parse("integration-results.xml").getroot()
suites = root.findall("testsuite") or [root]
total = sum(int(s.get("tests", 0)) for s in suites)
skipped = sum(int(s.get("skipped", 0)) for s in suites)
failures = sum(int(s.get("failures", 0)) for s in suites)
errors = sum(int(s.get("errors", 0)) for s in suites)
print(
f"integration tests: {total}, skipped: {skipped}, "
f"failures: {failures}, errors: {errors}"
)
if total == 0:
sys.exit("gate8: no integration test executed")
# Report failures and errors too, not just skips: a message that names
# only the skip count invites the reader to fix the wrong thing.
if errors:
sys.exit(f"gate8: {errors} integration test(s) errored")
if failures:
sys.exit(f"gate8: {failures} integration test(s) failed")
if skipped:
sys.exit(
f"gate8: {skipped} integration test(s) skipped - Docker, LocalStack or "
"the central migration bundle was unavailable, so the DB gate proved nothing"
)
PY
# ---------------------------------------------------------------- gate 9
gate9-localstack-storage:
name: gate9-localstack-storage (S3 object store)
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
services:
localstack:
image: localstack/localstack:4.7.0
env:
SERVICES: s3,sqs
ports:
- 4566:4566
options: >-
--health-cmd "curl -sf http://localhost:4566/_localstack/health || exit 1"
--health-interval 10s --health-timeout 5s --health-retries 12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install
run: python -m pip install -e ".[test]"
# The local and S3 adapters must satisfy one identical contract: same key,
# same checksum, same metadata semantics. The same contract test therefore
# runs against both backends, and the S3 leg must not skip here.
- name: Object-store contract against LocalStack S3
env:
LOCALSTACK_ENDPOINT_URL: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
run: >-
python -m pytest tests/test_object_store.py tests/test_feature_outputs.py
-m docker -p no:cacheprovider -rs
--junitxml=objectstore-results.xml
- name: The S3 leg actually ran (a skip is not a pass)
if: always()
run: |
python - <<'PY'
import sys
import xml.etree.ElementTree as ET
root = ET.parse("objectstore-results.xml").getroot()
suites = root.findall("testsuite") or [root]
total = sum(int(s.get("tests", 0)) for s in suites)
skipped = sum(int(s.get("skipped", 0)) for s in suites)
print(f"object-store tests: {total}, skipped: {skipped}")
if total == 0:
sys.exit("gate9: no object-store test executed")
if skipped:
sys.exit(f"gate9: {skipped} object-store test(s) skipped - the S3 leg proved nothing")
PY