Repository navigation
228 lines (220 loc) · 12.2 KB
/
Copy pathtest.yml
File metadata and controls
228 lines (220 loc) · 12.2 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
name: Test
# Fast host-side unit tests for the Python (MoonDeck / build scripts) and JS (web installer) code that the C++ unit and scenario suites can't reach. Runs on every PR and on pushes to main, scoped to the paths these tests cover so a docs-only or pure-firmware change doesn't spend a runner here.
#
# Today this pins the Improv frame wire format (test/python + test/js assert a
# shared golden vector so the device C++, Python, and JS builders can't drift).
# New Python/JS unit suites land under test/python and test/js and run here.
# Paths cover every input to the host-side tests: the Python/JS sources under test
# (scripts, mooninstaller), the test files themselves, AND the device-side C++ frame
# contract (src/core/Improv*.h + the platform handler) — a wire-format change in the
# firmware must run the cross-language golden-vector tests so it can't drift from the
# Python/JS builders silently. pull_request gates every PR; push runs main only (a
# direct-to-main hotfix). A PR branch is covered by pull_request alone — listing
# feature branches under push too would double-run every PR (push + pull_request).
on:
pull_request:
paths: &test-paths
- 'moondeck/**'
- 'mooninstaller/**'
- 'src/core/util/ImprovFrame.h'
- 'src/core/util/ImprovOpReassembler.h'
- 'src/platform/esp32/platform_esp32_improv.cpp'
- 'test/python/**'
- 'test/js/**'
- 'test/fixtures/**'
# A MoonLive script IS the subject of test_scripts_are_cpp.py: editing one is exactly when
# the "this is a subset of C++" claim needs re-checking.
- 'moonlive/**'
- 'src/light/moonlive/MoonLiveBuiltins_light.h'
# MoonWalk's stage files, the map's renderer and what the generator checks them against.
- 'moonwalk/**'
- 'docs/assets/moonwalk.js'
- 'docs/help/help.json'
- 'docs/explanation/architecture/index.md'
# The sanitizer jobs below compile the WHOLE C++ suite, so they have to run when any of it
# changes. Without these, a C++ change could break them with no run at all, and the fix could
# not prove itself either: exactly what happened to a missing <string> in a unit test.
- 'src/**'
- 'test/unit/**'
- 'CMakeLists.txt'
# The Teensy job below builds the firmware from these.
- 'teensy/**'
- '.github/workflows/test.yml'
- '.github/actions/**'
push:
branches:
- main
paths: *test-paths
permissions:
contents: read
jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v3
# pytest + pyserial come from the test file's inline PEP-723 block; passing
# them via --with is the explicit, discovery-friendly form (a bare `pytest
# <dir>` doesn't honour a test file's own inline deps). `markdown` (a MkDocs
# dep, not in the base env) is needed by test_mkdocs_slug.py, which pins _slug()
# against Python-Markdown's real toc slugify. `wled` is the frenck/python-wled
# library HA's WLED integration uses; test_wled_json_shape.py parses the
# device /json vector through its Device.from_dict to pin the wire contract.
# The MoonLive scripts compile as C++ FIRST, and by name: it is the one suite whose subject is
# the shipped content rather than the tooling, so "all 34 scripts are valid C++" should be
# readable in the log instead of folded into a single count. Named here rather than split into
# its own step so the compiler still runs exactly once.
- name: every MoonLive script is valid C++
run: uv run --with pytest pytest test/python/test_scripts_are_cpp.py -v --junitxml=results/junit-scripts.xml
# The rest, quietly. This directory is excluded because the step above already ran it: a bare
# `pytest test/python` here would compile all 34 scripts a second time.
- name: pytest
run: >
uv run --with pytest --with pyserial --with markdown --with wled --with pyyaml --with jsonschema
pytest test/python -q --ignore=test/python/test_scripts_are_cpp.py --junitxml=results/junit-python.xml
# Each failing test annotated where it fails, and the counts in the job summary, through workflow commands: no token, so a fork's pull request gets them too.
- if: always()
run: uv run moondeck/ci/github_annotations.py --junit results/*.xml
js:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Node's built-in test runner — no npm install, no package.json. The glob
# form is required: a bare directory arg is treated as a module to execute.
- name: node --test
run: |
mkdir -p results
node --test --test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=junit --test-reporter-destination=results/junit-js.xml "test/js/**/*.test.mjs"
- uses: astral-sh/setup-uv@v3
# Each failing test annotated where it fails, and the counts in the job summary, through workflow commands: no token, so a fork's pull request gets them too.
- if: always()
run: uv run moondeck/ci/github_annotations.py --junit results/*.xml
# A backup from before config files were state documents, restored onto the desktop app the way the File Manager's Restore does, with
# two other clients reading it meanwhile: every value back and the report clean, or the job fails.
restore:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v3
- uses: ./.github/actions/ccache
with:
key: restore
- name: build the desktop app
run: uv run moondeck/build/build_desktop.py
- name: restore a flat backup onto it
run: |
MM_DATA_DIR="$RUNNER_TEMP/fs" ./build/linux/MoonLight --no-browser > "$RUNNER_TEMP/app.log" 2>&1 &
curl -s -o /dev/null --retry 30 --retry-delay 1 --retry-connrefused http://localhost:8080/api/system
uv run moondeck/scenario/run_restore_live.py --bundle test/fixtures/backup-flat-2026-09-30.json --readers 2
- if: failure()
run: tail -50 "$RUNNER_TEMP/app.log"
# The Teensy 4.1 firmwares, built on every PR: a change to a platform seam the Teensy must implement fails here, not on the bench.
# The HEX images are kept as an artifact, so a PR's build can be flashed and tried before it merges.
teensy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v3
# Only the Arm toolchain is cached, the large download, keyed on its version alone: an edit elsewhere in setup_teensy.py forced a fresh download, and the small source clones fetch fresh anyway.
- name: the Arm toolchain's version, which keys its cache
id: gcc
run: echo "version=$(sed -n 's/^GCC_VERSION = "\(.*\)"/\1/p' moondeck/build/setup_teensy.py)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ~/teensy/arm-gnu-toolchain-*
key: teensy-gcc-${{ runner.os }}-${{ steps.gcc.outputs.version }}
- name: install the Teensy SDK
run: uv run moondeck/build/setup_teensy.py --no-loader
- uses: ./.github/actions/ccache
with:
key: teensy
- name: build the Teensy 4.1 firmwares
run: |
set -euo pipefail
mkdir -p dist
# Every shipping Teensy firmware builds and is collected, so a second one reaches the artifact too.
for fw in $(jq -r '.firmwares[] | select(.ships and .platform == "teensy") | .name' mooninstaller/firmwares.json); do
uv run moondeck/build/build_teensy.py --firmware "$fw"
cp "build/$fw/MoonLight.hex" "dist/$fw.hex"
cp "build/$fw/MoonLight.bin" "dist/$fw.bin"
done
- uses: actions/upload-artifact@v4
with:
name: firmware-teensy
path: dist/
# Sanitizers — the safety net for the multicore render/encode split. A second thread now sits
# beside code that was single-threaded for its whole life: a shared output buffer handed between
# cores, an atomic stop flag, a try-lock over the WebSocket sender, and a child array that can be
# reallocated while the worker walks it. Hand-written tests prove the races we THOUGHT of; ASan
# (use-after-free) and TSan (data races) find the ones we didn't — ASan is what would have caught
# the delete-a-driver-mid-encode bug automatically.
#
# Linux-only, and that is not laziness: on macOS/arm64 Apple's ASan runtime HANGS at startup and
# TSan SEGFAULTS on a hello-world (both reproduced with minimal programs — toolchain bugs, not our
# code). Linux is the only place TSan runs at all, which makes this job the sole race gate.
# Locally: `uv run moondeck/check/check_sanitizers.py` (uses Homebrew LLVM, ASan only on macOS).
sanitizers:
runs-on: ubuntu-latest
strategy:
fail-fast: false # a TSan race and an ASan UAF are independent signals — report both
matrix:
# `realtime` is the runtime half of the hot-path check: MM_NONBLOCKING marks the tick
# methods, -Wfunction-effects proves what it can at compile time, and RTSan catches what
# it cannot — allocation or blocking reached through virtual dispatch or a function
# pointer. Same attribute drives both, so this lane costs one matrix entry.
kind: [address, thread, realtime]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# The CMake configure needs uv on PATH — it resolves UV_EXECUTABLE for the UI-embed step
# (CLAUDE.md § Use uv for every Python invocation), so a build without it fails at configure.
- uses: astral-sh/setup-uv@v3
# RTSan needs Clang 20+, which Ubuntu 26.04's own archive carries as clang-20. That lane
# alone uses it; ASan and TSan run on the runner's default compiler.
- name: install clang for RealtimeSanitizer
if: matrix.kind == 'realtime'
run: sudo apt-get install -y clang-20
- uses: ./.github/actions/ccache
with:
key: sanitizer-${{ matrix.kind }}
- name: build + run unit tests under ${{ matrix.kind }}sanitizer
run: |
# RTSan is clang-only — GCC rejects `-fsanitize=realtime` outright — and the attribute
# it keys off is [[clang::nonblocking]], which GCC ignores anyway. Passed as a CMake
# variable on this lane only, rather than exporting CXX: an empty CXX="" on the other
# lanes is not the same as unset, and some probes read it as a broken compiler path.
compiler=""
if [ "${{ matrix.kind }}" = "realtime" ]; then
compiler="-DCMAKE_CXX_COMPILER=clang++-20"
fi
cmake -S . -B build/san -DCMAKE_BUILD_TYPE=Debug $compiler \
-DCMAKE_CXX_FLAGS="-fsanitize=${{ matrix.kind }} -fno-omit-frame-pointer -g" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=${{ matrix.kind }}"
cmake --build build/san --target mm_tests -j"$(nproc)"
# Leak detection is a different concern from the races/UAF this gate hunts, and it fires on
# third-party static init; keep the signal on what we're actually looking for.
#
# RTSan does NOT halt: the render path's known blocking calls are counted in
# check_code (docs/reference/metrics/code.json) and backlogged as architecture work, so halting would
# fail this lane on every run. It reports; the log is the signal.
# The address lane writes the unit results as JUnit for the annotations: one lane is enough.
results=""
if [ "${{ matrix.kind }}" = "address" ]; then
mkdir -p results
results="--reporters=junit --out=results/junit-unit.xml"
fi
# TSan reports every race of the run and still fails it (exit code 66): halting at the first hid the next one behind each fix.
ASAN_OPTIONS=detect_leaks=0 TSAN_OPTIONS=halt_on_error=0 \
RTSAN_OPTIONS=halt_on_error=0 ./build/san/test/mm_tests $results
- if: always() && matrix.kind == 'address'
run: uv run moondeck/ci/github_annotations.py --junit results/*.xml