CI/CD & releases¶
GitHub Actions builds, tests, and releases every part of MaD. There are three
workflows — ci.yml (build/test), pages.yml (deploy the docs + app), and
e2e-nightly.yml (scheduled emulator-backed e2e on main) — plus
GitHub Copilot code review as an advisory AI reviewer on every PR (configured via
instruction files, not a workflow).
CI (.github/workflows/ci.yml)¶
A changes gate job detects which areas a push/PR touched and runs only the
relevant jobs:
| Job | Runs when | What it does |
|---|---|---|
wasm-control-ci |
app or protocol changed | Build WASM, generate the protocol, npm run verify (typecheck + lint + tests + build), diagnostics e2e, M12 schema↔domain lockstep — fully offline. protoemb-runtime cargo test is protoemb-ci |
protoemb-ci |
protocol changed | Generator unit tests + cross-language (C == Rust == TS) wire conformance + framing/runtime crate tests |
embsim-ci |
SIL / firmware / protocol changed | Builds and tests the pinned SIL/embsim commit (workspace tests, doctests, embsim-trace with web off) |
embsim-pin-ci |
the SIL/embsim gitlink moved |
Blocking. The other half of embsim's upstream gate, run against the commit being pinned: determinism goldens (5× as separate processes) + stepped-clock suites + goldens-unmodified, rustfmt, clippy -D warnings, cargo doc deny-warnings, MSRV read from the pinned manifest, and cargo deny. Catches a pin bumped to an unpushed or never-CI'd commit |
protoemb-pin-ci |
the Protocol/ProtoEmb gitlink moved |
Blocking. Mirror of the above for the other submodule: make verify round-trip, ruff, clippy -D warnings (native + wasm32), wasm-pack build, cargo deny per crate + pip-audit, MSRV per crate manifest |
vibes-pin-ci |
the Vibes gitlink moved |
Blocking. Mirror of upstream Vibes CI for the commit being pinned: the tool's install, typecheck, tests and build, plus compile probes of its C, Rust and TypeScript bindings |
docs-ci |
docs/** or mkdocs.yml changed |
Blocking. mkdocs build --strict — broken nav entries, dead internal links and config warnings are errors. Previously this ran only on a push to main via pages.yml, so a bad docs PR merged green and took the Pages deploy down |
control-e2e-boardless |
app or protocol changed, the SIL/embsim gitlink moved, or ci.yml changed |
Blocking. The e2e scenarios that need no board (A1, the firmware-flash FW* ones, and the firmware-compatibility COMPAT-reconnect, COMPAT-silent, COMPAT-update-loop, COMPAT-app-release and COMPAT-app-firmware on an in-page fake board): the real app in a real host Chrome against in-page fakes, no emulator. The scenarios that touch the board run in control-e2e-board and the nightly. See below |
control-e2e-board |
app, protocol, firmware or SIL changed, the SIL/embsim gitlink moved, or ci.yml changed |
Blocking. A subset of the board-touching e2e scenarios on the board route: the machine's project, SIL/mad-cosim.toml under embsim run (the MaD P2 image on the ISS, booted off the module's flash), and the app in the runner's Chrome held to the board's clock by embsim's chrome-cdp node on the carrier's control port J4 (through IC2 to P30/P32), one fresh board per scenario (make check, then make e2e, BOARD_PER_SCENARIO=1), then BUNDLE-live on the bundle as pages.yml builds it (APP_DIST, served by vite preview), then the motion leg G2+G3+H2+I on the same file (make e2e-motion), then SERVO-stall on that file with a hard stop on its carriage, SIL/mad-hard-stop-cosim.toml (make e2e-stall; MaD #298), then the firmware-compatibility scenario on firmware the app cannot drive (make e2e-compat: this tree's firmware built one protocol_version ahead), and FW1 and COMPAT-reflash, the app updating the firmware for real on the reflash board, SIL/mad-reflash-cosim.toml, its node on the programming port J1 (make e2e-reflash; MaD #134): each ends with the new firmware's boot line, Starting MaD Board <version>, in the board's log, since the new firmware answers on J4, which that board's one Chrome port does not reach. These run as four matrix legs on four runners, so the job takes its longest leg's time, not the sum: motion (the motion leg), g1 (G1, SERVO-stall, the compatibility scenario), subset (the rest of the subset, then BUNDLE-live) and reflash (FW1 and COMPAT-reflash). The whole suite runs nightly on the same route (e2e-nightly.yml): a suite job runs all but the scenarios that move the carriage (run-all.mjs --list --no-moves), and three jobs beside it run those (make e2e-motion, MOTION_SHARD=1/3..3/3). See below |
firmware-unit-tests |
firmware or protocol changed | pio test -e native_test — the host Unity suite under AddressSanitizer (no Propeller toolchain needed) |
protocol-codegen |
protocol changed | Blocking. Regenerates all three targets (C/TS/Rust) twice and asserts success + byte-reproducibility (generated files are gitignored, so this guards the schema/templates + generator determinism, not committed-file drift). On a PR it also runs Protocol/scripts/check_protocol_compat.py against the base branch: a wire-incompatible schema change without a protocol_version bump fails, and so does any change to the frozen device_info handshake (release order) |
firmware-layering |
firmware changed | Blocking (baseline-gated). scripts/check_layering.py enforces downward-only includes (APP→DEV→IO→Library→HAL→HW); pre-existing violations are frozen in .layering-baseline, so it fails only on new upward includes |
python-lint |
firmware changed | Blocking. ruff over the PlatformIO SCons hooks (Firmware/MaDCore/extra_scripts; ruff.toml, mirrors the Python guide). The ProtoEmb generator is linted in its own repo's CI |
firmware-misra |
firmware changed | Blocking. pio check (cppcheck + MISRA) with check_severity = medium, high — low is not reported. Fails CI Gate on any medium/high defect. CERT is not enforced (no cert.py with bundled cppcheck) |
sil-rust |
SIL / firmware / protocol changed | Blocking. make protocol + the MaD image, then cargo fmt --check, cargo clippy -- -D warnings, and cargo test on the SIL workspace — all three gating. rustfmt covers models, mad-catalog and mad-runner; the generated protocol crate is excluded because make protocol rewrites it every build. Generated-codec clippy allows are scoped to the mod protoemb declaration in Protocol/rust/src/lib.rs, never crate-wide |
build-firmware |
firmware changed, or a firmware tag | Builds the one MaD image, released as MaD-Firmware-<version>.bin, and writes firmware-info.json (the release's protocolVersion, read from the generated header) |
build-hardware |
hardware changed, or a hardware tag | KiBot → Gerbers, BOM, interactive BOM, 3D models for each board |
vibes (Behaviour ledger) |
every PR, after firmware-unit-tests, wasm-control-ci and sil-rust |
Informational (not in ci-gate.needs). Runs no suite: builds the behaviour ledger from what those three jobs uploaded as they ran their tests (vibes report --from), diffs it against the base branch and posts the report as a sticky PR comment. A suite whose job was skipped keeps its base rows and is named in the report; a failed job's run is read test by test. A behaviour that breaks or is removed leads the comment but does not fail the job; it goes red only when a behaviour has no verdict (exit 3: a job left no run, or its run stopped before reaching it) or a picture could not be read, and it never blocks the merge |
ci-gate |
every PR | Aggregates all of the above except vibes. Fails if any job failed/was cancelled; passes when they succeeded or were legitimately skipped. This is the single required status check (CI Gate) on main. |
release-* |
a firmware-v* / hardware-v* tag |
Publishes a GitHub Release with the built artifacts. release-firmware then dispatches pages.yml on main, so /app/firmware/ serves the new release's image (Pages deploy). MaD Control releases are cut by pages.yml on madcontrol-v* tags. |
The PR trigger has no path filter — every PR runs at least changes + ci-gate, so the required CI Gate check always reports. Requiring the gate (instead of each job individually) is what keeps path-filtered skips from blocking a PR forever.
A docs-only PR now runs docs-ci (and nothing else); before that job existed it skipped every job, which is how a strict-mode docs break could merge green.
The e2e suite¶
The e2e suite runs the real app in a real Chrome against the firmware. There is
one valid configuration for the scenarios that touch the board: the shipped P2
image on the ISS, and the runner's Chrome with every page and worker held to
the board's clock by embsim's chrome-cdp node, its Web Serial port the
board's protocol line. The board is the machine's project, SIL/mad-cosim.toml
(make e2e); the scenarios that move the carriage run on the same file
(make e2e-motion: G2+G3+H2+I per PR, the whole set nightly in three
shards). SERVO-stall, which drives the carriage into a hard stop, runs per
PR on that file with the machine's stop, SIL/mad-hard-stop-cosim.toml
(make e2e-stall; MaD #298). FW-ISS runs nightly on the ROM's
serial boot, SIL/mad-serial-boot-cosim.toml (make e2e-rom). The
firmware-compatibility scenarios that need firmware the app cannot drive run
on mad-cosim.toml with that firmware in the module's flash (make
e2e-compat, per PR and nightly): COMPAT-app-too-old on this tree's
firmware built one protocol_version ahead (SIL/scripts/compat-images.sh;
no firmware source changes). Releases up to 0.14, from before the
device-info handshake, answer only on J1, never on the control port the
board's host is on, so no board scenario runs them.
COMPAT-ok runs in the per-PR subset and COMPAT-ok-motion in the motion set;
COMPAT-reconnect, COMPAT-silent, COMPAT-update-loop, COMPAT-app-release and
COMPAT-app-firmware are board-free, on the in-page fake board
(COMPAT-app-release serves the app from a second dev server at a release's
base, with a stubbed release list). Every budget in the
suite is board time, so a result does not depend on how fast the runner is.
The ISS behind the WS↔PTY bridge is not a configuration: the bridge hands the
board's bytes to a browser running at host speed, so every wait measures the
host rather than the machine.
The ISS runs at a few percent of real time, so a scenario costs 10–30 times its board time in host time:
| Job | When | What |
|---|---|---|
control-e2e-board |
per PR: the app, the protocol, the firmware, SIL/, a move of the SIL/embsim pin (#148), or ci.yml |
Gates. A subset of the board scenarios on the ISS with chrome-cdp (its comment names the scenarios and the time), and BUNDLE-live on the shipped bundle. A fetch that holds a virtual-time budget is granted that budget again under advance |
control-e2e-boardless |
per PR: the app, a pin move, or ci.yml |
Gates. The scenarios that never reach a board (A1 and the FW* flasher ones, a host Chrome against in-page fakes) |
e2e-nightly.yml |
06:17 UTC daily, or by hand | The whole suite on the same route, after the node's Chrome canary (a worker's timer on the board's clock): all but the scenarios that move the carriage, plus FW-ISS, in one job, and those scenarios in three make e2e-motion jobs (MOTION_SHARD=1/3..3/3), all on mad-cosim.toml |
A move of the SIL/embsim pin runs the board job as #148 intended: the node
the suite runs on is embsim's, and sil-rust exercises p2iss, mad-catalog
and mad-runner against the pin.
Until the native firmware library was removed, control-e2e-sil ran the full
suite per PR against that library behind the bridge, unpaced (--speed 0) so the
result did not depend on runner speed. It found the missing <mount>/gcode/
provisioning (every uploaded test stored zero moves on a fresh SD card), a UI that
reported a moving machine as idle, and a toolchain-specific protocol UB
(protoemb#23) that returned
0-byte downloads under GCC.
ci.yml is the source of truth for which jobs gate.
Branch protection (main)¶
main is protected — all changes land through pull requests:
- No direct pushes for collaborators; open a PR instead. (Repo admins keep a bypass for emergency hotfixes; flip Settings → Branches → enforce admins on if you want to lock that down too.)
CI Gatemust be green before a PR can merge.- 0 required approvals — a PR is required, but a solo author isn't blocked waiting for someone else to approve. Bump Settings → Branches → required approvals when there are more maintainers.
- Force-pushes and branch deletion are disabled; stale approvals are dismissed on new commits.
AI code review (GitHub Copilot, advisory)¶
Pull requests are reviewed by GitHub Copilot code review — GitHub's native, repo-aware reviewer. It's advisory (inline comments, not a required check) and enforces the judgment concerns the deterministic gates above can't decide (layering, HAL locking, wire-compat, reuse, docs freshness).
It reads the project's standards from custom-instruction files, so its context stays in sync with the repo:
.github/copilot-instructions.md— repo-wide review focus..github/instructions/*.instructions.md— path-scoped (via anapplyTo:glob) files that point Copilot at the rightdocs/coding-guidelines/doc for each area (firmware C, TypeScript, Rust, Python, protocol YAML).
The guidelines themselves stay the single source of truth in docs/coding-guidelines/;
the instruction files just route Copilot to them (plus a short list of the highest-
value foci per area). Edit the guideline doc, not a second copy.
One-time setup
Enable Settings → Copilot → Code review → automatic review for the repo (or add a ruleset that requests Copilot's review on PRs). Copilot code review consumes GitHub Copilot AI Credits; on this public repo it is exempt from the Actions-minutes surcharge that applies to private repos.
Pages deploy (.github/workflows/pages.yml)¶
One workflow publishes both this documentation site and every MaD Control release to GitHub Pages as a single deployment:
https://rileymccarthy.github.io/MaD/ → documentation (this site, from main)
https://rileymccarthy.github.io/MaD/app/ → redirects to the latest release
https://rileymccarthy.github.io/MaD/app/vX.Y.Z/ → one release, exactly as it shipped
- Each release is built once. Pushing a
madcontrol-vX.Y.Ztag runs therelease-appjob. It builds the app for its own folder (base path/<repo>/app/vX.Y.Z/), attaches the build to the MaD Control X.Y.Z GitHub Release asmad-control-vX.Y.Z.zip, then asksmainto redeploy. Every build writesapp-info.jsonbeside itsindex.html:{ version, protocolVersion }, the protocol read fromProtocol/MaDProtocol.yaml(the job fails if it is missing). - Every deploy re-assembles the site. The
buildjob builds the docs frommain, thentools/assemble-pages-app.mtsunpacks every release zip intosite/app/vX.Y.Z/and writes:versions.json, which lists the releases, newest first, for the app's version picker on About. Each entry also gives theprotocolVersionfrom that release'sapp-info.json, so an app facing firmware a protocol ahead can offer the newest release that speaks it. Releases built beforeapp-info.json(0.4.3 and older) have no protocol listed and are never offered that way; they read the list with a check that ignores the extra field;- the
/app/redirect, which keeps the page part of the URL; /app/firmware/, shared by every version:manifest.jsonlists the newest firmware release for each protocol version, with its image (MaD-Firmware-<version>.bin) beside it, and every app offers the one that speaks its own protocol as the recommended firmware. Only releases carryingfirmware-info.jsonand that one image are listed; releases up to 0.14 (single-port-P2Eval.bin/-MaDEdge.binbuilds) never are.latest.json+latest.binhold the newest release with that image, for app versions from before the manifest, falling back to the newest old production (-P2Eval.bin/-release.bin) build until one exists;- an
/app/sw.jsthat removes the service worker left by the unversioned app.
- Merging to
mainnever changes a published app. It redeploys only when the docs, the assembler or this workflow change. - Pages deploys from
main. Thegithub-pagesenvironment only accepts that branch, which is why the tag job dispatches amaindeploy rather than deploying itself. - A firmware release triggers a redeploy too, so the shared firmware stays
current. Once a
firmware-v*tag has published its release,ci.yml'srelease-firmwarejob dispatches this workflow onmain(gh workflow run pages.yml --ref main). That run only re-assembles: the existing release zips plus the new firmware in/app/firmware/.release-appruns only onmadcontrol-v*tags, so no app is rebuilt. - Releases from before 0.4.0 carry no build and are not hosted.
One-time setup
In the repo, set Settings → Pages → Source = GitHub Actions so the workflow can publish.
Releases¶
MaD Control (Software/Control/) — package.json is the source of truth.
Do not git tag by hand (webapp-v* is retired). From the repo root:
# Protected main (default): bump on a branch, merge, then tag origin/main
git checkout -b release/madcontrol origin/main
Software/Control/scripts/release.sh patch --no-tag # or minor | major | x.y.z
git push -u origin HEAD
# open PR → CI Gate → merge
Software/Control/scripts/release.sh --publish # tags madcontrol-vX.Y.Z
That tag builds the release, publishes the MaD Control X.Y.Z GitHub Release
with its mad-control-vX.Y.Z.zip, and redeploys Pages. /app/ then opens
X.Y.Z, and earlier releases stay at their own /app/vX.Y.Z/ addresses.
Firmware and hardware are still cut with version tags:
git tag firmware-v1.0.0 && git push origin firmware-v1.0.0 # firmware binaries
git tag hardware-v1.0.0 && git push origin hardware-v1.0.0 # manufacturing files
Release order after a protocol change¶
MaD Control drives only firmware whose protocol_version equals its own: on
connect it reads the firmware's number with the device_info handshake, and
when they differ it blocks motion and offers the update (or, when the firmware
is the newer one, a link to the latest app). So after a change that bumps
protocol_version in Protocol/MaDProtocol.yaml:
- Merge the change.
- Release the firmware first (
firmware-vX.Y.Z). Its release carriesfirmware-info.json, and the Pages redeploy lists it in/app/firmware/manifest.jsonfor the new protocol. - Then release MaD Control.
release.sh --publish, and the tag'scheckjob (release.sh --check), refuse until a published firmware release speaks the app's protocol, since the new app would otherwise find every machine too old and have nothing to offer.
Older app versions keep working with older firmware: each one is recommended the newest release of its own protocol.
Running the gates locally¶
You can reproduce the most important gates before pushing:
# MaD Control offline gate
cd Software/Control && npm run verify
# Full SIL integration
cd SIL && make test
# SIL Rust gates — all three block CI. rustfmt skips the generated `protocol`
# crate, which `make protocol` rewrites on every build.
cd SIL && cargo fmt -p models -p mad-catalog -p mad-runner --check \
&& cargo clippy --workspace --all-targets -- -D warnings \
&& cargo test --workspace --all-targets
# Firmware static analysis + unit tests + layer rule
cd Firmware/MaDCore && pio check -e MaD --fail-on-defect=medium --fail-on-defect=high && pio test -e native_test
python3 Firmware/MaDCore/scripts/check_layering.py Firmware/MaDCore/src
# Python SCons-hook lint (from repo root, ruff.toml is auto-discovered;
# the generator's lint lives in the protoemb repo)
ruff check Firmware/MaDCore/extra_scripts
# Protocol cross-language conformance
cd Protocol/ProtoEmb && ./examples/verify.sh
# Docs build (link check) — gated by docs-ci
pip install -r docs/requirements.txt && mkdocs build --strict
The e2e suite runs on the board route (the ISS with the app in Chrome on the
board's clock; no WS bridge). From SIL/, make e2e starts the dev server
and the board, runs the suite and stops everything; SCENARIOS=… selects a
subset, and BOARD_PER_SCENARIO=1 gives each scenario a fresh board, as CI
does:
cd SIL && make e2e BOARD_PER_SCENARIO=1 # as control-e2e-board and the nightly run it
cd SIL && make e2e SCENARIOS=B1+C1,F7 # a subset
cd SIL && make e2e-bundle # BUNDLE-live on the built bundle
CI and the nightly run the suite on the dev server, which compiles for the
newest Chrome. BUNDLE-live runs on the bundle the app ships as (vite build, the
Pages base path, served by vite preview; board-route.sh with APP_DIST),
which is compiled for the app's browser floor: a fault that compiling brings
in shows only there.
See SIL testing for the three-terminal
version and Software/Control/docs/TEST_PLAN.md for the scenarios. The
nightly runs the whole suite; dispatching it by hand with suite=smoke runs
the IDs in Software/Control/e2e/smoke-ids.txt instead.