-
Notifications
You must be signed in to change notification settings - Fork 0
496 lines (440 loc) · 19.5 KB
/
Copy pathci.yml
File metadata and controls
496 lines (440 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
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
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
name: CI
# Two things at once, because this repo is two things: a published component
# library AND the docs site at muicharts.2plot.dev.
#
# library the wheel build, the measured dash>=3.3 package floor, and the
# Python range setup.py claims (3.9-3.13);
# satellite the 2plot network baseline — a secretless pytest run (the 17
# MUI Pro pages must degrade to their license banners: zero env
# in CI is the proof, LESSONS §18), the real Docker image booted
# and probed by the same battery that runs against production,
# and version fingerprints asserted INSIDE the artifact.
#
# TWO different Dash floors, and conflating them is the easy mistake:
#
# * the PACKAGE needs >= 3.3 — measured by the `package` job's floor venv.
# * the DOCS SITE needs >= 4.1 — dash-improve-my-llms pins `dash<5,>=4.1`,
# so the smoke matrix starts there. (Local dev may sit lower with a
# checked-out dimll; PyPI artifacts cannot.)
#
# Deliberately NOT `push: branches: [main]`. cd.yml runs on that push and its
# first job `uses:` this workflow, so a push to main would start two runs
# that contend for the concurrency group and cancel each other. Pull requests
# get their own CI; `main` is owned by CD. 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"
# Never let a CI run inherit production behaviour: require_owned_base_url()
# keys off RENDER / APP_ENV=production, and the traffic reporter keys off
# the webhook secret. Both must stay inert here.
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
# Config in .flake8 — including the BUDGETED first-run debt ledger
# for pages/*.py. Tighten there, not here.
run: flake8 lib pages components docs tests scripts run.py usage.py verify_traffic.py _validate_init.py
# The workflows lint themselves. 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 — it silently killed every CI and CD run on
# boilerplate.2plot.dev for four days (LESSONS §14).
- name: actionlint
run: |
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) 1.7.7
./actionlint -color
docs-tests:
name: Docs site · pytest (zero secrets)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install the docs site
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# markdown2dash pins gunicorn<22 against the CVE-driven gunicorn>=23
# floor. Same two-command install as the Dockerfile and render.yaml.
pip install --no-deps markdown2dash==0.1.2
pip install -e .
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 SITE floor: dash-improve-my-llms pins dash>=4.1, so this is
# what requirements.txt actually resolves to in production.
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); the requirements floor is >=23.
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 MUI_PRO_API_KEY, no CROSS_APP_WEBHOOK_SECRET, no
# NETWORK_BULLETIN_URL here ON PURPOSE. tests/conftest.py pins them
# empty, and the app's degraded postures (license banners on the 17
# Pro pages, a dormant traffic reporter) are only provable when
# nothing is configured.
- name: Test suite (zero secrets)
run: pytest tests -q
- name: Boot under a production server
run: |
gunicorn run:server -b 127.0.0.1:8550 --daemon --access-logfile - --error-logfile -
for _ in $(seq 1 30); do
curl -sf http://127.0.0.1:8550/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:8550/ > /dev/null
curl -sf http://127.0.0.1:8550/sparkline > /dev/null
# The battery, against the same server this satellite deploys.
python3 scripts/network_smoke.py --base-url http://127.0.0.1:8550
docker:
name: docker image · boot · battery
runs-on: ubuntu-latest
timeout-minutes: 25
needs: [docs-tests]
steps:
- uses: actions/checkout@v4
# The same build Render runs. This is where a dependency-resolution
# failure surfaces — at CI time, not deploy time — and where a missing
# COPY line dies loudly instead of at gunicorn boot (LESSONS §19).
- uses: docker/setup-buildx-action@v3
- name: Build the production image
uses: docker/build-push-action@v6
with:
context: .
tags: dash-mui-charts-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-mui-charts-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}'
v = version('gunicorn')
print('gunicorn', v)
assert parts(v)[:2] >= (23, 0), f'expected gunicorn>=23 (CVE-2024-6827/-1135), image has {v}'
# markdown2dash installs with --no-deps to dodge its gunicorn<22
# pin; this proves the dodge kept working AND kept the package.
import markdown2dash # noqa: F401
print('markdown2dash importable')
import dash_mui_charts
print('dash_mui_charts', dash_mui_charts.__version__,
len(dash_mui_charts.__all__), 'components')
assert len(dash_mui_charts.__all__) == 13
"
# Boot with no secrets: the Pro pages fall back to their license
# banners, the traffic 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 8550:8550 dash-mui-charts-docs:ci
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:8550/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://muicharts.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:8550
- name: Container logs (for the record)
if: always()
run: docker logs --tail 40 docs 2>/dev/null || true
smoke:
name: Docs · Dash ${{ matrix.dash }} · Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# The SITE floor (dash-improve-my-llms pins dash>=4.1), the two
# intermediate minors, and the current release. The package's own
# dash>=3.3 floor is measured separately, in the `package` job.
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 12 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 JS checks real: the matrix rebuilds the
# component bundle and wrappers from source, so a broken webpack
# config or a component that stopped generating fails HERE, per
# supported environment, not on the next release.
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Dash ${{ matrix.dash }} first
# The version under test goes in BEFORE the rest, so the other
# requirements resolve against it rather than dragging in a newer
# Dash. requirements.txt only floors dash, so pip leaves the pin be.
run: |
python -m pip install --upgrade pip
python -m pip install "dash==${{ matrix.dash }}"
- name: Install documentation-site requirements
run: |
python -m pip install -r requirements.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: Rebuild the components from source
run: |
npm ci
npm run build
- name: Validate the generated wrappers
run: npm run validate-init
- 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
package:
name: Build + verify the wheel
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
# check_release.py compares the git commit times of the bundle and
# src/lib/components. A shallow clone can omit the commit that
# last touched one of them, turning 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 between package.json and package-info.json, a stale
# bundle, packaging drift. None of these break a test run — this
# repo ran fine for a full cycle advertising five different versions.
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_mui_charts as dmx
print("version:", dmx.__version__)
bundle = pathlib.Path(dmx.__file__).parent / "dash_mui_charts.min.js"
assert bundle.exists(), "JS bundle missing from the wheel"
print("bundle:", bundle.stat().st_size // 1024, "KB")
expected = [
"BarChart", "CandlestickChart", "CompositeChart", "Heatmap",
"LineChart", "LiveTradingChart", "PieChart", "ScatterChart",
"SimpleTreeView", "SparklineChart", "TimeClock", "TreeView",
"TreeViewPro",
]
for name in expected:
assert hasattr(dmx, name), f"missing component: {name}"
assert sorted(dmx.__all__) == sorted(expected), (
f"__all__ drift: {sorted(set(dmx.__all__) ^ set(expected))}"
)
print(f"components OK ({len(expected)})")
# The wheel must carry the library and nothing else. `lib`,
# `pages` and `assets` are docs-site directories that a careless
# packages= would happily install as top-level packages.
from importlib.metadata import distribution
top = (distribution("dash_mui_charts").read_text("top_level.txt") or "").split()
assert top == ["dash_mui_charts"], f"wheel installs more than the library: {top}"
print("top_level.txt:", top)
PY
- name: Measure the dash floor the wheel claims
# setup.py says dash>=3.3.0 — this is the measurement behind that
# number. The docs site cannot go this low (dimll pins >=4.1); the
# package can, and users on Dash 3.3 installs are real.
run: |
python -m venv /tmp/floor
/tmp/floor/bin/pip install --upgrade pip
/tmp/floor/bin/pip install "dash==3.3.0" dist/*.whl
/tmp/floor/bin/python - <<'PY'
import dash
import dash_mui_charts as dmx
layout = dmx.LineChart(
id="lc",
series=[{"data": [1, 2, 3], "label": "a"}],
xAxis=[{"data": [0, 1, 2]}],
).to_plotly_json()
for name in dmx.__all__:
cls = getattr(dmx, name)
assert callable(cls), name
print(f"dash={dash.__version__} dash_mui_charts={dmx.__version__} floor OK")
PY
- name: Assert the wheel version matches package.json
run: |
PY_VER=$(python -c "import json;print(json.load(open('package.json'))['version'])")
WHEEL_VER=$(/tmp/clean/bin/python -c "import dash_mui_charts;print(dash_mui_charts.__version__)")
echo "package.json=$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: 10
strategy:
fail-fast: false
matrix:
# Every interpreter python_requires in setup.py 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 chart layout
run: |
python - <<'PY'
import dash
import dash_mui_charts as dmx
layout = [
dmx.LineChart(id="lc", series=[{"data": [1, 2, 3]}],
xAxis=[{"data": [0, 1, 2]}]),
dmx.BarChart(id="bc", series=[{"data": [4, 5, 6]}],
xAxis=[{"scaleType": "band", "data": ["a", "b", "c"]}]),
dmx.PieChart(id="pc", series=[{"data": [
{"id": 0, "value": 10, "label": "A"}]}]),
dmx.SparklineChart(id="sc", data=[1, 3, 2]),
dmx.SimpleTreeView(id="tv", items=[
{"itemId": "/", "label": "Home"}]),
dmx.TimeClock(id="tc", value="10:30"),
]
for component in layout:
component.to_plotly_json()
print(f"dash={dash.__version__} dash_mui_charts={dmx.__version__} "
f"components={len(dmx.__all__)} OK")
PY
lint-js:
name: JS sources parse
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
# The committed bundle is what users actually load, and it is a build
# artifact nobody reviews line by line. Parsing it here is the
# cheapest guard against committing a truncated or half-written build.
- name: Parse the committed component bundle
run: |
for f in dash_mui_charts/*.js; do node --check "$f"; done
- name: Parse assets/*.js individually
# These are load-bearing at runtime: muiChartsFunctions.js is the
# functions-as-props registry, 00-loading-theme.js wins the asset
# race by name, 01-nav-restore.js replays the sidebar state. They
# only ever fail in the browser console.
run: |
for f in assets/*.js; do node --check "$f"; done
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.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pip-audit
- run: pip-audit -r requirements.txt --skip-editable