@kici-dev/compiler 0.4.0 → 0.6.0

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 (83) hide show
  1. package/dist/cli.js +37 -7
  2. package/dist/commands/approve.d.ts +12 -0
  3. package/dist/commands/approve.js +5 -2
  4. package/dist/commands/compile.js +5 -15
  5. package/dist/commands/doctor.js +2 -2
  6. package/dist/commands/endpoints.js +4 -6
  7. package/dist/commands/held-run-client.d.ts +21 -1
  8. package/dist/commands/held-run-client.js +34 -15
  9. package/dist/commands/hook.js +22 -20
  10. package/dist/commands/index.d.ts +2 -0
  11. package/dist/commands/index.js +2 -1
  12. package/dist/commands/init.d.ts +9 -2
  13. package/dist/commands/init.js +43 -16
  14. package/dist/commands/login.js +1 -1
  15. package/dist/commands/orchestrators.js +3 -2
  16. package/dist/commands/preview.js +1 -8
  17. package/dist/commands/reject.d.ts +12 -0
  18. package/dist/commands/reject.js +5 -2
  19. package/dist/commands/report/collect.d.ts +82 -0
  20. package/dist/commands/report/collect.js +234 -0
  21. package/dist/commands/report/identity.d.ts +48 -0
  22. package/dist/commands/report/identity.js +49 -0
  23. package/dist/commands/report/index.d.ts +63 -0
  24. package/dist/commands/report/index.js +119 -0
  25. package/dist/commands/report/upload.d.ts +38 -0
  26. package/dist/commands/report/upload.js +64 -0
  27. package/dist/commands/run-hold-watch.js +2 -2
  28. package/dist/commands/run.js +6 -3
  29. package/dist/commands/runs/show.js +80 -1
  30. package/dist/commands/types.js +51 -9
  31. package/dist/errors/formatter.d.ts +2 -4
  32. package/dist/errors/formatter.js +1 -3
  33. package/dist/errors/index.d.ts +1 -1
  34. package/dist/errors/index.js +2 -2
  35. package/dist/execution/sdk-alias.js +4 -2
  36. package/dist/fixtures/compiler.js +2 -1
  37. package/dist/format.js +3 -3
  38. package/dist/generators/secrets-dts.d.ts +8 -3
  39. package/dist/generators/secrets-dts.js +3 -3
  40. package/dist/hooks/installer.js +2 -1
  41. package/dist/llm-context/llms-architecture.txt +59 -23
  42. package/dist/llm-context/llms-cli.txt +168 -34
  43. package/dist/llm-context/llms-features-execution.txt +2017 -0
  44. package/dist/llm-context/llms-features.txt +96 -1100
  45. package/dist/llm-context/llms-full.txt +3131 -1407
  46. package/dist/llm-context/llms-getting-started.txt +164 -31
  47. package/dist/llm-context/llms-patterns.txt +186 -7
  48. package/dist/llm-context/llms-providers.txt +15 -33
  49. package/dist/llm-context/llms-sdk-runtime.txt +62 -40
  50. package/dist/llm-context/llms-sdk.txt +284 -58
  51. package/dist/llm-context/llms.txt +23 -15
  52. package/dist/local-plane/paths.d.ts +15 -0
  53. package/dist/local-plane/paths.js +22 -1
  54. package/dist/local-plane/plane-manager.js +2 -2
  55. package/dist/local-plane/port-holder.js +1 -1
  56. package/dist/local-plane/postgres.d.ts +3 -16
  57. package/dist/local-plane/postgres.js +10 -15
  58. package/dist/lockfile/generator.d.ts +12 -0
  59. package/dist/lockfile/generator.js +182 -54
  60. package/dist/lockfile/index.d.ts +0 -2
  61. package/dist/lockfile/index.js +1 -2
  62. package/dist/postinstall.js +2 -1
  63. package/dist/remote/config.d.ts +2 -15
  64. package/dist/remote/config.js +2 -16
  65. package/dist/remote/dashboard-client.d.ts +39 -0
  66. package/dist/remote/dashboard-client.js +41 -0
  67. package/dist/remote/oauth.js +7 -5
  68. package/dist/remote/uploader.js +2 -2
  69. package/dist/templates/package-json.js +1 -1
  70. package/dist/test-runner/dry-run.d.ts +1 -2
  71. package/dist/test-runner/dry-run.js +5 -20
  72. package/dist/test-runner/git-detector.js +2 -1
  73. package/dist/test-runner/job-executor.js +2 -1
  74. package/dist/test-runner/payload-builder.js +11 -17
  75. package/dist/types.d.ts +64 -10
  76. package/dist/types.js +7 -1
  77. package/dist/validation/validator.js +63 -6
  78. package/package.json +16 -11
  79. package/sbom.spdx.json +957 -900
  80. package/dist/lockfile/purity-analyzer.d.ts +0 -25
  81. package/dist/lockfile/purity-analyzer.js +0 -204
  82. package/dist/lockfile/purity-diagnostics.d.ts +0 -31
  83. package/dist/lockfile/purity-diagnostics.js +0 -52
@@ -0,0 +1,2017 @@
1
+ # KiCI Workflow features: execution
2
+
3
+ This bundle covers: Concurrency, dynamic values, events, container jobs, environment variables, global workflows, idempotent steps.
4
+
5
+ ## Concurrency groups
6
+
7
+ Source: https://docs.kici.dev/user/concurrency/
8
+
9
+ Concurrency groups prevent multiple workflow runs from executing in parallel when they target the same resource. Common use cases include preventing parallel deploys to the same environment or serializing database migrations.
10
+
11
+ ## Basic usage
12
+
13
+ ```typescript
14
+ import { workflow, job, step, push } from '@kici-dev/sdk';
15
+
16
+ export default workflow('deploy', {
17
+ on: push({ branches: ['main', 'staging'] }),
18
+ concurrency: {
19
+ group: (ctx) => `deploy-${ctx.branch}`,
20
+ cancelInProgress: true,
21
+ max: 1,
22
+ },
23
+ jobs: [
24
+ job('deploy', {
25
+ runsOn: 'linux',
26
+ steps: [
27
+ step('deploy', async ({ $ }) => {
28
+ await $`./deploy.sh`;
29
+ }),
30
+ ],
31
+ }),
32
+ ],
33
+ });
34
+ ```
35
+
36
+ ## Configuration
37
+
38
+ The `concurrency` option on a workflow accepts:
39
+
40
+ | Field | Type | Default | Description |
41
+ | ------------------ | -------- | -------- | ------------------------------------------ |
42
+ | `group` | Function | Required | Returns the concurrency group key string |
43
+ | `cancelInProgress` | boolean | `true` | Cancel older runs when a newer run arrives |
44
+ | `max` | number | `1` | Maximum concurrent runs in the same group |
45
+
46
+ ### Group key function
47
+
48
+ The group key function receives a context with the branch name and event payload. Runs with the same group key are subject to concurrency limits.
49
+
50
+ ```typescript
51
+ // Per-branch concurrency (most common)
52
+ group: (ctx) => `deploy-${ctx.branch}`;
53
+
54
+ // Global concurrency (across all branches)
55
+ group: () => 'deploy';
56
+
57
+ // Per-target-branch concurrency
58
+ group: (ctx) => `deploy-${ctx.event.targetBranch ?? 'default'}`;
59
+ ```
60
+
61
+ The workflow-level group function is always evaluated **agent-side** at runtime -- the lock file records only that a group function exists (`hasGroup: true`), not the function itself. The agent loads the workflow source, calls the group function with `{ branch, event }`, and reports the evaluated key back to the orchestrator before step execution begins. Job-level `concurrencyGroup` functions (see [Contexts](https://docs.kici.dev/user/contexts/#concurrency-groups)) resolve the same way — on the agent's init step, never in the orchestrator.
62
+
63
+ ## cancelInProgress mode
64
+
65
+ When `cancelInProgress: true`, a newer run supersedes older runs in the same group:
66
+
67
+ ```
68
+ Run #1 starts deploying to main -> running
69
+ Run #2 arrives for deploy-main group -> Run #1 cancelled ("Superseded by run in concurrency group 'deploy-main'")
70
+ Run #2 continues -> running
71
+ ```
72
+
73
+ This is the most common mode for deploy workflows -- you want the latest code deployed, not an outdated version.
74
+
75
+ The cancelled run:
76
+
77
+ - Receives a cancellation with reason "Superseded by run in concurrency group 'deploy-main'"
78
+ - Goes through the normal cancel flow (grace period, hooks if graceful)
79
+ - GitHub Check status updated to `cancelled` with the superseded reason
80
+
81
+ ```typescript
82
+ workflow('deploy', {
83
+ concurrency: {
84
+ group: (ctx) => `deploy-${ctx.branch}`,
85
+ cancelInProgress: true,
86
+ },
87
+ jobs: [/* ... */],
88
+ });
89
+ ```
90
+
91
+ ## Queue mode
92
+
93
+ When `cancelInProgress: false`, newer runs will wait until older runs complete:
94
+
95
+ ```
96
+ Run #1 starts deploying -> running
97
+ Run #2 arrives for same group -> queued ("Waiting for deploy-main (1 ahead)")
98
+ Run #1 completes -> success
99
+ Run #2 starts -> running
100
+ ```
101
+
102
+ In queue mode, the agent that picked up the queued run **stays connected** to the orchestrator and parks on a long-poll wait. When the holder finishes (success, failure, or cancel), the orchestrator dequeues the FIFO-next entry and pushes a `proceed` notification over the same WebSocket; the queued agent then continues with normal step execution against the workspace it already has. The agent's slot is therefore held for the duration of the queue wait — bound by `KICI_CONCURRENCY_WAIT_TIMEOUT_MS` (default 1 hour).
103
+
104
+ ```typescript
105
+ workflow('migrate-db', {
106
+ concurrency: {
107
+ group: () => 'migrations',
108
+ cancelInProgress: false,
109
+ max: 1,
110
+ },
111
+ jobs: [/* ... */],
112
+ });
113
+ ```
114
+
115
+ The dashboard will show a "Queued" badge with the reason: "Waiting for deploy-main (1 ahead)".
116
+
117
+ ## Max concurrent runs
118
+
119
+ The `max` field controls how many runs can execute simultaneously in the same group:
120
+
121
+ ```typescript
122
+ // Allow up to 3 parallel test runs per branch
123
+ workflow('test', {
124
+ concurrency: {
125
+ group: (ctx) => `test-${ctx.branch}`,
126
+ cancelInProgress: false,
127
+ max: 3,
128
+ },
129
+ jobs: [/* ... */],
130
+ });
131
+ ```
132
+
133
+ When `max: 1` (default), runs are fully serialized within the group.
134
+
135
+ ## Group key examples
136
+
137
+ ### Deploy per environment
138
+
139
+ ```typescript
140
+ workflow('deploy', {
141
+ concurrency: {
142
+ group: (ctx) => `deploy-${ctx.branch}`,
143
+ cancelInProgress: true,
144
+ },
145
+ jobs: [
146
+ job('deploy-staging', {
147
+ runsOn: 'linux',
148
+ context: 'staging',
149
+ steps: [/* ... */],
150
+ }),
151
+ ],
152
+ });
153
+ ```
154
+
155
+ ### Global singleton
156
+
157
+ ```typescript
158
+ // Only one migration can run at a time, regardless of branch
159
+ workflow('migrate', {
160
+ concurrency: {
161
+ group: () => 'db-migration',
162
+ cancelInProgress: false,
163
+ },
164
+ jobs: [/* ... */],
165
+ });
166
+ ```
167
+
168
+ ### Environment-aware groups
169
+
170
+ ```typescript
171
+ // Serialize deploys per environment
172
+ workflow('deploy', {
173
+ concurrency: {
174
+ group: (ctx) => {
175
+ const env = ctx.branch === 'main' ? 'production' : 'staging';
176
+ return `deploy-${env}`;
177
+ },
178
+ cancelInProgress: true,
179
+ },
180
+ jobs: [/* ... */],
181
+ });
182
+ ```
183
+
184
+ ## Interaction with context protection
185
+
186
+ When a workflow has both `concurrency` and `context` protection rules:
187
+
188
+ 1. Context protection gates (required reviewers, wait timer) apply first
189
+ 2. Concurrency group check happens after protection gates pass
190
+ 3. If the run is queued by concurrency, it keeps its protection approval
191
+
192
+ This means a run that passed approval won't need re-approval if it gets queued by concurrency.
193
+
194
+ ## Cancelling queued runs
195
+
196
+ Queued runs can be cancelled before they start executing. The cancel request removes them from the queue immediately -- they don't go through the grace period since no step is running.
197
+
198
+ ## Job-level concurrency groups
199
+
200
+ In addition to workflow-level concurrency, individual jobs can define their own concurrency group via the `concurrencyGroup` property. This controls concurrent execution at the job level rather than the workflow level. See [Contexts — concurrency groups](https://docs.kici.dev/user/contexts/#concurrency-groups) for details.
201
+
202
+ ## Local execution
203
+
204
+ `kici run <event> --local` is a real routed dispatch: your machine becomes an ephemeral agent behind the local dev plane, whose own orchestrator applies the same concurrency machinery described above. The `group` callback is evaluated agent-side against the simulated event, and `cancelInProgress` carries its usual semantics — `true` supersedes the older run in the group, `false` queues the newer one behind it.
205
+
206
+ Coordination is scoped to that plane. The plane's state (including its database) lives under `~/.kici/local/`, so enforcement is per-machine and per-user: running the same workflow on two different machines does not serialize across them. For cross-host enforcement (queueing across agents, dashboard visibility), use `kici run remote` against a deployed orchestrator.
207
+
208
+ See [`kici run <event> --local`](https://docs.kici.dev/user/cli/runs-and-approvals/#kici-run-event---local) for the rest of the local-run behavior, and [the local dev plane](https://docs.kici.dev/operator/orchestrator/local-dev-plane/) for the plane's state directory and lifecycle.
209
+
210
+ ---
211
+
212
+ _Source: `packages/sdk/src/types.ts` (WorkflowOptions.concurrency, JobOptions.concurrencyGroup)_
213
+
214
+ ---
215
+
216
+ ## Container jobs
217
+
218
+ Source: https://docs.kici.dev/user/container-jobs/
219
+
220
+ A job can run inside a container image you choose. Set `container` on the job:
221
+
222
+ ```typescript
223
+ job('build', {
224
+ runsOn: ['kici:os:linux'],
225
+ container: 'python:3.12-slim',
226
+ steps: [compile, test],
227
+ });
228
+ ```
229
+
230
+ Every step then runs inside that image.
231
+
232
+ ## Your image needs almost nothing
233
+
234
+ KiCI supplies its own runtime. It mounts a Node build and the step runner into
235
+ the container, read-only, and runs the steps with that Node. In most setups it
236
+ also clones your repository outside the image and copies the tree in.
237
+
238
+ So the image does **not** need Node, and does **not** need npm.
239
+
240
+ ### What the image must provide
241
+
242
+ Every container job needs these two:
243
+
244
+ | Requirement | Why | When it is checked |
245
+ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | --------------------- |
246
+ | **A GNU C library (glibc)** — the loader at `/lib64/ld-linux-x86-64.so.2` on x64, or `/lib/ld-linux-aarch64.so.1` on arm64 | The Node build KiCI mounts in is linked against glibc | Before the job starts |
247
+ | **A shell at `/bin/sh`** | Steps run shell commands | Before the job starts |
248
+
249
+ If the image fails either, the job fails immediately and the error names the
250
+ reason — you do not wait for a build to reach its first step.
251
+
252
+ Two more apply only when your pool runs your image **as the agent**. See
253
+ [Which pool runs your image how](https://docs.kici.dev/user/container-jobs/#which-pool-runs-your-image-how) to find out
254
+ which case you are in:
255
+
256
+ | Requirement | Why | When it is checked |
257
+ | -------------------- | ------------------------------------------------------------------------- | ------------------ |
258
+ | **`git` on `PATH`** | The agent clones your repository, and here the agent is inside your image | At agent startup |
259
+ | **`bash` on `PATH`** | The agent needs it to run steps | At agent startup |
260
+
261
+ These two are not checked ahead of time. The agent fails to start, and the job
262
+ waits for an agent that never arrives.
263
+
264
+ Most build images provide all four. `python`, `golang`, `node` and `rust` ship
265
+ git and bash; their `-slim` variants often drop git — `python:3.12-slim` is one
266
+ that does.
267
+
268
+ ### Alpine and other musl images
269
+
270
+ Alpine uses musl instead of glibc, so KiCI refuses it:
271
+
272
+ ```
273
+ image 'alpine:3.20' uses musl libc; the musl runtime variant is not enabled
274
+ (glibc images only in this version). Use a glibc image — for example the
275
+ '-slim' rather than the '-alpine' tag.
276
+ ```
277
+
278
+ Pick the glibc build of the same image. `python:3.12-slim` instead of
279
+ `python:3.12-alpine`, `node:24-slim` instead of `node:24-alpine`.
280
+
281
+ ### Which pool runs your image how
282
+
283
+ A pool runs your image one of two ways, and they ask for different things.
284
+
285
+ - **The agent stays outside your image** and starts a second container from it.
286
+ The agent clones and copies the tree in, so your image needs only glibc and a
287
+ shell. A bare-metal pool works this way, and so does any agent that has its
288
+ own container runtime.
289
+ - **The agent runs inside your image.** A container pool does this for a job
290
+ that names its own image: your image becomes the agent. Your image then needs
291
+ git and bash as well.
292
+
293
+ The second form fails at agent startup with:
294
+
295
+ ```
296
+ Agent required-tools validation failed:
297
+ - 'git' not found on PATH — required for repository checkout
298
+ This agent runs inside the job's own container image, so that image must provide
299
+ these tools. Either add them to the image, or run the job on a pool whose agent
300
+ stays outside it.
301
+ ```
302
+
303
+ If your image lacks git or bash, either add them, or run the job on a pool of
304
+ the first kind.
305
+
306
+ ## Build the image from a Dockerfile
307
+
308
+ Point `container` at a Dockerfile in your repository instead of naming an image.
309
+ KiCI builds it before the job starts, and runs the job in the result:
310
+
311
+ ```typescript
312
+ job('build', {
313
+ runsOn: ['kici:os:linux'],
314
+ container: {
315
+ dockerfile: '.kici/ci.Dockerfile',
316
+ context: '.', // default: the repository root
317
+ target: 'ci', // optional build stage
318
+ args: { NODE_VERSION: '24' },
319
+ },
320
+ steps: [compile],
321
+ });
322
+ ```
323
+
324
+ Set `image` or `dockerfile`, never both. KiCI rejects a workflow that sets both,
325
+ or neither, when you define it.
326
+
327
+ The build runs on the agent that runs the job, after it clones your repository.
328
+ So the build context is the tree at the commit that triggered the run, and your
329
+ Dockerfile's `COPY` sees exactly that code. `.dockerignore` applies as usual.
330
+
331
+ ### What is reused between runs
332
+
333
+ The image is built every run. Your container runtime's layer cache does the
334
+ work, exactly as it does on your own machine: a run that changes nothing below a
335
+ `COPY` reuses those layers and finishes in seconds.
336
+
337
+ Nothing is uploaded. The image lives on the agent host that built it, and KiCI
338
+ removes the tag after the job. A different host, or a host whose cache was
339
+ pruned, builds again.
340
+
341
+ ### Build arguments are not secrets
342
+
343
+ A build argument is recorded in the built image's history, so anyone who can
344
+ read the image can read the value. `args` therefore takes plain strings only —
345
+ you cannot point one at a secret. Pass a secret to a step instead.
346
+
347
+ ### Who may build
348
+
349
+ A Dockerfile build runs your `RUN` commands on the agent host, **outside** the
350
+ sandbox that contains your job's steps. So KiCI refuses one on an untrusted
351
+ ref — a fork pull request, or a contributor whose access it cannot confirm.
352
+
353
+ Your operator allows it per organization:
354
+
355
+ ```bash
356
+ kici-admin org-settings allow-untrusted-dockerfile-builds true --org <org>
357
+ ```
358
+
359
+ A trusted ref — a push to your default branch, or a pull request from a
360
+ contributor with write access — builds without that setting.
361
+
362
+ A schedule fire and the [auto-scaler's](https://docs.kici.dev/user/workflows/autoscaling-workflows/)
363
+ `kici.scaler.scale-up` / `kici.scaler.scale-down` events are trusted refs. No run
364
+ causes them. So a cron-fired Dockerfile job builds, and so does one in a
365
+ provisioning or teardown workflow.
366
+
367
+ Every other internal trigger runs at the
368
+ [trust tier](https://docs.kici.dev/user/events/#trust-tiers-on-internal-triggers) of the run that caused
369
+ it. A workflow triggered by `ctx.emit()` carries the emitting run's tier. A
370
+ workflow triggered by a run completing carries that run's tier. So a completion
371
+ or an emit from an untrusted ref cannot build a Dockerfile.
372
+
373
+ ### Requirements
374
+
375
+ The agent host needs `docker` or `podman` on its `PATH`, not only a container
376
+ runtime socket. Agents report this themselves, and KiCI routes a Dockerfile job
377
+ only to one that can build — so a pool without a CLI is skipped rather than
378
+ failing your job partway. The built image itself needs only what
379
+ [any container job's image needs](https://docs.kici.dev/user/container-jobs/#what-the-image-must-provide): a glibc and
380
+ `/bin/sh`. It never needs git or bash, because a built image is always run by an
381
+ agent that stays outside it.
382
+
383
+ ## Private images
384
+
385
+ Point `auth` at secrets that hold the registry credentials. Every value is the
386
+ **name** of a secret, in `<context>:<secret-name>` form — the same form
387
+ `gitCredentials` uses:
388
+
389
+ ```typescript
390
+ job('build', {
391
+ runsOn: ['kici:os:linux'],
392
+ container: {
393
+ image: 'reg.internal:5000/acme/ci:1.2',
394
+ auth: { usernameSecret: 'prod:REGISTRY_USER', tokenSecret: 'prod:REGISTRY_TOKEN' },
395
+ },
396
+ steps: [compile],
397
+ });
398
+ ```
399
+
400
+ Store the secrets first with `kici-admin secret set`. Pasting a token straight
401
+ into the workflow is rejected when the workflow is defined, because a token
402
+ written into `.kici/` would be committed to your repository.
403
+
404
+ The username is not a secret, so you may write it directly:
405
+
406
+ ```typescript
407
+ auth: { username: 'ci-bot', tokenSecret: 'prod:REGISTRY_TOKEN' }
408
+ ```
409
+
410
+ Your orchestrator resolves these names at dispatch and sends only the resolved
411
+ credentials to the agent. The agent never reads your secret store.
412
+
413
+ ### Credentials that only exist at run time
414
+
415
+ A token fetched during the run — from a cloud registry's login command, for
416
+ example — has no secret to name. Use the `*Value` half of the pair instead:
417
+
418
+ ```typescript
419
+ auth: { username: 'AWS', tokenValue: fetchedAtRuntime }
420
+ ```
421
+
422
+ ## Where container jobs run
423
+
424
+ A container job needs a container runtime on the host that runs it. KiCI does
425
+ not check that for you: your orchestrator cannot see what a given agent host
426
+ has installed.
427
+
428
+ The host also needs a copy of the KiCI runtime to mount in. Every pool your
429
+ auto-scaler provisions gets one automatically, from the agent image the pool is
430
+ configured with. An agent you start by hand needs `KICI_RUNTIME_IMAGE` set to a
431
+ `kici-agent` image — see
432
+ [Agent configuration](https://docs.kici.dev/operator/agent/configuration/). Without it, the job
433
+ runs on the image's own `node`, so the image must ship one.
434
+
435
+ If some of your pools have a runtime and some do not, label them and say so on
436
+ the job:
437
+
438
+ ```typescript
439
+ job('build', {
440
+ runsOn: ['kici:os:linux', 'kici:runtime:docker'],
441
+ container: 'python:3.12-slim',
442
+ steps: [compile],
443
+ });
444
+ ```
445
+
446
+ A job that reaches a host with no runtime fails with an error naming what is
447
+ missing, rather than running incorrectly.
448
+
449
+ ## Limits worth knowing
450
+
451
+ - **glibc only.** A musl image fails the preflight. Support for musl is a
452
+ planned follow-up.
453
+ - **The image is pulled fresh when it is not already on the host.** A large
454
+ image costs that pull on the first job that uses it.
455
+ - **`git` inside your steps still needs git in the image.** KiCI clones for you,
456
+ but a step that runs `git` itself uses the image's own copy.
457
+
458
+ ---
459
+
460
+ ## Dynamic values
461
+
462
+ Source: https://docs.kici.dev/user/dynamic-values/
463
+
464
+ Dynamic values let you compute `context`, `env`, and `concurrencyGroup` at runtime based on the incoming event. Instead of hardcoding static strings, you pass a function that receives the normalized event envelope and returns the resolved value.
465
+
466
+ ```typescript
467
+ job('deploy', {
468
+ runsOn: ['default'],
469
+ context: (event) => event.targetBranch,
470
+ env: (event) => ({ BRANCH: event.targetBranch }),
471
+ concurrencyGroup: (event) => `deploy-${event.targetBranch}`,
472
+ steps: [/* ... */],
473
+ });
474
+ ```
475
+
476
+ ```typescript
477
+ job('deploy', {
478
+ runsOn: 'default',
479
+ // One shape everywhere: branch on the normalized event type.
480
+ context: (event) => (event.type === 'pull_request' ? 'preview' : 'production'),
481
+ steps: [/* ... */],
482
+ });
483
+ ```
484
+
485
+ ## How it works
486
+
487
+ When you define a dynamic value as a function, it is resolved on the eval agent as a short **init** step that runs before the job:
488
+
489
+ 1. The orchestrator dispatches a lightweight `__init__` job to an agent.
490
+ 2. The agent loads the compiled workflow bundle and calls your function with the normalized event.
491
+ 3. The agent reports the resolved values back to the orchestrator, which dispatches the real execution job with them applied.
492
+
493
+ This resolution appears in the run timeline as an `Init:` entry. The orchestrator never evaluates workflow code — every dynamic `context`, `env`, and `concurrencyGroup` function runs agent-side, whatever it references.
494
+
495
+ `kici preview` lists the injected `__init__` job under each affected job, so you can spot it before the first run.
496
+
497
+ **Examples:**
498
+
499
+ ```typescript
500
+ // Simple branch extraction
501
+ context: (event) => event.targetBranch;
502
+
503
+ // Object literal with string operations
504
+ env: (event) => ({ BRANCH: event.targetBranch });
505
+
506
+ // Concatenation with event data
507
+ concurrencyGroup: (event) => `deploy-${event.targetBranch}`;
508
+
509
+ // Local variables and safe globals
510
+ context: (event) => {
511
+ const parts = event.targetBranch.split('/');
512
+ return parts[parts.length - 1];
513
+ };
514
+
515
+ // Async lookups, module access, and process/global reads all work
516
+ context: async (event) => await lookupEnv(event.targetBranch);
517
+ env: (event) => ({ DEFAULT_ENV: process.env.DEFAULT_ENV ?? 'staging' });
518
+ ```
519
+
520
+ ## Performance
521
+
522
+ | Value | Overhead | Example |
523
+ | ------------------------ | --------- | ---------------------------------------- |
524
+ | Static value | None | `context: 'staging'` |
525
+ | Dynamic value (function) | Init step | `context: (event) => event.targetBranch` |
526
+
527
+ A static value is baked into the lock file and needs no init step. A dynamic value always resolves through the agent's init step, so reach for a function only when the value genuinely depends on the event.
528
+
529
+ ## Tips
530
+
531
+ - **Prefer static values when you can.** Most context and env values are the same on every event; only make them dynamic when they truly depend on the event payload.
532
+ - **Run `kici preview`** to see the injected `__init__` job listed under each affected job before your first run.
533
+ - **A runtime error in a dynamic function fails the job.** If your function throws when the init step runs it (e.g., accessing a property on `undefined`), the job fails immediately.
534
+ - **See [how your workflow code executes](https://docs.kici.dev/user/execution-model/)** for the full picture of where dynamic values run relative to rules, hooks, and step bodies.
535
+ - **The event parameter is the normalized event envelope** — the same shape rules receive as `ctx.event`: `{ type, action, targetBranch, sourceBranch, changedFiles, payload, … }` (see the [event payload reference](https://docs.kici.dev/user/sdk/event-payloads/) for the complete schema). Narrow on `event.type` (`'push'`, `'pull_request'`, `'tag'`, …) to branch per trigger kind. The raw provider webhook body is nested at `event.payload` (for GitHub pushes: `payload.ref`, `payload.after`, `payload.repository`, …).
536
+
537
+ ---
538
+
539
+ ## Environment variables
540
+
541
+ Source: https://docs.kici.dev/user/env-vars/
542
+
543
+ The KiCI CLI reads the following environment variables to customize its behavior. OAuth login (`kici login` without `--token`) defaults `KICI_PLATFORM_URL`, `KICI_OIDC_ISSUER`, and `KICI_OIDC_CLIENT_ID` to the hosted KiCI Platform, so `kici login` works with no configuration. Set them only to target another KiCI environment (e.g. a testing instance) or a custom OIDC provider.
544
+
545
+ ## Authentication
546
+
547
+ | Variable | Description | Default |
548
+ | --------------------- | -------------------------------------- | -------------------------------------------- |
549
+ | `KICI_OIDC_ISSUER` | OIDC issuer URL for authentication | `https://auth.kici.dev/realms/kici-internal` |
550
+ | `KICI_OIDC_CLIENT_ID` | OIDC client ID for the CLI application | `kici-cli` |
551
+ | `KICI_PLATFORM_URL` | Platform API base URL | `https://api.kici.dev` |
552
+ | `KICI_CONFIG_DIR` | Override the KiCI config directory | `~/.kici` |
553
+
554
+ ## Browser behavior
555
+
556
+ | Variable | Description | Default |
557
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
558
+ | `KICI_BROWSER_CMD` | Custom browser command for OAuth login. Supports `{url}` placeholder. Set to `none` to suppress browser opening and print the URL to stdout instead. | Uses the system default browser |
559
+ | `KICI_CALLBACK_PORT` | Fixed port for the OAuth PKCE callback server. Useful when firewall rules require a known port. | Random available port |
560
+
561
+ ## Development
562
+
563
+ | Variable | Description | Default |
564
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
565
+ | `KICI_DEV` | Enable development mode. When `true`, uses prerelease-compatible version ranges (`>=0.0.1-0`) for dev dependencies and skips npm version resolution. | unset |
566
+ | `KICI_DEBUG` | Enable debug logging. When `true`, prints verbose diagnostics (SDK alias resolution, step-level debug logs, stack traces on errors). Equivalent to the `--debug` CLI flag. | unset |
567
+
568
+ ## Local dev plane
569
+
570
+ Read by the [local dev plane](https://docs.kici.dev/user/cli/authoring-and-local/#kici-local) that `kici run <event> --local` dispatches through.
571
+
572
+ | Variable | Description | Default |
573
+ | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
574
+ | `KICI_LOCAL_ORCH_PORT` | Port the plane orchestrator listens on (HTTP + WebSocket). Change it when another process already holds the default. | `4319` |
575
+ | `KICI_LOCAL_PG_PORT` | Port the plane's PostgreSQL listens on. | `45432` |
576
+ | `KICI_LOCAL_PG_MODE` | Set to `podman` to force the container PostgreSQL fallback instead of the embedded binary. | Embedded PostgreSQL |
577
+ | `KICI_LOCAL_ACCEPTANCE_TIMEOUT_MS` | How long a local run waits for an agent to claim its first job before failing fast. Raise it on a slow host; the run still fails quickly when no scaler label set matches `runsOn`. | `120000` (2 minutes) |
578
+
579
+ ## CI detection
580
+
581
+ The CLI also reads the conventional CI markers your CI provider sets. They are not KiCI variables — KiCI only consumes them.
582
+
583
+ | Variable | Description | Default |
584
+ | ---------------- | -------------------------------------------- | ------- |
585
+ | `CI` | Generic CI marker. Set by most CI providers. | unset |
586
+ | `GITHUB_ACTIONS` | Set to `true` by GitHub Actions. | unset |
587
+ | `GITLAB_CI` | Set to `true` by GitLab CI/CD. | unset |
588
+
589
+ ### How `CI` is interpreted
590
+
591
+ `kici` treats the environment as CI when `CI`, `GITHUB_ACTIONS`, or `GITLAB_CI` is set to any value other than an explicit opt-out. `0` and `false` are the opt-outs, compared case-insensitively, so `CI=0`, `CI=false`, and `CI=False` all mean "not CI". Surrounding whitespace is ignored, and a value that is empty or only whitespace (`CI=`) is treated as unset.
592
+
593
+ A vendor marker outranks the generic opt-out: `CI=false GITHUB_ACTIONS=true` is still CI, because an explicit vendor marker names a real runner rather than a preference.
594
+
595
+ This affects which login flow `kici login` chooses (browser vs device) and whether interactive commands such as `kici init` prompt.
596
+
597
+ ## Usage examples
598
+
599
+ ### CI/CD environment
600
+
601
+ Authenticate with a pre-existing API key (no browser needed):
602
+
603
+ ```bash
604
+ kici login --token "$KICI_API_KEY"
605
+ ```
606
+
607
+ ### Targeting another environment or custom OIDC provider
608
+
609
+ `kici login` targets the hosted KiCI Platform by default. To point the CLI at another KiCI environment (e.g. a testing instance) or a custom OIDC provider, override the defaults:
610
+
611
+ ```bash
612
+ export KICI_OIDC_ISSUER=https://your-idp.example.com
613
+ export KICI_OIDC_CLIENT_ID=your-client-id
614
+ export KICI_PLATFORM_URL=https://your-platform.example.com
615
+ kici login
616
+ ```
617
+
618
+ ### Headless SSH session
619
+
620
+ The CLI auto-detects headless environments and uses the device flow. To force PKCE with URL output instead:
621
+
622
+ ```bash
623
+ export KICI_BROWSER_CMD=none
624
+ kici login
625
+ ```
626
+
627
+ This prints the authorization URL to stdout as `KICI_AUTH_URL=<url>`. Open the URL in any browser to complete authentication.
628
+
629
+ ### Fixed callback port
630
+
631
+ When behind a firewall or using port forwarding:
632
+
633
+ ```bash
634
+ export KICI_CALLBACK_PORT=19876
635
+ kici login
636
+ ```
637
+
638
+ ### Custom config location
639
+
640
+ Store the KiCI config in a non-default location:
641
+
642
+ ```bash
643
+ export KICI_CONFIG_DIR=/tmp/kici-test
644
+ kici login
645
+ ```
646
+
647
+ ---
648
+
649
+ ## Event system
650
+
651
+ Source: https://docs.kici.dev/user/events/
652
+
653
+ KiCI supports two broad categories of workflow triggers: **git-based triggers** that work immediately, and **event-based triggers** that use a registration model. Understanding this distinction is key to working effectively with non-git triggers like schedules, custom events, and generic webhooks.
654
+
655
+ ## Overview
656
+
657
+ Git-based triggers (`push()`, `pr()`, `tag()`, `comment()`, `review()`, `release()`, etc.) work immediately after you commit your lock file. When a GitHub webhook arrives, the orchestrator fetches your lock file and evaluates triggers on the spot -- no advance setup needed.
658
+
659
+ Event-based triggers work differently. The orchestrator needs to know about them _before_ the event arrives. This is because event-based triggers are matched against a pre-built registration index rather than being evaluated per-event from a lock file fetch. The six event-based trigger types are:
660
+
661
+ - `kiciEvent()` -- custom events emitted from workflow steps
662
+ - `workflowComplete()` -- fires when a workflow finishes
663
+ - `jobComplete()` -- fires when a specific job finishes
664
+ - `genericWebhook()` -- HTTP webhooks from external services
665
+ - `schedule()` -- cron-based time triggers
666
+ - `lifecycle()` -- orchestrator lifecycle events (workflow completion, job failure, registration updates)
667
+
668
+ All six require the **registration model** to function -- covered in detail below.
669
+
670
+ ## Event types
671
+
672
+ ### Custom events
673
+
674
+ Custom events are user-defined events emitted from workflow steps using `ctx.emit()`. Use `kiciEvent()` to listen for them.
675
+
676
+ ```typescript
677
+ import { kiciEvent } from '@kici-dev/sdk';
678
+
679
+ // Listen for a custom event by name
680
+ kiciEvent({ name: 'deploy-complete' });
681
+
682
+ // With payload matching (JSONPath)
683
+ kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } });
684
+
685
+ // With negative filter
686
+ kiciEvent({ name: 'deploy-complete', not: { '$.env': 'staging' } });
687
+
688
+ // From a specific repository
689
+ kiciEvent({ name: 'deploy-complete', source: 'org/infra-repo' });
690
+ ```
691
+
692
+ **Config options:** `name` (required), `match`, `not`, `source`, `description`.
693
+
694
+ ### System events
695
+
696
+ The orchestrator automatically emits completion events when workflows and jobs finish. No manual emission needed -- these fire automatically.
697
+
698
+ **Workflow completion:**
699
+
700
+ ```typescript
701
+ import { workflowComplete } from '@kici-dev/sdk';
702
+
703
+ // Any workflow completion
704
+ workflowComplete();
705
+
706
+ // Specific workflow by name
707
+ workflowComplete({ name: 'build' });
708
+
709
+ // Only successful completions
710
+ workflowComplete({ name: 'build', status: ['success'] });
711
+ ```
712
+
713
+ **Config options:** `name`, `status` (`'success'`, `'failed'`, `'cancelled'`), `source`, `description`.
714
+
715
+ **Job completion:**
716
+
717
+ ```typescript
718
+ import { jobComplete } from '@kici-dev/sdk';
719
+
720
+ // Any job completion
721
+ jobComplete();
722
+
723
+ // Specific workflow + job
724
+ jobComplete({ workflow: 'build', job: 'test' });
725
+
726
+ // Only failures
727
+ jobComplete({ workflow: 'build', job: 'test', status: ['failed'] });
728
+ ```
729
+
730
+ **Config options:** `workflow`, `job`, `status` (`'success'`, `'failed'`, `'cancelled'`, `'skipped'`), `source`, `description`.
731
+
732
+ ### External events
733
+
734
+ Generic webhooks let you trigger workflows from any HTTP service -- Stripe, ArgoCD, Slack, Grafana, or your own internal services.
735
+
736
+ ```typescript
737
+ import { genericWebhook } from '@kici-dev/sdk';
738
+
739
+ // Match any event from a source
740
+ genericWebhook({ source: 'stripe' });
741
+
742
+ // Match specific event types
743
+ genericWebhook({ source: 'stripe', events: ['invoice.paid'] });
744
+
745
+ // With HMAC-SHA256 signature verification
746
+ genericWebhook({
747
+ source: 'stripe',
748
+ events: ['invoice.paid'],
749
+ auth: {
750
+ method: 'hmac-sha256',
751
+ secret: 'stripe-signing-key',
752
+ signatureHeader: 'stripe-signature',
753
+ },
754
+ });
755
+
756
+ // With API key auth
757
+ genericWebhook({
758
+ source: 'slack',
759
+ auth: { method: 'api-key', secret: 'slack-token' },
760
+ });
761
+ ```
762
+
763
+ **Config options:** `source` (required), `events`, `match`, `not`, `auth`, `path`, `description`.
764
+
765
+ The `source` field MUST match the `--name` that an operator passed to `kici-admin source add generic --name <name>` when the source was created — that string is the source's identifier in the orchestrator. Generic webhook sources must be created by an operator before events can be received; see [Operator guide: event routing](https://docs.kici.dev/operator/event-routing/) for setup instructions.
766
+
767
+ ### Schedule events
768
+
769
+ Cron-based triggers evaluated by the orchestrator on a periodic interval. Only the Raft leader evaluates schedules in a clustered deployment.
770
+
771
+ ```typescript
772
+ import { schedule } from '@kici-dev/sdk';
773
+
774
+ // Run every hour
775
+ schedule({ cron: '0 * * * *' });
776
+
777
+ // Run daily at 2 AM UTC
778
+ schedule({ cron: '0 2 * * *' });
779
+
780
+ // Run weekly on Mondays at 9 AM Eastern
781
+ schedule({ cron: '0 9 * * 1', timezone: 'America/New_York' });
782
+ ```
783
+
784
+ **Config options:** `cron` (required), `timezone` (defaults to `'UTC'`), `description`.
785
+
786
+ A cron-fired run records the commit sha of the registered lock file as its `sha`. Its `ref` is the repository's default branch, because that is the branch whose lock file the run executes. A [branch restriction](https://docs.kici.dev/user/contexts/#branch-restrictions) matches that branch.
787
+
788
+ ### Lifecycle events
789
+
790
+ Lifecycle triggers listen for orchestrator-level events related to workflow execution and system state changes.
791
+
792
+ ```typescript
793
+ import { lifecycle } from '@kici-dev/sdk';
794
+
795
+ // Trigger when any workflow completes
796
+ lifecycle({ events: ['workflow_complete'] });
797
+
798
+ // Trigger on job failures from a specific repo
799
+ lifecycle({ events: ['job_failed'], sources: ['org/deploy-repo'] });
800
+
801
+ // Trigger when registrations are updated
802
+ lifecycle({ events: ['registration_updated'] });
803
+ ```
804
+
805
+ **Available events:** `'workflow_complete'`, `'job_complete'`, `'job_failed'`, `'registration_updated'`.
806
+
807
+ **Config options:** `events` (required), `sources`, `description`.
808
+
809
+ ## The registration model
810
+
811
+ This is the most important concept for understanding event-based triggers.
812
+
813
+ ### Why registrations exist
814
+
815
+ When a GitHub webhook arrives (push, PR, etc.), the orchestrator fetches your lock file from the repository and evaluates triggers on the spot. This works because the event itself tells the orchestrator _which repository_ to look at.
816
+
817
+ Event-based triggers are different. When a cron timer fires or a custom event is emitted, there is no incoming webhook pointing to a specific repository. The orchestrator needs to know _in advance_ which workflows care about which events. That is what the registration model provides: a pre-built index of event-based workflows.
818
+
819
+ ### How registration works
820
+
821
+ 1. You define a workflow with an event-based trigger (e.g., `schedule()`, `kiciEvent()`, `genericWebhook()`)
822
+ 2. You compile the workflow (`kici compile`), which produces a lock file
823
+ 3. You push the lock file to your repository's **default branch** (e.g., `main` or `master`)
824
+ 4. The orchestrator receives the push webhook, detects it targets the default branch, and extracts all workflows with event-based triggers from the lock file
825
+ 5. Those workflows are stored in the orchestrator's registration database
826
+ 6. From that point on, matching events will trigger those workflows
827
+
828
+ ### Key implications
829
+
830
+ - **Event-based workflows do not trigger until you push to the default branch.** If you add a new `schedule()` workflow, it will not start running until you merge to your default branch. This is by design -- the orchestrator cannot match events to workflows it does not know about.
831
+
832
+ - **Registration is automatic.** There is no manual setup. Push your code, and the orchestrator handles the rest.
833
+
834
+ - **Registrations refresh on every default-branch push.** If you add, remove, or modify event-based workflows and push to the default branch, the orchestrator updates its registration index automatically. Removed workflows stop triggering. New workflows start triggering.
835
+
836
+ - **Git-based triggers are unaffected.** Triggers like `push()`, `pr()`, and `tag()` do not use registrations. They work immediately from any branch because the orchestrator evaluates them per-event from the lock file.
837
+
838
+ ### Practical example
839
+
840
+ You create a nightly build workflow:
841
+
842
+ ```typescript
843
+ import { workflow, job, step, schedule } from '@kici-dev/sdk';
844
+
845
+ export default workflow('nightly-build', {
846
+ on: schedule({ cron: '0 2 * * *' }),
847
+ jobs: [
848
+ job('build', {
849
+ runsOn: 'linux',
850
+ steps: [
851
+ step('build', async ({ $ }) => {
852
+ await $`pnpm build`;
853
+ }),
854
+ ],
855
+ }),
856
+ ],
857
+ });
858
+ ```
859
+
860
+ You compile it, commit the lock file, and push to a feature branch. **Nothing happens** -- the cron will not fire because the orchestrator has not registered this workflow yet.
861
+
862
+ You merge the feature branch into `main`. On the merge push, the orchestrator extracts the `nightly-build` workflow (it has a `ScheduleTrigger`) and registers it. Starting at the next 2 AM UTC, the workflow will trigger.
863
+
864
+ ## How events are matched
865
+
866
+ When an event arrives, the orchestrator follows this flow:
867
+
868
+ 1. **Event received** -- a custom event is emitted by a step, a cron timer fires, or a generic webhook arrives
869
+ 2. **Registration lookup** -- the orchestrator queries its registration index for workflows matching the event type (e.g., all workflows with `ScheduleTrigger` for a cron fire, or all workflows with `KiciEventTrigger` for a custom event)
870
+ 3. **Trigger evaluation** -- for each candidate workflow, the orchestrator evaluates the trigger conditions: event name patterns, payload matching, status filters, source filters
871
+ 4. **Dispatch** -- matched workflows are dispatched to agents for execution, following the same job queue and agent routing as git-triggered workflows
872
+
873
+ This lookup is fast because the registration index is held in memory and refreshed only when the registry version changes (on default-branch pushes).
874
+
875
+ ### What an event-triggered run resolves
876
+
877
+ An event-triggered run takes the same dispatch path as a webhook-triggered run. Four things follow from that.
878
+
879
+ **Bound contexts resolve in full.** The run reads each job's [contexts](https://docs.kici.dev/user/contexts/): context variables, [scoped secrets](https://docs.kici.dev/user/secrets/), and every protection rule the context carries. A job that calls `ctx.secrets.get()` must bind the context that holds the secret:
880
+
881
+ ```typescript
882
+ job('provision', {
883
+ runsOn: ['default'],
884
+ context: 'hetzner-autoscale',
885
+ run: async (ctx) => {
886
+ const token = await ctx.secrets.get('HETZNER_API_TOKEN');
887
+ // ...
888
+ },
889
+ });
890
+ ```
891
+
892
+ **Protection rules gate the run.** A branch restriction matches the branch the run presents, an approval gate holds it, and a [concurrency group](https://docs.kici.dev/user/concurrency/) serializes it — exactly as for a push or a pull request. A `kiciEvent()` subscriber presents the branch of the run that emitted the event. A scaler event and a failure batch present none, so a branch restriction rejects those two: see [branch restrictions](https://docs.kici.dev/user/contexts/#branch-restrictions). Nothing runs unattended past a gate the operator set. The [approval queue](https://docs.kici.dev/user/dashboard/contexts-and-secrets/#approval-queue) lists each held run, names the context that holds it, and gives the reason. `kici runs show <run-id>` prints the same holds for one run.
893
+
894
+ **A build job packs the source first.** The run dispatches a `__build__<workflow>` job to an agent labelled `kici:role:builder`, then runs its own jobs against the [cached source and dependency tarballs](https://docs.kici.dev/operator/dependency-caching/). A fleet with no builder-role agent queues that job.
895
+
896
+ **The run carries a trust tier.** See [trust tiers on internal triggers](https://docs.kici.dev/user/events/#trust-tiers-on-internal-triggers).
897
+
898
+ ### Trust tiers on internal triggers
899
+
900
+ An internally-triggered run resolves its [trust tier](https://docs.kici.dev/user/contexts/#minimum-trust) from the trigger. The tier decides the run's cache scope, whether it may run a [Dockerfile build](https://docs.kici.dev/user/container-jobs/#who-may-build), whether it receives [install secrets](https://docs.kici.dev/user/private-registries/), and whether a `minimumTrust` context holds it.
901
+
902
+ Four rules resolve the tier, and KiCI applies them in this order:
903
+
904
+ | Order | Trigger | Tier |
905
+ | ----- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
906
+ | 1 | A run summoned by an [invoke gate](https://docs.kici.dev/user/global-workflows/#invoking-a-source-repos-own-workflows) | the tier of the summoning run |
907
+ | 2 | `__schedule_fire`, `kici.scaler.scale-up`, `kici.scaler.scale-down` | trusted — no run causes these, the orchestrator mints them |
908
+ | 3 | `__workflows_failed_batch` | the most restrictive tier across the failed runs it carries |
909
+ | 4 | `__workflow_complete`, `__job_complete`, or a `kiciEvent()` subscriber | the tier of the run that emitted the event |
910
+
911
+ Rule 1 runs first on purpose. A workflow author writes the gate's event name, so a gate that named a minted event would otherwise claim rule 2's trusted tier.
912
+
913
+ Rule 2 lists the three names exactly. A prefix is not enough: the `__` and `kici.` prefixes are both reserved, but only a name on this list is one KiCI mints with no run behind it. Any other reserved name falls through to rule 4, which finds no emitting run and resolves no tier. So an [autoscaling workflow](https://docs.kici.dev/user/workflows/autoscaling-workflows/) that subscribes to `kici.scaler.scale-up` or `kici.scaler.scale-down` runs trusted, and can build a Dockerfile job.
914
+
915
+ Rule 3 covers the failure batch, which one whole accumulation window of failed runs causes at once. A batch is only as trusted as its least trusted member, so a notifier fired by a window that included one untrusted failure runs at that failure's tier. A window holding more failed runs than the event carries truncates that list to a sample. The batch then resolves no tier at all: a minimum over a sample is not a minimum over the window.
916
+
917
+ Rule 4 covers the two lifecycle events a single run causes. A run completing does not raise the privilege of what its completion triggers. A `__workflow_complete` subscriber runs at the tier of the run that completed, exactly as a `kiciEvent()` subscriber runs at the tier of the run that emitted.
918
+
919
+ KiCI resolves no tier it cannot confirm. A missing emitting run, an unreadable tier, or a failed lookup resolves no tier at all, which isolates the run's caches.
920
+
921
+ An unresolved tier is not uniform across the controls it reaches. The differences are deliberate, and this table is the whole rule:
922
+
923
+ | Control | Unresolved tier | A tier below `trusted` |
924
+ | ---------------------- | --------------- | ---------------------- |
925
+ | Cache scope | isolated | isolated |
926
+ | Dockerfile build | denied | denied |
927
+ | Install secrets | delivered | stripped |
928
+ | `minimumTrust` context | passes | holds an `unknown` run |
929
+
930
+ The first two treat "no tier" as untrusted. The last two treat it as "no opinion", and pass. More than one kind of run carries no resolved tier, and each behaves this way. Among them: an internally-triggered run whose lookup fails, a pull request from a source other than a GitHub App, a cross-source delivery, and a `kici run` remote test run. Only a tier that RESOLVES below `trusted` strips install secrets, and only a tier that resolves `unknown` trips a `minimumTrust` gate.
931
+
932
+ A subscriber that inherits a tier below `trusted` loses its install secrets. A job that installs from a private registry then fails at install time.
933
+
934
+ A `minimumTrust` context holds an `unknown` subscriber for security review, whatever value the context declares. Trust is a ref-based judgement with two answers, so `minimumTrust: 'trusted'` and the deprecated `minimumTrust: 'known'` block the same thing. The declared value still decides the wording of the hold reason. A subscriber that inherited the legacy `known` tier from a run row written by an earlier build passes both.
935
+
936
+ Both symptoms appear far from their cause. The tier belongs to the **emitting** run, so read that run's tier first.
937
+
938
+ ### Cross-source webhook delivery
939
+
940
+ The catch-all `webhook()` trigger (see [SDK reference: webhook()](https://docs.kici.dev/user/sdk/triggers/#webhook)) participates in this same registration lookup, but with one twist: it fires for matching events arriving via **any** inbound webhook source in the same org, not just the source the workflow's repo is bound to. The orchestrator maintains a `(customerId, eventName)` index over webhook trigger registrations and consults it on every inbound generic webhook.
941
+
942
+ The lookup is structurally org-isolated — a generic webhook delivered to org A can never reach a workflow registered against org B, because foreign-org rows live in a different bucket of the index. When a webhook fires across sources, the runtime clone token, repo URL, and check-status posting all come from the **registration's** source bundle, not the inbound source. The inbound source contributes only the event payload.
943
+
944
+ ## Circuit breaker
945
+
946
+ Events can trigger workflows that emit more events, creating chains. The circuit breaker prevents runaway event storms.
947
+
948
+ ### Chain depth limit
949
+
950
+ Each event carries a `chainDepth` counter. When a workflow triggered by an event emits a new event, the new event's chain depth increments. The orchestrator rejects events that exceed the maximum chain depth.
951
+
952
+ - **Default limit:** 10 levels deep
953
+ - **What happens when hit:** the emission is rejected with a `Circuit breaker tripped` error. The event is never persisted, so it is not queued for later delivery.
954
+
955
+ For example: Workflow A emits event X (depth 0) -> Workflow B triggers, emits event Y (depth 1) -> ... -> at depth 10, any further emitted events are dropped.
956
+
957
+ ### Rate limiting
958
+
959
+ Emitted events are rate-limited using a sliding 60-second window, keyed per **(source routing key + event name)** — so one noisy event name in one repository cannot starve the same event name emitted from another.
960
+
961
+ - **Default limit:** 100 events per (source routing key + event name) per minute
962
+ - **What happens when hit:** the emission is rejected with a `Rate limit exceeded` error naming the retry-after delay.
963
+ - **System events are exempt:** orchestrator-emitted events (names prefixed `__` or `kici.`) cannot loop, so they bypass the limiter entirely.
964
+
965
+ Both defaults are configurable. Your operator can set them at startup with `KICI_EVENT_ROUTER_MAX_CHAIN_DEPTH` and `KICI_EVENT_ROUTER_RATE_LIMIT_PER_WORKFLOW_PER_MINUTE` (or the equivalent `eventRouter.maxChainDepth` / `eventRouter.rateLimitPerWorkflowPerMinute` config fields). The rate limit is additionally a live fleet-wide [cluster setting](https://docs.kici.dev/operator/orchestrator/cluster-settings/) — `kici-admin cluster-settings set --event-router-rate-limit-per-workflow-per-minute <n>` takes effect without a restart.
966
+
967
+ ## Delivery guarantees
968
+
969
+ KiCI's event router delivers every accepted event with **at-least-once** semantics:
970
+
971
+ - An event that passes the circuit breaker (chain depth + rate limit) and commits
972
+ to the `kici_events` table is guaranteed to dispatch to all matching workflows
973
+ at least once.
974
+ - Each dispatch attempt acquires a short-lived lease (default 60 s) on the row.
975
+ If the dispatching node crashes or the handler throws, the lease expires (or
976
+ is released on failure) and the event is automatically retried.
977
+ - The retry policy is exponential backoff with full jitter: base 5 s, cap 5 min,
978
+ up to 5 attempts before the event lands in the **DLQ** (dead-letter queue).
979
+ Operators triage DLQ entries via `kici-admin event-dlq list / count / retry / discard`.
980
+
981
+ **What this means for workflow authors:**
982
+
983
+ - **Make event handlers idempotent.** A retried dispatch may run a handler more
984
+ than once (e.g. if the first attempt threw after a partial side-effect).
985
+ Workflows that mutate external state should use idempotency keys, conditional
986
+ writes, or other deduplication patterns — same advice as for any distributed
987
+ CI system.
988
+ - **Schedule fires are at-least-once too.** A cron schedule that fires while a
989
+ leader is being killed will commit (atomically with `cron_last_fired`) or roll
990
+ back together — never half. Recovery on the new leader does not backfill
991
+ multiple missed instants; if your workflow needs at-least-N guarantees across
992
+ outages, drive it from a different mechanism (e.g. a workflow that runs more
993
+ frequently and emits its own custom event).
994
+ - **Drops are still possible — and visible.** Events rejected by the circuit
995
+ breaker (chain depth or rate limit exceeded) are dropped and logged, not
996
+ retried. That's a deliberate safety mechanism; the metric to watch is
997
+ `kici_orch_events_dropped_total{reason}`.
998
+
999
+ ## Emitting custom events
1000
+
1001
+ Custom events are emitted from workflow steps using `ctx.emit()`. You can optionally define typed event schemas using `defineEvent()`.
1002
+
1003
+ ### Basic emission
1004
+
1005
+ ```typescript
1006
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1007
+
1008
+ export default workflow('build', {
1009
+ on: push({ branches: 'main' }),
1010
+ jobs: [
1011
+ job('build', {
1012
+ runsOn: 'linux',
1013
+ steps: [
1014
+ step('build', async ({ $ }) => {
1015
+ await $`pnpm build`;
1016
+ }),
1017
+ step('notify', async (ctx) => {
1018
+ await ctx.emit('build-complete', {
1019
+ version: '1.0.0',
1020
+ success: true,
1021
+ });
1022
+ }),
1023
+ ],
1024
+ }),
1025
+ ],
1026
+ });
1027
+ ```
1028
+
1029
+ ### Typed event definitions
1030
+
1031
+ Use `defineEvent()` with Zod schemas to create a typed contract for event payloads:
1032
+
1033
+ ```typescript
1034
+ import { defineEvent, z } from '@kici-dev/sdk';
1035
+
1036
+ export const deployComplete = defineEvent(
1037
+ 'deploy-complete',
1038
+ z.object({
1039
+ env: z.string(),
1040
+ version: z.string(),
1041
+ services: z.array(z.string()),
1042
+ }),
1043
+ );
1044
+ ```
1045
+
1046
+ Then emit using the definition — the payload is checked against the schema:
1047
+
1048
+ ```typescript
1049
+ step('emit', async (ctx) => {
1050
+ await ctx.emit(deployComplete, {
1051
+ env: 'prod',
1052
+ version: '1.2.3',
1053
+ services: ['api', 'web'],
1054
+ });
1055
+ });
1056
+ ```
1057
+
1058
+ And consume in another workflow:
1059
+
1060
+ ```typescript
1061
+ import { workflow, job, step, kiciEvent } from '@kici-dev/sdk';
1062
+
1063
+ export default workflow('post-deploy', {
1064
+ on: kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }),
1065
+ jobs: [
1066
+ job('smoke-test', {
1067
+ runsOn: 'linux',
1068
+ steps: [
1069
+ step('test', async ({ $ }) => {
1070
+ await $`./scripts/smoke-test.sh`;
1071
+ }),
1072
+ ],
1073
+ }),
1074
+ ],
1075
+ });
1076
+ ```
1077
+
1078
+ Custom events are delivered immediately when emitted (mid-workflow, not queued until workflow completion). See the [SDK reference: emitting events](https://docs.kici.dev/user/sdk/validation-events/#emitting-events) section for the full `ctx.emit()` API.
1079
+
1080
+ ### Reserved event names
1081
+
1082
+ Two name prefixes belong to the orchestrator, and `ctx.emit()` refuses both:
1083
+
1084
+ - `__` -- the orchestrator's own lifecycle and schedule events (`__schedule_fire`, `__workflow_complete`, `__job_complete`, `__workflows_failed_batch`).
1085
+ - `kici.` -- KiCI internal system events.
1086
+
1087
+ A step that emits either name fails with `event name prefix "__" is reserved for KiCI internal events and cannot be emitted from a workflow step (got "__foo")`, naming the prefix that matched. A caller that reaches the orchestrator without the SDK gets the shorter `event name prefix "__" is reserved for KiCI internal events`, and no event is written. These events run at a higher trust level and skip the rate limiter, so a workflow must not be able to forge one. Prefix only -- a name that merely contains the text, such as `deploy__done`, is fine.
1088
+
1089
+ The same reservation covers an [invoke gate](https://docs.kici.dev/user/global-workflows/#invoking-a-source-repos-own-workflows). `invokeSource()` rejects a reserved name when you compile. A lock file that still carries one fails the gate job at dispatch, with status `failed` rather than skipped.
1090
+
1091
+ Subscribing is unaffected: `kiciEvent({ name })` may name a reserved event, and only emission is refused.
1092
+
1093
+ ## See also
1094
+
1095
+ - [SDK reference: event triggers](https://docs.kici.dev/user/sdk/triggers/#event-triggers) -- complete API signatures for all trigger builders
1096
+ - [SDK reference: emitting events](https://docs.kici.dev/user/sdk/validation-events/#emitting-events) -- `ctx.emit()` and `defineEvent()` API
1097
+ - [Workflow patterns: workflow chaining](https://docs.kici.dev/user/patterns/integrations/#workflow-chaining) -- examples of event-driven workflow chains
1098
+ - [Operator guide: event routing](https://docs.kici.dev/operator/event-routing/) -- configuring generic webhook sources, trust relationships, and event routing
1099
+ - [Architecture: event system](https://docs.kici.dev/architecture/webhooks/event-system/) -- internal event routing design, registration model, cluster synchronization
1100
+
1101
+ ---
1102
+
1103
+ ## Global workflows
1104
+
1105
+ Source: https://docs.kici.dev/user/global-workflows/
1106
+
1107
+ Global workflows let one **workflow repo** define jobs that run on events from many **source repos** in the same org. They're the answer to "I want one CI policy / release pipeline / security scan to fire on every repo without copy-pasting `.kici/` folders everywhere."
1108
+
1109
+ If you've only ever used per-repo workflows so far, start with the mental model section — global workflows add two new concepts (workflow repo vs. source repo, and authoring vs. source axes) that show up everywhere from SDK syntax to dashboard settings.
1110
+
1111
+ ## Mental model
1112
+
1113
+ | Term | Meaning |
1114
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
1115
+ | Workflow repo | The repo whose `.kici/workflows/*.ts` file **declares** the global workflow. Holds the steps. Also known as the _authoring_ repo. |
1116
+ | Source repo | The repo that **emits** the event (push / PR / tag / ...) that causes the global workflow to fire. The agent checks out this repo as the working copy. |
1117
+ | Global | A workflow whose trigger carries one or more `repos:` glob patterns. The presence of `repos:` is what classifies a workflow as global. |
1118
+ | Authoring axis | Policy that answers "which repos may **author** global workflows?" Controlled by the allow-list in the dashboard's _Allowed author repos_ setting. |
1119
+ | Source axis | Policy that answers "which **source** repos' events are allowed to trigger global workflows?" Controlled by the deny-list in _Blocked source repos_. |
1120
+
1121
+ The two axes are independent. A global workflow fires only if it passes **both** — its authoring repo is allowed AND the source repo is not denied.
1122
+
1123
+ ## Declaring a global workflow
1124
+
1125
+ Add `repos:` to any trigger. Any workflow with at least one `repos:`-bearing trigger becomes global automatically; no separate flag is required.
1126
+
1127
+ ```ts
1128
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1129
+
1130
+ export default workflow('org-lint', {
1131
+ on: [
1132
+ push({
1133
+ repos: ['myorg/*', '!myorg/archived-*'],
1134
+ branches: ['main'],
1135
+ }),
1136
+ ],
1137
+ jobs: [
1138
+ job('lint', {
1139
+ steps: [
1140
+ step('lint-all', async ({ $, env }) => {
1141
+ await $`echo source=${env.KICI_SOURCE_REPO_PATH ?? 'unknown'}`;
1142
+ await $`npm run lint`;
1143
+ }),
1144
+ ],
1145
+ }),
1146
+ ],
1147
+ });
1148
+ ```
1149
+
1150
+ Patterns in `repos:` use the same globbing as `branches:` / `paths:` — plain globs (`myorg/*`), a leading `!` for exclusions (`!myorg/fork-*`), and a fully-qualified `owner/repo` identity for exact matches (`myorg/platform`). A bare `**` matches every repo in the org, including one whose identifier starts with a dot (`.github/workflows-config`) — a repo identifier is an owner/name pair, not a file path, so a leading dot carries no meaning of its own. Path globs in `paths:` keep the usual convention and do not match dot-prefixed files unless the pattern spells the dot out.
1151
+
1152
+ ### At a dual-repo checkout
1153
+
1154
+ The agent checks out both repos. **Inside a step body**, `env` carries a pointer to each working tree:
1155
+
1156
+ | `env` var | Points to |
1157
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
1158
+ | `KICI_SOURCE_REPO_PATH` | The **source** repo's working tree (the repo that emitted the event). This is the repo the job's `$` / `git` commands operate on by default. |
1159
+ | `KICI_WORKFLOW_REPO_PATH` | The **workflow** repo's working tree (the repo that authored the workflow). Useful for reading shared scripts or config from your CI repo. |
1160
+
1161
+ Both variables are step-body context. They are **not** projected into the job's process environment, so a job-level `env:` block, a container image's entrypoint, or a shell command outside a step body will not see them. Outside a step body, use the `sourceRepo` / `workflowRepo` pair on the filter, generator, and rule contexts described below.
1162
+
1163
+ They are also set **only when there are two repos to point at**. An event from the workflow's own repo is matched from that repo's lock file rather than as a global candidate, so the workflow runs as an ordinary single-repo workflow: one checkout, and neither variable set. Read them with a fallback, as the example above does.
1164
+
1165
+ A global workflow's job runs with **no secrets at all** — neither the source repo's nor its own. See _Secrets are not available_ below.
1166
+
1167
+ ### The triggering event
1168
+
1169
+ `ctx.event` inside a global workflow's job is the **source** repo's normalized event — the push or PR that fired the workflow, from a repo the workflow's own author may not own. `ctx.event.sourceRepo` names that repo.
1170
+
1171
+ That field is what makes a per-source-repo concurrency group expressible — and you have to write it. A global workflow runs on events from many repos, and their default branches share a name, so a group keyed on the branch alone puts every repo in one group, and with `cancelInProgress` (the default) one repo's push cancels another repo's in-flight run. That is still the behaviour of a branch-only group; naming the source repo in the key is what separates them:
1172
+
1173
+ ```ts
1174
+ concurrency: {
1175
+ group: ({ branch, event }) => `${event.sourceRepo}:${branch}`,
1176
+ cancelInProgress: true,
1177
+ },
1178
+ ```
1179
+
1180
+ ### Narrowing to the repos that need it
1181
+
1182
+ A global workflow that matches `myorg/*` will, by default, run on every repo in the org. Three mechanisms narrow it to the repos it actually applies to, in increasing order of power:
1183
+
1184
+ 1. **A `requires` content filter on the trigger** — the cheapest gate. The orchestrator checks a file's contents (a JSON-path probe over `package.json`, for example) and drops the workflow **before any agent is dispatched** when the condition is not met. See [`requires` on triggers](https://docs.kici.dev/user/sdk/triggers/#content-requirements-requires). This is provider-dependent — it needs a file-contents fetcher, which the GitHub provider supplies.
1185
+ 2. **A workflow-level `filter` predicate** — arbitrary TypeScript over the checked-out source tree (below). Works with any provider that clones.
1186
+ 3. **A `DynamicJobFn`** — generate the exact job set from the source repo's state ([Generating jobs per source repo](https://docs.kici.dev/user/global-workflows/#generating-jobs-per-source-repo) below).
1187
+
1188
+ ### Narrowing with a filter
1189
+
1190
+ Before reaching for a `filter`, check whether a declarative filter answers the question. `commitMessage` (on the trigger) and `requires` (over source files) are evaluated by the orchestrator from data it already has, so they cost no evaluation job at all — while a `filter` predicate dispatches one per (event × workflow repo). Gating on a `[skip ci]` marker, a conventional-commit prefix, or the contents of a named config file needs no predicate.
1191
+
1192
+ A workflow can declare a `filter`: a predicate that decides whether the workflow applies to this event at all.
1193
+
1194
+ ```ts
1195
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1196
+
1197
+ export default workflow('org-container-lint', {
1198
+ on: [push({ repos: ['myorg/*'] })],
1199
+ filter: async ({ sourceRepo, changedFilesStatus, $ }) => {
1200
+ // `changedFiles` throws when the diff is unavailable, so guard first.
1201
+ if (changedFilesStatus !== 'fetched') return true;
1202
+ const found = await $`ls ${sourceRepo.path}`;
1203
+ return found.stdout.includes('Dockerfile');
1204
+ },
1205
+ jobs: [
1206
+ job('lint-dockerfile', {
1207
+ runsOn: ['kici:os:linux'],
1208
+ steps: [
1209
+ step('lint', async ({ $, env }) => $`hadolint ${env.KICI_SOURCE_REPO_PATH}/Dockerfile`),
1210
+ ],
1211
+ }),
1212
+ ],
1213
+ });
1214
+ ```
1215
+
1216
+ The filter receives a `FilterContext`:
1217
+
1218
+ | Property | Type | Description |
1219
+ | -------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------ |
1220
+ | `sourceRepo` | `RepoInfo` | The repo whose event triggered this evaluation, checked out on the evaluating agent. |
1221
+ | `workflowRepo` | `RepoInfo` | The repo that registered the workflow. Identical to `sourceRepo` for a same-repo workflow. |
1222
+ | `event` | `EventPayload` | The normalized event envelope. |
1223
+ | `changedFiles` | `string[]` | Files changed in this event. Throws when unavailable — guard with `changedFilesStatus`. |
1224
+ | `changedFilesStatus` | `'fetched' \| 'unavailable' \| 'skipped'` | Whether `changedFiles` can be read. |
1225
+ | `env` | `Record<string, string\|undefined>` | Environment variables. |
1226
+ | `$` | zx shell | Shell executor. |
1227
+
1228
+ `RepoInfo` carries `path` (an absolute path to the checkout on the evaluating agent) plus optional `ref` and `sha`. **Both are optional** — an event that carries no single ref leaves them undefined, so guard before reading them.
1229
+
1230
+ **`sourceRepo.path` is not stable across evaluations.** Its _contents_ are: the evaluating agent and the later run see the same tree at the same commit. The path itself is not — a different working directory, and possibly a different machine. Read _through_ it; never embed it in a job name, an output, or anything compared across calls.
1231
+
1232
+ **A `filter` must be pure and deterministic.** Decide from the context alone — the event, the changed files, and the checked-out tree — so the same event always yields the same verdict.
1233
+
1234
+ ### Global and same-repo filters differ
1235
+
1236
+ The same `filter` keyword means two different things depending on whether the workflow is global:
1237
+
1238
+ | | Global workflow (`repos:` on a trigger) | Same-repo workflow |
1239
+ | ------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------------- |
1240
+ | Evaluated | once per (event × workflow repo) | once per job that reaches dispatch, and once per job generator |
1241
+ | Evaluated relative to the run | **before** any run row exists | **after** the run row exists |
1242
+ | A `false` verdict leaves | no run at all — nothing appears in the dashboard | a run whose only entries are the evaluation jobs, rolling up to `success` |
1243
+ | `sourceRepo` vs `workflowRepo` | two different repos | the same repo |
1244
+
1245
+ Two consequences of the same-repo shape are worth designing for. A workflow with ten jobs calls its filter ten times for one event — each on its own agent with its own checkout and its own `$` — so anything the predicate does happens that many times: keep it cheap and side-effect free. And if the predicate can answer differently for the same event, the workflow will _partially_ dispatch, running some jobs and not others.
1246
+
1247
+ **A held or rejected job is not filtered at all.** A job held for approval, or rejected by a context rule, already has a gate — the hold or the rule — so it never takes a filter verdict, and an approved job dispatches without one. Concretely: a path filter cannot stop an approval request for a job the change does not concern.
1248
+
1249
+ ### Generating jobs per source repo
1250
+
1251
+ A global workflow's job generators run in the same pre-run evaluation as the filter, with both repos on disk. `sourceRepo` and `workflowRepo` are on the generator context, so one workflow repo can produce a different job set per source repo:
1252
+
1253
+ ```ts
1254
+ import { job, step, workflow, push, type DynamicJobFn } from '@kici-dev/sdk';
1255
+ import { readFile } from 'node:fs/promises';
1256
+
1257
+ const perRepoJobs: DynamicJobFn = async ({ sourceRepo }) => {
1258
+ if (!sourceRepo) return [];
1259
+ const pkg = JSON.parse(await readFile(`${sourceRepo.path}/package.json`, 'utf8'));
1260
+ return Object.keys(pkg.scripts ?? {})
1261
+ .filter((s) => s.startsWith('ci:'))
1262
+ .map((s) =>
1263
+ job(s.replace(':', '-'), {
1264
+ runsOn: ['kici:os:linux'],
1265
+ steps: [step('run', async ({ $ }) => $`pnpm ${s}`)],
1266
+ }),
1267
+ );
1268
+ };
1269
+
1270
+ export default workflow('org-ci', {
1271
+ on: [push({ repos: ['myorg/*'] })],
1272
+ jobs: [perRepoJobs],
1273
+ });
1274
+ ```
1275
+
1276
+ The same `sourceRepo.path` caution applies: read the tree through it, and derive job names from the repo's _contents_, never from the path.
1277
+
1278
+ ## Invoking a source repo's own workflows
1279
+
1280
+ A global workflow can run the source repo's **own** workflows and gate on them. Use the `invoke:` job option, built with `invokeSource()`:
1281
+
1282
+ ```ts
1283
+ import { job, workflow, push, kiciEvent, invokeSource } from '@kici-dev/sdk';
1284
+
1285
+ // Source repo (myorg/backend/.kici/workflows/tests.ts) — opts in by subscribing.
1286
+ export const repoTests = workflow('repo-tests', {
1287
+ on: [kiciEvent({ name: 'myorg.repo-tests' })],
1288
+ jobs: [
1289
+ job('unit', {
1290
+ runsOn: ['kici:os:linux'],
1291
+ run: async ({ $ }) => {
1292
+ await $`npm test`;
1293
+ },
1294
+ }),
1295
+ ],
1296
+ });
1297
+
1298
+ // Global workflow (myorg/ci-pipelines/.kici/workflows/org-pipeline.ts).
1299
+ export default workflow('org-pipeline', {
1300
+ on: [push({ repos: ['myorg/*'], branches: ['main'] })],
1301
+ jobs: [
1302
+ // The invoke gate: emits `myorg.repo-tests` at the source repo and waits for
1303
+ // every run it triggers. It runs no steps of its own.
1304
+ job('repo-tests', { invoke: invokeSource('myorg.repo-tests') }),
1305
+
1306
+ // Gated on the invoked runs through the standard needs vocabulary.
1307
+ job('deploy', {
1308
+ needs: ['repo-tests'],
1309
+ runsOn: ['kici:os:linux'],
1310
+ run: async (ctx) => {
1311
+ for (const r of ctx.needs['repo-tests'].result) {
1312
+ // r = { repo, workflow, runId, status, outputs }
1313
+ if (r.status === 'success') ctx.log.info(`coverage=${r.outputs.coverage}`);
1314
+ }
1315
+ },
1316
+ }),
1317
+ ],
1318
+ });
1319
+ ```
1320
+
1321
+ An invoke gate never runs steps, so it is mutually exclusive with `steps` / `run`. A repo opts in by subscribing to the event with `kiciEvent({ name })` — a global cannot invoke a repo that did not subscribe.
1322
+
1323
+ The gate's event name follows the same rule as `ctx.emit`: the prefixes `__` and `kici.` are reserved for KiCI, and `invokeSource()` rejects them when you compile. A lock file that still carries a reserved gate fails that gate job at dispatch with `invoke gate cannot summon '…': the event-name prefix "…" is reserved for KiCI internal events. Choose a name a workflow may emit.` The job's status is `failed`, not skipped, so `optional` does not turn the refusal green. Nothing is summoned and no proxy job is created. See [reserved event names](https://docs.kici.dev/user/events/#reserved-event-names).
1324
+
1325
+ A summoned run inherits the [trust tier](https://docs.kici.dev/user/events/#trust-tiers-on-internal-triggers) of the run that holds the gate.
1326
+
1327
+ ### Required by default
1328
+
1329
+ An emit that matches **zero** subscribers **fails** the gate. A repo that forgot to wire up its tests must not silently pass the org gate. To let a repo opt out, pass `optional`:
1330
+
1331
+ ```ts
1332
+ job('repo-tests', { invoke: invokeSource('myorg.repo-tests', { optional: true }) });
1333
+ ```
1334
+
1335
+ A zero-subscriber gate with `optional: true` succeeds immediately with no proxies. `optional` is separate from `continueOnError`: `optional` governs whether there was anything to invoke, `continueOnError` governs whether an invoked run passed.
1336
+
1337
+ ### Reading invoked-run results
1338
+
1339
+ Each invoked run appears as a **proxy node** under the gate in the run graph, and its result is available to downstream jobs on `ctx.needs['<gate>'].result` — an array of `{ repo, workflow, runId, status, outputs }`, one entry per invoked run. `outputs` carries the run's non-secret declared outputs; a repo's secret outputs never cross into the global run.
1340
+
1341
+ ### Standard job options apply
1342
+
1343
+ The gate is a standard job. Tolerate a failed invoked run with `continueOnError`, react to a failed gate with a downstream `needs` `when: 'on-failure'`, bound the wait with the job `timeout`, and bound the fan-out with `maxParallel` / `failFast`:
1344
+
1345
+ ```ts
1346
+ job('repo-tests', {
1347
+ invoke: invokeSource('myorg.repo-tests'),
1348
+ continueOnError: true,
1349
+ timeout: '1h',
1350
+ maxParallel: 10,
1351
+ failFast: true,
1352
+ });
1353
+ ```
1354
+
1355
+ ### Generating invoke gates
1356
+
1357
+ Because `invoke:` is a job shape, a generator can inspect the source repo and return only the gates that apply:
1358
+
1359
+ ```ts
1360
+ import { existsSync } from 'node:fs';
1361
+ import { join } from 'node:path';
1362
+
1363
+ const perRepoGates: DynamicJobFn = async ({ sourceRepo }) => {
1364
+ if (!sourceRepo) return [];
1365
+ const jobs = [];
1366
+ if (existsSync(join(sourceRepo.path, 'Dockerfile')))
1367
+ jobs.push(job('docker', { invoke: invokeSource('myorg.docker-test', { optional: true }) }));
1368
+ if (existsSync(join(sourceRepo.path, 'package.json')))
1369
+ jobs.push(job('node', { invoke: invokeSource('myorg.node-test') }));
1370
+ return jobs;
1371
+ };
1372
+ ```
1373
+
1374
+ The generator decides whether to create a gate at all; `optional` decides what a created gate does when nothing subscribes.
1375
+
1376
+ **Set a `timeout` when a summoned run can be held.** A gate waits for every run it summoned. An invoked run binds its own [contexts](https://docs.kici.dev/user/contexts/), so a [protection rule](https://docs.kici.dev/user/contexts/#protection-rules) can hold it for reviewer approval or a wait timer. A gate with no `timeout` then waits for as long as the hold lasts, which is until a human acts on it. Give such a gate a `timeout` so the wait is bounded.
1377
+
1378
+ ## Enabling global workflows
1379
+
1380
+ Global workflows are gated by a **fleet-wide master switch** held by the orchestrator operator, off by default. Until it is on, `repos:`-bearing workflows are registered but never dispatched.
1381
+
1382
+ 1. **The operator enables it cluster-wide** with `kici-admin cluster-settings set --global-workflows-enabled true`. This is the kill-switch — every per-org control below is ignored while it is off, and it cannot be flipped from the dashboard. The dashboard's **Settings → Global workflows** tab shows its current state as a read-only badge.
1383
+ 2. In the dashboard → **Settings → Global workflows**, decide which authoring/source controls you need. These per-org lists stay dashboard-editable; an org that has set none means "no per-org restrictions", not a denial.
1384
+
1385
+ | Setting | What it controls | Typical use |
1386
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
1387
+ | Allowed author repos | Restricts which repos can **author** (register) global workflows. Globs matched against the authoring repo identifier. When OFF, any repo in the org may author globals. | Lock authoring to `myorg/ci-*` so random product repos can't ship org-wide automation. |
1388
+ | Blocked source repos | Blocks dispatch for events emitted from these **source** repos, regardless of authoring. Globs matched against the event source repo identifier. When OFF, events from any repo may trigger globals. | Protect against fork spam — e.g. `!myorg/*` via `myorg/fork-*`. |
1389
+ | Elevated access | **Deprecated and not enforced.** Stored and echoed back, but nothing reads it — a global workflow's job receives no secrets, so there is no access for it to grant. See _Secrets are not available_. | None. Clear the list so it does not imply a grant that is not in force. |
1390
+
1391
+ All three lists accept globs. Leading `!` inside a single pattern is not supported here; negation is via the list-is-implicit-deny semantics, so keep it simple (`myorg/ci-*`, `myorg/platform-*`).
1392
+
1393
+ Patterns match repo identifiers by the same rule as `repos:` on a trigger: an identifier is an owner/name pair, not a file path, so a leading dot carries no meaning of its own and a wildcard segment matches one. `myorg/*` covers `myorg/.github`, and `**` covers every repo in the org. Review any existing entry that relies on a wildcard to reach — or to spare — a dot-prefixed repo name.
1394
+
1395
+ ### Saving and reverting
1396
+
1397
+ The page is a two-state editor — changes are local until you click **Save changes**, and you can abandon them with **Discard changes**. There is no partial save; the PATCH is all-or-nothing per save click.
1398
+
1399
+ ## Security model
1400
+
1401
+ ### Two independent axes
1402
+
1403
+ A global workflow fires only if:
1404
+
1405
+ 1. **The authoring repo is allowed.** If _Allowed author repos_ is ON, the workflow's authoring repo must match at least one allow-list glob. If OFF, any repo may author. Enforced at two points:
1406
+ - At registration time (extraction from the lock file — non-matching globals are dropped, and the orchestrator logs `Global workflows excluded from registration` naming each one).
1407
+ - At dispatch time (defense-in-depth — policy changes after registration still take effect).
1408
+ 2. **The source repo is not denied.** If the event's source repo matches any glob in _Blocked source repos_, the global workflow is skipped. Enforced at dispatch time.
1409
+
1410
+ Both checks are logged to the orchestrator. Grep for `Global workflows excluded from registration` (registration time) and `Skipping global workflow dispatch` (dispatch time) to see enforcement in action.
1411
+
1412
+ Both checks read the settings of the organization the **event's source** resolves to. If no webhook source maps the event's routing key to an organization, the orchestrator resolves the built-in `__default__` organization anchor instead — and since nobody has enabled global workflows for that anchor, every global workflow is refused. The registration log line carries the organization it decided against plus the remedy, so this case is distinguishable from a real opt-in that is simply switched off. See the troubleshooting table below.
1413
+
1414
+ ### Secrets are not available
1415
+
1416
+ A global workflow's job is dispatched with **no secret material** — not the source repo's, and not the workflow repo's own. The organization-wide dispatch path binds no secret contexts, so a `contexts:` declaration on a global workflow resolves to nothing and any secret the steps expect is simply absent. Plan for it: a global workflow is for checks, policy and reporting that need only the two checkouts, not for deploys that need credentials.
1417
+
1418
+ This is about your **stored secrets**, not about repository access: the job is still handed a short-lived clone token for each repo it checks out, which is how the dual checkout works at all. What it does not get is anything from a secret context.
1419
+
1420
+ To run something that needs secrets on a source repo's event, put those jobs in a per-repository workflow in that repo, where the workflow's `contexts:` resolve normally.
1421
+
1422
+ The **Elevated access** setting reads as the way to lift this, and it is not: it is **deprecated and never consulted**. Nothing in the dispatch path reads the list, and adding a repo to it does not make any secret readable. It is kept only so an existing value stays visible and clearable, and is removed at the next major version — see [Deprecations](https://docs.kici.dev/user/deprecations/).
1423
+
1424
+ ## When does it fire?
1425
+
1426
+ Same-repo globals (a workflow in `myorg/app` with `repos: ['myorg/app']`) fire on pushes to `myorg/app`. Cross-repo globals fire on pushes to any source repo whose identifier matches a glob on the authoring workflow's trigger. The orchestrator de-duplicates between the per-repo and cross-repo matching passes, so a single event produces at most one run per (workflow, source-repo, trigger) triple.
1427
+
1428
+ Non-push triggers work too — `pr()`, `tag()`, `comment()`, `release()`, `workflowRun()`, etc. all accept `repos:`. `kiciEvent()` / `schedule()` / cron-like triggers have no source repo, so they're always per-org-registered regardless of `repos:`.
1429
+
1430
+ A global workflow that declares a `filter` or a job generator is decided by one **evaluation job per (event × workflow repo)**, dispatched before any run exists. That job checks out both repos once and evaluates every candidate workflow from that repo, so ten global workflows in one CI repo cost one evaluation, not ten.
1431
+
1432
+ When that evaluation cannot reach a verdict — it fails, breaches its budget, or never reports — the workflows it was deciding on **do not run**. On a provider that supports commit checks, that posts a `failure` check named **`KiCI: Organization workflow evaluation`** on the source commit, so the outcome is visible instead of silent. Three things to know about it:
1433
+
1434
+ - The check is posted whether the evaluation failed **outright** or only **partly**. A per-workflow budget breach, or a `filter` that throws, leaves that one workflow undecided while its neighbours from the same repo are decided and run normally; the check then names only the undecided ones. So a broken `filter` is reported the same way whether or not other global workflows happen to share its repo.
1435
+ - Branch protection that lists required checks by name is unaffected, because the check is not on that list. Merge automation that requires _every_ check to be green will block on it.
1436
+ - **Re-run the failed evaluation to clear the check.** A failed evaluation is recorded as one errored run named `__globaleval__<owner>/<workflow-repo>`. Fix the cause, then re-run that run — `kici runs rerun <run-id>`, or the **Re-run** button on the run in the dashboard. The re-run re-evaluates the original event against the workflow repo's current state, dispatches whatever it now admits, and posts a `success` check under the same name on the same commit. The request is **accepted immediately**; the evaluation itself is a job on an agent and runs after the answer, exactly as it does for the push that first triggered it. So watch the run and the check for the outcome, not the response. The check clears only when the re-evaluation reaches a verdict: if it fails again, or the orchestrator cannot run it, the `failure` check stands. A provider redelivery of the same webhook will not do this: it is dropped as a duplicate. Pushing a new commit also works, and is what you need when the payload of the original delivery is no longer stored.
1437
+ - **Two failed evaluations on one commit share the check.** The check name carries no repo, so if two workflow repos both fail on the same push, re-running one of them posts `success` over the other's `failure`. The success summary names the workflow repo it re-evaluated; re-run the other round too.
1438
+
1439
+ ## Approval gates are not supported
1440
+
1441
+ A global workflow cannot carry an `approval` gate, at the workflow level or on a job. Approval holds are applied by the per-repository dispatch path; the global path dispatches its jobs without consulting one, so a gate declared here would never be enforced. `kici compile` refuses it with `error [E124]` rather than accepting a security control the workflow does not actually have. A job produced by a `dynamicJob` generator never passes through the compiler, so that case is caught at dispatch instead — the orchestrator logs an error naming the workflow and job, and runs it ungated.
1442
+
1443
+ To gate a deployment behind a human, put the gated jobs in a workflow whose triggers carry no `repos:`.
1444
+
1445
+ ## Re-running an organization-wide run
1446
+
1447
+ An organization-wide run that executed against another repository cannot be re-run from that repository. This is a permanent authorization boundary, not a limitation.
1448
+
1449
+ The re-run path resolves a workflow out of the repo the run acted on. For an organization-wide run that is the **source** repo, not the workflow repo that declares it. So a re-run from the source repo would re-execute the defining repo's code without the defining repo's policy pass. If the source repo carries a workflow of the same name, the re-run would run that workflow instead — with the source repo's credentials and none of the organization-wide job configuration.
1450
+
1451
+ Two tiers refuse it: your orchestrator, and the hosted Platform on every path that exposes re-run. Each refusal names both repos.
1452
+
1453
+ That refusal is what makes the run visible to both teams. A member scoped to **either** repo reads and cancels the run. Neither team can re-execute the other's code.
1454
+
1455
+ To run it again, trigger it from the repo that defines the workflow. You can also push a new commit to the source repo; a provider redelivery of the same event is dropped as a duplicate.
1456
+
1457
+ A failed organization-workflow **evaluation** is the exception. Re-running one re-evaluates the original event instead of resolving a workflow, so the substitution above cannot happen. See [When does it fire?](https://docs.kici.dev/user/global-workflows/#when-does-it-fire).
1458
+
1459
+ ## Notifications
1460
+
1461
+ An organization-wide run belongs to two repositories: the one it executed against, and the one that defines the workflow. A notification subscription's repository filter matches on either. So a subscription scoped to the defining repo hears about every organization-wide run of its own workflows, even though those runs execute against other repositories.
1462
+
1463
+ This is the same either-repo rule the run history uses, so the runs a team sees in the dashboard are the runs it is notified about.
1464
+
1465
+ ## Requirements a filter places on the run
1466
+
1467
+ A `filter` reads the source tree, so the evaluation must be able to obtain one. A job that restores its workflow source from the cache and has no source repository to clone from fails with an explicit error rather than evaluating the filter against an empty tree. This applies to dispatch paths that run without a source repository configured — a filter and such a path are mutually exclusive; drop one or the other.
1468
+
1469
+ ## Troubleshooting
1470
+
1471
+ | Symptom | Likely cause | Where to look |
1472
+ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1473
+ | Global workflow registered but never runs | Master toggle OFF, or allow-list blocks the authoring repo, or deny-list blocks the source repo | Orchestrator log: `Skipping global workflow dispatch` (dispatch time) / `Global workflows excluded from registration` (registration time) |
1474
+ | A global workflow is never registered at all — it is absent from `kici-admin registration list` | The push that should have registered it resolved to the `__default__` organization anchor, because no webhook source maps its routing key to an organization, or the fleet-wide master switch is off. | Orchestrator log: `Global workflows excluded from registration` with `"orgId": "__default__"`. Its `remedy` field names both fixes — map the source (`kici-admin source update <routingKey> --customer-id <org>`) and enable global workflows cluster-wide if it is not already (`kici-admin cluster-settings set --global-workflows-enabled true`). |
1475
+ | `repos:` has no effect — workflow only fires on its own repo | The fleet-wide master switch is off. Without it, the orchestrator treats the workflow as per-repo-only. | Check the fleet-wide switch with `kici-admin cluster-settings show`. The dashboard → Settings → Global workflows tab shows it as a read-only badge. |
1476
+ | Secrets unavailable in a global job | Expected — a global workflow's job receives no secrets at all, and the _Elevated access_ list is not enforced. | Move the jobs that need credentials into a per-repository workflow in the repo that owns the secrets |
1477
+ | Dashboard shows workflow twice after registering | Both a generic webhook source and a provider source (github, generic) re-registered the same repo. | Check `workflow_registrations` via `kici-admin workflow list` and confirm the right routing key owns the workflow. |
1478
+ | Global workflow registered, enabled, allowed — and still no run appears | Its `filter` returned `false`. A global filter runs before the run is created, so a suppressed workflow leaves nothing behind at all. | [Reading a global workflow's filter output](https://docs.kici.dev/user/global-workflows/#reading-a-global-workflows-filter-output) — the evaluation round's own log. The orchestrator also logs `Global workflow skipped by eval round`, naming the workflow and the reason. |
1479
+ | Global workflow never fires for one particular source repo | Its `repos:` patterns do not match that repo's identifier. | Orchestrator log: `Global workflows dropped by their repos filter` — one line per delivery, naming each dropped workflow, its repo and its patterns. |
1480
+ | A `failure` check named `KiCI: Organization workflow evaluation` on a commit | The pre-run evaluation failed or timed out, so the global workflows from that repo were not run. | Orchestrator log for the evaluation job. Fix the cause, then re-run the errored `__globaleval__…` run (`kici runs rerun <run-id>`) to re-evaluate and clear the check; a redelivery is dropped as a duplicate. |
1481
+ | Same-repo workflow shows a `success` run with no jobs in it | Its `filter` returned `false`. A same-repo filter runs after the run exists, so the run remains, carrying only the evaluation jobs. | The run detail page — the evaluation job's log records the filter verdict. |
1482
+ | Re-run is refused with "Cannot re-run an organization-wide workflow" | Expected — the run executed against a source repo that does not declare the workflow. | [Re-running an organization-wide run](https://docs.kici.dev/user/global-workflows/#re-running-an-organization-wide-run) — trigger it from the repo that defines the workflow instead. |
1483
+ | Every global workflow stopped running right after an orchestrator upgrade | The agents were not upgraded first. An agent older than v0.5.0 cannot evaluate a global workflow, and one containing a `dynamicJob` now needs an evaluation even without a `filter` — so its **static** jobs stop too. | The `KiCI: Organization workflow evaluation` check names the agent versions it found. Upgrade every `kici:role:init-runner` agent to v0.5.0 or newer. |
1484
+
1485
+ ### Reading the decision trace for a delivery
1486
+
1487
+ The dashboard records why each workflow did or did not fire. Open
1488
+ **Settings → Event log**, select the delivery, and read the **Workflow
1489
+ decisions** section. It lists every workflow the delivery was evaluated
1490
+ against — per-repository and organization-wide alike.
1491
+
1492
+ Each entry expands to the individual checks the trigger evaluation performed:
1493
+ the check, the pattern, the value tested against it, and whether it passed. An
1494
+ organization-wide workflow is named with the repository that defines it, so you
1495
+ can find your own workflow even though it is absent from the source
1496
+ repository's lock file.
1497
+
1498
+ This is the first place to look when a workflow does not fire. A failed `repo`
1499
+ check means the `repos:` patterns do not match the source repository. A failed
1500
+ `filter` check means the evaluation round excluded the workflow.
1501
+
1502
+ The value a check tested and the reason it gives quote the webhook body, so
1503
+ those two fields need the `event_log:read_payload` permission. Without it the
1504
+ row still names the check, the pattern, and whether it passed.
1505
+
1506
+ The orchestrator records the trace when trigger matching runs. A delivery the
1507
+ Platform rejected at the relay therefore has none.
1508
+
1509
+ ### Reading a global run in the dashboard
1510
+
1511
+ A global run is attributed to the **source** repo — the repo whose event
1512
+ triggered it, and whose code the jobs check out. Its run detail page names both
1513
+ repos, so you can tell it apart from an ordinary per-repo run:
1514
+
1515
+ | Row | Shows |
1516
+ | ------------ | ------------------------------------------------------------------------ |
1517
+ | `Repository` | the source repo — the one the run acted on |
1518
+ | `Defined in` | the workflow repo, tagged `Organization-wide`. Absent on an ordinary run |
1519
+ | `Workflow` | links into the **workflow** repo, on its default branch |
1520
+
1521
+ The `Workflow` link points at the workflow repo's default branch rather than at
1522
+ a commit: the run's own commit belongs to the source repo, and nothing records
1523
+ which commit of the workflow repo a given run used. So the link always shows the
1524
+ file as it stands now, which may have changed since the run.
1525
+
1526
+ The `Payload` tab shows the source repo's event — the webhook delivery the
1527
+ workflow reacted to, which for a global workflow comes from a repo you may not
1528
+ own. A global run dispatched before your orchestrator stored payloads for this
1529
+ path has none, and its tab reports that it could not load one.
1530
+
1531
+ #### Who can see it
1532
+
1533
+ A global run belongs to **both** repos, so a member whose role is scoped to
1534
+ either one reaches it — the team whose push triggered it, and the team that
1535
+ authored the workflow. Both see it in the run list, in the repository filter
1536
+ (which offers both names), and on the run detail page. Cancelling follows the
1537
+ same rule, so the team whose workflow is running can always stop it.
1538
+
1539
+ Releasing a **held** run is the one exception: approving a hold permits code to
1540
+ run against the source repo, so it stays with a member scoped to that repo. A
1541
+ member scoped only to the workflow repo sees the run but not its hold.
1542
+
1543
+ This applies only where the two repos genuinely differ. An ordinary per-repo run
1544
+ records no separate workflow repo and is scoped to its own repo exactly as
1545
+ before, and a member scoped to neither repo sees nothing in either case.
1546
+
1547
+ ### Reading a global workflow's filter output
1548
+
1549
+ A global workflow's `filter` runs in a pre-run evaluation round, and that round
1550
+ decides whether a run exists at all — so on the path where it suppresses a
1551
+ workflow there is no run, and nothing appears in the dashboard. The round's own
1552
+ log is still recorded. Read it with the orchestrator admin CLI, in two steps:
1553
+
1554
+ ```bash
1555
+ # 1. Find the round. Its workflow name is __globaleval__<owner>/<repo> of the
1556
+ # WORKFLOW repo. In the JSON rows, `id` is the job id and `run_id` is the
1557
+ # run id.
1558
+ kici-admin queue list --workflow-name '__globaleval__myorg/ci-pipelines' --limit 5 --json
1559
+
1560
+ # 2. Print the round's log (step 0 is the evaluation itself).
1561
+ kici-admin runs logs <run_id> --job <id>
1562
+ ```
1563
+
1564
+ Use `--json` on the first command: the plain table abbreviates both ids to their
1565
+ first eight characters, and the second command needs them in full.
1566
+
1567
+ The two steps need different permissions, so run both with an **owner or admin**
1568
+ token. Step 1 reads the dispatch queue, which requires `secret.read` — an auditor
1569
+ token is refused with a 403 and never reaches step 2. Step 2 requires only
1570
+ `run.read`, which every role carries.
1571
+
1572
+ Anything your `filter` writes with `console.log` appears there, alongside the
1573
+ per-candidate verdicts the round recorded.
1574
+
1575
+ ## See also
1576
+
1577
+ - [Architecture — global workflows](https://docs.kici.dev/architecture/global-workflows/) — dual-query dispatch flow, cross-provider auth, security model, lock-file schema.
1578
+ - [Universal-git provider](https://docs.kici.dev/user/providers/universal-git/#global-workflows) — how global workflows interact with `generic:<orgId>:<sourceId>` routing keys.
1579
+ - [SDK reference](https://docs.kici.dev/user/sdk-reference/) — the full set of triggers that accept `repos:`.
1580
+
1581
+ ---
1582
+
1583
+ ## Idempotent steps and check mode
1584
+
1585
+ Source: https://docs.kici.dev/user/idempotent-steps/
1586
+
1587
+ An **idempotent step** describes _desired state_ rather than a fixed sequence of
1588
+ commands. You give the step a `check` function that inspects the world and a
1589
+ `run` function that converges it. KiCI then executes the workflow in one of two
1590
+ modes:
1591
+
1592
+ - **Apply mode** (the default): for each step, `check()` runs first; on drift the
1593
+ step applies the change; when already in sync the step is skipped.
1594
+ - **Check mode** (`--check`): for each step, `check()` runs and KiCI reports what
1595
+ _would_ change — **without changing anything**. This is the same model as a
1596
+ dry-run plan: you see the drift before any side effect happens.
1597
+
1598
+ This turns a workflow into convergent configuration management: re-running an
1599
+ apply is safe (in-sync steps do nothing), and a check-mode run is a read-only
1600
+ preview you can gate a build on.
1601
+
1602
+ ## Authoring a checked step
1603
+
1604
+ Add a `check` facet to the existing `step()` factory. When `check` is present,
1605
+ `run` becomes the _apply_ function and receives the drift value `check`
1606
+ returned:
1607
+
1608
+ ```typescript
1609
+ import { step, z } from '@kici-dev/sdk';
1610
+
1611
+ const configureNginx = step('configure-nginx', {
1612
+ // optional schema for the drift value — gives the dashboard a typed shape
1613
+ drift: z.object({ want: z.string() }),
1614
+
1615
+ // read-only inspection; return null when already in the desired state
1616
+ check: async (ctx) => {
1617
+ const current = await ctx.$`nginx -T`;
1618
+ return current.stdout.includes(DESIRED) ? null : { want: DESIRED };
1619
+ },
1620
+
1621
+ // human-readable preview line — REQUIRED when check is set. It is the drift's
1622
+ // serializable face: it streams to the logs and persists for the dashboard.
1623
+ summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
1624
+
1625
+ // apply — runs only when check returned drift (apply mode); receives that drift
1626
+ run: async (ctx, drift) => {
1627
+ await writeConfig(drift.want);
1628
+ return { reloaded: true };
1629
+ },
1630
+
1631
+ // optional — runs when check returned null, to produce the step's outputs
1632
+ whenInSync: async () => ({ reloaded: false }),
1633
+ });
1634
+ ```
1635
+
1636
+ ### The facet fields
1637
+
1638
+ | Field | Required | Purpose |
1639
+ | ------------ | ---------------- | ---------------------------------------------------------------------- |
1640
+ | `check` | to opt in | Read-only inspection. Return a drift value, or `null` when in sync. |
1641
+ | `summarize` | when `check` set | Human-readable, serializable preview of the drift. Streams + persists. |
1642
+ | `run` | always | Apply function. With `check`, it receives the drift as its second arg. |
1643
+ | `whenInSync` | optional | Produces the step's outputs when `check` returned `null`. |
1644
+ | `drift` | optional | Schema that validates / shapes the drift value. |
1645
+
1646
+ `summarize` is **required** whenever `check` is declared. `run` and `whenInSync`
1647
+ both produce the same output type — one output shape per step, whichever path
1648
+ runs. Every other step facet (`cache`, `rules`, `continueOnError`, `timeout`,
1649
+ `retry`, `approval`, `onCancel`, `cleanup`, `outputs`) composes unchanged.
1650
+
1651
+ A plain `step()` without `check` keeps its exact current behavior — the check
1652
+ facet is fully optional.
1653
+
1654
+ ## Run modes
1655
+
1656
+ A run carries one of three modes:
1657
+
1658
+ | Mode | CLI flags | Behavior |
1659
+ | --------------------- | ------------------------- | ------------------------------------------------------------------------------------------ |
1660
+ | `apply` | (default, no flags) | Converge: drift ⇒ apply ⇒ **applied**; null ⇒ **in sync** (skipped). |
1661
+ | `check` | `--check` | Preview only: drift ⇒ **would change**; null ⇒ **in sync**. Never applies. Always exits 0. |
1662
+ | `check-fail-on-drift` | `--check --fail-on-drift` | Same as check, but the run **fails** if any step reports drift. |
1663
+
1664
+ Per-step outcomes:
1665
+
1666
+ - **applied** — drift was found and the step applied the change (apply mode).
1667
+ - **in sync** — `check` returned `null`; nothing to do.
1668
+ - **would change** — drift was found in check mode; the change was previewed, not applied.
1669
+ - **no check** — a plain step (no `check`) reached under check mode. A
1670
+ side-effecting step can't be safely previewed, so it is skipped.
1671
+
1672
+ In check mode KiCI never invokes a checked step's `run` (apply) — the preview is
1673
+ guaranteed side-effect-free.
1674
+
1675
+ ## Running in check mode
1676
+
1677
+ `--check` and `--fail-on-drift` control drift reporting on `kici run remote`:
1678
+
1679
+ ```bash
1680
+ # Apply (default): converge the workflow.
1681
+ kici run push --local
1682
+ kici run remote my-fixture
1683
+
1684
+ # Check: report drift, change nothing. Always exits 0.
1685
+ kici run remote my-fixture --check
1686
+
1687
+ # Check + fail on drift: fail the run when any step reports drift. Use this as a
1688
+ # CI gate ("fail the build if prod has drifted").
1689
+ kici run remote my-fixture --check --fail-on-drift
1690
+ ```
1691
+
1692
+ `--fail-on-drift` only modifies check mode — passing it without `--check` is an
1693
+ error.
1694
+
1695
+ ## Where outcomes show up
1696
+
1697
+ A check-mode run is labeled in the dashboard with a **CHECK MODE — preview**
1698
+ badge on the run header. Each step shows its outcome chip — applied / in sync /
1699
+ would change / no check — and, when drift was detected, the `summarize` line
1700
+ describing what would change. The rendering is read-only.
1701
+
1702
+ ## See also
1703
+
1704
+ - [Idempotent SDK helpers](https://docs.kici.dev/user/sdk/idempotent/) — the `idempotent()` / `idempotentStep()` convenience wrappers (always apply on drift), plus `checkStep()`, the clean-shape sibling that respects the run-level check mode.
1705
+ - [Core SDK reference](https://docs.kici.dev/user/sdk/core/) — the `step()`, `job()`, and `workflow()` factories the check facet extends.
1706
+ - [Lock file and drift](https://docs.kici.dev/user/lock-file-and-drift/) — how the lock file carries step capability flags.
1707
+
1708
+ ---
1709
+
1710
+ ## Autoscaling workflows
1711
+
1712
+ Source: https://docs.kici.dev/user/workflows/autoscaling-workflows/
1713
+
1714
+ The [event scaler backend](https://docs.kici.dev/operator/orchestrator/event-scaler/) turns cloud autoscaling into ordinary workflow authoring. When the orchestrator needs a new agent, the event scaler emits a `kici.scaler.scale-up` event. When an agent is no longer needed, it emits a `kici.scaler.scale-down` event. You write two workflows: one that boots a cloud instance on scale-up, and one that deletes it on scale-down.
1715
+
1716
+ No cloud SDK ships inside KiCI. Your workflow calls the cloud provider's API directly. The examples below target Hetzner Cloud, but the same shape fits any provider with a create/delete API.
1717
+
1718
+ This page assumes you know the [`kiciEvent()`](https://docs.kici.dev/user/sdk/triggers/) trigger and [custom events](https://docs.kici.dev/user/events/). For the full event payloads, see the [event contract reference](https://docs.kici.dev/operator/orchestrator/event-scaler-events/).
1719
+
1720
+ The SDK exports the two event names and their payload schemas, so you subscribe with the same constant the scaler emits and parse the payload instead of casting it. Import `SCALER_EVENT_NAMES`, `ScalerScaleUpPayload`, `ScalerScaleDownPayload` and `ScaleDownReason` from `@kici-dev/sdk` — see [validation and events](https://docs.kici.dev/user/sdk/validation-events/#event-scaler-events).
1721
+
1722
+ ## The provisioning workflow
1723
+
1724
+ The provisioning workflow subscribes to `kici.scaler.scale-up` and matches on the scaler name. It reads the payload from `ctx.rawPayload`, forwards the single-use claim code into a cloud instance, and boots that instance. The agent claims its own token in-instance and registers with the given `agentId`.
1725
+
1726
+ ```ts
1727
+ import {
1728
+ workflow,
1729
+ job,
1730
+ kiciEvent,
1731
+ buildAgentCloudInit,
1732
+ SCALER_EVENT_NAMES,
1733
+ ScalerScaleUpPayload,
1734
+ } from '@kici-dev/sdk';
1735
+
1736
+ const SCALER_NAME = 'hetzner';
1737
+
1738
+ export default workflow('hetzner-autoscale-provision', {
1739
+ on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleUp, match: { '$.scalerName': SCALER_NAME } })],
1740
+ jobs: [
1741
+ job('provision', {
1742
+ runsOn: ['default'],
1743
+ // Bind the context that holds the credential this job reads. A job
1744
+ // resolves only the secrets of the contexts it binds.
1745
+ context: 'hetzner-autoscale',
1746
+ run: async (ctx) => {
1747
+ const payload = ScalerScaleUpPayload.parse(ctx.rawPayload);
1748
+
1749
+ // Forward the single-use claim code into cloud-init. The agent claims
1750
+ // its own token in-instance, so the token never transits provisioning.
1751
+ const userData = buildAgentCloudInit(
1752
+ {
1753
+ claimCode: payload.claimCode,
1754
+ agentId: payload.agentId,
1755
+ orchestratorUrl: payload.orchestratorUrl,
1756
+ labels: payload.labels,
1757
+ },
1758
+ {
1759
+ maxLifetimeMinutes: 30,
1760
+ deliveryMode: 'container',
1761
+ },
1762
+ );
1763
+
1764
+ const token = await ctx.secrets.get('HETZNER_API_TOKEN');
1765
+ const res = await fetch('https://api.hetzner.cloud/v1/servers', {
1766
+ method: 'POST',
1767
+ headers: {
1768
+ Authorization: `Bearer ${token}`,
1769
+ 'Content-Type': 'application/json',
1770
+ },
1771
+ body: JSON.stringify({
1772
+ name: `kici-agent-${payload.agentId}`,
1773
+ server_type: 'cpx12',
1774
+ image: 'debian-12',
1775
+ user_data: userData,
1776
+ // Every teardown layer keys off these labels.
1777
+ labels: {
1778
+ 'kici-managed': 'hetzner-autoscale',
1779
+ 'kici-agent-id': payload.agentId,
1780
+ 'kici-scaler': SCALER_NAME,
1781
+ },
1782
+ }),
1783
+ });
1784
+ if (!res.ok) throw new Error(`Hetzner create failed: ${res.status}`);
1785
+ ctx.log.info(`Provisioned instance for agent ${payload.agentId}`);
1786
+ },
1787
+ }),
1788
+ ],
1789
+ });
1790
+ ```
1791
+
1792
+ Read the [event contract reference](https://docs.kici.dev/operator/orchestrator/event-scaler-events/) for every payload field. The `agentId` correlates the spawn, so the instance must register with exactly that id.
1793
+
1794
+ ### The bound context gates the scale-up
1795
+
1796
+ A provisioning workflow is an [event-triggered run](https://docs.kici.dev/user/events/#what-an-event-triggered-run-resolves), so it obeys every protection rule on the [context](https://docs.kici.dev/user/contexts/#protection-rules) it binds. That is what makes the cloud credential resolve, and it also means an approval hold stops the scale-up.
1797
+
1798
+ Read the chain, because the symptom sits far from the cause:
1799
+
1800
+ 1. The scaler emits `kici.scaler.scale-up` and reserves a claim for the new agent.
1801
+ 2. The provisioning workflow matches, and its context holds the run for approval.
1802
+ 3. No instance boots, so no agent registers against the claim.
1803
+ 4. The event-provision reaper reaps the stranded claim once it expires.
1804
+ 5. The queued jobs wait, and you see agents that never appear.
1805
+
1806
+ Nothing in that chain reports "waiting for an approval" at the fleet level. Check the [approval queue](https://docs.kici.dev/user/dashboard/contexts-and-secrets/#approval-queue): it lists the held run, names the context that holds it, and gives the reason. The run-detail page shows the same hold under its approval block, and `kici runs show <run-id>` prints it from the terminal.
1807
+
1808
+ A provisioning workflow also needs an agent labelled `kici:role:builder` to pack its source before its own job runs. Keep at least one builder-role agent outside the pool the scaler provisions, so a scale-up never waits on the fleet it is scaling.
1809
+
1810
+ A [branch restriction](https://docs.kici.dev/user/contexts/#branch-restrictions) rejects the run outright. The orchestrator mints a scaler event itself, with no run behind it, so a scale-up carries no branch for a pattern to match.
1811
+
1812
+ Bind provisioning and teardown workflows to a context that carries the cloud credential and **no** approval hold, no wait timer, no branch restriction, and no `minimumTrust` gate. Gate a human-triggered deploy workflow instead. Keep the credential in that ungated context narrow: the scaler's own token, scoped to create and delete instances, and nothing else.
1813
+
1814
+ ## The cloud-init that starts the agent
1815
+
1816
+ `buildAgentCloudInit(creds, options)` renders the `#cloud-config` that boots the KiCI agent. In the claim-code form it writes the single-use claim code — never a token — into a root-only env file (`0600`, owned by root). The agent exchanges that code for its own token inside the instance, so the token never transits cloud-init, the instance metadata, or any other provisioning channel. The env file holds:
1817
+
1818
+ - `KICI_ORCHESTRATOR_URL` — from `creds.orchestratorUrl`.
1819
+ - `KICI_SCALER_CLAIM_CODE` — from `creds.claimCode`. The agent exchanges it for its own token in-instance.
1820
+ - `KICI_AGENT_ID` — from `creds.agentId`.
1821
+ - `KICI_LABELS` — from `creds.labels`, comma-joined.
1822
+ - `KICI_SCALER_MANAGED=1` — marks the agent as scaler-managed, so it self-drains on idle and shutdown.
1823
+
1824
+ The `0600` env file still protects the non-secret env from other users on the instance. The claim code it carries is single-use and short-lived, so even that value is spent the moment the agent claims its token.
1825
+
1826
+ `maxLifetimeMinutes` is the one required option. It adds a max-lifetime self-poweroff (teardown layer L2): an instance that never receives a scale-down still removes itself after a hard cap. `deliveryMode` selects how the agent binary arrives — `'container'` runs the published agent image, `'payload'` fetches it from the orchestrator.
1827
+
1828
+ ### Customization axes
1829
+
1830
+ Pass any of these options to shape the boot:
1831
+
1832
+ - `packages` — extra apt/yum packages, merged into the cloud-init `packages:` list.
1833
+ - `writeFiles` — extra `write_files` entries (path, content, permissions, owner). The reserved env-file path is rejected, so a custom file cannot overwrite the credentials.
1834
+ - `runcmdBefore` / `runcmdAfter` — shell lines that run before or after the agent starts.
1835
+ - `agentEnv` — extra variables appended to the agent env file. Keys must be valid env names, and a value with a newline is rejected.
1836
+ - `baseCloudConfig` — a raw cloud-config document to merge everything into (users, ssh keys, apt mirrors, mounts, bootcmd). The builder unions its `packages`, `runcmd`, and `write_files` with yours.
1837
+
1838
+ ## The teardown workflow
1839
+
1840
+ The teardown workflow subscribes to `kici.scaler.scale-down` and deletes the instance registered under the scaled-down `agentId`. It finds the instance by the `kici-agent-id` label that the provisioning workflow set.
1841
+
1842
+ ```ts
1843
+ import {
1844
+ workflow,
1845
+ job,
1846
+ kiciEvent,
1847
+ SCALER_EVENT_NAMES,
1848
+ ScalerScaleDownPayload,
1849
+ } from '@kici-dev/sdk';
1850
+
1851
+ const SCALER_NAME = 'hetzner';
1852
+
1853
+ export default workflow('hetzner-autoscale-teardown', {
1854
+ on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleDown, match: { '$.scalerName': SCALER_NAME } })],
1855
+ jobs: [
1856
+ job('teardown', {
1857
+ runsOn: ['default'],
1858
+ context: 'hetzner-autoscale',
1859
+ run: async (ctx) => {
1860
+ const payload = ScalerScaleDownPayload.parse(ctx.rawPayload);
1861
+ const token = await ctx.secrets.get('HETZNER_API_TOKEN');
1862
+
1863
+ const list = await fetch(
1864
+ `https://api.hetzner.cloud/v1/servers?label_selector=kici-agent-id==${payload.agentId}`,
1865
+ { headers: { Authorization: `Bearer ${token}` } },
1866
+ );
1867
+ const { servers } = (await list.json()) as { servers: Array<{ id: number }> };
1868
+ if (servers.length === 0) {
1869
+ ctx.log.info(`No instance found for agent ${payload.agentId}; nothing to tear down`);
1870
+ return;
1871
+ }
1872
+ for (const server of servers) {
1873
+ await fetch(`https://api.hetzner.cloud/v1/servers/${server.id}`, {
1874
+ method: 'DELETE',
1875
+ headers: { Authorization: `Bearer ${token}` },
1876
+ });
1877
+ ctx.log.info(`Deleted instance ${server.id} for agent ${payload.agentId}`);
1878
+ }
1879
+ },
1880
+ }),
1881
+ ],
1882
+ });
1883
+ ```
1884
+
1885
+ Keep the teardown idempotent. "None found" logs and succeeds, and a delete that returns "already gone" is not an error. A scale-down can arrive after the instance already removed itself through the self-poweroff backstop.
1886
+
1887
+ ## Guaranteed teardown
1888
+
1889
+ The scale-down workflow is the primary teardown path, but it is not the only one. The reference Hetzner implementation guarantees teardown with five independent layers, keyed off the resource labels every instance carries. The host-side reaper is the backstop that survives a crash or reboot. See the [teardown reaper runbook](https://docs.kici.dev/operator/orchestrator/hetzner-autoscale-reaper/) for the full model and the recommended alert.
1890
+
1891
+ ## Running on other clouds (AWS / GCP / Azure)
1892
+
1893
+ The architecture is cloud-agnostic. Nothing inside KiCI is provider-specific. Only two things change per cloud: the API calls in your provision and teardown workflows, and the host-side reaper (teardown layer L4). You still subscribe to the same `kici.scaler.scale-up` / `kici.scaler.scale-down` events, and the agent still self-claims from the forwarded claim code the same way on every cloud, through the same `buildAgentCloudInit` call.
1894
+
1895
+ The five teardown layers map to each cloud's own idiom:
1896
+
1897
+ | Layer | Hetzner | AWS | Azure | GCP |
1898
+ | ---------------------------------- | -------------------------- | --------------------------- | ----------------------------------- | ------------------------ |
1899
+ | **L1 — Scale-down workflow** | same event-driven workflow | same | same | same |
1900
+ | **L2 — In-instance self-poweroff** | same cloud-init | same cloud-init | same cloud-init | same cloud-init |
1901
+ | **L3 — Harness finalizer** | same | same | same | same |
1902
+ | **L4 — Out-of-band reaper** | host systemd timer | tag-scoped scheduled Lambda | auto-shutdown or scheduled Function | scheduled Cloud Function |
1903
+ | **L5 — Pre-suite sweep** | same | same | same | same |
1904
+
1905
+ Only L4 has a real per-cloud shape, because it runs outside your instances and outside KiCI. Everything else is identical across providers.
1906
+
1907
+ ### AWS provision workflow shape
1908
+
1909
+ The provision workflow keeps the same structure. It swaps the cloud call for the AWS EC2 SDK, and encodes the cloud-init as base64 because AWS `UserData` expects base64 (Azure `customData` wants base64 too). Read the AWS credentials from `ctx.secrets` and tag every instance so each teardown layer can find it.
1910
+
1911
+ ```ts
1912
+ import {
1913
+ workflow,
1914
+ job,
1915
+ kiciEvent,
1916
+ buildAgentCloudInit,
1917
+ SCALER_EVENT_NAMES,
1918
+ ScalerScaleUpPayload,
1919
+ } from '@kici-dev/sdk';
1920
+ import { EC2Client, RunInstancesCommand, ResourceType } from '@aws-sdk/client-ec2';
1921
+
1922
+ const SCALER_NAME = 'aws';
1923
+
1924
+ export default workflow('aws-autoscale-provision', {
1925
+ on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleUp, match: { '$.scalerName': SCALER_NAME } })],
1926
+ jobs: [
1927
+ job('provision', {
1928
+ runsOn: ['default'],
1929
+ context: 'aws-autoscale',
1930
+ run: async (ctx) => {
1931
+ const payload = ScalerScaleUpPayload.parse(ctx.rawPayload);
1932
+
1933
+ // Forward the claim code; the agent self-claims its token in-instance.
1934
+ // AWS UserData expects base64. Azure customData does too.
1935
+ const userData = buildAgentCloudInit(
1936
+ {
1937
+ claimCode: payload.claimCode,
1938
+ agentId: payload.agentId,
1939
+ orchestratorUrl: payload.orchestratorUrl,
1940
+ labels: payload.labels,
1941
+ },
1942
+ {
1943
+ maxLifetimeMinutes: 30,
1944
+ deliveryMode: 'container',
1945
+ userDataEncoding: 'base64',
1946
+ },
1947
+ );
1948
+
1949
+ const client = new EC2Client({
1950
+ region: 'us-east-1',
1951
+ credentials: {
1952
+ accessKeyId: await ctx.secrets.get('AWS_ACCESS_KEY_ID'),
1953
+ secretAccessKey: await ctx.secrets.get('AWS_SECRET_ACCESS_KEY'),
1954
+ },
1955
+ });
1956
+
1957
+ await client.send(
1958
+ new RunInstancesCommand({
1959
+ ImageId: 'ami-00000000000000000', // customer-supplied AMI with docker
1960
+ InstanceType: 't3.micro',
1961
+ MinCount: 1,
1962
+ MaxCount: 1,
1963
+ UserData: userData,
1964
+ // Every teardown layer keys off these tags.
1965
+ TagSpecifications: [
1966
+ {
1967
+ ResourceType: ResourceType.instance,
1968
+ Tags: [
1969
+ { Key: 'kici-managed', Value: 'aws-autoscale' },
1970
+ { Key: 'kici-agent-id', Value: payload.agentId },
1971
+ { Key: 'kici-scaler', Value: SCALER_NAME },
1972
+ ],
1973
+ },
1974
+ ],
1975
+ }),
1976
+ );
1977
+ ctx.log.info(`Provisioned EC2 instance for agent ${payload.agentId}`);
1978
+ },
1979
+ }),
1980
+ ],
1981
+ });
1982
+ ```
1983
+
1984
+ The teardown workflow mirrors this: it runs `DescribeInstances` filtered by the `kici-agent-id` tag, then `TerminateInstances` on the matches. "None found" logs and succeeds.
1985
+
1986
+ The AWS reference lives at `e2e/fixtures/aws-autoscale/`. It is compiled and typechecked against the AWS EC2 SDK, but it is not run against real AWS — unlike the Hetzner reference, which has a real-cloud E2E. Adapt the AMI, instance type, subnet, and IAM instance profile for your account.
1987
+
1988
+ ## GitHub Actions runners
1989
+
1990
+ A provisioning workflow does not have to boot a cloud VM. Instead of a create/delete API, it can dispatch a GitHub Actions run that boots a one-shot agent. The agent self-claims from the forwarded claim code, registers with the orchestrator, runs exactly one job, and exits.
1991
+
1992
+ The scaler entry names the repo that holds the provisioning and teardown workflows, exactly as any other event backend does:
1993
+
1994
+ ```yaml
1995
+ scalers:
1996
+ - name: github-actions
1997
+ type: event
1998
+ maxAgents: 20
1999
+ provisioningTargets:
2000
+ - myorg/infra
2001
+ labelSets:
2002
+ - labels: [github-actions]
2003
+ ```
2004
+
2005
+ On `kici.scaler.scale-up`, the provisioning workflow dispatches a `kici-agent.yml` workflow run in a GitHub repo. It passes the claim code, orchestrator URL, agent id, and labels as dispatch inputs. The token never appears in those inputs — only the single-use claim code, which the agent exchanges for its own token in-instance.
2006
+
2007
+ The `kici-agent.yml` run starts the agent on the runner itself with `KICI_SCALER_CLAIM_CODE` set. `KICI_SCALER_MANAGED=1` and a zero idle timeout make the agent register, run one job, and exit. The GitHub Actions run then completes on its own. By default the run installs the published agent from npm; set `agent_bundle_release` to a release tag holding a `kici-admin agent package` tarball to pin an exact build or to serve runners that cannot reach npm.
2008
+
2009
+ Teardown is largely automatic. A GitHub Actions run self-completes when its agent exits. So the `kici.scaler.scale-down` workflow only cancels a run GitHub has not yet marked finished, and only for reasons where the agent will never do useful work (`spawn-timeout`, `heartbeat-timeout`).
2010
+
2011
+ Every other reason leaves the run alone to reap itself. That includes the reason a healthy one-shot agent produces when it exits after its job — cancelling there would turn a succeeding run into a cancelled one.
2012
+
2013
+ Both workflows read a `GITHUB_DISPATCH_TOKEN` [scoped secret](https://docs.kici.dev/user/secrets/) with `actions: write` permission on the target repo — provisioning to dispatch a run, teardown to cancel one. Bind the context that holds it on the job (`context: 'github-actions'`) — a job reads only the secrets of the contexts it binds. The runner workflow is at [`examples/github-actions-autoscale/`](https://github.com/kici-dev/kici-public/tree/main/examples/github-actions-autoscale), ready to copy into your runner repo’s `.github/workflows/`.
2014
+
2015
+ Both workflows read `GITHUB_RUNNER_REPO` as an org-level context variable on the same `github-actions` context that holds the secret — `kici-admin variable set <orgId> github-actions GITHUB_RUNNER_REPO --value myorg/ci-runners`. The provisioning workflow reads one more, the optional `GITHUB_AGENT_BUNDLE_RELEASE`. Copy [`provision.workflow.ts`](https://github.com/kici-dev/kici-public/blob/main/examples/github-actions-autoscale/provision.workflow.ts) and [`teardown.workflow.ts`](https://github.com/kici-dev/kici-public/blob/main/examples/github-actions-autoscale/teardown.workflow.ts) into your `.kici/workflows/`.
2016
+
2017
+ ---