Skip to content

Conditionals & matrix patterns

Rules control whether a workflow or job runs. Use rule() for conditions that must pass, and skip() for conditions that should skip execution.

import { workflow, job, step, pr, rule } from '@kici-dev/sdk';
const test = job('test', {
runsOn: 'linux',
steps: [
step('test', async ({ $ }) => {
await $`pnpm test`;
}),
],
});
export default workflow('ci', {
on: pr(),
rules: [
rule('has source changes', async (ctx) => {
return ctx.changedFiles.some((f) => f.startsWith('src/'));
}),
],
jobs: [test],
});
import { workflow, job, step, pr, rule, skip } from '@kici-dev/sdk';
const unitTests = job('unit-tests', {
runsOn: 'linux',
steps: [
step('test', async ({ $ }) => {
await $`pnpm test:unit`;
}),
],
});
const e2eTests = job('e2e-tests', {
runsOn: 'linux',
rules: [
// Skip E2E when only docs change
skip('docs only', async (ctx) => {
return ctx.changedFiles.every((f) => f.endsWith('.md'));
}),
],
steps: [
step('test', async ({ $ }) => {
await $`pnpm test:e2e`;
}),
],
});
export default workflow('ci', {
on: pr(),
jobs: [unitTests, e2eTests],
});

Rule check functions receive a RuleContext with:

PropertyTypeDescription
eventEventPayloadThe triggering event data
changedFilesstring[]Files changed in this event
envRecord<string, string|undefined>Environment variables
$zx shellShell executor for running commands

A rule without a check function always passes. Useful for labeling in the decision trace:

rule('ci: required check');

Matrix configurations run a job across multiple parameter combinations.

Run a job for each value in an array:

import { workflow, job, step, push } from '@kici-dev/sdk';
const test = job('test', {
runsOn: 'linux',
matrix: ['18', '20', '22'],
steps: [
step('test', async ({ $, matrix }) => {
await $`nvm use ${matrix!.value}`;
await $`pnpm test`;
}),
],
});
export default workflow('test-matrix', {
on: push(),
jobs: [test],
});

With a single-dimension matrix, the current value is available as matrix.value in the step context.

Use an object to define multiple dimensions. KiCI expands all combinations (capped at 256):

const test = job('test', {
runsOn: ['linux', 'kici:agent:container'],
matrix: {
os: ['linux', 'arm64'],
node: ['18', '20', '22'],
},
steps: [
step('test', async ({ $, matrix }) => {
// matrix.os = 'linux' | 'arm64'
// matrix.node = '18' | '20' | '22'
await $`echo "Testing on ${matrix!.os} with Node ${matrix!.node}"`;
await $`pnpm test`;
}),
],
});

This creates 6 job instances (2 OS x 3 Node versions).

Labels are customer-defined. runsOn values such as linux or arm64 are scaler labels you define in your orchestrator’s labelSets — they are matched by subset semantics, not by a hosted-runner name. You can also target reserved auto-injected labels in the kici: namespace (e.g. kici:agent:firecracker, kici:agent:container) to pin a job to a specific backend type.

Fine-tune matrix combinations:

const test = job('test', {
runsOn: 'linux',
matrix: {
os: ['linux', 'arm64', 'windows'],
node: ['18', '20', '22'],
},
// Remove specific combination
exclude: [{ os: 'windows', node: '18' }],
// Add specific combination not in the matrix
include: [{ os: 'linux', node: '23' }],
steps: [
step('test', async ({ $ }) => {
await $`pnpm test`;
}),
],
});

Exclude is applied first (removes matching combinations), then include adds additional entries.

Compute matrix values at runtime using an async function:

const test = job('test', {
runsOn: 'linux',
matrix: async ({ $ }) => {
// Discover packages in a monorepo
const result = await $`ls packages/`;
return result.stdout.trim().split('\n');
},
steps: [
step('test', async ({ $, matrix }) => {
await $`cd packages/${matrix!.value} && pnpm test`;
}),
],
});

Dynamic matrix functions receive the same context as dynamic job functions ($, ctx, log, env).

Use type guards to inspect matrix configuration at compile time:

import { isStaticArray, isStaticObject, isDynamicFunction } from '@kici-dev/sdk';
if (isStaticArray(myMatrix)) {
// string[]
}
if (isStaticObject(myMatrix)) {
// Record<string, string[]>
}
if (isDynamicFunction(myMatrix)) {
// async function
}

Generate jobs at runtime using async factory functions. Useful for monorepos or when the set of jobs depends on the repository state:

import { workflow, job, step, push } from '@kici-dev/sdk';
import type { DynamicJobFn } from '@kici-dev/sdk';
const discoverAndTest: DynamicJobFn = async ({ $ }) => {
// Discover packages at runtime
const result = await $`ls packages/`;
const packages = result.stdout.trim().split('\n');
return packages.map((pkg) =>
job(`test-${pkg}`, {
runsOn: 'linux',
steps: [
step('test', async ({ $ }) => {
await $`cd packages/${pkg} && pnpm test`;
}),
],
}),
);
};
export default workflow('monorepo-ci', {
on: push(),
jobs: [discoverAndTest],
});

The jobs array accepts both static Job objects and DynamicJobFn functions:

const lint = job('lint', {
runsOn: 'linux',
steps: [
step('lint', async ({ $ }) => {
await $`pnpm lint`;
}),
],
});
export default workflow('monorepo-ci', {
on: push(),
jobs: [lint, discoverAndTest],
});

Static jobs and dynamic generators live side by side. The isDynamicJobFn() type guard distinguishes them at runtime.

A full example combining triggers, rules, matrix, and job dependencies:

import { workflow, job, step, pr, push, rule, skip } from '@kici-dev/sdk';
// Only run on PRs targeting main with source changes
const prTrigger = pr({ target: 'main', paths: ['src/**', 'packages/**', '!**/*.md'] });
// Also run on pushes to main
const pushTrigger = push({ branches: 'main' });
const lint = job('lint', {
runsOn: 'linux',
steps: [
step('install', async ({ $ }) => {
await $`pnpm install --frozen-lockfile`;
}),
step('lint', async ({ $ }) => {
await $`pnpm lint`;
}),
],
});
const test = job('test', {
runsOn: 'linux',
needs: [lint],
matrix: { node: ['18', '20', '22'] },
steps: [
step('test', async ({ $, matrix }) => {
await $`pnpm test`;
}),
],
});
const deploy = job('deploy', {
runsOn: 'linux',
needs: [test],
rules: [
// Only deploy from push events (not PRs)
rule('push event only', async (ctx) => {
return ctx.event.type === 'push';
}),
],
steps: [
step('deploy', async ({ $ }) => {
await $`pnpm build && pnpm deploy`;
}),
],
});
export default workflow('full-pipeline', {
on: [prTrigger, pushTrigger],
rules: [
skip('docs only', async (ctx) => {
return ctx.changedFiles.every((f) => f.endsWith('.md'));
}),
],
jobs: [lint, test, deploy],
});

This workflow:

  1. Triggers on PRs targeting main (with path filters) and pushes to main
  2. Skips entirely if only docs files changed (workflow-level skip rule)
  3. Runs lint first, then tests across 3 Node versions in parallel
  4. Deploys only on push events (not on PRs), after all tests pass