@kici-dev/compiler 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cli.js +25 -7
  2. package/dist/commands/compile.d.ts +6 -0
  3. package/dist/commands/compile.js +6 -3
  4. package/dist/commands/docs.d.ts +8 -8
  5. package/dist/commands/docs.js +35 -16
  6. package/dist/commands/held-run-client.d.ts +7 -2
  7. package/dist/commands/held-run-client.js +9 -3
  8. package/dist/commands/held-run-resolve.d.ts +5 -0
  9. package/dist/commands/login.d.ts +2 -0
  10. package/dist/commands/login.js +15 -7
  11. package/dist/commands/org.js +2 -2
  12. package/dist/commands/run-hold-watch.d.ts +57 -0
  13. package/dist/commands/run-hold-watch.js +87 -0
  14. package/dist/commands/run.d.ts +23 -0
  15. package/dist/commands/run.js +150 -17
  16. package/dist/commands/test.d.ts +14 -0
  17. package/dist/commands/types.d.ts +2 -0
  18. package/dist/commands/types.js +1 -1
  19. package/dist/fixtures/describe-event.d.ts +6 -0
  20. package/dist/fixtures/describe-event.js +18 -0
  21. package/dist/fixtures/picker.d.ts +19 -0
  22. package/dist/fixtures/picker.js +64 -0
  23. package/dist/llm-context/llms-architecture.txt +1440 -0
  24. package/dist/llm-context/llms-cli.txt +2386 -0
  25. package/dist/llm-context/llms-features.txt +2389 -0
  26. package/dist/llm-context/llms-full.txt +1304 -349
  27. package/dist/llm-context/llms-getting-started.txt +519 -0
  28. package/dist/llm-context/llms-patterns.txt +1324 -0
  29. package/dist/llm-context/llms-providers.txt +805 -0
  30. package/dist/llm-context/llms-sdk.txt +3725 -0
  31. package/dist/llm-context/llms.txt +15 -1
  32. package/dist/local-executor/index.js +40 -3
  33. package/dist/local-executor/job-runner.d.ts +2 -0
  34. package/dist/local-executor/job-runner.js +37 -4
  35. package/dist/local-executor/types.d.ts +2 -0
  36. package/dist/lockfile/generator.js +46 -20
  37. package/dist/remote/config.d.ts +2 -0
  38. package/dist/remote/config.js +1 -0
  39. package/dist/remote/platform-client.d.ts +12 -1
  40. package/dist/remote/uploader.js +1 -0
  41. package/dist/templates/package-json.js +1 -1
  42. package/dist/test-runner/rule-evaluator.d.ts +1 -1
  43. package/dist/test-runner/rule-evaluator.js +2 -1
  44. package/dist/test-runner/step-context.d.ts +1 -1
  45. package/dist/test-runner/step-context.js +8 -2
  46. package/dist/types.d.ts +15 -6
  47. package/package.json +4 -4
  48. package/sbom.spdx.json +35 -35
@@ -0,0 +1,1324 @@
1
+ # KiCI Workflow patterns
2
+
3
+ This bundle covers: Copy-paste workflow recipes: triggers, conditionals, matrix, scheduling, integrations.
4
+
5
+ ## Basic workflow patterns
6
+
7
+ Source: https://docs.kici.dev/user/patterns/basic/
8
+
9
+ A standard lint-then-test pipeline using job dependencies (`needs`):
10
+
11
+ ```typescript
12
+ import { workflow, job, step, pr } from '@kici-dev/sdk';
13
+
14
+ const lint = job('lint', {
15
+ runsOn: 'linux',
16
+ steps: [
17
+ step('install', async ({ $ }) => {
18
+ await $`pnpm install --frozen-lockfile`;
19
+ }),
20
+ step('check', async ({ $ }) => {
21
+ await $`pnpm lint`;
22
+ await $`pnpm format:check`;
23
+ }),
24
+ ],
25
+ });
26
+
27
+ const test = job('test', {
28
+ runsOn: 'linux',
29
+ needs: [lint],
30
+ steps: [
31
+ step('install', async ({ $ }) => {
32
+ await $`pnpm install --frozen-lockfile`;
33
+ }),
34
+ step('test', async ({ $ }) => {
35
+ await $`pnpm test`;
36
+ }),
37
+ ],
38
+ });
39
+
40
+ const typecheck = job('typecheck', {
41
+ runsOn: 'linux',
42
+ needs: [lint],
43
+ steps: [
44
+ step('install', async ({ $ }) => {
45
+ await $`pnpm install --frozen-lockfile`;
46
+ }),
47
+ step('typecheck', async ({ $ }) => {
48
+ await $`pnpm typecheck`;
49
+ }),
50
+ ],
51
+ });
52
+
53
+ export default workflow('ci', {
54
+ on: pr({ target: 'main' }),
55
+ jobs: [lint, test, typecheck],
56
+ });
57
+ ```
58
+
59
+ 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.
60
+
61
+ **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(...)]`:
62
+
63
+ ```typescript
64
+ import { job, workflow, push } from '@kici-dev/sdk';
65
+
66
+ const smoke = job('smoke', {
67
+ runsOn: 'default',
68
+ run: async ({ $, log }) => {
69
+ await $`curl -fsS https://example.com/health`;
70
+ log.info('Health check passed');
71
+ },
72
+ });
73
+
74
+ export default workflow('smoke', {
75
+ on: push({ branches: 'main' }),
76
+ jobs: [smoke],
77
+ });
78
+ ```
79
+
80
+ `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.
81
+
82
+ ## PR-only workflow with branch filters
83
+
84
+ Use `pr()` to filter by events, target branches, source branches, and file paths:
85
+
86
+ ```typescript
87
+ import { workflow, job, step, pr } from '@kici-dev/sdk';
88
+
89
+ // Only trigger on opened/synchronize events targeting main,
90
+ // and only when source code files change
91
+ const trigger = pr({
92
+ events: ['opened', 'synchronize'],
93
+ target: ['main', 'develop'],
94
+ paths: ['src/**', 'packages/**', '!**/*.md', '!docs/**'],
95
+ });
96
+
97
+ const build = job('build', {
98
+ runsOn: 'linux',
99
+ steps: [
100
+ step('build', async ({ $ }) => {
101
+ await $`pnpm build`;
102
+ }),
103
+ ],
104
+ });
105
+
106
+ export default workflow('pr-checks', {
107
+ on: trigger,
108
+ jobs: [build],
109
+ });
110
+ ```
111
+
112
+ ### PR trigger options
113
+
114
+ | Option | Type | Description |
115
+ | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------- |
116
+ | `target` | `string \| RegExp \| (string \| RegExp)[]` | Match target branches (glob or regex) |
117
+ | `source` | `string \| RegExp \| (string \| RegExp)[]` | Match source branches (glob or regex) |
118
+ | `events` | `PrEvent[]` | Filter PR event types |
119
+ | `paths` | `string[]` | Only trigger when matching files change. Use `!` prefix for exclusions (e.g., `'!docs/**'`) |
120
+ | `description` | `string` | Add a human-readable description |
121
+
122
+ Default PR events (when `events` is not specified): `opened`, `synchronize`, `reopened`, `closed`.
123
+
124
+ ## Push trigger with branch filters
125
+
126
+ Use `push()` for push-based workflows:
127
+
128
+ ```typescript
129
+ import { workflow, job, step, push } from '@kici-dev/sdk';
130
+
131
+ // Deploy on pushes to main
132
+ const deploy = job('deploy', {
133
+ runsOn: 'linux',
134
+ steps: [
135
+ step('deploy', async ({ $ }) => {
136
+ await $`pnpm build`;
137
+ await $`pnpm deploy`;
138
+ }),
139
+ ],
140
+ });
141
+
142
+ export default workflow('deploy', {
143
+ on: push({ branches: 'main' }),
144
+ jobs: [deploy],
145
+ });
146
+ ```
147
+
148
+ ### Push trigger options
149
+
150
+ | Option | Type | Description |
151
+ | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------- |
152
+ | `branches` | `string \| RegExp \| (string \| RegExp)[]` | Match branch names (glob or regex) |
153
+ | `tags` | `string \| RegExp \| (string \| RegExp)[]` | Match tag names (glob or regex) |
154
+ | `paths` | `string[]` | Only trigger when matching files change. Use `!` prefix for exclusions (e.g., `'!docs/**'`) |
155
+ | `description` | `string` | Add a human-readable description |
156
+
157
+ ### Regex branch patterns
158
+
159
+ Both `pr()` and `push()` accept regex patterns alongside glob strings:
160
+
161
+ ```typescript
162
+ // Glob pattern
163
+ push({ branches: 'release/*' });
164
+
165
+ // Regex pattern
166
+ push({ branches: /^release\/v\d+\.\d+$/ });
167
+ ```
168
+
169
+ ## Multiple triggers
170
+
171
+ A workflow can respond to multiple trigger types:
172
+
173
+ ```typescript
174
+ import { workflow, job, step, pr, push } from '@kici-dev/sdk';
175
+
176
+ const test = job('test', {
177
+ runsOn: 'linux',
178
+ steps: [
179
+ step('test', async ({ $ }) => {
180
+ await $`pnpm test`;
181
+ }),
182
+ ],
183
+ });
184
+
185
+ export default workflow('ci', {
186
+ on: [pr({ target: 'main' }), push({ branches: 'main' })],
187
+ jobs: [test],
188
+ });
189
+ ```
190
+
191
+ ## Manual / local-only workflow (no git events)
192
+
193
+ 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.
194
+
195
+ There are two ways to "explicitly invoke" a `dispatch()` workflow:
196
+
197
+ 1. **Locally from your laptop**, with `kici run local dispatch` — no orchestrator, no agent, no webhook, nothing deployed. This is the only path while you haven't wired the repo to a deployed KiCI orchestrator.
198
+ 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.
199
+
200
+ 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.
201
+
202
+ ```typescript
203
+ import { workflow, job, step, dispatch } from '@kici-dev/sdk';
204
+
205
+ export default workflow('hello-world', {
206
+ on: dispatch(),
207
+ jobs: [
208
+ job('greet', {
209
+ runsOn: 'linux',
210
+ steps: [
211
+ step('say-hello', async ({ $ }) => {
212
+ await $`echo "Hello, World!"`;
213
+ }),
214
+ ],
215
+ }),
216
+ ],
217
+ });
218
+ ```
219
+
220
+ Run it locally, without any orchestrator or agent infrastructure:
221
+
222
+ ```bash
223
+ npx kici compile # regenerate .kici/kici.lock.json
224
+ npx kici run local dispatch
225
+ ```
226
+
227
+ `kici run local` compiles the workflow, matches triggers against a simulated `dispatch` event, and executes the matched jobs directly on your machine with DAG-based scheduling. No webhook, no GitHub, no deployed orchestrator involved. See [`kici run local`](https://docs.kici.dev/user/cli-reference/#kici-run-local) for options like `--job`, `--env`, `--json`, and `--junit`.
228
+
229
+ ### Scoping to a single workflow
230
+
231
+ Because `kici run local dispatch` matches **every** workflow that listens for a `dispatch` event, running it in a repo with several dispatch-triggered workflows will fire all of them. Narrow execution to one with `--workflow <name>`:
232
+
233
+ ```bash
234
+ npx kici run local dispatch --workflow hello-world
235
+ ```
236
+
237
+ `--workflow` is a post-match filter: the workflow still has to have a trigger that matches the event argument. If `hello-world` does not list a `dispatch()` trigger, the command reports `No workflow named "hello-world" matched the event` and exits successfully without running anything.
238
+
239
+ If you do not want to memorise event args, use the interactive picker instead:
240
+
241
+ ```bash
242
+ npx kici run local --pick
243
+ ```
244
+
245
+ `--pick` (aliased as `-p`) lists every workflow alongside a compact summary of its triggers, lets you select one, and derives a matching event arg from the chosen trigger — so the execution still flows through the normal trigger-matching pipeline and "cannot produce an inconsistent run". Multi-trigger workflows show a second prompt for which trigger to simulate. `--pick` is mutually exclusive with `--workflow`; in a non-TTY shell it prints the workflow list and exits without running anything.
246
+
247
+ ### Unfiltered vs typed `dispatch()`
248
+
249
+ Leave `dispatch()` unfiltered while you drive it from `kici run local`. The CLI currently 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 local dispatch` — the typed form is intended for real `repository_dispatch` deliveries from the orchestrator.
250
+
251
+ ## Conditional execution with rules
252
+
253
+ ---
254
+
255
+ ## Conditionals & matrix patterns
256
+
257
+ Source: https://docs.kici.dev/user/patterns/conditionals-matrix/
258
+
259
+ Rules control whether a workflow or job runs. Use `rule()` for conditions that must pass, and `skip()` for conditions that should skip execution.
260
+
261
+ ### Workflow-level rules
262
+
263
+ ```typescript
264
+ import { workflow, job, step, pr, rule } from '@kici-dev/sdk';
265
+
266
+ const test = job('test', {
267
+ runsOn: 'linux',
268
+ steps: [
269
+ step('test', async ({ $ }) => {
270
+ await $`pnpm test`;
271
+ }),
272
+ ],
273
+ });
274
+
275
+ export default workflow('ci', {
276
+ on: pr(),
277
+ rules: [
278
+ rule('has source changes', async (ctx) => {
279
+ return ctx.changedFiles.some((f) => f.startsWith('src/'));
280
+ }),
281
+ ],
282
+ jobs: [test],
283
+ });
284
+ ```
285
+
286
+ ### Job-level rules
287
+
288
+ ```typescript
289
+ import { workflow, job, step, pr, rule, skip } from '@kici-dev/sdk';
290
+
291
+ const unitTests = job('unit-tests', {
292
+ runsOn: 'linux',
293
+ steps: [
294
+ step('test', async ({ $ }) => {
295
+ await $`pnpm test:unit`;
296
+ }),
297
+ ],
298
+ });
299
+
300
+ const e2eTests = job('e2e-tests', {
301
+ runsOn: 'linux',
302
+ rules: [
303
+ // Skip E2E when only docs change
304
+ skip('docs only', async (ctx) => {
305
+ return ctx.changedFiles.every((f) => f.endsWith('.md'));
306
+ }),
307
+ ],
308
+ steps: [
309
+ step('test', async ({ $ }) => {
310
+ await $`pnpm test:e2e`;
311
+ }),
312
+ ],
313
+ });
314
+
315
+ export default workflow('ci', {
316
+ on: pr(),
317
+ jobs: [unitTests, e2eTests],
318
+ });
319
+ ```
320
+
321
+ ### Rule context
322
+
323
+ Rule check functions receive a `RuleContext` with:
324
+
325
+ | Property | Type | Description |
326
+ | -------------- | ----------------------------------- | ----------------------------------- |
327
+ | `event` | `EventPayload` | The triggering event data |
328
+ | `changedFiles` | `string[]` | Files changed in this event |
329
+ | `env` | `Record<string, string\|undefined>` | Environment variables |
330
+ | `$` | zx shell | Shell executor for running commands |
331
+
332
+ ### Marker rules
333
+
334
+ A rule without a check function always passes. Useful for labeling in the decision trace:
335
+
336
+ ```typescript
337
+ rule('ci: required check');
338
+ ```
339
+
340
+ ## Matrix builds
341
+
342
+ Matrix configurations run a job across multiple parameter combinations.
343
+
344
+ ### Simple array matrix
345
+
346
+ Run a job for each value in an array:
347
+
348
+ ```typescript
349
+ import { workflow, job, step, push } from '@kici-dev/sdk';
350
+
351
+ const test = job('test', {
352
+ runsOn: 'linux',
353
+ matrix: ['18', '20', '22'],
354
+ steps: [
355
+ step('test', async ({ $, matrix }) => {
356
+ await $`nvm use ${matrix!.value}`;
357
+ await $`pnpm test`;
358
+ }),
359
+ ],
360
+ });
361
+
362
+ export default workflow('test-matrix', {
363
+ on: push(),
364
+ jobs: [test],
365
+ });
366
+ ```
367
+
368
+ With a single-dimension matrix, the current value is available as `matrix.value` in the step context.
369
+
370
+ ### Multi-dimensional matrix
371
+
372
+ Use an object to define multiple dimensions. KiCI expands all combinations (capped at 256):
373
+
374
+ ```typescript
375
+ const test = job('test', {
376
+ runsOn: ['linux', 'kici:agent:container'],
377
+ matrix: {
378
+ os: ['linux', 'arm64'],
379
+ node: ['18', '20', '22'],
380
+ },
381
+ steps: [
382
+ step('test', async ({ $, matrix }) => {
383
+ // matrix.os = 'linux' | 'arm64'
384
+ // matrix.node = '18' | '20' | '22'
385
+ await $`echo "Testing on ${matrix!.os} with Node ${matrix!.node}"`;
386
+ await $`pnpm test`;
387
+ }),
388
+ ],
389
+ });
390
+ ```
391
+
392
+ This creates 6 job instances (2 OS x 3 Node versions).
393
+
394
+ > **Labels are customer-defined.** `runsOn` values such as `linux` or `arm64` are scaler
395
+ > labels **you** define in your orchestrator's `labelSets` — they are matched by subset
396
+ > semantics, not by a hosted-runner name. You can also target reserved auto-injected labels
397
+ > in the `kici:` namespace (e.g. `kici:agent:firecracker`, `kici:agent:container`) to pin a
398
+ > job to a specific backend type.
399
+
400
+ ### Include and exclude
401
+
402
+ Fine-tune matrix combinations:
403
+
404
+ ```typescript
405
+ const test = job('test', {
406
+ runsOn: 'linux',
407
+ matrix: {
408
+ os: ['linux', 'arm64', 'windows'],
409
+ node: ['18', '20', '22'],
410
+ },
411
+ // Remove specific combination
412
+ exclude: [{ os: 'windows', node: '18' }],
413
+ // Add specific combination not in the matrix
414
+ include: [{ os: 'linux', node: '23' }],
415
+ steps: [
416
+ step('test', async ({ $ }) => {
417
+ await $`pnpm test`;
418
+ }),
419
+ ],
420
+ });
421
+ ```
422
+
423
+ Exclude is applied first (removes matching combinations), then include adds additional entries.
424
+
425
+ ### Dynamic matrix
426
+
427
+ Compute matrix values at runtime using an async function:
428
+
429
+ ```typescript
430
+ const test = job('test', {
431
+ runsOn: 'linux',
432
+ matrix: async ({ $ }) => {
433
+ // Discover packages in a monorepo
434
+ const result = await $`ls packages/`;
435
+ return result.stdout.trim().split('\n');
436
+ },
437
+ steps: [
438
+ step('test', async ({ $, matrix }) => {
439
+ await $`cd packages/${matrix!.value} && pnpm test`;
440
+ }),
441
+ ],
442
+ });
443
+ ```
444
+
445
+ Dynamic matrix functions receive the same context as dynamic job functions (`$`, `ctx`, `log`, `env`).
446
+
447
+ ### Matrix type guards
448
+
449
+ Use type guards to inspect matrix configuration at compile time:
450
+
451
+ ```typescript
452
+ import { isStaticArray, isStaticObject, isDynamicFunction } from '@kici-dev/sdk';
453
+
454
+ if (isStaticArray(myMatrix)) {
455
+ // string[]
456
+ }
457
+ if (isStaticObject(myMatrix)) {
458
+ // Record<string, string[]>
459
+ }
460
+ if (isDynamicFunction(myMatrix)) {
461
+ // async function
462
+ }
463
+ ```
464
+
465
+ ## Dynamic job generation
466
+
467
+ Generate jobs at runtime using async factory functions. Useful for monorepos or when the set of jobs depends on the repository state:
468
+
469
+ ```typescript
470
+ import { workflow, job, step, push } from '@kici-dev/sdk';
471
+ import type { DynamicJobFn } from '@kici-dev/sdk';
472
+
473
+ const discoverAndTest: DynamicJobFn = async ({ $ }) => {
474
+ // Discover packages at runtime
475
+ const result = await $`ls packages/`;
476
+ const packages = result.stdout.trim().split('\n');
477
+
478
+ return packages.map((pkg) =>
479
+ job(`test-${pkg}`, {
480
+ runsOn: 'linux',
481
+ steps: [
482
+ step('test', async ({ $ }) => {
483
+ await $`cd packages/${pkg} && pnpm test`;
484
+ }),
485
+ ],
486
+ }),
487
+ );
488
+ };
489
+
490
+ export default workflow('monorepo-ci', {
491
+ on: push(),
492
+ jobs: [discoverAndTest],
493
+ });
494
+ ```
495
+
496
+ ### Mixing static and dynamic jobs
497
+
498
+ The `jobs` array accepts both static `Job` objects and `DynamicJobFn` functions:
499
+
500
+ ```typescript
501
+ const lint = job('lint', {
502
+ runsOn: 'linux',
503
+ steps: [
504
+ step('lint', async ({ $ }) => {
505
+ await $`pnpm lint`;
506
+ }),
507
+ ],
508
+ });
509
+
510
+ export default workflow('monorepo-ci', {
511
+ on: push(),
512
+ jobs: [lint, discoverAndTest],
513
+ });
514
+ ```
515
+
516
+ Static jobs and dynamic generators live side by side. The `isDynamicJobFn()` type guard distinguishes them at runtime.
517
+
518
+ ## Combining patterns
519
+
520
+ A full example combining triggers, rules, matrix, and job dependencies:
521
+
522
+ ```typescript
523
+ import { workflow, job, step, pr, push, rule, skip } from '@kici-dev/sdk';
524
+
525
+ // Only run on PRs targeting main with source changes
526
+ const prTrigger = pr({ target: 'main', paths: ['src/**', 'packages/**', '!**/*.md'] });
527
+
528
+ // Also run on pushes to main
529
+ const pushTrigger = push({ branches: 'main' });
530
+
531
+ const lint = job('lint', {
532
+ runsOn: 'linux',
533
+ steps: [
534
+ step('install', async ({ $ }) => {
535
+ await $`pnpm install --frozen-lockfile`;
536
+ }),
537
+ step('lint', async ({ $ }) => {
538
+ await $`pnpm lint`;
539
+ }),
540
+ ],
541
+ });
542
+
543
+ const test = job('test', {
544
+ runsOn: 'linux',
545
+ needs: [lint],
546
+ matrix: { node: ['18', '20', '22'] },
547
+ steps: [
548
+ step('test', async ({ $, matrix }) => {
549
+ await $`pnpm test`;
550
+ }),
551
+ ],
552
+ });
553
+
554
+ const deploy = job('deploy', {
555
+ runsOn: 'linux',
556
+ needs: [test],
557
+ rules: [
558
+ // Only deploy from push events (not PRs)
559
+ rule('push event only', async (ctx) => {
560
+ return ctx.event.type === 'push';
561
+ }),
562
+ ],
563
+ steps: [
564
+ step('deploy', async ({ $ }) => {
565
+ await $`pnpm build && pnpm deploy`;
566
+ }),
567
+ ],
568
+ });
569
+
570
+ export default workflow('full-pipeline', {
571
+ on: [prTrigger, pushTrigger],
572
+ rules: [
573
+ skip('docs only', async (ctx) => {
574
+ return ctx.changedFiles.every((f) => f.endsWith('.md'));
575
+ }),
576
+ ],
577
+ jobs: [lint, test, deploy],
578
+ });
579
+ ```
580
+
581
+ This workflow:
582
+
583
+ 1. Triggers on PRs targeting main (with path filters) and pushes to main
584
+ 2. Skips entirely if only docs files changed (workflow-level `skip` rule)
585
+ 3. Runs lint first, then tests across 3 Node versions in parallel
586
+ 4. Deploys only on push events (not on PRs), after all tests pass
587
+
588
+ ## Workflow chaining
589
+
590
+ ---
591
+
592
+ ## Host restart & wait-for-alive
593
+
594
+ Source: https://docs.kici.dev/user/patterns/host-restart/
595
+
596
+ When a KiCI agent runs on a host you are provisioning, a workflow can reboot
597
+ that host and resume work once it comes back — the Ansible `reboot` +
598
+ `wait_for_connection` pattern, expressed as two jobs pinned to the same host.
599
+
600
+ ## The two-job pattern
601
+
602
+ Host restart is a **job-boundary** capability: the reboot is the last step of a
603
+ "restart" job, and the post-restart work lives in a **separate job** pinned to
604
+ the same host that `needs` the restart job. The orchestrator holds the
605
+ post-restart job until the host completes a reboot cycle, then dispatches it.
606
+
607
+ ```typescript
608
+ import { workflow, job, step, restartHost, waitForHostAlive } from '@kici-dev/sdk';
609
+
610
+ export default workflow('patch-and-verify', {
611
+ on: [
612
+ /* ... */
613
+ ],
614
+ jobs: [
615
+ // Restart job: apply updates, then reboot. restartHost() MUST be the last step.
616
+ job('patch', {
617
+ runsOn: 'kici:host:box-01',
618
+ steps: [
619
+ step('upgrade', async (ctx) => {
620
+ await ctx.$`apt-get upgrade -y`;
621
+ }),
622
+ restartHost(),
623
+ ],
624
+ }),
625
+ // Post-restart job: pinned to the SAME host, needs the restart job.
626
+ job('verify', {
627
+ runsOn: 'kici:host:box-01',
628
+ needs: ['patch'],
629
+ steps: [
630
+ waitForHostAlive(() => fetch('http://localhost:8080/health')),
631
+ step('check-service', async (ctx) => {
632
+ await ctx.$`systemctl is-active myservice`;
633
+ }),
634
+ ],
635
+ }),
636
+ ],
637
+ });
638
+ ```
639
+
640
+ ## `restartHost()`
641
+
642
+ `restartHost(opts?)` reboots the host the job runs on. It signals the
643
+ orchestrator that a reboot is pending (which holds the post-restart job and
644
+ treats the agent's imminent disconnect as expected, not a failure), reports the
645
+ step success, and the agent issues the OS reboot once the step completes.
646
+
647
+ - **Must be the last step** of its job — the job completes before the box goes
648
+ down.
649
+ - `deadlineMs` (optional) overrides how long the orchestrator waits for the host
650
+ to return after the reboot. The default is the orchestrator's
651
+ `KICI_HOST_REBOOT_DEADLINE_MS` (15 minutes). If the host does not reconnect by
652
+ the deadline, the held post-restart job fails with a clear "host did not
653
+ return after reboot" reason.
654
+ - The reboot command is chosen per operating system (Linux `systemctl reboot`,
655
+ macOS `shutdown -r now`, Windows `shutdown /r /t 0`).
656
+
657
+ ## `waitForHostAlive(probe, opts?)`
658
+
659
+ `waitForHostAlive()` is the optional first step of the post-restart job. The
660
+ baseline "the host is back" guarantee comes for free — the post-restart job only
661
+ dispatches after the agent reconnects. `waitForHostAlive(probe)` adds a
662
+ **service-readiness** gate on top: it polls `probe` until it resolves, for hosts
663
+ where "agent connected" does not yet mean "services ready".
664
+
665
+ - The probe can return anything (an HTTP response, an open port check, a marker
666
+ file). Any non-null resolution means "ready"; a throw or rejection keeps
667
+ polling.
668
+ - `intervalMs` (default 3000) and `timeoutMs` (default 300000) tune the poll. If
669
+ the probe never succeeds within `timeoutMs`, the step fails with "services did
670
+ not come up".
671
+
672
+ ## Same-host pinning
673
+
674
+ The "same host" relationship is the pin: both jobs target the same host via
675
+ `runsOn` (a `kici:host:<id>` label or another label the host carries), and the
676
+ post-restart job `needs` the restart job. Durable provisioning hosts MUST set a
677
+ stable agent id (`KICI_AGENT_ID`) so the host re-registers under the same
678
+ identity after the reboot — that stable identity is what lets the orchestrator
679
+ recognise the down-then-up cycle and release the held job.
680
+
681
+ ## Failure behavior
682
+
683
+ - **Host never returns by the deadline** → the held post-restart job fails.
684
+ - **Reboot privilege denied** → the restart step fails with a clear privilege
685
+ error (see the operator note below), and the orchestrator clears the
686
+ reboot-pending hold.
687
+ - **`waitForHostAlive` probe never succeeds** → that step fails.
688
+
689
+ The orchestrator refuses to reboot the host it runs on, so a co-located agent
690
+ cannot take down the orchestrator's own box.
691
+
692
+ ## Operator prerequisite: reboot privilege
693
+
694
+ Rebooting needs host privilege. An agent used for host provisioning must be able
695
+ to run the OS reboot primitive — run the agent service with reboot privilege, or
696
+ grant a narrow `shutdown` / `systemctl reboot` permission. Agents used for
697
+ provisioning generally need broad, near-root host privileges; see the operator
698
+ agent documentation for the full posture.
699
+
700
+ ---
701
+
702
+ ## Integration patterns
703
+
704
+ Source: https://docs.kici.dev/user/patterns/integrations/
705
+
706
+ 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.
707
+
708
+ ### Using system completion events
709
+
710
+ The orchestrator automatically emits `workflow_complete` and `job_complete` events. Use `workflowComplete()` and `jobComplete()` triggers to listen for them:
711
+
712
+ ```typescript
713
+ import { workflow, job, step, push, workflowComplete } from '@kici-dev/sdk';
714
+
715
+ // Workflow A: deploy on push to main
716
+ export const deploy = workflow('deploy', {
717
+ on: push({ branches: 'main' }),
718
+ jobs: [
719
+ job('deploy', {
720
+ runsOn: 'linux',
721
+ steps: [
722
+ step('deploy', async ({ $ }) => {
723
+ await $`./scripts/deploy.sh`;
724
+ }),
725
+ ],
726
+ }),
727
+ ],
728
+ });
729
+
730
+ // Workflow B: runs after deploy succeeds
731
+ export const postDeploy = workflow('post-deploy', {
732
+ on: workflowComplete({ name: 'deploy', status: ['success'] }),
733
+ jobs: [
734
+ job('notify', {
735
+ runsOn: 'linux',
736
+ steps: [
737
+ step('slack', async ({ $ }) => {
738
+ await $`./scripts/notify-slack.sh "Deploy succeeded"`;
739
+ }),
740
+ ],
741
+ }),
742
+ ],
743
+ });
744
+ ```
745
+
746
+ `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.
747
+
748
+ ### Same-run discovery → fan-out
749
+
750
+ 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:
751
+
752
+ ```typescript
753
+ import { workflow, job, step, push, dynamicJob, z } from '@kici-dev/sdk';
754
+
755
+ const discover = job('discover', {
756
+ runsOn: 'linux',
757
+ steps: [
758
+ step('list-services', {
759
+ outputs: { services: z.array(z.string()) },
760
+ run: async ({ $ }) => {
761
+ const out = await $`ls services/`;
762
+ return { services: out.stdout.trim().split('\n') };
763
+ },
764
+ }),
765
+ ],
766
+ });
767
+
768
+ const deployEach = dynamicJob('deploys', {
769
+ needs: ['discover'],
770
+ generate: async ({ ctx }) =>
771
+ ctx.needs.discover.result.services.map((svc) =>
772
+ job(`deploy-${svc}`, {
773
+ runsOn: 'linux',
774
+ run: async ({ $ }) => {
775
+ await $`./scripts/deploy.sh ${svc}`;
776
+ },
777
+ }),
778
+ ),
779
+ });
780
+
781
+ export default workflow('deploy-discovered-services', { on: push(), jobs: [discover, deployEach] });
782
+ ```
783
+
784
+ 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.
785
+
786
+ ### Using custom events
787
+
788
+ For richer payload data, emit custom events from steps using `ctx.emit()`:
789
+
790
+ ```typescript
791
+ import { workflow, job, step, push, kiciEvent } from '@kici-dev/sdk';
792
+
793
+ // Workflow A: deploy and emit custom event with payload
794
+ export const deploy = workflow('deploy', {
795
+ on: push({ branches: 'main' }),
796
+ jobs: [
797
+ job('deploy', {
798
+ runsOn: 'linux',
799
+ steps: [
800
+ step('deploy', async ({ $ }) => {
801
+ await $`./scripts/deploy.sh`;
802
+ }),
803
+ step('notify', async (ctx) => {
804
+ await ctx.emit('deploy-complete', {
805
+ env: 'prod',
806
+ version: '1.2.3',
807
+ });
808
+ }),
809
+ ],
810
+ }),
811
+ ],
812
+ });
813
+
814
+ // Workflow B: triggered by custom event with payload matching
815
+ export const postDeploy = workflow('post-deploy', {
816
+ on: kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }),
817
+ jobs: [
818
+ job('smoke-test', {
819
+ runsOn: 'linux',
820
+ steps: [
821
+ step('test', async ({ $ }) => {
822
+ await $`./scripts/smoke-test.sh`;
823
+ }),
824
+ ],
825
+ }),
826
+ ],
827
+ });
828
+ ```
829
+
830
+ Custom events are delivered immediately (mid-workflow, not queued until workflow completion).
831
+
832
+ ## Generic webhook integration
833
+
834
+ 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()`.
835
+
836
+ ```typescript
837
+ import { workflow, job, step, genericWebhook } from '@kici-dev/sdk';
838
+
839
+ // Triggered by ArgoCD deploy events
840
+ export default workflow('on-argocd-deploy', {
841
+ on: genericWebhook({ source: 'argocd', events: ['deploy.success'] }),
842
+ jobs: [
843
+ job('post-deploy', {
844
+ runsOn: 'linux',
845
+ steps: [
846
+ step('verify', async ({ $, rawPayload }) => {
847
+ // rawPayload contains the full webhook body from ArgoCD
848
+ await $`./scripts/verify-deploy.sh`;
849
+ }),
850
+ ],
851
+ }),
852
+ ],
853
+ });
854
+ ```
855
+
856
+ 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.
857
+
858
+ ## Stripe webhook handler
859
+
860
+ Process payment events from Stripe using `genericWebhook()` with HMAC-SHA256 signature verification. This pattern applies to any external service that sends signed HTTP webhooks.
861
+
862
+ ```typescript
863
+ import { workflow, job, step, genericWebhook } from '@kici-dev/sdk';
864
+
865
+ export default workflow('stripe-invoice-handler', {
866
+ on: genericWebhook({
867
+ source: 'stripe',
868
+ events: ['invoice.paid'],
869
+ auth: {
870
+ method: 'hmac-sha256',
871
+ secret: 'stripe-signing-key',
872
+ signatureHeader: 'stripe-signature',
873
+ },
874
+ description: 'Process Stripe invoice.paid events',
875
+ }),
876
+ jobs: [
877
+ job('process-invoice', {
878
+ runsOn: 'linux',
879
+ steps: [
880
+ step('extract-customer', async ({ $, log }) => {
881
+ log.info('Processing paid invoice from Stripe');
882
+ await $`./scripts/process-invoice.sh`;
883
+ }),
884
+ step('update-billing', async ({ $ }) => {
885
+ await $`./scripts/update-billing-records.sh`;
886
+ }),
887
+ step('notify-team', async ({ $ }) => {
888
+ await $`./scripts/notify-billing-team.sh`;
889
+ }),
890
+ ],
891
+ }),
892
+ ],
893
+ });
894
+ ```
895
+
896
+ **Prerequisites:**
897
+
898
+ - 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).
899
+ - The `stripe-signing-key` secret must contain your Stripe webhook signing secret.
900
+ - 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.
901
+
902
+ ## Self-hosted git forge (Gogs, Forgejo, Gitea)
903
+
904
+ 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.
905
+
906
+ **Operator setup:**
907
+
908
+ ```bash
909
+ # Forgejo / Gitea send event name in X-Gitea-Event and signature in X-Gitea-Signature.
910
+ # Gogs uses X-Gogs-Event and X-Gogs-Signature (same HMAC-SHA256 hex-digest format).
911
+ kici-admin source add generic \
912
+ --org my-org \
913
+ --name forgejo-main \
914
+ --verification hmac_sha256 \
915
+ --secret @/path/to/webhook-secret.txt \
916
+ --event-type-header X-Gitea-Event \
917
+ --rate-limit 120
918
+ ```
919
+
920
+ Note the returned source ID, then register a webhook in the forge pointing at `https://<platform>/webhooks/<orgId>/generic/<sourceId>` (or the orchestrator's direct URL). Set content type to `application/json` and paste the same secret.
921
+
922
+ **Workflow:**
923
+
924
+ ```typescript
925
+ import { workflow, job, step, genericWebhook } from '@kici-dev/sdk';
926
+
927
+ export default workflow('on-forgejo-push', {
928
+ on: genericWebhook({
929
+ source: 'forgejo-main',
930
+ events: ['push'], // Forgejo/Gitea sends 'push', 'pull_request', 'issues', etc.
931
+ match: { '$.ref': 'refs/heads/main' }, // JSONPath filter on the payload
932
+ }),
933
+ jobs: [
934
+ job('react-to-push', {
935
+ runsOn: 'linux',
936
+ steps: [
937
+ step('log', async ({ rawPayload, log }) => {
938
+ const ref = (rawPayload as { ref?: string }).ref;
939
+ log.info(`Forgejo push to ${ref}`);
940
+ }),
941
+ ],
942
+ }),
943
+ ],
944
+ });
945
+ ```
946
+
947
+ **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:
948
+
949
+ - **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.
950
+ - **Clone yourself using a secret.** Set `checkout: false` on the job to skip the framework clone, store an SSH private key or forge personal access token as a secret, and run `git clone` explicitly in the first step. 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.
951
+ - **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.
952
+
953
+ Manual-clone example (pattern 2) using an SSH deploy key:
954
+
955
+ ```typescript
956
+ job('forgejo-ci', {
957
+ runsOn: 'linux',
958
+ checkout: false, // skip framework clone
959
+ steps: [
960
+ step('clone', async ({ $, ctx, rawPayload }) => {
961
+ const sshKey = await ctx.secrets.get('FORGEJO_DEPLOY_KEY');
962
+ await $`mkdir -p ~/.ssh`;
963
+ await $`ssh-keyscan forgejo.example.com >> ~/.ssh/known_hosts`;
964
+ await $({ input: sshKey })`tee ~/.ssh/id_ed25519 > /dev/null`;
965
+ await $`chmod 600 ~/.ssh/id_ed25519`;
966
+ const url = (rawPayload as { repository: { ssh_url: string } }).repository.ssh_url;
967
+ const sha = (rawPayload as { after: string }).after;
968
+ await $`git clone ${url} src && cd src && git checkout ${sha}`;
969
+ }),
970
+ step('test', async ({ $ }) => {
971
+ await $`cd src && pnpm install && pnpm test`;
972
+ }),
973
+ ],
974
+ });
975
+ ```
976
+
977
+ 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`.
978
+
979
+ **Prerequisites:**
980
+
981
+ - An operator must create a generic webhook source via `kici-admin source add generic` (see above).
982
+ - The forge's webhook secret must match the `--secret` value.
983
+ - 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.
984
+
985
+ ## Plain GitHub repo webhooks (no GitHub App)
986
+
987
+ The Gogs/Forgejo/Gitea pattern above also applies when you want to trigger workflows from a GitHub repository **without installing the KiCI GitHub App** — for example because you lack org-admin rights, you're on a restricted GitHub Enterprise tenant, or you simply don't want an App installation. Model the repo-level webhook as a generic source, accepting the same `genericWebhook()`-only ergonomics.
988
+
989
+ **Operator setup:**
990
+
991
+ ```bash
992
+ # GitHub sends event name in X-GitHub-Event and HMAC-SHA256 signature in X-Hub-Signature-256.
993
+ kici-admin source add generic \
994
+ --org my-org \
995
+ --name gh-repo-foo \
996
+ --verification hmac_sha256 \
997
+ --secret @/path/to/webhook-secret.txt \
998
+ --event-type-header X-GitHub-Event \
999
+ --rate-limit 120
1000
+
1001
+ # Patch the verificationConfig to use GitHub's signature header
1002
+ # (the CLI has no --signature-header flag; use the admin REST API):
1003
+ curl -X PATCH https://<orchestrator>/api/v1/admin/generic-sources/<sourceId> \
1004
+ -H "Authorization: Bearer <admin-token>" \
1005
+ -H "Content-Type: application/json" \
1006
+ -d '{"verificationConfig":{"secret":"<same-secret>","headerName":"x-hub-signature-256"}}'
1007
+ ```
1008
+
1009
+ Then in the GitHub repo, go to **Settings → Webhooks → Add webhook**, set:
1010
+
1011
+ - **Payload URL:** `https://<platform>/webhooks/<orgId>/generic/<sourceId>` (or the orchestrator's direct URL)
1012
+ - **Content type:** `application/json`
1013
+ - **Secret:** the same secret
1014
+ - **Events:** pick what you care about (e.g., `push`, `pull_request`)
1015
+
1016
+ **Workflow:**
1017
+
1018
+ ```typescript
1019
+ import { workflow, job, step, genericWebhook } from '@kici-dev/sdk';
1020
+
1021
+ export default workflow('on-github-repo-push', {
1022
+ on: genericWebhook({
1023
+ source: 'gh-repo-foo',
1024
+ events: ['push'],
1025
+ match: { '$.ref': 'refs/heads/main' },
1026
+ }),
1027
+ jobs: [
1028
+ job('notify', {
1029
+ runsOn: 'linux',
1030
+ checkout: false, // no App token -> skip auto-clone
1031
+ steps: [
1032
+ step('log', async ({ rawPayload, log }) => {
1033
+ const sha = (rawPayload as { after?: string }).after;
1034
+ log.info(`GitHub push ${sha}`);
1035
+ }),
1036
+ ],
1037
+ }),
1038
+ ],
1039
+ });
1040
+ ```
1041
+
1042
+ **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):
1043
+
1044
+ - 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.
1045
+ - 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.
1046
+ - No changed-files enrichment — `event.changedFiles` is empty. Use JSONPath `match` on `rawPayload.commits[*].added/modified/removed` if you need path filters.
1047
+ - No check-run integration — KiCI cannot post Check Run results back to GitHub.
1048
+ - Workflow authors must use `genericWebhook()`, not `push()` / `pr()` / `webhook()` — the latter three only match events delivered through the native GitHub App provider.
1049
+
1050
+ **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.
1051
+
1052
+ ## Nightly cron build
1053
+
1054
+ ---
1055
+
1056
+ ## Pattern reference
1057
+
1058
+ Source: https://docs.kici.dev/user/patterns/reference/
1059
+
1060
+ Every step receives a `StepContext` with these properties:
1061
+
1062
+ | Property | Type | Description |
1063
+ | ------------------- | ----------------------------------- | ---------------------------------------------------------------- |
1064
+ | `$` | zx shell | Shell executor for running commands |
1065
+ | `log` | `Logger` | Structured logger (info, warn, error, debug) |
1066
+ | `env` | `Record<string, string\|undefined>` | Environment variables |
1067
+ | `setEnv()` | `(key, value) => void` | Set an env var visible to this step and all subsequent steps |
1068
+ | `addPath()` | `(dir) => void` | Prepend a directory to PATH for this and all subsequent steps |
1069
+ | `inputs` | `Record<string, unknown>` | Typed inputs from dependency outputs |
1070
+ | `workflow` | `{ name: string }` | Current workflow metadata |
1071
+ | `job` | `{ name: string, runsOn: string }` | Current job metadata |
1072
+ | `matrix` | `MatrixValues \| undefined` | Matrix values for current job instance |
1073
+ | `setSecretOutput()` | `(key, value) => void` | Publish an encrypted secret output consumable by downstream jobs |
1074
+
1075
+ ### Step outputs
1076
+
1077
+ Steps can declare typed outputs using Zod schemas:
1078
+
1079
+ ```typescript
1080
+ import { step } from '@kici-dev/sdk';
1081
+ import { z } from 'zod';
1082
+
1083
+ const build = step('build', {
1084
+ outputs: {
1085
+ version: z.string(),
1086
+ artifacts: z.array(z.string()),
1087
+ },
1088
+ run: async ({ $ }) => {
1089
+ await $`pnpm build`;
1090
+ return {
1091
+ version: '1.0.0',
1092
+ artifacts: ['dist/main.js', 'dist/styles.css'],
1093
+ };
1094
+ },
1095
+ });
1096
+ ```
1097
+
1098
+ ## Examples repository
1099
+
1100
+ For more runnable examples, see the [examples/](https://github.com/kici-dev/kici-public/tree/main/examples) directory in the KiCI repository.
1101
+
1102
+ ## GitHub check run output
1103
+
1104
+ When workflows run via GitHub pull requests or pushes, KiCI creates GitHub Check runs that show detailed execution feedback directly in the GitHub UI.
1105
+
1106
+ ### What you see
1107
+
1108
+ - **Live progress:** As steps execute, the check run updates with a checklist showing which steps are running, completed, or pending
1109
+ - **Step durations:** Each step shows its execution time (e.g., "Install deps (1.2s)")
1110
+ - **Failure details:** When a step fails, the check run includes the error message, exit code, and the last 20 lines of log output
1111
+ - **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
1112
+
1113
+ ### Source location annotations
1114
+
1115
+ 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:
1116
+
1117
+ ```typescript
1118
+ // This step's source location is captured automatically
1119
+ step('run tests', async ({ $ }) => {
1120
+ await $`pnpm test`; // If this fails, GitHub annotates this step() call
1121
+ });
1122
+ ```
1123
+
1124
+ To enable source location annotations, recompile your workflows after updating KiCI. The compiler captures step locations starting from compile schema version 2.
1125
+
1126
+ ```bash
1127
+ pnpm kici compile # Regenerates kici.lock.json with source locations
1128
+ ```
1129
+
1130
+ ## See also
1131
+
1132
+ ---
1133
+
1134
+ ## Scheduling & event patterns
1135
+
1136
+ Source: https://docs.kici.dev/user/patterns/scheduling-and-events/
1137
+
1138
+ 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.
1139
+
1140
+ ```typescript
1141
+ import { workflow, job, step, schedule } from '@kici-dev/sdk';
1142
+
1143
+ const install = step('install', async ({ $ }) => {
1144
+ await $`pnpm install --frozen-lockfile`;
1145
+ });
1146
+
1147
+ const fullTest = job('full-test', {
1148
+ runsOn: 'linux',
1149
+ steps: [
1150
+ install,
1151
+ step('test', async ({ $ }) => {
1152
+ await $`pnpm test`;
1153
+ }),
1154
+ step('typecheck', async ({ $ }) => {
1155
+ await $`pnpm typecheck`;
1156
+ }),
1157
+ ],
1158
+ });
1159
+
1160
+ const publish = job('publish-nightly', {
1161
+ runsOn: 'linux',
1162
+ needs: [fullTest],
1163
+ steps: [
1164
+ install,
1165
+ step('build', async ({ $ }) => {
1166
+ await $`pnpm build`;
1167
+ }),
1168
+ step('publish', async ({ $ }) => {
1169
+ await $`./scripts/publish-nightly.sh`;
1170
+ }),
1171
+ ],
1172
+ });
1173
+
1174
+ export default workflow('nightly-build', {
1175
+ on: schedule({ cron: '0 2 * * *', description: 'Every day at 2 AM UTC' }),
1176
+ jobs: [fullTest, publish],
1177
+ });
1178
+ ```
1179
+
1180
+ **Notes:**
1181
+
1182
+ - 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' })`.
1183
+ - 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.
1184
+ - In clustered orchestrator deployments, only the Raft leader evaluates cron schedules. If the leader changes, the new leader recovers missed schedules.
1185
+
1186
+ **Timing precision and scaling:**
1187
+
1188
+ - 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.
1189
+ - 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.
1190
+ - 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)`.
1191
+ - 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.
1192
+
1193
+ ## Workflow-complete-triggered deploy
1194
+
1195
+ Trigger a deployment automatically when a build workflow succeeds, using `workflowComplete()`. This is one of the most common event chaining patterns.
1196
+
1197
+ ```typescript
1198
+ import { workflow, job, step, push, workflowComplete } from '@kici-dev/sdk';
1199
+
1200
+ // Workflow A: build and test on push to main
1201
+ export const build = workflow('build', {
1202
+ on: push({ branches: 'main' }),
1203
+ jobs: [
1204
+ job('test', {
1205
+ runsOn: 'linux',
1206
+ steps: [
1207
+ step('install', async ({ $ }) => {
1208
+ await $`pnpm install --frozen-lockfile`;
1209
+ }),
1210
+ step('test', async ({ $ }) => {
1211
+ await $`pnpm test`;
1212
+ }),
1213
+ step('build', async ({ $ }) => {
1214
+ await $`pnpm build`;
1215
+ }),
1216
+ ],
1217
+ }),
1218
+ ],
1219
+ });
1220
+
1221
+ // Workflow B: deploy when build succeeds
1222
+ export const deploy = workflow('deploy-on-success', {
1223
+ on: workflowComplete({ name: 'build', status: ['success'] }),
1224
+ jobs: [
1225
+ job('deploy', {
1226
+ runsOn: 'linux',
1227
+ steps: [
1228
+ step('deploy-staging', async ({ $ }) => {
1229
+ await $`./scripts/deploy.sh staging`;
1230
+ }),
1231
+ step('run-smoke-tests', async ({ $ }) => {
1232
+ await $`./scripts/smoke-test.sh staging`;
1233
+ }),
1234
+ step('deploy-production', async ({ $ }) => {
1235
+ await $`./scripts/deploy.sh production`;
1236
+ }),
1237
+ ],
1238
+ }),
1239
+ ],
1240
+ });
1241
+ ```
1242
+
1243
+ **Notes:**
1244
+
1245
+ - `workflowComplete()` is a system event trigger -- the orchestrator automatically emits these events when workflows finish. You do not need to call `ctx.emit()`.
1246
+ - The `status` filter accepts `'success'`, `'failed'`, and `'cancelled'`. Omit `status` to trigger on any completion.
1247
+ - 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.
1248
+ - You can also use `jobComplete()` to trigger on individual job completions within a workflow.
1249
+
1250
+ ## Custom event chaining
1251
+
1252
+ 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.
1253
+
1254
+ ```typescript
1255
+ import { workflow, job, step, push, kiciEvent, defineEvent, z } from '@kici-dev/sdk';
1256
+
1257
+ // Define a typed event contract
1258
+ const testsPassedEvent = defineEvent(
1259
+ 'tests-passed',
1260
+ z.object({
1261
+ branch: z.string(),
1262
+ commit: z.string(),
1263
+ testCount: z.number(),
1264
+ duration: z.number(),
1265
+ }),
1266
+ );
1267
+
1268
+ // Workflow A: run tests and emit result event
1269
+ export const testSuite = workflow('test-suite', {
1270
+ on: push({ branches: 'main' }),
1271
+ jobs: [
1272
+ job('test', {
1273
+ runsOn: 'linux',
1274
+ steps: [
1275
+ step('install', async ({ $ }) => {
1276
+ await $`pnpm install --frozen-lockfile`;
1277
+ }),
1278
+ step('run-tests', async ({ $ }) => {
1279
+ await $`pnpm test`;
1280
+ }),
1281
+ step('emit-results', async (ctx) => {
1282
+ await ctx.emit(testsPassedEvent.name, {
1283
+ branch: 'main',
1284
+ commit: 'abc123',
1285
+ testCount: 142,
1286
+ duration: 45,
1287
+ });
1288
+ }),
1289
+ ],
1290
+ }),
1291
+ ],
1292
+ });
1293
+
1294
+ // Workflow B: deploy when tests pass (in the same or separate file)
1295
+ export const autoDeploy = workflow('auto-deploy', {
1296
+ on: kiciEvent({ name: 'tests-passed' }),
1297
+ jobs: [
1298
+ job('deploy', {
1299
+ runsOn: 'linux',
1300
+ steps: [
1301
+ step('deploy', async ({ $ }) => {
1302
+ await $`./scripts/deploy.sh`;
1303
+ }),
1304
+ step('notify', async ({ $ }) => {
1305
+ await $`./scripts/notify-slack.sh "Deployment complete"`;
1306
+ }),
1307
+ ],
1308
+ }),
1309
+ ],
1310
+ });
1311
+ ```
1312
+
1313
+ **Notes:**
1314
+
1315
+ - Both workflows can live in the same `.kici/workflows/` file or in separate files -- the event system routes by event name, not by file.
1316
+ - `defineEvent()` creates a typed contract using Zod. This is optional but recommended for documenting event payloads.
1317
+ - Custom events are delivered immediately when `ctx.emit()` is called (mid-workflow), not queued until the workflow completes.
1318
+ - Payload matching is available via the `match` option: `kiciEvent({ name: 'tests-passed', match: { '$.branch': 'main' } })`.
1319
+ - 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.
1320
+ - 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.
1321
+
1322
+ ## Step context
1323
+
1324
+ ---