Manifest
.ciresk/ci.yaml is the only place a repository tells Ciresk CI what a check, test, build or lint is, which files feed it, which toolchains it needs and whether its result may be cached.
The runner executes what the manifest declares and knows no language: Cargo, pnpm, CMake and Gradle repositories onboard with this one file. ciresk ci init writes a first version for the toolchain it detects; this page is the reference for everything in it.
Rules that apply to the whole file
| Rule | What it means |
|---|---|
| Location | .ciresk/ci.yaml at the repository root. A monorepo may hold more, and the nearest one at or above the directory you run from governs. |
| Unknown fields are errors | The parser rejects any field it does not know, so a typo never silently changes a cache key. |
| Globs are relative to the manifest's directory | src/** in apps/web/.ciresk/ci.yaml means apps/web/src/**. |
| The manifest is an input to each task | A task's cache key holds its own definition and what it inherits (toolchains, globals, workspace, the definitions of the tasks it depends on), so an edit to another task leaves its key alone. Any change to the governing manifest still runs every task on its full target set. |
Fields
Top level
| Field | One sentence | Example |
|---|---|---|
version | Schema version; the parser rejects any value other than 1. | version: 1 |
toolchains | Tools and exact versions provisioned on the worker with mise; every entry enters every cache key. | toolchains: { rust: "1.95.0", node: "24.x", pnpm: "10.33" } |
globals | Settings that apply to every task in the manifest. | See below |
workspace | How the checkout on the worker is kept between jobs. | See below |
tasks | The named tasks; check, test, build and lint are the standard kinds the CLI aliases. | See below |
A version range such as "24.x" is resolved to an exact version by mise on the worker at the first lease; the resolved version enters the key and the report as toolchains_resolved. Unpinned values (stable, latest, nightly, lts, *) and an empty value are refused by the CLI before anything is uploaded (exit 2).
A name with a backend prefix installs a tool through that backend: "cargo:cargo-nextest": "0.9.148" (what ciresk ci init writes for Rust) builds cargo-nextest with cargo, and npm:, pipx:, aqua: and asdf: work the same. A tool your run line calls must be in toolchains: with no rust entry, cargo is not on the task's PATH and the task fails with exit 127 (cargo: not found).
A version mise cannot install, such as a Rust release that does not exist, is not caught before the run today: each worker that tries it refuses the task with toolchain_unavailable, and after three workers the job ends as infra_error (CLI exit 8) although nothing is wrong with the workers. The tail of the task names mise's error; fix the version and run again.
An image entry (a container image pinned by digest) passes the parser, but no worker runs container toolchains yet: a task that needs one fails with toolchain_unavailable.
globals
| Field | One sentence | Example |
|---|---|---|
globals.inputs | Files hashed into every task's key; a change to any of them runs every task on its full target set. Name the helper files under .ciresk/ (.ciresk/*.sh), not .ciresk/**: that glob matches ci.yaml, whose task definitions the keys already hold, and puts every task's definition into every key. | inputs: ["Cargo.lock", "pnpm-lock.yaml", "rust-toolchain.toml", ".ciresk/*.sh"] |
globals.env | Name to value pairs exported to every task and hashed into every key. | env: { CI: "true", SQLX_OFFLINE: "true" } |
globals.pass_env | Names whose values are taken from the worker's secrets backend at run time and are never hashed, stored, logged or returned. | pass_env: ["GITHUB_TOKEN"] |
A name in pass_env may not also appear in env, and CIRESK_GIT_CREDENTIAL may not appear at all (the cell holds git credentials, never a task); the parser rejects both.
workspace
| Field | One sentence | Example |
|---|---|---|
workspace.warm | Lets trusted_interactive jobs use the persistent workspace at a fixed path on the worker; false forces an ephemeral checkout for every tier. | warm: true |
workspace.keep | Globs kept between jobs in a warm workspace; everything else is reset to the base commit plus your diff. | keep: ["target", "node_modules", ".pnpm-store"] |
workspace.sparse | Directory cones for a monorepo: only they travel, plus root-level files and .ciresk/; empty means the whole tree. | sparse: ["apps/web", "packages/ui"] |
tasks.<id>
| Field | One sentence | Example |
|---|---|---|
run | One shell line, run in the manifest's directory with the pinned toolchains on PATH and only env plus pass_env exported. | run: "cargo check --all-targets" |
inputs | Globs whose contents are hashed into the key, evaluated on the base commit plus your diff and matched against the files of your tree (git's index plus every untracked file no .gitignore excludes); the default is all of them, which misses the cache on any change. | inputs: ["crates/**", "Cargo.toml"] |
outputs | Globs restored on a cache hit; empty means the task is a verdict and a hit reuses exit 0. | outputs: ["$CIRESK_ARCHIVE"] |
env | Task-level values, hashed, merged over globals.env. | env: { RUSTFLAGS: "-D warnings" } |
pass_env | Task-level pass-through names, merged with globals.pass_env. | pass_env: ["NPM_TOKEN"] |
depends_on | Task ids that must succeed first; their outputs are restored into the workspace before this task runs; cycles are rejected at parse time. Only declared outputs travel: the dependent task may run in another workspace or on another worker, so anything else a dependency leaves behind (node_modules, target) is not there. Install dependencies in each task's own run (see the TypeScript example). | depends_on: ["archive"] |
affected.strategy | How changed files map to targets when you pass none: cargo (crates from cargo metadata), pnpm (packages from pnpm-workspace.yaml and each package.json), go (modules from go.work and each go.mod), files, or none (the default, always the full target set). See Targets. | affected: { strategy: cargo } |
affected.targets_arg | How each target, affected or passed with -p, reaches run, one flag per target ({crate}, {package}, {module} or {target} is replaced); omitted, run is executed unchanged, so affected selection does not narrow the task and only its inputs save cost. | targets_arg: "-p {crate}" |
cache | Store the result under the key (default true); false for tasks with side effects. | cache: true |
timeout | Wall clock per task, default 10m; exceeding it is timed_out, CLI exit 7. | timeout: "10m" |
shards | Number of shards, at most 256; one leased task per shard with CIRESK_SHARD_INDEX (1-based) and CIRESK_SHARD_COUNT set. | shards: 4 |
report.junit | Path or glob of the JUnit file to ingest; the summary and ciresk ci tests are built from it. | report: { junit: "target/nextest/ci/junit.xml" } |
report.timings | Upload the cargo build --timings report (target/cargo-timings/) with the result. | report: { timings: true } |
artifacts | Globs uploaded as artifacts after the run, never cached. | artifacts: ["target/dist/*.tar.zst"] |
labels | Worker requirements: os and arch are matched against the worker's; other keys are kept but not matched yet. Without them a task takes any worker. | labels: { os: linux, arch: aarch64 } |
resources | Memory, whole CPUs and scratch space the task needs; each is optional. A value no online worker of your organization can hold is refused when you submit, before anything runs, with resources_exceed_workers (exit 2), which names the largest worker; a value over your policy's max_task_memory or max_task_cpus is resources_exceed_policy. Sizes take K, M, G, T (binary) or bytes. | resources: { memory: "16G", cpus: 8, tmp: "20G" } |
trace_inputs | Record the files the task reads (fanotify or strace on Linux) and report them with the result; off by default. | trace_inputs: true |
Variables a task can use
| Variable | Value |
|---|---|
CIRESK_JOB_ID, CIRESK_TASK_ID, CIRESK_TASK | The job, the task and the namespaced task name |
CIRESK_SHARD_INDEX, CIRESK_SHARD_COUNT, CIRESK_SHARD | 1, 4, 1/4; unset for unsharded tasks |
CIRESK_TARGETS | Comma-separated job targets; unset when empty |
CIRESK_KIND, CIRESK_TRUST, CIRESK_BASE_DIGEST, CIRESK_REPO, CIRESK_WORKSPACE | Job facts and the absolute workspace path |
CIRESK_OUT, CIRESK_ARCHIVE | <workspace>/.ciresk/out/<task> (created empty) and $CIRESK_OUT/archive.tar.zst |
CI | true |
HOME, TMPDIR | A home for the run user and a private, empty scratch directory per task, deleted after the run; resources.tmp reserves space for it |
The Rust pattern: archive once, shard the run
Cargo's incremental state is bound to a path, so the compile happens once on the warm worker and the test binaries travel as a nextest archive to as many shards as you want. Nothing recompiles per shard.
version: 1
toolchains:
rust: "1.95.0"
globals:
inputs: ["Cargo.lock", "rust-toolchain.toml", ".config/nextest.toml", ".ciresk/*.sh"]
env: { CI: "true" }
workspace:
warm: true
keep: ["target"]
tasks:
check:
run: "cargo check --all-targets"
inputs: ["crates/**", "Cargo.toml"]
env: { RUSTFLAGS: "-D warnings" }
affected: { strategy: cargo, targets_arg: "-p {crate}" }
timeout: "10m"
archive:
run: "cargo nextest archive --workspace --cargo-profile ci --archive-file $CIRESK_ARCHIVE"
inputs: ["crates/**", "Cargo.toml"]
outputs: ["$CIRESK_ARCHIVE"]
affected: { strategy: cargo, targets_arg: "-p {crate}" }
timeout: "20m"
test:
run: "cargo nextest run --archive-file $CIRESK_ARCHIVE --workspace-remap . --profile ci --partition slice:$CIRESK_SHARD_INDEX/$CIRESK_SHARD_COUNT"
depends_on: ["archive"]
affected: { strategy: cargo, targets_arg: "-E 'rdeps({crate})'" }
shards: 4
timeout: "15m"
report: { junit: "target/nextest/ci/junit.xml" }cargo nextest archive needs a ci profile in Cargo.toml and a nextest profile in .config/nextest.toml that writes JUnit.
TypeScript example (pnpm, vitest)
version: 1
toolchains:
node: "24.x"
pnpm: "10.33"
globals:
inputs: ["pnpm-lock.yaml", "pnpm-workspace.yaml", "tsconfig.base.json", ".ciresk/*.sh"]
env: { CI: "true" }
workspace:
warm: true
keep: ["node_modules", "**/node_modules", ".pnpm-store"]
tasks:
check:
run: "pnpm install --frozen-lockfile --store-dir $PWD/.pnpm-store && pnpm -r typecheck"
inputs: ["package.json", "apps/**", "packages/**", "tsconfig*.json"]
affected: { strategy: files }
lint:
run: "pnpm install --frozen-lockfile --store-dir $PWD/.pnpm-store && pnpm -r lint"
inputs: ["package.json", "apps/**", "packages/**", "biome.json"]
affected: { strategy: files }
test:
run: "pnpm install --frozen-lockfile --store-dir $PWD/.pnpm-store && pnpm -r test"
inputs: ["package.json", "apps/**", "packages/**", "vitest.config.*"]
affected: { strategy: files }
report: { junit: "**/junit.xml" }Each task installs for itself: tasks of one job can run in different workspaces, so a separate install task with depends_on would leave the others without node_modules. In a warm workspace the kept store and node_modules make the second install a no-op. The JUnit file comes from each package's own test script, for example "test": "vitest run --reporter=default --reporter=junit --outputFile.junit=junit.xml"; pnpm test -- --reporter=junit ... hands vitest a literal -- before the options, so vitest ignores them and writes no report. A report.junit glob that matches no file is not an error today: the task passes and ciresk ci tests lists no tests.
CMake example (C++)
mise provides cmake; the C and C++ compiler is the one installed on the worker host.
version: 1
toolchains:
cmake: "3.31"
globals:
inputs: ["CMakeLists.txt", "cmake/**", "conanfile.lock", ".ciresk/*.sh"]
env: { CI: "true" }
workspace:
warm: true
keep: ["build"]
tasks:
build:
run: "cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build"
inputs: ["src/**", "include/**", "CMakeLists.txt", "cmake/**"]
outputs: ["build/bin/**"]
affected: { strategy: none }
timeout: "30m"
test:
run: "ctest --test-dir build --output-junit junit.xml --output-on-failure"
depends_on: ["build"]
inputs: ["tests/**"]
affected: { strategy: none }
report: { junit: "build/junit.xml" }What goes into a cache key is in Caching and tree sync.
Tests that start their own Postgres
A suite that brings up its own database servers (initdb, pg_ctl start, twenty instances on 127.0.0.1 under one cargo test) needs three things from the manifest: the server binaries on PATH, room for the data directories, and a loopback that works. All three are declared; none needs a worker change.
version: 1
toolchains:
rust: "1.95.0"
postgres: "17" # newest 17.x portable build; initdb, pg_ctl, postgres and psql on PATH
globals:
inputs: ["Cargo.lock", "rust-toolchain.toml", ".ciresk/*.sh"]
env: { CI: "true" }
workspace:
warm: true
keep: ["target"]
tasks:
test:
run: "cargo test --workspace"
inputs: ["crates/**", "Cargo.toml"]
resources: { memory: "16G", cpus: 8, tmp: "20G" }
timeout: "20m"postgres: "17"does not compile PostgreSQL. The worker resolves it to a relocatable build for its platform;"17"resolves to the newest 17.x,"17.9"to the newest 17.9.x. The resolved version is reported and enters the cache key like any other toolchain.- Tasks run as an unprivileged user, never root, so
initdbworks. Put every data directory under$TMPDIR(initdb -D "$TMPDIR/pg-$i"): it is a private, empty directory per task, deleted afterwards, andresources.tmpmakes the worker check that the data volume has that much free before the run. - Bind to
127.0.0.1on any free port. Loopback works in every egress mode: atrusted_*task shares the host's loopback, and anuntrustedtask runs in its own network namespace where loopback is up before the task starts and nothing else is reachable except the egress proxy. Ports are per namespace, so two untrusted tasks on one worker never collide. resourceslowers the task's limits to what the suite needs, never above the worker's own. Sizememoryfor the test binary plus the servers, not for the worker.