# KiCI Workflow patterns This bundle covers: Copy-paste workflow recipes: triggers, conditionals, matrix, scheduling, integrations. ## Basic workflow patterns Source: https://docs.kici.dev/user/patterns/basic/ A standard lint-then-test pipeline using job dependencies (`needs`): ```typescript 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`)](https://docs.kici.dev/user/sdk/core/#job-dependencies-needs) in the SDK reference for the full matrix of `needs` forms (string, `Job` ref, `{ name, when }`, `dynamicGroup()`) and [needs-scheduler](https://docs.kici.dev/architecture/execution/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(...)]`: ```typescript 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](https://docs.kici.dev/user/sdk/core/#single-step-job-shorthand) in the SDK reference. ## PR-only workflow with branch filters Use `pr()` to filter by events, target branches, source branches, and file paths: ```typescript 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], }); ``` ### PR trigger options | Option | Type | Description | | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------- | | `target` | `string \| RegExp \| (string \| RegExp)[]` | Match target branches (glob or regex) | | `source` | `string \| RegExp \| (string \| RegExp)[]` | Match source branches (glob or regex) | | `events` | `PrEvent[]` | Filter PR event types | | `paths` | `string[]` | Only trigger when matching files change. Use `!` prefix for exclusions (e.g., `'!docs/**'`) | | `description` | `string` | Add a human-readable description | Default PR events (when `events` is not specified): `opened`, `synchronize`, `reopened`, `closed`. ## Push trigger with branch filters Use `push()` for push-based workflows: ```typescript 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], }); ``` ### Push trigger options | Option | Type | Description | | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------- | | `branches` | `string \| RegExp \| (string \| RegExp)[]` | Match branch names (glob or regex) | | `tags` | `string \| RegExp \| (string \| RegExp)[]` | Match tag names (glob or regex) | | `paths` | `string[]` | Only trigger when matching files change. Use `!` prefix for exclusions (e.g., `'!docs/**'`) | | `description` | `string` | Add a human-readable description | ### Regex branch patterns Both `pr()` and `push()` accept regex patterns alongside glob strings: ```typescript // Glob pattern push({ branches: 'release/*' }); // Regex pattern push({ branches: /^release\/v\d+\.\d+$/ }); ``` ## Multiple triggers A workflow can respond to multiple trigger types: ```typescript 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) 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 " -H "Accept: application/vnd.github+json" https://api.github.com/repos///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. ```typescript 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: ```bash 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 --local`](https://docs.kici.dev/user/cli/runs-and-approvals/#kici-run-event---local) for options like `--env`, `--in-place`, and `--offline`. ### Unfiltered vs typed `dispatch()` Leave `dispatch()` unfiltered while you drive it from `kici run --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. ## Conditional execution with rules --- ## Conditionals & matrix patterns Source: https://docs.kici.dev/user/patterns/conditionals-matrix/ Rules control whether a workflow or job runs. Use `rule()` for conditions that must pass, and `skip()` for conditions that should skip execution. ### Workflow-level rules ```typescript 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], }); ``` ### Job-level rules ```typescript 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 context Rule check functions receive a `RuleContext` with: | Property | Type | Description | | -------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | | `event` | `EventPayload` | The triggering event data | | `changedFiles` | `string[]` | Files changed in this event | | `sourceRepo` | `RepoInfo \| undefined` | The repo whose event triggered the run, when the evaluation has a checkout | | `workflowRepo` | `RepoInfo \| undefined` | The repo that registered the workflow. The same repo as `sourceRepo` outside a global workflow | | `env` | `Record` | Environment variables | | `$` | zx shell | Shell executor for running commands | `RepoInfo` carries `path` — an absolute path to that repo's checkout — plus optional `ref` and `sha`. In a [global workflow](https://docs.kici.dev/user/global-workflows/) the two are different repos, which is what lets one rule read the source repo's tree while the workflow lives elsewhere. Read _through_ `path`: its contents are stable, but the path itself differs between the evaluation and the later run. ### Marker rules A rule without a check function always passes. Useful for labeling in the decision trace: ```typescript rule('ci: required check'); ``` ## Matrix builds Matrix configurations run a job across multiple parameter combinations. ### Simple array matrix Run a job for each value in an array: ```typescript 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. ### Multi-dimensional matrix Use an object to define multiple dimensions. KiCI expands all combinations (capped at 256, and each combination must be unique — a repeated value fails the job rather than running it twice): ```typescript 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. ### Include and exclude Fine-tune matrix combinations: ```typescript 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. An include entry's values appear in the child job name ordered by dimension name, whichever order you write the keys in — see [Include and exclude](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#include-and-exclude). ### Dynamic matrix Compute matrix values at runtime using an async function: ```typescript 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`). ### Matrix type guards Use type guards to inspect matrix configuration at compile time: ```typescript import { isStaticArray, isStaticObject, isDynamicFunction } from '@kici-dev/sdk'; if (isStaticArray(myMatrix)) { // string[] } if (isStaticObject(myMatrix)) { // Record } if (isDynamicFunction(myMatrix)) { // async function } ``` ## Dynamic job generation Generate jobs at runtime using async factory functions. Useful for monorepos or when the set of jobs depends on the repository state: ```typescript 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], }); ``` ### Mixing static and dynamic jobs The `jobs` array accepts both static `Job` objects and `DynamicJobFn` functions: ```typescript 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. ## Combining patterns A full example combining triggers, rules, matrix, and job dependencies: ```typescript 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 ## Workflow chaining --- ## Git credentials Source: https://docs.kici.dev/user/patterns/git-credentials/ Two facts drive everything on this page, and neither is guessable: - **Cloning your own repository needs no credential.** The framework checks it out for you, and the app KiCI installs already holds read access. - **Pushing always needs a credential you supply.** The KiCI app holds read access only, so every push — including to the job's own repository — needs one. ## Declare credentials once, by name Credentials are declared on the job as a named map. **Every value is the name of a secret**, in `:` form — never the credential itself: ```typescript job('release', { runsOn: 'linux', gitCredentials: { // `default` is used whenever a call names no credential default: { kind: 'app', appIdSecret: 'ci:ACME_APP_ID', installationIdSecret: 'ci:ACME_INSTALL_ID', privateKeySecret: 'ci:ACME_APP_KEY', }, forge: { kind: 'token', tokenSecret: 'ci:FORGE_PAT' }, vendor: { kind: 'ssh', privateKeySecret: 'ci:VENDOR_DEPLOY_KEY' }, }, steps: [build, tagAndPush], }); ``` Store the secrets first with `kici-admin secret set`. Pasting a private key straight into the workflow is rejected when the workflow is defined, naming the field — a key written into `.kici/` would be committed to your repository. ## Push Your checkout is read-only by default. Opening a write window is explicit: ```typescript step('tag', async ({ $, repo }) => { await $`git tag v${version}`; await repo.withWrite({ permissions: { contents: 'write' } }, async () => { await $`git push origin v${version}`; }); }); ``` Inside the callback, git operations on that repository use a write credential. Outside it they do not, so an accidental push elsewhere in the job fails. Pass `credential: 'forge'` to use a named entry instead of `default`. **KiCI never guesses the permission set.** What a push needs depends on what is being pushed — changing anything under `.github/workflows/` additionally requires `workflows`: ```typescript await repo.withWrite({ permissions: { contents: 'write', workflows: 'write' } }, async () => { await $`git push origin HEAD`; }); ``` If the app was not granted a permission you request, the forge refuses to issue the credential at all. The error names the repository and the permissions you asked for, **before any git command runs** — not at the end of a long build. ## Clone more than one repository A workflow often needs several repositories, not just its own. Mint one token that covers all of them, then clone each with it: ```typescript const { token } = await kici.git.github.getToken({ repositories: ['acme/app', 'acme/shared-lib'], permissions: { contents: 'read' }, }); for (const repo of ['acme/app', 'acme/shared-lib']) { await $`git clone https://x-access-token:${token}@github.com/${repo}.git`; } ``` A GitHub App token is issued per installation, so one call covers every repository you name. Each repository must be inside the app's installation. If one is not, the forge refuses the whole request and the error names it. The credential helper is installed on your own checkout only, so a repository you clone yourself does not inherit it. That is why this case mints a token rather than relying on the helper. ## Call the forge API `gh` does not read git credential helpers, so this is the one case that wants the token as a value: ```typescript const { token } = await kici.git.github.getToken({ repositories: ['acme/app'], permissions: { contents: 'write' }, }); await $({ env: { ...process.env, GH_TOKEN: token } })`gh release create v${version}`; ``` The token is masked in step logs. Prefer `withWrite` for git itself, which never places a credential in the step environment. ## Credentials that only exist at run time A credential fetched during the run — from a vault, or a cloud secret store via the job's OIDC identity — cannot be named ahead of time. Use the `*Value` half of the pair, which says "this is the credential, not a name for one": ```typescript gitCredentials: { default: { kind: 'token', tokenValue: fetchedAtRuntime } } ``` To pass a derived credential to a **later** job, publish it with `ctx.setSecretOutput()` and name it with the reserved `needs:` context — that path is encrypted, scoped to the run, and deleted when the run ends: ```typescript const mint = job('mint', { runsOn: 'linux', run: async (ctx) => { const token = (await ctx.$`vault write -f auth/token/create`).stdout.trim(); ctx.setSecretOutput('FORGE_TOKEN', token); }, }); const build = job('build', { runsOn: 'linux', needs: [mint], gitCredentials: { default: { kind: 'token', tokenSecret: 'needs:FORGE_TOKEN' } }, steps: [cloneAndPush], }); ``` Never put a credential in a regular job output: regular outputs are not masked, are stored, and are shown in the dashboard. For a **minted app token**, prefer re-deriving over transporting — those expire after an hour, so one minted in an earlier job is often already dead by the time a later job reads it. Have the later job name the same secret, or mint its own. ## What a job may ask for A credential is authorized against the workflow you wrote, not against the code running in the job. Three things must all hold before the orchestrator resolves one: 1. **The job declared it.** The orchestrator records the job's `gitCredentials` map when it dispatches the job, and compares every request against that record. A request naming a credential the job did not declare is refused. This is why you pass `credential: 'forge'` — a name — rather than building a credential reference in step code. 2. **The named context admits the run.** A `prod:` reference runs the `prod` context's own protection rules first: its branch restrictions, its `minimumTrust`, its approval requirement. A credential named from a branch the context does not allow is refused, and the git operation fails. The rule that refused it is named in your orchestrator's log, not in the run — the orchestrator returns a fixed error to the job rather than describing its own configuration to code it does not trust. 3. **The contributor is trusted.** A run from an untrusted ref — a fork pull request — gets no declared credential at all. It still clones with the source credential, so the build runs; only the declared credentials are withheld. The reduced-privilege note on the run says so. The context in a reference does **not** have to appear in the job's `contexts:` list. The reference names its own context, and that context's rules are what authorize it. ## Generated jobs A job produced by a `dynamicJob` generator has no entry in the lock file, so it cannot declare credentials of its own. The **generator** declares them, and every job it produces inherits that map: ```typescript dynamicJob('shards', { gitCredentials: { forge: { kind: 'token', tokenSecret: 'ci:FORGE_PAT' }, }, generate: async ({ ctx }) => ctx.event.payload.targets.map((target) => job(`publish-${target}`, { runsOn: 'linux', run: async ({ $, repo }) => { await repo.withWrite( { permissions: { contents: 'write' }, credential: 'forge' }, async () => { await $`git push origin HEAD`; }, ); }, }), ), }); ``` Three points follow from where the declaration lives: - **All generated jobs share one map.** The generator is granted one ceiling, and every job it produces gets exactly that ceiling. Use a second generator when two sets of jobs need different credentials. - **A `gitCredentials` map on a generated job is ignored.** The generator's declaration is committed source that KiCI reads from the lock file. A generated job's own declaration would come from the code that produced it, which is what the authorization check above exists to be independent of. - **The options form is required.** `dynamicJob('shards', async () => …)` — the bare function form — has nowhere to put the declaration. Pass `{ generate, gitCredentials }` instead; `needs` stays optional. ## How it works, and why long jobs still push An app token expires an hour after it is issued, and cannot be renewed. Rather than capture one at checkout time, the agent installs a git credential helper on the checkout: git asks it on every network operation, and it obtains a fresh credential each time. A push at the end of a three-hour build works exactly as it does at the start, and no credential is ever written into `.git/config`, into `git remote -v`, or into the step's environment. ## Limits worth knowing - **Container jobs cannot use this yet.** A container job runs git inside the container, which has no route to the credential service. Bare-metal jobs are unaffected. - **The reserved `needs:` context is not resolvable yet** on a deployed orchestrator; naming it produces a clear error rather than a wrong credential. - **A credential reference built in step code is refused.** The SDK takes a credential _name_; there is no way to pass a reference. Code that constructs one and sends it directly is rejected by the agent and, if it reaches the orchestrator, by the declaration check above. - **A write window is bounded by the repository and the callback, not the step.** Steps running concurrently in the same job can push to the same repository while it is open. They cannot reach a different one. - **A credential you supply yourself cannot be narrowed.** A personal access token or SSH key grants whatever it was created with, so a requested permission set is reported as unscoped rather than pretended to be enforced. - **Being allowed to push is not the same as the push succeeding.** A branch protection rule or repository ruleset can still reject it. --- ## Host restart & wait-for-alive Source: https://docs.kici.dev/user/patterns/host-restart/ When a KiCI agent runs on a host you are provisioning, a workflow can reboot that host and resume work once it comes back — the Ansible `reboot` + `wait_for_connection` pattern, expressed as two jobs pinned to the same host. ## The two-job pattern Host restart is a **job-boundary** capability: the reboot is the last step of a "restart" job, and the post-restart work lives in a **separate job** pinned to the same host that `needs` the restart job. The orchestrator holds the post-restart job until the host completes a reboot cycle, then dispatches it. ```typescript import { workflow, job, step, restartHost, waitForHostAlive } from '@kici-dev/sdk'; export default workflow('patch-and-verify', { on: [/* ... */], jobs: [ // Restart job: apply updates, then reboot. restartHost() MUST be the last step. job('patch', { runsOn: 'kici:host:box-01', steps: [ step('upgrade', async (ctx) => { await ctx.$`apt-get upgrade -y`; }), restartHost(), ], }), // Post-restart job: pinned to the SAME host, needs the restart job. job('verify', { runsOn: 'kici:host:box-01', needs: ['patch'], steps: [ waitForHostAlive(() => fetch('http://localhost:8080/health')), step('check-service', async (ctx) => { await ctx.$`systemctl is-active myservice`; }), ], }), ], }); ``` ## `restartHost()` `restartHost(opts?)` reboots the host the job runs on. It signals the orchestrator that a reboot is pending (which holds the post-restart job and treats the agent's imminent disconnect as expected, not a failure), reports the step success, and the agent issues the OS reboot once the step completes. - **Must be the last step** of its job — the job completes before the box goes down. - `deadlineMs` (optional) overrides how long the orchestrator waits for the host to return after the reboot. The default is the orchestrator's `KICI_HOST_REBOOT_DEADLINE_MS` (15 minutes). If the host does not reconnect by the deadline, the held post-restart job fails with a clear "host did not return after reboot" reason. - The reboot command is chosen per operating system (Linux `systemctl reboot`, macOS `shutdown -r now`, Windows `shutdown /r /t 0`). ## `waitForHostAlive(probe, opts?)` `waitForHostAlive()` is the optional first step of the post-restart job. The baseline "the host is back" guarantee comes for free — the post-restart job only dispatches after the agent reconnects. `waitForHostAlive(probe)` adds a **service-readiness** gate on top: it polls `probe` until it resolves, for hosts where "agent connected" does not yet mean "services ready". - The probe can return anything (an HTTP response, an open port check, a marker file). Any non-null resolution means "ready"; a throw or rejection keeps polling. - `intervalMs` (default 3000) and `timeoutMs` (default 300000) tune the poll. If the probe never succeeds within `timeoutMs`, the step fails with "services did not come up". ## Same-host pinning The "same host" relationship is the pin: both jobs target the same host via `runsOn` (a `kici:host:` label or another label the host carries), and the post-restart job `needs` the restart job. Durable provisioning hosts MUST set a stable agent id (`KICI_AGENT_ID`) so the host re-registers under the same identity after the reboot — that stable identity is what lets the orchestrator recognise the down-then-up cycle and release the held job. ## Failure behavior - **Host never returns by the deadline** → the held post-restart job fails. - **Reboot privilege denied** → the restart step fails with a clear privilege error (see the operator note below), and the orchestrator clears the reboot-pending hold. - **`waitForHostAlive` probe never succeeds** → that step fails. The orchestrator refuses to reboot the host it runs on, so a co-located agent cannot take down the orchestrator's own box. ## Operator prerequisite: reboot privilege Rebooting needs host privilege. An agent used for host provisioning must be able to run the OS reboot primitive — run the agent service with reboot privilege, or grant a narrow `shutdown` / `systemctl reboot` permission. Agents used for provisioning generally need broad, near-root host privileges; see the operator agent documentation for the full posture. --- ## Integration patterns Source: https://docs.kici.dev/user/patterns/integrations/ Use internal event triggers to chain workflows together. Workflow A completes, emits an event (or the system auto-emits a completion event), and Workflow B triggers in response. ### Using system completion events The orchestrator automatically emits `workflow_complete` and `job_complete` events. Use `workflowComplete()` and `jobComplete()` triggers to listen for them: ```typescript import { workflow, job, step, push, workflowComplete } from '@kici-dev/sdk'; // Workflow A: deploy on push to main export const deploy = workflow('deploy', { on: push({ branches: 'main' }), jobs: [ job('deploy', { runsOn: 'linux', steps: [ step('deploy', async ({ $ }) => { await $`./scripts/deploy.sh`; }), ], }), ], }); // Workflow B: runs after deploy succeeds export const postDeploy = workflow('post-deploy', { on: workflowComplete({ name: 'deploy', status: ['success'] }), jobs: [ job('notify', { runsOn: 'linux', steps: [ step('slack', async ({ $ }) => { await $`./scripts/notify-slack.sh "Deploy succeeded"`; }), ], }), ], }); ``` `workflowComplete()` / `jobComplete()` start a **separate** workflow run that reacts to the prior one finishing, gated on its status. They are the right tool when a _different_ workflow should respond. When you instead need to add more jobs to the **same** run based on what a job just produced — fanning out follow-up work from a prior job's outputs — use a result-aware generator (next section), not a completion-event chain. #### Failure notifier (any source → any destination) Because the orchestrator auto-emits `workflow_complete` for **every** run, a single workflow with `on: workflowComplete({ status: ['failed'] })` (no `source` filter) becomes an org-wide failure notifier: every failed workflow, whatever repo or provider triggered it, dispatches this one workflow. The runnable example `examples/workflows/failed-workflow-slack-notifier.ts` builds exactly that — it maps the failed run's repo (`ctx.sourceRepo?.identifier`) through an inline `repo → { channel, tag }` table and posts to Slack with `fetch`, guarding the real POST behind `ctx.isTestRun` so a test run never messages a live channel. Swap the `fetch` body for Discord, Teams, PagerDuty, or a plain HTTP endpoint to change the destination — no external package required. The one failure class this pattern cannot catch is a **dead orchestrator**: a workflow can only run while the orchestrator that would dispatch it is alive, so if the orchestrator itself is gone, nothing dispatches the notifier. Watching for an orchestrator that has stopped reporting is the managed notification plane's job, not a workflow's. ### Same-run discovery → fan-out A result-aware [`dynamicJob(group, { needs, generate })`](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#dynamicjob--result-aware-generation) is deferred until its declared upstreams complete, then runs with their frozen outputs as `ctx.needs` — so a discovery job can emit a list at runtime and the generator fans out one follow-up job per item, all in the same run: ```typescript import { workflow, job, step, push, dynamicJob, z } from '@kici-dev/sdk'; const discover = job('discover', { runsOn: 'linux', steps: [ step('list-services', { outputs: { services: z.array(z.string()) }, run: async ({ $ }) => { const out = await $`ls services/`; return { services: out.stdout.trim().split('\n') }; }, }), ], }); const deployEach = dynamicJob('deploys', { needs: ['discover'], generate: async ({ ctx }) => ctx.needs.discover.result.services.map((svc) => job(`deploy-${svc}`, { runsOn: 'linux', run: async ({ $ }) => { await $`./scripts/deploy.sh ${svc}`; }, }), ), }); export default workflow('deploy-discovered-services', { on: push(), jobs: [discover, deployEach] }); ``` Contrast: this keeps everything in one run with results flowing job→job. A cross-workflow `jobComplete()` chain (above) reacts to a job finishing but only sees its _status_, in a new run — use that when the reacting logic belongs to a different workflow. ### Using custom events For richer payload data, emit custom events from steps using `ctx.emit()`: ```typescript import { workflow, job, step, push, kiciEvent } from '@kici-dev/sdk'; // Workflow A: deploy and emit custom event with payload export const deploy = workflow('deploy', { on: push({ branches: 'main' }), jobs: [ job('deploy', { runsOn: 'linux', steps: [ step('deploy', async ({ $ }) => { await $`./scripts/deploy.sh`; }), step('notify', async (ctx) => { await ctx.emit('deploy-complete', { env: 'prod', version: '1.2.3', }); }), ], }), ], }); // Workflow B: triggered by custom event with payload matching export const postDeploy = workflow('post-deploy', { on: kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }), jobs: [ job('smoke-test', { runsOn: 'linux', steps: [ step('test', async ({ $ }) => { await $`./scripts/smoke-test.sh`; }), ], }), ], }); ``` Custom events are delivered immediately (mid-workflow, not queued until workflow completion). ## Generic webhook integration Trigger workflows from non-GitHub sources like ArgoCD, Jenkins, Grafana, or any HTTP service. Generic webhook sources are configured via the orchestrator admin API, and workflows listen using `genericWebhook()`. ```typescript import { workflow, job, step, genericWebhook } from '@kici-dev/sdk'; // Triggered by ArgoCD deploy events export default workflow('on-argocd-deploy', { on: genericWebhook({ source: 'argocd', events: ['deploy.success'] }), jobs: [ job('post-deploy', { runsOn: 'linux', steps: [ step('verify', async ({ $, rawPayload }) => { // rawPayload contains the full webhook body from ArgoCD await $`./scripts/verify-deploy.sh`; }), ], }), ], }); ``` See the [Operator guide: event routing](https://docs.kici.dev/operator/event-routing/) for how to set up generic webhook sources, verification methods, and trust relationships. ## Stripe webhook handler Process payment events from Stripe using `genericWebhook()` with HMAC-SHA256 signature verification. This pattern applies to any external service that sends signed HTTP webhooks. ```typescript import { workflow, job, step, genericWebhook } from '@kici-dev/sdk'; export default workflow('stripe-invoice-handler', { on: genericWebhook({ source: 'stripe', events: ['invoice.paid'], auth: { method: 'hmac-sha256', secret: 'stripe-signing-key', signatureHeader: 'stripe-signature', }, description: 'Process Stripe invoice.paid events', }), jobs: [ job('process-invoice', { runsOn: 'linux', steps: [ step('extract-customer', async ({ $, log }) => { log.info('Processing paid invoice from Stripe'); await $`./scripts/process-invoice.sh`; }), step('update-billing', async ({ $ }) => { await $`./scripts/update-billing-records.sh`; }), step('notify-team', async ({ $ }) => { await $`./scripts/notify-billing-team.sh`; }), ], }), ], }); ``` **Prerequisites:** - An operator must create a generic webhook source named `stripe` via the admin API. See [Operator guide: creating a source](https://docs.kici.dev/operator/event-routing/#creating-a-source). - The `stripe-signing-key` secret must contain your Stripe webhook signing secret. - This workflow uses the [registration model](https://docs.kici.dev/user/events/#the-registration-model) -- it will not trigger until you push to your default branch. ## Self-hosted git forge (Gogs, Forgejo, Gitea) KiCI has no native provider for Gogs, Forgejo, or Gitea, but these forges send HMAC-SHA256-signed webhooks with a predictable header layout. Model them as a generic webhook source: point the forge's webhook at the orchestrator (or the Platform relay), configure HMAC verification with the shared secret, and map the forge's event header so `genericWebhook()` can match on it. **Operator setup:** ```bash # Forgejo / Gitea send event name in X-Gitea-Event and signature in X-Gitea-Signature. # Gogs uses X-Gogs-Event and X-Gogs-Signature (same HMAC-SHA256 hex-digest format). # --org must be your Platform organization id: a generic source's routing key # embeds it, and the Platform refuses to register a key naming another org. kici-admin source add generic \ --org \ --name forgejo-main \ --verification hmac_sha256 \ --secret @/path/to/webhook-secret.txt \ --event-type-header X-Gitea-Event \ --rate-limit 120 ``` Note the returned source ID, then register a webhook in the forge pointing at `https:///webhook//generic/` (or the orchestrator's direct URL, which takes the source **name** in place of the id). Set content type to `application/json` and paste the same secret. **Workflow:** ```typescript import { workflow, job, step, genericWebhook } from '@kici-dev/sdk'; export default workflow('on-forgejo-push', { on: genericWebhook({ source: 'forgejo-main', events: ['push'], // Forgejo/Gitea sends 'push', 'pull_request', 'issues', etc. match: { '$.ref': 'refs/heads/main' }, // JSONPath filter on the payload }), jobs: [ job('react-to-push', { runsOn: 'linux', steps: [ step('log', async ({ rawPayload, log }) => { const ref = (rawPayload as { ref?: string }).ref; log.info(`Forgejo push to ${ref}`); }), ], }), ], }); ``` **Caveat — cloning:** generic webhook sources deliver only the payload; they do not carry a clone token, and KiCI's automatic pre-step clone (`packages/agent/src/checkout/git-clone.ts`) is GitHub-only today (HTTPS + `http.extraHeader` Basic auth with a GitHub installation token). Three practical patterns: - **Mirror to GitHub and fan out.** Keep the repo on GitHub, register the workflow via a GitHub default-branch push, and have Gogs/Forgejo webhooks fan out via [cross-source delivery](https://docs.kici.dev/architecture/webhooks/webhook-delivery/#cross-source-delivery). The clone runs against the GitHub mirror using the GitHub App's token. - **Clone yourself using a declared credential.** Set `checkout: false` on the job to skip the framework clone, declare the forge credential on the job as a named `gitCredentials` entry (values are **secret names**, never the credential), and run `git clone` in the first step — the agent authenticates it for you, and the credential never appears in workflow source. See [Git credentials](https://docs.kici.dev/user/patterns/git-credentials/). This works for any forge the agent can reach, no mirror needed. You still need a way to **register** the workflow — either keep a one-file GitHub repo whose only job is to own the registration, or bootstrap the registration manually against the orchestrator DB. - **Self-contained workflow.** No clone at all. The step reads whatever it needs from `rawPayload` (e.g., `rawPayload.after`, `rawPayload.repository.clone_url`) and drives external systems — notifications, deploys, third-party CI triggers. Manual-clone example (pattern 2) using an SSH deploy key: ```typescript job('forgejo-ci', { runsOn: 'linux', checkout: false, // skip framework clone steps: [ step('clone', async ({ $, ctx, rawPayload }) => { const sshKey = await ctx.secrets.get('FORGEJO_DEPLOY_KEY'); await $`mkdir -p ~/.ssh`; await $`ssh-keyscan forgejo.example.com >> ~/.ssh/known_hosts`; await $({ input: sshKey })`tee ~/.ssh/id_ed25519 > /dev/null`; await $`chmod 600 ~/.ssh/id_ed25519`; const url = (rawPayload as { repository: { ssh_url: string } }).repository.ssh_url; const sha = (rawPayload as { after: string }).after; await $`git clone ${url} src && cd src && git checkout ${sha}`; }), step('test', async ({ $ }) => { await $`cd src && pnpm install && pnpm test`; }), ], }); ``` HTTPS with a forge PAT works the same way — store the token as a secret, `await ctx.secrets.expose('FORGEJO_TOKEN')`, then `git clone https://oauth2:$FORGEJO_TOKEN@forgejo.example.com/org/repo.git`. **Prerequisites:** - An operator must create a generic webhook source via `kici-admin source add generic` (see above). - The forge's webhook secret must match the `--secret` value. - The workflow uses the [registration model](https://docs.kici.dev/user/events/#the-registration-model) -- push to the default branch of a registered repo before the first webhook fires. ## Plain GitHub repo webhooks (no GitHub App) The Gogs/Forgejo/Gitea pattern above also applies when you want to trigger workflows from a GitHub repository **without installing the KiCI GitHub App**. You may lack org-admin rights, sit on a restricted GitHub Enterprise tenant, or not want an App installation. Model the repo-level webhook as a generic source, accepting the same `genericWebhook()`-only ergonomics. **Operator setup:** ```bash # GitHub sends event name in X-GitHub-Event and HMAC-SHA256 signature in X-Hub-Signature-256. # --org must be your Platform organization id: a generic source's routing key # embeds it, and the Platform refuses to register a key naming another org. kici-admin source add generic \ --org \ --name gh-repo-foo \ --verification hmac_sha256 \ --secret @/path/to/webhook-secret.txt \ --event-type-header X-GitHub-Event \ --rate-limit 120 # Patch the verificationConfig to use GitHub's signature header # (the CLI has no --signature-header flag; use the admin REST API): curl -X PATCH https:///api/v1/admin/generic-sources/ \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"verificationConfig":{"secret":"","headerName":"x-hub-signature-256"}}' ``` Then in the GitHub repo, go to **Settings → Webhooks → Add webhook**, set: - **Payload URL:** `https:///webhook//generic/` (or the orchestrator's direct URL, which takes the source **name** in place of the id) - **Content type:** `application/json` - **Secret:** the same secret - **Events:** pick what you care about (e.g., `push`, `pull_request`) **Workflow:** ```typescript import { workflow, job, step, genericWebhook } from '@kici-dev/sdk'; export default workflow('on-github-repo-push', { on: genericWebhook({ source: 'gh-repo-foo', events: ['push'], match: { '$.ref': 'refs/heads/main' }, }), jobs: [ job('notify', { runsOn: 'linux', checkout: false, // no App token -> skip auto-clone steps: [ step('log', async ({ rawPayload, log }) => { const sha = (rawPayload as { after?: string }).after; log.info(`GitHub push ${sha}`); }), ], }), ], }); ``` **What you lose compared to the GitHub App** (these are the same cloning / metadata caveats that apply to the Gogs/Forgejo pattern, plus GitHub-specific integrations): - No auto-clone — `packages/agent/src/checkout/git-clone.ts` uses GitHub App installation tokens to fetch the repo; a generic source has none. Either set `checkout: false` and clone yourself with a PAT/Deploy Key secret (same pattern as the Forgejo manual-clone example above), or keep the workflow self-contained. - No lock-file fetch — the orchestrator cannot fetch `.kici/kici.lock.json` at the pushed SHA via the GitHub API. The workflow must be pre-registered via the [registration model](https://docs.kici.dev/user/events/#the-registration-model); ad-hoc per-commit workflow discovery that a GitHub App push gives you is not available. - No changed-files enrichment — `event.changedFiles` is empty. Use JSONPath `match` on `rawPayload.commits[*].added/modified/removed` if you need path filters. - No check-run integration — KiCI cannot post Check Run results back to GitHub. - Workflow authors must use `genericWebhook()`, not `push()` / `pr()` / `webhook()` — the latter three only match events delivered through the native GitHub App provider. **When to use it anyway:** trigger-only workflows that don't need the cloned repo — posting Slack messages, kicking off external deploys, forwarding to downstream systems, or exposing GitHub repo events as `genericWebhook` for same-org [cross-source fan-out](https://docs.kici.dev/architecture/webhooks/webhook-delivery/#cross-source-delivery). For anything that compiles, tests, or checks code, install the GitHub App instead. ## Nightly cron build --- ## Pattern reference Source: https://docs.kici.dev/user/patterns/reference/ Every step receives a `StepContext` with these properties: | Property | Type | Description | | ------------------- | ----------------------------------- | ---------------------------------------------------------------- | | `$` | zx shell | Shell executor for running commands | | `log` | `Logger` | Structured logger (info, warn, error, debug) | | `env` | `Record` | Environment variables | | `setEnv()` | `(key, value) => void` | Set an env var visible to this step and all subsequent steps | | `addPath()` | `(dir) => void` | Prepend a directory to PATH for this and all subsequent steps | | `inputs` | `Record` | Typed inputs from dependency outputs | | `workflow` | `{ name: string }` | Current workflow metadata | | `job` | `{ name: string, runsOn: string }` | Current job metadata | | `matrix` | `MatrixValues \| undefined` | Matrix values for current job instance | | `setSecretOutput()` | `(key, value) => void` | Publish an encrypted secret output consumable by downstream jobs | ### Step outputs Steps can declare typed outputs using Zod schemas: ```typescript import { step } from '@kici-dev/sdk'; import { z } from 'zod'; const build = step('build', { outputs: { version: z.string(), artifacts: z.array(z.string()), }, run: async ({ $ }) => { await $`pnpm build`; return { version: '1.0.0', artifacts: ['dist/main.js', 'dist/styles.css'], }; }, }); ``` ## Examples repository For more runnable examples, see the [examples/](https://github.com/kici-dev/kici-public/tree/main/examples) directory in the KiCI repository. ## GitHub check run output When workflows run via GitHub pull requests or pushes, KiCI creates GitHub Check runs that show detailed execution feedback directly in the GitHub UI. ### What you see - **Live progress:** As steps execute, the check run updates with a checklist showing which steps are running, completed, or pending - **Step durations:** Each step shows its execution time (e.g., "Install deps (1.2s)") - **Failure details:** When a step fails, the check run includes the error message, exit code, and the last 20 lines of log output - **Source annotations:** Failed steps are annotated directly on your workflow file (`.kici/workflows/*.ts`) in the GitHub PR diff, linking the failure to the exact `step()` call that failed ### Source location annotations KiCI captures the source location of each `step()` call during compilation and stores it in the lock file. When a step fails, GitHub displays an annotation on the corresponding line in your workflow file: ```typescript // This step's source location is captured automatically step('run tests', async ({ $ }) => { await $`pnpm test`; // If this fails, GitHub annotates this step() call }); ``` To enable source location annotations, recompile your workflows after updating KiCI. The compiler captures step locations starting from compile schema version 2. ```bash pnpm kici compile # Regenerates kici.lock.json with source locations ``` ## See also --- ## Scheduling & event patterns Source: https://docs.kici.dev/user/patterns/scheduling-and-events/ Run a full build and test suite on a schedule using `schedule()`. Schedule triggers are evaluated by the orchestrator's Raft leader in clustered deployments. ```typescript import { workflow, job, step, schedule } from '@kici-dev/sdk'; const install = step('install', async ({ $ }) => { await $`pnpm install --frozen-lockfile`; }); const fullTest = job('full-test', { runsOn: 'linux', steps: [ install, step('test', async ({ $ }) => { await $`pnpm test`; }), step('typecheck', async ({ $ }) => { await $`pnpm typecheck`; }), ], }); const publish = job('publish-nightly', { runsOn: 'linux', needs: [fullTest], steps: [ install, step('build', async ({ $ }) => { await $`pnpm build`; }), step('publish', async ({ $ }) => { await $`./scripts/publish-nightly.sh`; }), ], }); export default workflow('nightly-build', { on: schedule({ cron: '0 2 * * *', description: 'Every day at 2 AM UTC' }), jobs: [fullTest, publish], }); ``` **Notes:** - The `cron` field uses standard 5-field cron syntax. Use the `timezone` option (defaults to `'UTC'`) to control evaluation in a specific timezone: `schedule({ cron: '0 2 * * *', timezone: 'America/New_York' })`. - Schedule workflows use the [registration model](https://docs.kici.dev/user/events/#the-registration-model) -- the cron will not start firing until you push to your default branch. - In clustered orchestrator deployments, only the Raft leader evaluates cron schedules. If the leader changes, the new leader recovers missed schedules. **Timing precision and scaling:** - The orchestrator's cron evaluator wakes up every **30 seconds** (fixed interval, not configurable at runtime). A schedule due at 02:00:00 fires on the next tick after that moment, so expect **0-30 seconds of jitter after the scheduled time** -- never early. The event payload's `scheduledAt` field carries the exact cron-computed time (not the fire time), so downstream consumers can reason about the intended schedule rather than the dispatch moment. - All cron schedules are evaluated **serially** within a single tick on the leader. Each evaluation does an in-memory cron computation plus two DB writes (atomic claim + event emit), so per-schedule cost is on the order of low tens of milliseconds. Practically, dozens of schedules firing in the same tick add up to well under a second of extra dispatch latency between the first and the last -- negligible compared to the 0-30 s tick alignment. - If the leader fails over, the new leader recovers **at most one fire per schedule** -- the most recent past scheduled time. KiCI does not backfill multiple missed runs (a cron stuck for two hours fires once, not four times). The per-schedule lower bound on fire frequency is the cron expression's natural period; the upper bound on lateness is `30 s + (Raft election + restart time)`. - Sub-minute crons (`* * * * *`) are supported but constrained by the 30-second tick: a schedule for `* * * * *` will fire roughly once per minute, but the actual fire time within each minute can drift by up to 30 seconds. ## Workflow-complete-triggered deploy Trigger a deployment automatically when a build workflow succeeds, using `workflowComplete()`. This is one of the most common event chaining patterns. ```typescript import { workflow, job, step, push, workflowComplete } from '@kici-dev/sdk'; // Workflow A: build and test on push to main export const build = workflow('build', { on: push({ branches: 'main' }), jobs: [ job('test', { runsOn: 'linux', steps: [ step('install', async ({ $ }) => { await $`pnpm install --frozen-lockfile`; }), step('test', async ({ $ }) => { await $`pnpm test`; }), step('build', async ({ $ }) => { await $`pnpm build`; }), ], }), ], }); // Workflow B: deploy when build succeeds export const deploy = workflow('deploy-on-success', { on: workflowComplete({ name: 'build', status: ['success'] }), jobs: [ job('deploy', { runsOn: 'linux', steps: [ step('deploy-staging', async ({ $ }) => { await $`./scripts/deploy.sh staging`; }), step('run-smoke-tests', async ({ $ }) => { await $`./scripts/smoke-test.sh staging`; }), step('deploy-production', async ({ $ }) => { await $`./scripts/deploy.sh production`; }), ], }), ], }); ``` **Notes:** - `workflowComplete()` is a system event trigger -- the orchestrator automatically emits these events when workflows finish. You do not need to call `ctx.emit()`. - The `status` filter accepts `'success'`, `'failed'`, and `'cancelled'`. Omit `status` to trigger on any completion. - The `deploy-on-success` workflow uses the [registration model](https://docs.kici.dev/user/events/#the-registration-model) -- it will not trigger until you push to your default branch. The `build` workflow (using `push()`) works immediately. - You can also use `jobComplete()` to trigger on individual job completions within a workflow. ## Custom event chaining Two workflows communicating through custom events using `kiciEvent()` and `ctx.emit()`. Workflow A runs tests and emits a typed event with results. Workflow B listens for that event and triggers a deployment. ```typescript import { workflow, job, step, push, kiciEvent, defineEvent, z } from '@kici-dev/sdk'; // Define a typed event contract const testsPassedEvent = defineEvent( 'tests-passed', z.object({ branch: z.string(), commit: z.string(), testCount: z.number(), duration: z.number(), }), ); // Workflow A: run tests and emit result event export const testSuite = workflow('test-suite', { on: push({ branches: 'main' }), jobs: [ job('test', { runsOn: 'linux', steps: [ step('install', async ({ $ }) => { await $`pnpm install --frozen-lockfile`; }), step('run-tests', async ({ $ }) => { await $`pnpm test`; }), step('emit-results', async (ctx) => { await ctx.emit(testsPassedEvent, { branch: 'main', commit: 'abc123', testCount: 142, duration: 45, }); }), ], }), ], }); // Workflow B: deploy when tests pass (in the same or separate file) export const autoDeploy = workflow('auto-deploy', { on: kiciEvent({ name: 'tests-passed' }), jobs: [ job('deploy', { runsOn: 'linux', steps: [ step('deploy', async ({ $ }) => { await $`./scripts/deploy.sh`; }), step('notify', async ({ $ }) => { await $`./scripts/notify-slack.sh "Deployment complete"`; }), ], }), ], }); ``` **Notes:** - Both workflows can live in the same `.kici/workflows/` file or in separate files -- the event system routes by event name, not by file. - `defineEvent()` creates a typed contract using Zod. This is optional but recommended for documenting event payloads. - Custom events are delivered immediately when `ctx.emit()` is called (mid-workflow), not queued until the workflow completes. - Payload matching is available via the `match` option: `kiciEvent({ name: 'tests-passed', match: { '$.branch': 'main' } })`. - The `auto-deploy` workflow uses the [registration model](https://docs.kici.dev/user/events/#the-registration-model) -- it will not trigger until you push to your default branch. - The [circuit breaker](https://docs.kici.dev/user/events/#circuit-breaker) limits chain depth (default: 10) and rate (default: 100/min per workflow) to prevent infinite loops. ## Step context ---