Documentation
Build and boot your code on every chip — in CI.
cilicon cross-builds each target and boots it in an emulator (or on a real GPU), in parallel on Modal, and reports one PR check. It's a step you add to your existing CI, not a new CI.
Overview
Regular CI tells you your code compiled. cilicon tells you it runs on the chip. Each target carries its own toolchain image and its own way of proving the artifact actually runs — fanned out across Modal cloud containers, owning zero hardware.
There are two ways to run it — as a GitHub Action (the primary path) and as a local CLI. Both are the same engine.
# add cilicon as a step in your existing CI
- uses: RyanRana/cilicon@v1
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
Authenticate Modal
cilicon runs every build and boot in a Modal sandbox, so you need a (free) Modal account and a local token. This is a one-time step.
$ modal token new # opens a browser, writes a local token
modal token new. Instead set MODAL_TOKEN_ID and
MODAL_TOKEN_SECRET as repository secrets — see GitHub Action setup.
Your first run
With a cilicon.yml in place, build and boot the whole matrix in
parallel. A bare cilicon with no subcommand is equivalent to
cilicon run.
$ cilicon run
You'll see a live table as each target builds and boots in its own container:
cilicon · 4 target(s) · fanned across Modal cloud containers
┌──────────────────────────────┬────────────────┬─────────────────────────────────┐
│ TARGET │ BUILD │ ON-TARGET CHECK │
├──────────────────────────────┼────────────────┼─────────────────────────────────┤
│ jetson-perception/linux-arm │ ✓ 0s │ ✓ loads + runs ('perception: e… │
│ stm32h7/cortex-m │ ✓ 0s │ ✓ boots, reaches main ('BOOT O… │
│ esp32/freertos │ ✓ 1m57s │ ✓ boots, reaches main ('Hello … │
│ pi5-loadtest/linux-arm │ ✓ 0s │ ✗ crash (SIGSEGV), caught pre-… │
└──────────────────────────────┴────────────────┴─────────────────────────────────┘
3 / 4 passed · wall-clock 2m50s · vs ~4m20s sequential
The process exits 0 only if every target passed, 1 if any
failed, and 2 if --target matched nothing. Failing targets
print an expanded block with the failing step and the tail of its output.
CLI commands
The global flag -c / --config sets the config path
(default cilicon.yml) and goes before the subcommand,
e.g. cilicon -c examples/advanced.yml targets.
| Command | What it does |
|---|---|
cilicon targets | List the targets in cilicon.yml, fully matrix-expanded, with what each builds, validates, and proves. |
cilicon presets | List every built-in validation tier (see tiers). |
cilicon boards | List the one-word board bundles (base + apt + tier + machine). |
cilicon gpus | List the Modal GPU types usable by the real_gpu tier. |
cilicon doctor | Validate a config without running anything. |
cilicon run | Build + validate the whole matrix in parallel. |
cilicon run flags
| Flag | Meaning |
|---|---|
--target / -t <id> | Run a single target by id (matches exact id, slug, then substring). |
--json <path> | Write a JSON report (every phase, timing, output tail, artifacts). |
--junit <path> | Write a JUnit XML report — the format every CI dashboard already understands. |
--summary <path> | Write a GitHub-flavoured Markdown summary (point at $GITHUB_STEP_SUMMARY in CI). |
--artifacts <dir> | Pull built artifacts (per-target artifacts: globs) back into this directory. |
--baseline <path> | Compare flash/RAM/boot-time against a saved baseline (write one with --update-baseline). |
--fail-on-regression | Fail the run on a size regression past --regression-pct (default 5%). |
--telemetry <path> | Append JSONL run/target/phase events to this path. |
# produce a CI report and pull binaries back
$ cilicon run --junit out.xml --artifacts ./out
$ cilicon run -t stm32 # just one target
GitHub Action setup
This is the primary way to use cilicon: a step inside your existing CI that builds
and boots every chip in cilicon.yml and publishes a normal PR check.
1. Add a cilicon.yml
Define one target per chip you ship to. Minimal example:
targets:
- id: firmware/cortex-m
board: cortex-m # debian + arm-none-eabi + qemu-system-arm
build: >
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -nostartfiles
-nostdlib -ffreestanding -T src/cortex-m.ld
src/firmware.c -o build/firmware.elf
artifact: build/firmware.elf
expect: "BOOT OK" # the on-target proof string
2. Add the workflow
Create .github/workflows/cilicon.yml in your repo:
name: cilicon
on:
push: { branches: [main] }
pull_request:
jobs:
build-and-boot:
name: build + boot # → check: "cilicon / build + boot"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: cilicon
uses: RyanRana/cilicon@v1
with: { config: cilicon.yml }
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
3. Set the two Modal secrets
Run modal token new locally (it prints a token id and secret), then add
them as repository secrets under
Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
MODAL_TOKEN_ID | the token id from modal token new |
MODAL_TOKEN_SECRET | the token secret from modal token new |
Action inputs
The two Modal tokens are passed as env:, not inputs.
| Input | Default | Meaning |
|---|---|---|
config | cilicon.yml | Path to cilicon.yml, relative to your repo. |
target | "" | Run a single target by id; empty runs the whole matrix. |
report | cilicon-results.xml | Path the JUnit report is written to. |
artifacts | cilicon-artifacts | Directory built artifacts are pulled into. |
baseline | "" | Baseline JSON to check for flash/RAM/boot regressions. |
fail-on-regression | false | Fail the check if a size regression exceeds the threshold. |
telemetry | "" | Optional path to append JSONL run/target/phase events. |
Gate merges on the check
To require a green build-and-boot before any merge:
- Go to Settings → Branches → Branch protection rules for your default branch.
- Enable Require status checks to pass before merging.
- Add
cilicon / build + bootas a required check (the name comes from the workflow'sjob.name).
Now a PR can't merge unless every target builds, fits, and boots.
What shows up on the PR
- A status check — the JUnit report (one
<testcase>per target) published as a check run. - A Markdown summary — written to
$GITHUB_STEP_SUMMARY: each target's pass/fail, build time, on-target check detail, and flash size. - A fan-out sweep grid — when a target uses a
matrix:, a ✅/❌ heatmap of the sweep (a row for one axis, a table for two). - A failure-logs section — a collapsible block with the tail of any failing step's output.
- Uploaded artifacts — a
cilicon-resultsbundle: the JUnit report, the pulled binaries, and any telemetry file.
The cilicon.yml
A cilicon.yml is a list of targets. A target is
intentionally small and uniform across wildly different silicon: it carries its own
toolchain (a base image + apt, or a dockerfile)
and its own way of proving the artifact actually runs (a validation tier).
Every target needs at least an id and a build. Unknown
fields are rejected, and so is an unknown validate tier (unless it's
custom). The whole project directory is mounted into every sandbox.
targets:
- id: stm32h7/cortex-m
base: debian:bookworm-slim
apt: [gcc-arm-none-eabi, qemu-system-arm]
build: arm-none-eabi-gcc ... -o build/firmware.elf
validate: qemu_system
machine: lm3s6965evb
artifact: build/firmware.elf
expect: "BOOT OK"
Field reference
Identity
| Field | Type | Meaning |
|---|---|---|
id | required | Unique target id. Its slug is used for matching and reports. |
build | required | Shell command that produces the artifact, run in /work. |
validate | string | A preset tier name (default native) or custom. |
Toolchain
| Field | Type | Meaning |
|---|---|---|
base | string | Base Docker image (default debian:bookworm-slim). |
apt | list | apt packages to install on top of base. |
dockerfile | string | Path to a custom Dockerfile; overrides base + apt. |
board | string | One-word alias that fills base/apt/validate/machine as defaults. |
Proof-it-runs
| Field | Type | Meaning |
|---|---|---|
run | string | Custom validate command — required when validate: custom. |
artifact | string | Path (in /work) to the built binary the tier runs. |
machine | string | qemu-system machine (default lm3s6965evb). |
qemu_bin | string | qemu-user launcher for this arch (default qemu-arm). |
renode_script | string | .resc path — required for the renode tier. |
sim_bin | string | The FVP / vendor simulator binary — required for the sim tier. |
gpu | string | Modal GPU type for real_gpu / custom, e.g. T4 or H100:2. |
Assertions — how cilicon judges "it actually ran"
| Field | Type | Meaning |
|---|---|---|
expect | list | All substrings must appear in the output. |
expect_regex | string | A regex that must match the output. |
expect_exit | int | Require this exact exit code. |
expect_not | list | None may appear — fault strings that fail the target even if expect matched. |
crash_check | bool | Auto-fail on the tier's crash markers (HardFault, Segmentation fault, …). Default true. |
Size budget — does it fit the silicon?
| Field | Type | Meaning |
|---|---|---|
size_tool | string | e.g. arm-none-eabi-size; cilicon runs it on the artifact. |
flash_max | size | text + data must fit. Accepts 256K / 1M / raw bytes. |
ram_max | size | data + bss must fit. Same human-size parsing. |
Test phase & plumbing
| Field | Type | Meaning |
|---|---|---|
test | string | Command whose exit 0 == pass — a second on-target check after boot. |
test_format | string | unity or tap — parse into per-test pass/fail rows. |
env | mapping | Environment variables exported in the sandbox. |
secrets | list | Modal secret names to mount into the sandbox (e.g. a vendor license). |
artifacts | list | Globs to pull back out (with --artifacts). |
paths | list | Only run this target when a changed file matches (with --changed-files). |
boot_timeout | int | Seconds to let an emulator boot (default 60). |
timeout | int | Seconds for the whole target (default 900). |
The matrix: block
A matrix: expands one target entry into the cartesian
product of its values — each combination becomes its own independent target and its
own cloud container. Keep {var} in the id to keep ids unique.
- id: node-{arch}
matrix:
arch: [arm, aarch64, riscv64]
base: debian:bookworm-slim
apt: ["gcc-{arch}-linux-gnu", qemu-user]
build: "{arch}-linux-gnu-gcc -static -O2 src/perception.c -o build/app-{arch}"
validate: custom
run: "stdbuf -oL qemu-{arch} ./build/app-{arch}"
artifact: build/app-{arch}
expect: "perception: engine ok"
This produces node-arm, node-aarch64, node-riscv64. Substitution is a plain token replace (not str.format), so shell ${...} and $(( ... )) in your commands survive untouched.
Boards
A board is a one-word alias for a bundle of target fields, applied as defaults
(anything you set explicitly on the target wins). Define your own under a top-level
boards: — a board can set any target field.
boards:
my-mcu: # then use: board: my-mcu
base: my-registry/toolchain:latest
apt: [gcc-arm-none-eabi, qemu-system-arm]
validate: qemu_system
machine: mps2-an385
Your boards extend — and can override by name — a built-in catalog of 100+ starters across families. Run cilicon boards to list them all, grouped by tier:
| Family | Examples | Tier |
|---|---|---|
| Cortex-M / bare-metal | ti-lm3s6965, arm-mps2-an505, bbc-microbit | qemu_system |
| ARM Linux SoCs | rpi-3, rpi-5, jetson-orin, imx8mp | qemu_user |
| RISC-V | sifive-u, riscv-spike, starfive-visionfive2 | qemu_*_riscv |
| ESP / Xtensa | esp32, esp32-s3, esp32-c3 | qemu_esp32 |
| Renode (peripheral-accurate) | renode-stm32f4-discovery, renode-nrf52840 | renode |
| GPU (real silicon) | gpu-t4, gpu-a100-80gb, gpu-h100 | real_gpu |
Validation tiers
A validation tier is how a target proves its artifact actually runs.
Tiers are data, not code — adding a chip is one YAML entry, never a
cilicon code change. A tier cilicon has never heard of is validate: custom + run:.
real_gpu runs in an emulator or simulator. A green check
proves the code builds, fits, and runs far enough to print an expected string — it is
not silicon certification. The real_gpu tier is the one exception:
it runs on a physical Modal GPU.
Runs the artifact directly on the host (./{artifact}). For host-arch binaries.
ARM Linux ELF under qemu-arm: the ELF loads, shared libs resolve, it reaches main, and runs to completion (crashes like SIGSEGV are caught). Output is line-buffered so partial output survives a crash.
ARM64 / RISC-V 64 Linux ELF under qemu-aarch64 / qemu-riscv64.
Boot firmware in a full-system emulator and drive the virtual UART; ESP32 images boot under qemu-system-xtensa. The expect string is the proof — a clean exit isn't required.
Firmware in Renode — peripheral-accurate where QEMU isn't: it models the real board's peripherals, not just the CPU. Needs a .resc script. Use the antmicro/renode image.
Cycle-accurate vendor simulator / ARM Fast Models (FVP) — models actual core timing + peripherals. You bring the simulator binary in your image and name it with sim_bin. Higher fidelity than QEMU, but still a model.
Not emulation — runs the artifact on a real GPU in Modal. Defaults gpu: T4; set gpu: to any Modal type, optionally with a count like H100:2.
The escape hatch: you supply run: — any tier cilicon has never heard of, in pure YAML, with no code change. A gpu: can be requested here too.
GPU catalog
The gpu: field (used by real_gpu and custom)
accepts any Modal GPU type. Run cilicon gpus to list them. Smallest → biggest:
T4 L4 A10G A100 A100-40GB A100-80GB L40S H100 H200 B200
- Add a count with
:N, e.g.A100-80GB:2orH100:2. A spec likeT4means one GPU. - Unknown names pass through to Modal (so a newly-launched GPU works day one) but won't be flagged as "known" by
cilicon gpus.
- id: infer/cuda
base: nvidia/cuda:12.4.1-devel-ubuntu22.04
build: nvcc -O2 src/gpu/infer.cu -o build/infer
validate: real_gpu
gpu: "H100"
artifact: build/infer
expect: "infer: gpu ok"