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.

.github/workflows/cilicon.yml
# 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.

zsh
$ modal token new          # opens a browser, writes a local token
In CI You don't run 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.

zsh
$ cilicon run

You'll see a live table as each target builds and boots in its own container:

cilicon — output
  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.

CommandWhat it does
cilicon targetsList the targets in cilicon.yml, fully matrix-expanded, with what each builds, validates, and proves.
cilicon presetsList every built-in validation tier (see tiers).
cilicon boardsList the one-word board bundles (base + apt + tier + machine).
cilicon gpusList the Modal GPU types usable by the real_gpu tier.
cilicon doctorValidate a config without running anything.
cilicon runBuild + validate the whole matrix in parallel.

cilicon run flags

FlagMeaning
--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-regressionFail the run on a size regression past --regression-pct (default 5%).
--telemetry <path>Append JSONL run/target/phase events to this path.
zsh
# 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:

cilicon.yml
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:

.github/workflows/cilicon.yml
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:

SecretValue
MODAL_TOKEN_IDthe token id from modal token new
MODAL_TOKEN_SECRETthe token secret from modal token new

Action inputs

The two Modal tokens are passed as env:, not inputs.

InputDefaultMeaning
configcilicon.ymlPath to cilicon.yml, relative to your repo.
target""Run a single target by id; empty runs the whole matrix.
reportcilicon-results.xmlPath the JUnit report is written to.
artifactscilicon-artifactsDirectory built artifacts are pulled into.
baseline""Baseline JSON to check for flash/RAM/boot regressions.
fail-on-regressionfalseFail 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:

  1. Go to Settings → Branches → Branch protection rules for your default branch.
  2. Enable Require status checks to pass before merging.
  3. Add cilicon / build + boot as a required check (the name comes from the workflow's job.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-results bundle: 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.

cilicon.yml
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

FieldTypeMeaning
idrequiredUnique target id. Its slug is used for matching and reports.
buildrequiredShell command that produces the artifact, run in /work.
validatestringA preset tier name (default native) or custom.

Toolchain

FieldTypeMeaning
basestringBase Docker image (default debian:bookworm-slim).
aptlistapt packages to install on top of base.
dockerfilestringPath to a custom Dockerfile; overrides base + apt.
boardstringOne-word alias that fills base/apt/validate/machine as defaults.

Proof-it-runs

FieldTypeMeaning
runstringCustom validate command — required when validate: custom.
artifactstringPath (in /work) to the built binary the tier runs.
machinestringqemu-system machine (default lm3s6965evb).
qemu_binstringqemu-user launcher for this arch (default qemu-arm).
renode_scriptstring.resc path — required for the renode tier.
sim_binstringThe FVP / vendor simulator binary — required for the sim tier.
gpustringModal GPU type for real_gpu / custom, e.g. T4 or H100:2.

Assertions — how cilicon judges "it actually ran"

FieldTypeMeaning
expectlistAll substrings must appear in the output.
expect_regexstringA regex that must match the output.
expect_exitintRequire this exact exit code.
expect_notlistNone may appear — fault strings that fail the target even if expect matched.
crash_checkboolAuto-fail on the tier's crash markers (HardFault, Segmentation fault, …). Default true.

Size budget — does it fit the silicon?

FieldTypeMeaning
size_toolstringe.g. arm-none-eabi-size; cilicon runs it on the artifact.
flash_maxsizetext + data must fit. Accepts 256K / 1M / raw bytes.
ram_maxsizedata + bss must fit. Same human-size parsing.

Test phase & plumbing

FieldTypeMeaning
teststringCommand whose exit 0 == pass — a second on-target check after boot.
test_formatstringunity or tap — parse into per-test pass/fail rows.
envmappingEnvironment variables exported in the sandbox.
secretslistModal secret names to mount into the sandbox (e.g. a vendor license).
artifactslistGlobs to pull back out (with --artifacts).
pathslistOnly run this target when a changed file matches (with --changed-files).
boot_timeoutintSeconds to let an emulator boot (default 60).
timeoutintSeconds 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.

cilicon.yml
- 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.

cilicon.yml
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:

FamilyExamplesTier
Cortex-M / bare-metalti-lm3s6965, arm-mps2-an505, bbc-microbitqemu_system
ARM Linux SoCsrpi-3, rpi-5, jetson-orin, imx8mpqemu_user
RISC-Vsifive-u, riscv-spike, starfive-visionfive2qemu_*_riscv
ESP / Xtensaesp32, esp32-s3, esp32-c3qemu_esp32
Renode (peripheral-accurate)renode-stm32f4-discovery, renode-nrf52840renode
GPU (real silicon)gpu-t4, gpu-a100-80gb, gpu-h100real_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:.

Honesty about fidelity Every tier except 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.
native

Runs the artifact directly on the host (./{artifact}). For host-arch binaries.

qemu_user

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.

qemu_user_aarch64qemu_user_riscv64

ARM64 / RISC-V 64 Linux ELF under qemu-aarch64 / qemu-riscv64.

qemu_systemqemu_esp32

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.

renode

Firmware in Renodeperipheral-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.

sim

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.

real_gpu

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.

custom

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:2 or H100:2. A spec like T4 means 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.
cilicon.yml
- 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"