embeddedCI documentation

Writing your own buildpacks

The embeddedci-registry is the public repository that contains all buildpacks. You can contribute new packs by opening a pull request.

Registry structure

The registry organizes packs by category. Each pack is a directory containing a definitions.yaml and a scripts/ directory:

embeddedci-registry/
└── packs/
    ├── apps/
    │   └── build/
    │       ├── definitions.yaml
    │       └── scripts/
    │           └── build.sh
    ├── firmware/
    │   └── stm32-build/
    │       ├── definitions.yaml
    │       └── scripts/
    │           └── build.sh
    ├── system/
    │   ├── kernel-linux/
    │   ├── uboot/
    │   ├── rootfs-image/
    │   ├── initramfs-busybox/
    │   ├── buildroot-kernel/
    │   ├── buildroot-rootfs/
    │   └── yocto-image/
    ├── images/
    │   ├── bootfat/
    │   └── sdcard/
    └── test/
        ├── hardware/
        └── qemu/

The pack ID used in embeddedci.yaml is the path relative to packs/. For example, packs/firmware/stm32-build/ becomes firmware/stm32-build.

Anatomy of a buildpack

Every pack needs two files at minimum:

  • definitions.yaml — declares the pack's type, dependencies, config template, and artifacts.
  • scripts/build.sh — the shell script that does the actual work.

definitions.yaml reference

The full schema with inline comments:

type: firmware          # Pack type (see table below)
image: ghcr.io/...      # Optional: Docker image to run the build in

scripts:
  build: scripts/build.sh   # Path to the build script (relative to pack dir)
  run: scripts/run.sh       # For test packs: the test runner script

default_vars:              # Default values for template variables
  my_var: default_value    # Overridden by user's config in embeddedci.yaml

dependencies:              # Sources and upstream pack artifacts to fetch
  - pack: fetch            # "fetch" = git clone a repo
    dest: my_source        # Directory name in build root
    src: "{{my_repo}}"     # Template referencing a var or config key
    optional: true         # Don't fail if src is empty

  - pack: apps/build       # Reference another pack's output
    dest: "out/app-build/{{artifact}}"
    optional: true

artifacts:                 # What this pack produces
  - path: output/          # Fixed path (directory or file)
  - path_var: artifact_bin # Path read from a config variable
    path_prefix: "out/"    # Prepended to the variable value

skip:                      # Rebuild optimization
  check_paths:             # Skip build if these paths already exist
    - output/

config:                    # Template → env var mapping
  my_var: "{{my_var}}"    # Becomes MY_VAR in the build environment
  build_root_path: "{{build_root}}/my_source"

Pack types

TypePurposeExamples
firmwareMicrocontroller firmware buildfirmware/stm32-build
appUserspace application buildapps/build
kernelLinux kernel buildsystem/kernel-linux, system/buildroot-kernel
bootBootloader buildsystem/uboot
rootfsRoot filesystem generationsystem/buildroot-rootfs, system/initramfs-busybox
rootfs-imageStandalone rootfs image (ext4)system/rootfs-image
packageCombine artifacts into a bootable imageimages/bootfat
image-sdcardFull disk image from partitionsimages/sdcard
yoctoYocto/KAS-based image buildsystem/yocto-image
testTest runner (QEMU or hardware)test/qemu, test/hardware

Dependencies

Dependencies declare what a pack needs before it can run. There are two kinds:

Fetch dependencies

Clone a git repository into the build root. Use pack: fetch with a src template.

dependencies:
  # Clone a git repo into the build root
  - pack: fetch
    dest: kernel_src               # Directory name under build root
    src: "{{repo}}?ref={{ref}}"    # Git URL with optional ref

  # Clone user's project source (optional — may not have src set)
  - pack: fetch
    dest: project_src
    src: "{{src}}"
    optional: true

Pack dependencies

Consume artifacts from another pack that ran earlier in the pipeline.

dependencies:
  # Consume output from apps/build
  - name: app_bin                  # Logical name (used in env_mapping for test packs)
    pack: apps/build               # Pack ID to depend on
    dest: "out/app-build/{{artifact}}"
    optional: true                 # Don't fail if apps/build wasn't in the pipeline

Config and template variables

The config section maps template variables to environment variables. Templates use {{variable}} syntax. The engine resolves them from (in priority order):

  1. User's config in embeddedci.yaml
  2. Pack's default_vars
  3. Board variables (prefixed BOARD_)
  4. Built-in variables: {{build_root}}, {{project_root}}, {{arch_image_name}}

Config keys become uppercase environment variables in the build script. For example, my_lib_dir: "{{build_root}}/my_lib" becomes MY_LIB_DIR=/path/to/build/my_lib.

Artifacts

Declare what your pack produces so downstream packs and the engine can find the outputs:

# Fixed path — always produces this file/directory
artifacts:
  - path: kernel

# Variable path — filename comes from user config
artifacts:
  - path_var: artifact_elf
    path_prefix: "out/stm32-build/"

# With build_path — artifact is at a different location during build
artifacts:
  - path: rootfs.ext4
    build_path: "buildroot_out/images/rootfs.ext4"

Use path for fixed outputs, path_var when the filename depends on user config, and build_path when the file lives at a different location during the build.

Writing the build script

The build script runs inside the build environment with all config variables as uppercase env vars. Key environment variables available to every build script:

VariableDescription
BUILD_ROOTRoot directory for the build workspace (fetched sources, intermediate files).
PROJECT_ROOTRoot of the user's project (where embeddedci.yaml lives).
BOARD_ARCHTarget architecture (e.g. arm, arm64).
BOARD_CROSS_COMPILECross-compiler prefix (e.g. aarch64-linux-gnu-).
BOARD_CCC compiler (e.g. aarch64-linux-musl-gcc).
BOARD_DEFCONFIGKernel/U-Boot defconfig for the target board.

Example build script:

#!/bin/bash
set -euo pipefail

# Environment variables from config are available as uppercase:
#   CMD, ARTIFACT_BIN, MY_LIB_DIR, PROJECT_DIR
# Board variables are also available:
#   BOARD_ARCH, BOARD_CROSS_COMPILE, BOARD_CC, etc.

echo "==> Building with: $CMD"

# Use project source if fetched, otherwise fall back to PROJECT_ROOT
SRC_DIR="${PROJECT_DIR:-$PROJECT_ROOT}"

cd "$SRC_DIR"

# Run the user's build command
eval "$CMD"

# Copy artifact to the output location
ARTIFACT_DIR="$BUILD_ROOT/out/my-pack"
mkdir -p "$ARTIFACT_DIR"
cp "$ARTIFACT_BIN" "$ARTIFACT_DIR/"

echo "==> Build complete: $ARTIFACT_BIN"

Writing test packs

Test packs use type: test and typically have a scripts.run instead of scripts.build. They include a test_config section with timeout, success patterns, and environment variable mapping from dependency names.

type: test
scripts:
  run: scripts/run.sh

test_config:
  timeout_sec: 30
  cpu: cortex-a53
  memory: 1024
  boot_success_pattern: "Kernel: Linux"
  env_mapping:
    UBOOT_BIN: uboot_bin
    BOOTFAT_IMG: bootfat_img

dependencies:
  - name: uboot_bin
    pack: boot/uboot
    dest: u-boot.bin
  - name: bootfat_img
    pack: boot/bootfat
    dest: bootfat.img

artifacts: []

The env_mapping maps environment variable names to dependency name fields, so the run script can find artifact paths via environment variables.

Complete example

Here's a complete custom pack that builds firmware with a custom library:

definitions.yaml

type: firmware
scripts:
  build: scripts/build.sh

default_vars:
  my_lib_repo: https://github.com/example/my-lib.git
  my_lib_ref: v1.0.0

dependencies:
  - pack: fetch
    dest: project_src
    src: "{{src}}"
    optional: true
  - pack: fetch
    dest: my_lib
    src: "{{my_lib_repo}}?ref={{my_lib_ref}}"

artifacts:
  - path_var: artifact_bin
    path_prefix: "out/my-pack/"

config:
  src: "{{src}}"
  cmd: "{{cmd}}"
  artifact_bin: "{{artifact_bin}}"
  my_lib_dir: "{{build_root}}/my_lib"
  project_dir: "{{build_root}}/project_src"

scripts/build.sh

#!/bin/bash
set -euo pipefail

# Environment variables from config are available as uppercase:
#   CMD, ARTIFACT_BIN, MY_LIB_DIR, PROJECT_DIR
# Board variables are also available:
#   BOARD_ARCH, BOARD_CROSS_COMPILE, BOARD_CC, etc.

echo "==> Building with: $CMD"

# Use project source if fetched, otherwise fall back to PROJECT_ROOT
SRC_DIR="${PROJECT_DIR:-$PROJECT_ROOT}"

cd "$SRC_DIR"

# Run the user's build command
eval "$CMD"

# Copy artifact to the output location
ARTIFACT_DIR="$BUILD_ROOT/out/my-pack"
mkdir -p "$ARTIFACT_DIR"
cp "$ARTIFACT_BIN" "$ARTIFACT_DIR/"

echo "==> Build complete: $ARTIFACT_BIN"

Reading existing packs

The best way to learn how packs work is to read the existing ones. The registry is public — browse the packs/ directory to see how each pack is structured:

  • packs/firmware/stm32-build/ — STM32 firmware build with HAL/CMSIS fetching and Docker image.
  • packs/apps/build/ — Simple app build, good starting point for a custom pack.
  • packs/system/kernel-linux/ — Kernel build with defconfig merging and board variable integration.
  • packs/system/rootfs-image/ — Rootfs generation with user management and app staging.
  • packs/test/qemu/ — QEMU test runner with dependency wiring via env_mapping.

Contributing a new pack

  1. Fork the embeddedci-registry repository.
  2. Create your pack directory under packs/<category>/<name>/.
  3. Add definitions.yaml with type, dependencies, config, and artifacts.
  4. Add scripts/build.sh (make it executable with chmod +x).
  5. Test your pack locally using the EmbeddedCI client.
  6. Open a pull request with a description of your pack and an example embeddedci.yaml.

PR checklist

  • Pack lives under packs/<category>/<name>/
  • definitions.yaml has type, scripts, dependencies, artifacts, and config
  • scripts/build.sh is executable
  • Build script uses set -euo pipefail
  • All config keys use template variables ({{var}})
  • Default values provided in default_vars where sensible
  • Tested locally with the EmbeddedCI client

Further reading