LabWired provides a CI-friendly runner mode driven by a YAML test script:
labwired test --script test.yamlEvery v1.0 script selects exactly one run shape: a single machine or an
environment world. The two inputs forms are mutually exclusive.
Use inputs.firmware (and optionally inputs.system) to run one machine. A
per-machine system.yaml is the same system manifest consumed by the
Playground; CI does not use a separate board-description format.
labwired test --firmware path/to/fw.elf --system system.yaml --script test.yamlschema_version: "1.0"
inputs:
firmware: "relative/or/absolute/path/to/fw.elf"
chip: "stm32f103"
limits:
max_steps: 100000
assertions:
- uart_contains: "Hello"inputs.chip names one of the chips bundled with the CLI, for firmware with
nothing wired to it. This is the whole configuration — there is no manifest to
write and no chip descriptor to copy into your repository, so your test tracks
our silicon model instead of freezing a copy of it. Run labwired chips for
the available names.
Firmware with external devices or board I/O uses inputs.system instead,
pointing at a manifest:
inputs:
firmware: "path/to/fw.elf"
system: "path/to/system.yaml"A manifest's chip: field takes the same two forms: a built-in name, or a path
to your own descriptor for silicon we do not ship. Setting both inputs.chip
and inputs.system is an error.
--firmware and --system are single-machine overrides only. They are not
valid with an environment script.
Use inputs.env to select an environment manifest. The manifest owns the
world topology: every node supplies its id, the shared-with-Playground
system.yaml, and its firmware ELF. Connectivity belongs only in explicit
interconnects; it is never implied by CLI overrides.
# test.yaml
schema_version: "1.0"
inputs:
env: "two-node-env.yaml"
limits:
max_steps: 100000
max_cycles: 123456 # optional
max_uart_bytes: 4096 # optional
wall_time_ms: 5000 # optional
stop_when_assertions_pass: true # optional; default false
stop_when_assertions_pass_settle_steps: 1000 # optional; default 100000
stop_when_assertions_pass_min_steps: 1000 # optional; default 0
assertions:
- memory_value:
node: alpha
address: 0x20000000
expected_value: 0
size: 8
mask: 0xff# two-node-env.yaml
schema_version: "1.0"
name: "two-node-smoke"
nodes:
- id: alpha
system: "systems/alpha.yaml"
firmware: "firmware/alpha.elf"
- id: beta
system: "systems/beta.yaml"
firmware: "firmware/beta.elf"
interconnects:
- type: can_bus
nodes: [alpha, beta]
config:
peripheral: can1An environment assertion must be a node-qualified memory_value assertion.
v0.19 environment worlds are Cortex-M-only: each resolved node chip must
resolve to arch: arm and explicitly declare core: cortex-m*. Its firmware
must be an EM_ARM ELF with a valid Cortex-M Thumb reset vector at
flash.base + reset_vector_offset: the initial stack pointer must land in RAM
and the Thumb-bit reset handler must land in flash. A node outside that contract
is rejected as a configuration error before a Cortex-M machine is constructed.
nodes[].config_overrides must be omitted. Every explicit occurrence,
including {} and null, is rejected in environment schema 1.0.
Interconnect membership and config are validated before the world starts.
Each config mapping is closed and type-checked:
uart_cross_linkrequires exactly two unique, known nodes. Its only optional keys are non-empty stringsnode_a_uartandnode_b_uart(each defaults touart2).can_busrequires at least two unique, known nodes. Its only key is required non-empty stringconfig.peripheral.egressrequires exactly one known node. Its only keys are optional non-empty stringsuart(defaultusart2),transport(tcp,mqtt, orhttp),encoding(raw,ndjson-trace, orframes-json), required non-empty stringurl, MQTT-only required non-empty stringtopic, and positive integerbuffer_max.topicis invalid for TCP and HTTP.
Environment scripts do not accept single-machine firmware/system overrides or topology-affecting CLI options.
stop_when_assertions_pass is opt-in for an environment world and has the
same durable completion contract as a single-machine run. Once every
node-qualified memory_value assertion passes at or after
stop_when_assertions_pass_min_steps, it must remain true for
stop_when_assertions_pass_settle_steps world rounds (default 100000). A
regression restarts that window. A runtime failure or a same-round
wall_time_ms, max_cycles, or max_uart_bytes limit wins over completion.
max_steps remains the outer execution bound, so completion on its final
allowed world round reports assertions_passed; otherwise the result has
stop_reason: assertions_passed. The three fields are respectively a boolean
and non-negative integers; explicit YAML null is rejected.
| Code | Meaning | Notes |
|---|---|---|
0 |
Pass | All assertions passed, and any non-success stop reason was explicitly asserted (if applicable). |
1 |
Assertion failure | Includes failed assertions, and hitting wall_time_ms / max_uart_bytes / no_progress_steps without asserting the matching expected_stop_reason. |
2 |
Config/script error | Invalid YAML, unknown fields, unsupported schema_version, missing/invalid inputs/limits, or a safety guard (e.g. max-steps cap). |
3 |
Simulation/runtime error | Runtime failure (e.g. memory_violation, decode_error) unless an expected_stop_reason assertion matches the stop reason. |
Exit-code precedence:
- Any failed assertion ⇒
1(even if a runtime error also occurred) wall_time/max_uart_bytes/no_progressstop without matchingexpected_stop_reason⇒1- Runtime error stop without matching
expected_stop_reason⇒3 - Otherwise ⇒
0
schema_version: "1.0"
inputs:
firmware: "relative/or/absolute/path/to/fw.elf"
system: "optional/path/to/system.yaml"
limits:
max_steps: 100000
max_cycles: 123456 # optional
max_uart_bytes: 4096 # optional
no_progress_steps: 500 # optional (PC unchanged for N steps)
wall_time_ms: 5000 # optional
assertions:
- uart_contains: "Hello"
- uart_regex: "^Hello.*$"
- expected_stop_reason: max_stepsNotes:
- Unknown fields are rejected (script parse/validation returns exit code
2). - Relative
inputs.firmware/inputs.systempaths are resolved relative to the directory containing the script file (not the current working directory). - Relative
inputs.envis resolved relative to the test script; each node'ssystemandfirmwarepaths are resolved relative to the environment manifest. - For a single-machine script, CLI flags override script inputs:
--firmwareoverridesinputs.firmware--systemoverridesinputs.system
- For an environment script,
inputs.envis the only topology input;--firmwareand--systemare rejected. - CLI flags override script limits:
--max-stepsoverrideslimits.max_steps--max-cyclesoverrideslimits.max_cycles--max-uart-bytesoverrideslimits.max_uart_bytes--detect-stuck(alias:--no-progress) overrideslimits.no_progress_steps
--breakpoint <addr>(repeatable) stops the run when PC matches and setsstop_reason: halt.
The single-machine example above permits the documented single-machine
assertions. Environment scripts are stricter: they require at least one
node-qualified memory_value assertion and support max_steps, max_cycles,
max_uart_bytes, wall_time_ms, stop_when_assertions_pass,
stop_when_assertions_pass_settle_steps, and
stop_when_assertions_pass_min_steps limits. They reject
limits.no_progress_steps and single-machine-only controls such as
--breakpoint.
For backward compatibility, schema_version: 1 is still accepted, but is deprecated and will be removed in a future release.
Legacy shape:
schema_version: 1
firmware: "optional/path/to/fw.elf" # optional (can be provided by --firmware)
system: "optional/path/to/system.yaml"
max_steps: 100000
wall_time_ms: 5000 # optional
assertions: []expected_stop_reason supports:
max_stepsmax_cyclesmax_uart_bytesno_progresswall_timeassertions_passedmemory_violationdecode_errorhaltexceptionconfig_error(runner failed before simulation started; e.g. script parse/validation error)
Semantics:
- If the simulator hits
wall_time_ms, the run is treated as an assertion failure (exit code1) unless anexpected_stop_reasonassertion matcheswall_time. - If the simulator hits
max_uart_bytesorno_progress_steps, the run is treated as an assertion failure (exit code1) unless anexpected_stop_reasonassertion matches (max_uart_bytes/no_progress). - If the simulator hits
max_stepsormax_cycles, the run is considered a normal stop (exit code0) as long as assertions pass. - If opt-in assertion completion reaches its durable settling window, the run
stops with
assertions_passedand passes. - If the simulator hits a runtime error stop reason (e.g.
memory_violation), the run is treated as a runtime error (exit code3) unless anexpected_stop_reasonassertion matches the stop reason.
result.json uses:
result_schema_version: the result-union discriminator:"1.0"for a single machine or"1.0-environment"for an environment worldrun_type: "environment": present only on the environment armstop_reason: the terminal reason the simulator stoppedstop_reason_details: which stop condition triggered (+ the limit/observed value when applicable)limits: the resolved limits used for the run (after applying any CLI overrides)status: one ofpass,fail,error
Use --output-dir to write artifacts:
labwired test --script test.yaml --output-dir out/artifactsArtifacts:
out/artifacts/result.json: machine-readable summaryout/artifacts/snapshot.json: a CPU snapshot for a single machine, or an environment snapshot with anodesarray for a world (also written for config errors)out/artifacts/uart.log: captured UART TX bytes; world output is grouped by nodeout/artifacts/junit.xml: JUnit XML report (one testcase forrun+ one per assertion)
Alternatively, you can write JUnit XML to a specific path:
labwired test --script test.yaml --junit out/junit.xml--script: if relative, resolved relative to the current working directory.- Script-relative paths:
inputs.firmware,inputs.system(v1.0)inputs.env(environment v1.0)firmware,system(legacy v1) are resolved relative to the directory containing the script file.
- Environment-manifest-relative paths: each
nodes[].systemandnodes[].firmwarevalue is resolved relative to the environment manifest. - System manifest-relative paths:
system.yamlmay referencechip: ...; thischippath is resolved relative to the directory containing the system manifest file. --output-dir/--junit: if relative, resolved relative to the current working directory.
The runner writes result.json only when --output-dir is provided,
including config/script errors that exit with code 2. Its top-level shape is
a discriminated union. Consumers must branch on
result_schema_version—and, for worlds, run_type—rather than assuming one
firmware identity.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "LabWired CI runner result.json",
"$defs": {
"common": {
"type": "object",
"required": [
"status",
"steps_executed",
"cycles",
"instructions",
"stop_reason",
"stop_reason_details",
"limits",
"assertions"
],
"properties": {
"status": { "enum": ["pass", "fail", "error"] },
"steps_executed": { "type": "integer", "minimum": 0 },
"cycles": { "type": "integer", "minimum": 0 },
"instructions": { "type": "integer", "minimum": 0 },
"stop_reason": {
"enum": [
"config_error",
"max_steps",
"max_cycles",
"max_uart_bytes",
"no_progress",
"wall_time",
"assertions_passed",
"memory_violation",
"decode_error",
"halt",
"exception"
]
},
"stop_reason_details": { "type": "object" },
"limits": { "type": "object" },
"message": { "type": ["string", "null"] },
"assertions": { "type": "array" },
"fidelity": { "type": "array" }
}
},
"environment_node": {
"type": "object",
"required": ["id", "system", "firmware", "system_hash", "firmware_hash"],
"properties": {
"id": { "type": "string" },
"system": { "type": "string" },
"firmware": { "type": "string" },
"system_hash": { "type": "string" },
"firmware_hash": { "type": "string" }
}
}
},
"oneOf": [
{
"title": "single-machine result",
"allOf": [
{ "$ref": "#/$defs/common" },
{
"type": "object",
"required": ["result_schema_version", "firmware_hash", "config"],
"properties": {
"result_schema_version": { "const": "1.0" },
"firmware_hash": {
"type": "string",
"description": "SHA-256 of the firmware ELF bytes."
},
"config": {
"type": "object",
"required": ["firmware", "system", "script"],
"properties": {
"firmware": { "type": "string" },
"system": { "type": ["string", "null"] },
"script": { "type": "string" }
}
}
}
}
]
},
{
"title": "environment/world result",
"allOf": [
{ "$ref": "#/$defs/common" },
{
"type": "object",
"required": ["result_schema_version", "run_type", "config"],
"not": { "required": ["firmware_hash"] },
"properties": {
"result_schema_version": { "const": "1.0-environment" },
"run_type": { "const": "environment" },
"config": {
"type": "object",
"required": ["script", "environment", "world_firmware_hash", "nodes"],
"properties": {
"script": { "type": "string" },
"environment": { "type": "string" },
"world_firmware_hash": { "type": "string" },
"nodes": {
"type": "array",
"items": { "$ref": "#/$defs/environment_node" }
}
}
}
}
}
]
}
]
}The single-machine arm has the top-level firmware_hash and a
config.firmware/system/script provenance object. The environment arm has no
top-level firmware_hash: it identifies the world with
config.world_firmware_hash and records each node's id, system,
firmware, system_hash, and firmware_hash. A world config error still
uses the environment arm; its nodes list can be empty when the manifest
could not be resolved. That includes explicit config_overrides (including
{} and null), a chip without an explicit Cortex-M core, an ARM ELF without
a valid Thumb reset vector, and unknown, mistyped, or invalid interconnect
configuration; these produce status: "error" with stop_reason: "config_error" without changing the result-union arm.
Use the pinned v0.21.0 release runner in CI. It runs the same labwired test
command described above and writes the same artifact contract.
Use the public Core action and pin the Core CLI with its version input. Its
only inputs are required script, optional version (default
v0.21.0), output-dir, and args:
- id: labwired
name: Run LabWired tests
uses: w1ne/labwired-core/.github/actions/labwired-test@bfd879522914b586223081c4c89ba315db4a97ed
with:
version: v0.21.0
script: examples/ci/dummy-max-steps.yaml
output-dir: out/artifacts
args: --no-uart-stdout
- name: Link the automatic LabWired artifact
if: always()
run: echo "${{ steps.labwired.outputs.artifact-url }}" >> "$GITHUB_STEP_SUMMARY"The Core action is an immutable action-source pin to
bfd879522914b586223081c4c89ba315db4a97ed; version: v0.21.0 independently
pins the immutable Core CLI release. It downloads that public release archive
with curl, creates output-dir/junit.xml plus Markdown and HTML reports,
appends the Markdown report to the job summary, and always uploads the entire
output directory—even after a failed test. Its status, summary-md,
report-html, artifact-url, and exit-code outputs are available through
the labwired step ID.
The GHCR image has labwired as its entrypoint. For Docker, pass test directly
after the pinned image name. Docker and the Action accept the same test YAML:
use either a single-machine script or an inputs.env world script.
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace \
ghcr.io/w1ne/labwired:v0.21.0 \
test --script examples/ci/dummy-max-steps.yaml \
--output-dir out/artifacts \
--no-uart-stdoutGitLab should clear that entrypoint and invoke labwired from its job shell:
image:
name: ghcr.io/w1ne/labwired:v0.21.0
entrypoint: [""]
script:
- labwired test --script examples/ci/dummy-max-steps.yaml --output-dir out/artifacts --no-uart-stdoutBuild from source only when validating an unreleased LabWired commit. It is not the normal CI path:
cargo build --release -p labwired-cli
./target/release/labwired test --script examples/ci/dummy-max-steps.yaml --output-dir out/artifacts --no-uart-stdout