-
Notifications
You must be signed in to change notification settings - Fork 0
463 lines (412 loc) · 18.5 KB
/
Copy pathci.yml
File metadata and controls
463 lines (412 loc) · 18.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
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
# CI for leaflet.2plot.dev AND for the dash-leaflet2 PyPI package.
#
# This repo is unusual among the 2plot satellites: it ships two artifacts from
# one tree. The network's CI baseline (copied from
# dash-documentation-boilerplate, which 2plot.ai and 2plot.dev also run) covers
# the documentation site; the `package*` jobs below are this repo's own and
# cover the wheel. Both have to stay green.
#
# The network baseline, and why each piece is here:
#
# * least-privilege `permissions` and a cancel-in-progress `concurrency`
# group, so a workflow cannot write more than it reads and a rapid second
# push does not race the first;
# * an explicit `timeout-minutes` on every job — the default is six hours,
# which is how a hung `curl` burns a day of runner minutes unnoticed;
# * `actionlint`, because an invalid workflow file is the one defect CI
# structurally cannot report: the run dies before a job exists to fail;
# * the real Docker image, built with a buildx GHA cache, then BOOTED, then
# probed by the same battery that runs against production;
# * version fingerprints asserted INSIDE the image, because pip metadata is
# invisible from the outside and a stale artifact serves quietly;
# * a secretless in-process pytest suite — no CLERK_*, no
# CROSS_APP_WEBHOOK_SECRET — because the fail-closed behaviour is only
# provable when nothing is configured;
# * an advisory pip-audit.
name: CI
# Deliberately NOT `push: branches: [main]`. cd.yml runs on that push and its
# first job `uses:` this workflow, so a push to main would otherwise start two
# runs of it — which then contend for the `ci-${{ github.ref }}` concurrency
# group below and cancel each other. The work still gets done, but every push
# leaves a `cancelled` CI run next to the green CD one, which reads as a
# failure at a glance.
#
# So: pull requests get their own CI, and `main` is owned by CD. There is no
# coverage gap — CD cannot deploy without this workflow passing first.
on:
pull_request:
workflow_dispatch:
# Called by cd.yml so a deploy can never ship something the matrix rejected.
workflow_call:
# Read-only. Nothing here publishes, comments or tags; the deploy lives in
# cd.yml behind a `production` environment, and the PyPI release in release.yml.
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
FORCE_COLOR: "1"
# Never let a CI run inherit production behaviour: the satellite reporter
# keys off CROSS_APP_WEBHOOK_SECRET, which is absent here by design.
APP_ENV: ci
jobs:
lint:
name: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install flake8
- name: flake8
run: flake8 lib components pages tests scripts run.py usage.py
# The workflows lint themselves. This is not belt-and-braces: an invalid
# workflow file is the one defect CI structurally cannot report, because
# the run dies before a job exists to fail. A double-quoted string inside
# a ${{ }} expression is a LEX error that invalidates the whole file, and
# it surfaces only as `conclusion: failure` with zero jobs and nothing to
# click. actionlint catches it in a second, with the column underlined.
- name: actionlint
run: |
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) 1.7.7
./actionlint -color
test:
name: pytest · ${{ matrix.backend }} · py${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
# Flask is what production runs (the Dockerfile sets DASH_BACKEND);
# FastAPI is run.py's local default, so both need coverage. The
# before_request ordering that makes bot_hits countable is a WSGI
# concern, which is exactly why Flask cannot be the only backend here.
python: ["3.12"]
backend: [flask, fastapi]
include:
# The docs site's Python floor and ceiling, on the default backend.
# 3.10 is the floor: python-frontmatter 1.3 imports typing.TypeGuard.
- python: "3.10"
backend: flask
- python: "3.13"
backend: flask
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- name: Install the app
run: |
pip install -r requirements.txt
# markdown2dash 0.1.2 declares gunicorn<22 against the CVE-driven
# gunicorn>=23 floor. Same two-command install as the Dockerfile.
pip install --no-deps markdown2dash==0.1.2
# Dash's own extras are required for the ASGI backends: a bare
# `fastapi` install is not enough for dash.backends._fastapi to
# import. httpx backs starlette's TestClient.
if [ "${{ matrix.backend }}" != "flask" ]; then
pip install "dash[${{ matrix.backend }}]" httpx
pip install "dash-improve-my-llms[${{ matrix.backend }}]>=2.3.4"
fi
pip install pytest
- name: Confirm the pinned dependency versions
run: |
python - <<'PY'
import dash, dash_improve_my_llms as pkg, gunicorn
def parts(v):
return tuple(int(x) for x in v.split(".")[:3] if x.isdigit())
# The docs site pins 4.4.1; the PACKAGE floor (dash>=4.1) is proven
# separately by the `package-python-range` job below.
assert parts(dash.__version__)[:2] >= (4, 1), dash.__version__
# 2.3.4 is the network standard: below it `resolve_site_title` does
# not exist and this site's published identity degrades to app.title.
assert parts(pkg.__version__) >= (2, 3, 4), pkg.__version__
# 21.x carried two request-smuggling CVEs (CVE-2024-6827,
# CVE-2024-1135). markdown2dash's spurious <22 pin must not win.
assert parts(gunicorn.__version__)[:2] >= (23, 0), gunicorn.__version__
print(f"dash {dash.__version__}, dash-improve-my-llms "
f"{pkg.__version__}, gunicorn {gunicorn.__version__}")
PY
# No CLERK_*, no CROSS_APP_WEBHOOK_SECRET, no SESSION_SECRET here ON
# PURPOSE. tests/conftest.py pins them empty and the fail-closed checks
# depend on that posture; a secret injected here would make the suite
# pass for the wrong reason.
- name: Test suite (${{ matrix.backend }}, zero secrets)
env:
DASH_BACKEND: ${{ matrix.backend }}
run: pytest tests -q
- name: Boot under a production server
if: matrix.backend == 'flask'
run: |
gunicorn run:server -b 127.0.0.1:8050 --daemon --access-logfile - --error-logfile -
for _ in $(seq 1 30); do
curl -sf http://127.0.0.1:8050/healthz && break
sleep 1
done
# A page that renders under the test client can still fail under a
# real WSGI worker — different import path, different working
# directory, no test-client conveniences.
curl -sf http://127.0.0.1:8050/ > /dev/null
curl -sf http://127.0.0.1:8050/pointer-events > /dev/null
# The battery, against the same server a satellite deploys.
python3 scripts/network_smoke.py --base-url http://127.0.0.1:8050
docs-compat:
name: Docs · Dash ${{ matrix.dash }} · Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# The floor, the two intermediate minors, and the current release.
dash: ["4.1.0", "4.2.0", "4.3.0", "4.4.1"]
python: ["3.12"]
include:
# The docs-site Python range, against the current Dash. A full
# cross-product would be 16 jobs for very little extra signal.
- dash: "4.4.1"
python: "3.10"
- dash: "4.4.1"
python: "3.13"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
# node is what makes the clientside-JS syntax check real. Without it the
# smoke test skips that check rather than failing, so a broken
# clientside_callback would sail through — which is exactly how the
# light/dark tile swaps shipped invalid JS once already.
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Dash ${{ matrix.dash }} first
# Install the version under test BEFORE the rest, so the other
# requirements resolve against it rather than dragging in a newer Dash.
run: |
python -m pip install --upgrade pip
python -m pip install "dash[fastapi]==${{ matrix.dash }}"
- name: Install documentation-site requirements
# The pinned Dash line is stripped so it cannot override the matrix
# version. Same rule as scripts/compat_matrix.py.
run: |
grep -v 'COMPAT-MATRIX: dash' requirements.txt > /tmp/reqs.txt
python -m pip install -r /tmp/reqs.txt
python -m pip install --no-deps markdown2dash==0.1.2
- name: Report the resolved Dash version
# A silent upgrade here would make the whole matrix meaningless.
run: |
RESOLVED=$(python -c "import dash; print(dash.__version__)")
echo "requested=${{ matrix.dash }} resolved=$RESOLVED"
if [ "$RESOLVED" != "${{ matrix.dash }}" ]; then
echo "::warning::Dash resolved to $RESOLVED, not ${{ matrix.dash }}"
fi
- name: Smoke test
run: python scripts/smoke_test.py --json smoke-${{ matrix.dash }}-py${{ matrix.python }}.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: smoke-${{ matrix.dash }}-py${{ matrix.python }}
path: smoke-*.json
if-no-files-found: ignore
docker:
name: docker image · boot · battery
runs-on: ubuntu-latest
timeout-minutes: 25
needs: [test]
steps:
- uses: actions/checkout@v4
# The same build Render runs. This is where a dependency-resolution
# failure surfaces — at CI time, not deploy time, where the only signal
# is a dashboard log while the old image keeps serving.
- uses: docker/setup-buildx-action@v3
- name: Build the production image
uses: docker/build-push-action@v6
with:
context: .
tags: dash-leaflet2-docs:ci
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
# pip metadata is invisible from outside a running host, so the versions
# are asserted here, inside the artifact that actually ships.
- name: Version fingerprints inside the image
run: |
docker run --rm dash-leaflet2-docs:ci python -c "
from importlib.metadata import version
def parts(v):
return tuple(int(x) for x in v.split('.')[:3] if x.isdigit())
v = version('dash')
print('dash', v)
assert parts(v)[:2] >= (4, 1), f'expected dash >=4.1, image has {v}'
v = version('dash-improve-my-llms')
print('dash-improve-my-llms', v)
assert parts(v) >= (2, 3, 4), f'expected >=2.3.4 (resolve_site_title), image has {v}'
# markdown2dash installs with --no-deps to dodge its gunicorn<22
# pin; this assert is what proves the dodge kept working. 21.x
# carried two request-smuggling CVEs (CVE-2024-6827, CVE-2024-1135).
v = version('gunicorn')
print('gunicorn', v)
assert parts(v)[:2] >= (23, 0), f'expected gunicorn>=23, image has {v}'
# ...and that skipping its dependency graph did not skip the package.
import markdown2dash # noqa: F401
print('markdown2dash importable')
"
# Boot with no secrets: Clerk falls open (dev mode) and the reporter
# stays dormant. What this catches is any import-time or preload crash —
# the class of failure where the platform loops the worker and the deploy
# never goes live.
- name: Boot the container and wait for /healthz
run: |
docker run -d --name docs -p 8050:8050 dash-leaflet2-docs:ci
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:8050/healthz > /dev/null; then
echo "healthy after ~$((i*2))s"
exit 0
fi
if [ "$(docker inspect -f '{{.State.Running}}' docs)" != "true" ]; then
echo "container exited during boot:"
docker logs docs
exit 1
fi
sleep 2
done
echo "never became healthy; last logs:"
docker logs --tail 100 docs
exit 1
# The SAME script CD runs against https://leaflet.2plot.dev, so a failure
# in CI and a failure in production read identically.
- name: Smoke battery against the booted container
run: python3 scripts/network_smoke.py --base-url http://127.0.0.1:8050
- name: Container logs (for the record)
if: always()
run: docker logs --tail 40 docs 2>/dev/null || true
package:
name: Build + verify the wheel
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
# check_release.py compares the git commit times of the bundle and
# src/ts. A shallow clone can omit the commit that last touched one
# of them, which turns the check into a false "no git history" skip.
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Release consistency
# Version drift across pyproject / package.json / package-info.json,
# a stale bundle, packaging leaks. None of these break a test run.
run: python scripts/check_release.py
- name: Build
run: |
python -m pip install --upgrade pip build twine
python -m build
- name: Check metadata
run: python -m twine check dist/*
- name: Install the wheel in a clean venv and import it
# The package must work with ONLY `dash` present — nothing from
# requirements.txt, which is the docs site's dependency set.
run: |
python -m venv /tmp/clean
/tmp/clean/bin/pip install --upgrade pip
/tmp/clean/bin/pip install dist/*.whl
/tmp/clean/bin/python - <<'PY'
import pathlib
import dash_leaflet2 as dl2
print("version:", dl2.__version__)
bundle = pathlib.Path(dl2.__file__).parent / "dash_leaflet2.js"
assert bundle.exists(), "JS bundle missing from the wheel"
print("bundle:", bundle.stat().st_size // 1024, "KB")
# The 27 MB react-docgen artifact must NOT ship.
meta = pathlib.Path(dl2.__file__).parent / "metadata.json"
assert not meta.exists(), "metadata.json leaked into the wheel"
for name in ("Map", "TileLayer", "Marker", "TileSelector", "EditControl"):
assert hasattr(dl2, name), f"missing component: {name}"
print("components OK")
PY
- name: Assert the wheel version matches pyproject
run: |
PY_VER=$(python -c "import re;print(re.search(r'^version = \"([^\"]+)\"', open('pyproject.toml').read(), re.M).group(1))")
WHEEL_VER=$(/tmp/clean/bin/python -c "import dash_leaflet2;print(dash_leaflet2.__version__)")
echo "pyproject=$PY_VER installed=$WHEEL_VER"
test "$PY_VER" = "$WHEEL_VER"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
package-python-range:
name: Package · Python ${{ matrix.python }}
needs: package
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# Every interpreter `requires-python` in pyproject.toml claims. This is
# what makes that claim measured rather than asserted — and it installs
# ONLY the wheel plus Dash, never the docs requirements, because the
# package's floor is not the docs site's.
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Install the wheel (pulls in dash, nothing else)
run: |
python -m pip install --upgrade pip
python -m pip install dist/*.whl
- name: Import and build a layout
run: |
python - <<'PY'
import dash, dash_leaflet2 as dl2
from dash import Dash, html
from dash._utils import to_json
app = Dash(__name__)
app.layout = html.Div([
dl2.Map(id="m", center=[49.286, -123.12], zoom=12, children=[
dl2.TileLayer(),
dl2.Marker(id="pin", position=[49.286, -123.12],
children=dl2.Tooltip("hi")),
dl2.TileSelector(id="ts"),
dl2.EditControl(id="ec"),
dl2.LayersControl(children=[
dl2.BaseLayer(dl2.TileLayer(), name="OSM", checked=True),
dl2.Overlay(dl2.Circle(center=[49.286, -123.12], radius=800),
name="ring"),
]),
]),
])
to_json(app.layout)
print(f"dash={dash.__version__} dl2={dl2.__version__} "
f"components={len(dl2.__all__)} OK")
PY
pip-audit:
name: pip-audit (advisory)
runs-on: ubuntu-latest
timeout-minutes: 10
# Advisory on purpose. A CVE in a transitive dependency of a docs site is
# worth knowing about the day it lands, and worth nobody's broken build at
# 2am. The report is the value; flip this off once the baseline is quiet.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pip-audit
# Skip local vendor/ paths — pip-audit can only assess PyPI dists.
- run: |
grep -v '^\./vendor/' requirements.txt > /tmp/req-pypi.txt
pip-audit -r /tmp/req-pypi.txt --skip-editable