Skip to content

Basic workflow patterns

A standard lint-then-test pipeline using job dependencies (needs):

import { workflow, job, step, pr } from '@kici-dev/sdk';
const lint = job('lint', {
runsOn: 'linux',
steps: [
step('install', async ({ $ }) => {
await $`pnpm install --frozen-lockfile`;
}),
step('check', async ({ $ }) => {
await $`pnpm lint`;
await $`pnpm format:check`;
}),
],
});
const test = job('test', {
runsOn: 'linux',
needs: [lint],
steps: [
step('install', async ({ $ }) => {
await $`pnpm install --frozen-lockfile`;
}),
step('test', async ({ $ }) => {
await $`pnpm test`;
}),
],
});
const typecheck = job('typecheck', {
runsOn: 'linux',
needs: [lint],
steps: [
step('install', async ({ $ }) => {
await $`pnpm install --frozen-lockfile`;
}),
step('typecheck', async ({ $ }) => {
await $`pnpm typecheck`;
}),
],
});
export default workflow('ci', {
on: pr({ target: 'main' }),
jobs: [lint, test, typecheck],
});

The test and typecheck jobs both depend on lint, so they run in parallel after lint succeeds. KiCI validates the dependency graph at compile time — cycles and missing references are caught before you commit. At runtime, jobs are gated on upstream completion: a job only dispatches after every entry in its needs array reaches a terminal status that satisfies the edge. If an upstream fails, downstream jobs skip by default (override per-edge with when: 'always'). See Job dependencies (needs) in the SDK reference for the full matrix of needs forms (string, Job ref, { name, when }, dynamicGroup()) and needs-scheduler for the dispatch semantics.

Single-step jobs don’t need a steps array. When a job only does one thing, pass run to job() instead of wrapping it in steps: [step(...)]:

import { job, workflow, push } from '@kici-dev/sdk';
const smoke = job('smoke', {
runsOn: 'default',
run: async ({ $, log }) => {
await $`curl -fsS https://example.com/health`;
log.info('Health check passed');
},
});
export default workflow('smoke', {
on: push({ branches: 'main' }),
jobs: [smoke],
});

run is mutually exclusive with steps (throws at compile time if both are set). Outputs are flat on job.result (no step-name nesting). See Single-step job shorthand in the SDK reference.

Use pr() to filter by events, target branches, source branches, and file paths:

import { workflow, job, step, pr } from '@kici-dev/sdk';
// Only trigger on opened/synchronize events targeting main,
// and only when source code files change
const trigger = pr({
events: ['opened', 'synchronize'],
target: ['main', 'develop'],
paths: ['src/**', 'packages/**', '!**/*.md', '!docs/**'],
});
const build = job('build', {
runsOn: 'linux',
steps: [
step('build', async ({ $ }) => {
await $`pnpm build`;
}),
],
});
export default workflow('pr-checks', {
on: trigger,
jobs: [build],
});
OptionTypeDescription
targetstring | RegExp | (string | RegExp)[]Match target branches (glob or regex)
sourcestring | RegExp | (string | RegExp)[]Match source branches (glob or regex)
eventsPrEvent[]Filter PR event types
pathsstring[]Only trigger when matching files change. Use ! prefix for exclusions (e.g., '!docs/**')
descriptionstringAdd a human-readable description

Default PR events (when events is not specified): opened, synchronize, reopened, closed.

Use push() for push-based workflows:

import { workflow, job, step, push } from '@kici-dev/sdk';
// Deploy on pushes to main
const deploy = job('deploy', {
runsOn: 'linux',
steps: [
step('deploy', async ({ $ }) => {
await $`pnpm build`;
await $`pnpm deploy`;
}),
],
});
export default workflow('deploy', {
on: push({ branches: 'main' }),
jobs: [deploy],
});
OptionTypeDescription
branchesstring | RegExp | (string | RegExp)[]Match branch names (glob or regex)
tagsstring | RegExp | (string | RegExp)[]Match tag names (glob or regex)
pathsstring[]Only trigger when matching files change. Use ! prefix for exclusions (e.g., '!docs/**')
descriptionstringAdd a human-readable description

Both pr() and push() accept regex patterns alongside glob strings:

// Glob pattern
push({ branches: 'release/*' });
// Regex pattern
push({ branches: /^release\/v\d+\.\d+$/ });

A workflow can respond to multiple trigger types:

import { workflow, job, step, pr, push } from '@kici-dev/sdk';
const test = job('test', {
runsOn: 'linux',
steps: [
step('test', async ({ $ }) => {
await $`pnpm test`;
}),
],
});
export default workflow('ci', {
on: [pr({ target: 'main' }), push({ branches: 'main' })],
jobs: [test],
});

Manual / local-only workflow (no git events)

Section titled “Manual / local-only workflow (no git events)”

Sometimes you want a workflow that does not fire on pushes, pull requests, tags, or any other git activity — only when you explicitly ask for it. Use dispatch() as the trigger: it corresponds to GitHub’s repository_dispatch event, which is never emitted by commits, PRs, tags, releases, or any other automatic git action. The workflow stays idle until someone explicitly invokes it.

There are two ways to “explicitly invoke” a dispatch() workflow:

  1. Locally from your laptop, with kici run dispatch --local — this machine joins as an ephemeral agent through the warm local dev plane, so no orchestrator deployment is required. This is the path to use while you haven’t wired the repo to a deployed KiCI orchestrator.
  2. Remotely, if the repo is connected to a KiCI orchestrator via a GitHub App, by calling GitHub’s repository-dispatch API: curl -X POST -H "Authorization: token <PAT>" -H "Accept: application/vnd.github+json" https://api.github.com/repos/<owner>/<repo>/dispatches -d '{"event_type":"hello"}'. GitHub fans the webhook out to the App, the orchestrator normalizes it into a KiCI dispatch event (see packages/orchestrator/src/providers/github/normalizer.ts), and the matched workflow runs.

Note that GitHub’s workflow_dispatch event (the “Run workflow” button / /actions/workflows/.../dispatches API) is GitHub-Actions-internal and is not delivered to KiCI. The SDK has no workflowDispatch() trigger. Only repository_dispatch reaches KiCI.

import { workflow, job, step, dispatch } from '@kici-dev/sdk';
export default workflow('hello-world', {
on: dispatch(),
jobs: [
job('greet', {
runsOn: 'linux',
steps: [
step('say-hello', async ({ $ }) => {
await $`echo "Hello, World!"`;
}),
],
}),
],
});

Run it locally, without any orchestrator deployment:

Terminal window
npx kici compile # regenerate .kici/kici.lock.json
npx kici run dispatch --local

kici run dispatch --local compiles the workflow, matches triggers against a simulated dispatch event, and executes the matched jobs on this machine — which joins as an ephemeral agent through the warm local dev plane — with DAG-based scheduling. No webhook, no GitHub, no deployed orchestrator involved. See kici run <event> --local for options like --env, --in-place, and --offline.

Leave dispatch() unfiltered while you drive it from kici run <event> --local. The CLI simulates a dispatch event with no event type (i.e. action is undefined), so a trigger defined as dispatch({ types: ['deploy', 'rollback'] }) will not match kici run dispatch --local — the typed form is intended for real repository_dispatch deliveries from the orchestrator.