@kici-dev/compiler 0.1.22 → 0.1.24

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 (61) hide show
  1. package/dist/cli.js +34 -10
  2. package/dist/commands/compile.d.ts +6 -0
  3. package/dist/commands/compile.js +6 -3
  4. package/dist/commands/docs.d.ts +8 -8
  5. package/dist/commands/docs.js +35 -16
  6. package/dist/commands/index.d.ts +4 -2
  7. package/dist/commands/index.js +3 -2
  8. package/dist/commands/init.js +2 -2
  9. package/dist/commands/org.js +2 -2
  10. package/dist/commands/pat.d.ts +27 -0
  11. package/dist/commands/pat.js +76 -0
  12. package/dist/commands/preview.d.ts +88 -0
  13. package/dist/commands/{test.js → preview.js} +15 -14
  14. package/dist/commands/run.d.ts +27 -2
  15. package/dist/commands/run.js +117 -18
  16. package/dist/commands/test.d.ts +4 -0
  17. package/dist/commands/types.d.ts +2 -0
  18. package/dist/commands/types.js +1 -1
  19. package/dist/commands/verify-attestation.d.ts +4 -1
  20. package/dist/commands/verify-attestation.js +26 -10
  21. package/dist/fixtures/describe-event.d.ts +6 -0
  22. package/dist/fixtures/describe-event.js +18 -0
  23. package/dist/fixtures/picker.d.ts +19 -0
  24. package/dist/fixtures/picker.js +64 -0
  25. package/dist/generators/secrets-dts.js +2 -0
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.js +2 -2
  28. package/dist/llm-context/llms-architecture.txt +1440 -0
  29. package/dist/llm-context/llms-cli.txt +2509 -0
  30. package/dist/llm-context/llms-features.txt +2491 -0
  31. package/dist/llm-context/llms-full.txt +1364 -361
  32. package/dist/llm-context/llms-getting-started.txt +519 -0
  33. package/dist/llm-context/llms-patterns.txt +1324 -0
  34. package/dist/llm-context/llms-providers.txt +805 -0
  35. package/dist/llm-context/llms-sdk.txt +3844 -0
  36. package/dist/llm-context/llms.txt +16 -1
  37. package/dist/local-executor/index.js +42 -4
  38. package/dist/local-executor/job-runner.d.ts +2 -0
  39. package/dist/local-executor/job-runner.js +38 -6
  40. package/dist/local-executor/types.d.ts +2 -0
  41. package/dist/lockfile/generator.d.ts +10 -2
  42. package/dist/lockfile/generator.js +112 -49
  43. package/dist/remote/history.d.ts +1 -1
  44. package/dist/remote/history.js +1 -1
  45. package/dist/remote/local-repo-identity.d.ts +32 -0
  46. package/dist/remote/local-repo-identity.js +74 -0
  47. package/dist/remote/platform-client.d.ts +6 -0
  48. package/dist/remote/prod-defaults.d.ts +8 -0
  49. package/dist/remote/prod-defaults.js +9 -1
  50. package/dist/remote/uploader.js +1 -0
  51. package/dist/templates/agents-md.d.ts +1 -1
  52. package/dist/templates/agents-md.js +2 -2
  53. package/dist/templates/package-json.js +1 -1
  54. package/dist/test-runner/rule-evaluator.d.ts +1 -1
  55. package/dist/test-runner/rule-evaluator.js +2 -1
  56. package/dist/test-runner/step-context.d.ts +1 -1
  57. package/dist/test-runner/step-context.js +8 -2
  58. package/dist/types.d.ts +38 -7
  59. package/dist/types.js +5 -1
  60. package/package.json +4 -7
  61. package/sbom.spdx.json +35 -35
@@ -0,0 +1,2491 @@
1
+ # KiCI Workflow features
2
+
3
+ This bundle covers: Workflow features: concurrency, environments, secrets, approvals, provenance, events.
4
+
5
+ ## Account and sign-in
6
+
7
+ Source: https://docs.kici.dev/user/account-and-login/
8
+
9
+ Your KiCI account is a single identity. It stays the same no matter how you
10
+ sign in — whether you signed up with GitHub or with an email and password.
11
+ Changing your sign-in method does not create a new account or move your data;
12
+ your organizations, roles, and API keys stay attached to the same identity.
13
+
14
+ ## Where sign-in methods are managed
15
+
16
+ Sign-in methods and passwords are managed in your **account console**, provided
17
+ by the identity provider that handles single sign-on for KiCI. The dashboard's
18
+ **Linked accounts** page does not control how you sign in — see
19
+ [Linked accounts vs sign-in methods](https://docs.kici.dev/user/account-and-login/#linked-accounts-vs-sign-in-methods) below.
20
+
21
+ You can open the account console from the dashboard: go to your personal
22
+ settings, open **Linked accounts**, and use the **Account console** link.
23
+
24
+ ## Adding a password to a GitHub-created account
25
+
26
+ If you registered by signing in with GitHub and now want to sign in with an
27
+ email and password as well:
28
+
29
+ 1. Open your account console.
30
+ 2. Add a password (and, if prompted, confirm your email).
31
+
32
+ After this, you can sign in either with GitHub or with your email and password —
33
+ it is the same account.
34
+
35
+ ## Removing GitHub as a sign-in method
36
+
37
+ To stop using GitHub to sign in:
38
+
39
+ 1. First add a password (see above). The identity provider will not let you
40
+ remove your only sign-in method, so you must have another one first.
41
+ 2. In your account console, remove the GitHub sign-in method.
42
+
43
+ Your account, organizations, and data are unaffected — you simply sign in a
44
+ different way afterward.
45
+
46
+ ## Linked accounts vs sign-in methods
47
+
48
+ The dashboard's **Linked accounts** page controls **run-attribution metadata**
49
+ only — for example, showing your GitHub username on the runs you trigger and
50
+ determining your contributor trust level. Unlinking a provider there removes
51
+ that display link; it does **not** remove the provider as a way to sign in.
52
+
53
+ To actually change how you sign in, use your account console as described above.
54
+
55
+ ---
56
+
57
+ ## Approval gates
58
+
59
+ Source: https://docs.kici.dev/user/approvals/
60
+
61
+ An **approval gate** pauses execution until an authorized person approves it. Execution resumes from exactly where it paused; a rejection (or an expired hold) fails the run.
62
+
63
+ You declare a gate in your workflow with `approval`. It is available at three levels of granularity:
64
+
65
+ - **Step** — pause mid-job, before a specific step runs. The agent holds the live workspace (with all prior-step state intact) for the duration of the wait.
66
+ - **Job** — hold the job before any of its steps run.
67
+ - **Workflow** — hold the whole run before any job is dispatched.
68
+
69
+ A step-level gate can also fire **only when a check/apply step finds drift** — Terraform's plan→apply, per step. See [Drift gates](https://docs.kici.dev/user/approvals/#drift-gates-whendrift) below.
70
+
71
+ Approvers are named as **teams** and **users**. A team is an operator-defined group of org members; your workflow code may name a team but can never change its membership, which is what makes a team clause a real gate rather than a suggestion. See [Approval gates (operator guide)](https://docs.kici.dev/operator/approvals/) for how operators define teams, the approval queue, and expiry; see [the architecture overview](https://docs.kici.dev/architecture/approvals/) for how a hold is evaluated and resumed.
72
+
73
+ ## Quick start
74
+
75
+ Hold a deploy job until a member of the `leads` team approves:
76
+
77
+ ```typescript
78
+ import { workflow, job, step, push } from '@kici-dev/sdk';
79
+
80
+ export default workflow('deploy', {
81
+ on: [push({ branches: ['main'] })],
82
+ jobs: [
83
+ job('deploy-production', {
84
+ runsOn: 'default',
85
+ approval: [{ team: 'leads' }],
86
+ steps: [step('deploy', async (ctx) => ctx.$`deploy --prod`)],
87
+ }),
88
+ ],
89
+ });
90
+ ```
91
+
92
+ When the run reaches this job, it is held instead of dispatched. The held run appears in the dashboard approval queue and can be released from there or with the [`kici approve`](https://docs.kici.dev/user/approvals/#approving-from-the-cli) command. Once a member of `leads` approves, the job dispatches normally.
93
+
94
+ ## The `approval` field
95
+
96
+ `approval` accepts three forms.
97
+
98
+ ### Shorthand: `true`
99
+
100
+ ```typescript
101
+ job('deploy', {
102
+ runsOn: 'default',
103
+ approval: true,
104
+ steps: [
105
+ /* ... */
106
+ ],
107
+ });
108
+ ```
109
+
110
+ `approval: true` holds the element until **any** org member who can act on approvals signs off — anyone with the `environments:write` or `ci_trust:write` permission. Use it when you want a manual gate without restricting who may release it.
111
+
112
+ ### Approver list (AND)
113
+
114
+ ```typescript
115
+ approval: [{ team: 'leads' }, { user: 'cto' }],
116
+ ```
117
+
118
+ A list of approver clauses is an **AND** list: every clause must be satisfied before the element is released.
119
+
120
+ - `{ team: 'leads' }` is satisfied once **any** member of the `leads` team approves.
121
+ - `{ user: 'cto' }` is satisfied once the user `cto` approves.
122
+
123
+ A single approver may satisfy more than one clause. If `cto` is also a member of `leads`, one approval from `cto` satisfies both `{ team: 'leads' }` and `{ user: 'cto' }`, releasing the element. A user is named by their KiCI user identifier (their linked identity), and a team by its name as defined by your operator.
124
+
125
+ There is no OR or nested logic — clauses are always a flat AND list.
126
+
127
+ ### Object form: when, reason, and timeout
128
+
129
+ ```typescript
130
+ approval: {
131
+ when: 'always',
132
+ approvers: [{ team: 'security' }, { team: 'leads' }],
133
+ reason: 'Production deploy requires security + leads sign-off',
134
+ timeout: 7200, // seconds
135
+ },
136
+ ```
137
+
138
+ | Field | Type | Description |
139
+ | ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
140
+ | `when` | `'always' \| 'drift'` | When the gate fires. `'always'` (default) gates before the element; `'drift'` gates a check/apply step only when it finds drift. See [Drift gates](https://docs.kici.dev/user/approvals/#drift-gates-whendrift). |
141
+ | `approvers` | `ApproverClause[]` | The AND list of `{ team }` / `{ user }` clauses. An empty list means "any approval-capable member". |
142
+ | `reason` | `string` | A human-readable label shown in the dashboard queue and the held-for-approval status check. |
143
+ | `timeout` | `number` | Per-gate expiry in **seconds**, overriding the org default. On expiry the element is rejected. |
144
+
145
+ When `timeout` is omitted, the gate uses the org's default approval expiry (set by the operator). On expiry, the held element is rejected and the run fails — see [expiry](https://docs.kici.dev/operator/approvals/#expiry).
146
+
147
+ ## Granularity
148
+
149
+ The same `approval` field is accepted on a workflow, a job, and a step.
150
+
151
+ ### Workflow-level
152
+
153
+ A workflow-level gate holds the entire run before any job is dispatched:
154
+
155
+ ```typescript
156
+ export default workflow('release', {
157
+ on: [push({ branches: ['main'] })],
158
+ approval: [{ team: 'release-managers' }],
159
+ jobs: [buildJob, publishJob],
160
+ });
161
+ ```
162
+
163
+ ### Job-level
164
+
165
+ A job-level gate holds just that job; other jobs in the run proceed normally:
166
+
167
+ ```typescript
168
+ job('publish', {
169
+ runsOn: 'default',
170
+ approval: [{ team: 'leads' }],
171
+ steps: [
172
+ /* ... */
173
+ ],
174
+ });
175
+ ```
176
+
177
+ ### Step-level
178
+
179
+ A step-level gate pauses mid-job, immediately before the named step. Earlier steps in the job have already run and their workspace state is preserved across the wait:
180
+
181
+ ```typescript
182
+ job('migrate-and-deploy', {
183
+ runsOn: 'default',
184
+ steps: [
185
+ step('build-plan', async (ctx) => ctx.$`./gen-migration-plan.sh`),
186
+ step('apply-migration', {
187
+ approval: [{ team: 'dba' }],
188
+ run: async (ctx) => ctx.$`./apply-migration.sh`,
189
+ }),
190
+ step('deploy', async (ctx) => ctx.$`deploy --prod`),
191
+ ],
192
+ });
193
+ ```
194
+
195
+ Here `build-plan` runs, then the job pauses for a `dba` approval. On approval, `apply-migration` runs against the exact workspace `build-plan` produced, followed by `deploy`. A rejection or expiry fails the job.
196
+
197
+ Because a step-level hold keeps an agent and its workspace occupied for the whole human wait, prefer job- or workflow-level gates when you do not need prior-step state, and keep step-level timeouts short. See the [operator note on agent occupancy](https://docs.kici.dev/operator/approvals/#agent-occupancy-during-step-level-holds).
198
+
199
+ ## Drift gates (`when: 'drift'`)
200
+
201
+ A `when: 'drift'` gate is **step-scope only** and requires a [check/apply step](https://docs.kici.dev/user/idempotent-steps/). Instead of pausing unconditionally, it fires **between the step's `check` and `run`, only when `check` finds drift in apply mode** — exactly Terraform's plan→apply, scoped to one step. When the step is already in sync (no drift), nothing pauses and the step skips.
202
+
203
+ When the gate fires, the held run carries the **computed drift** as a payload: the rendering your `summarize(drift)` produced (the per-file diff, the commands that would run), plus the structured drift. The dashboard approval queue and the [CLI](https://docs.kici.dev/user/approvals/#approving-from-the-cli) show the actual diff the operator is approving — not a static reason string.
204
+
205
+ ```typescript
206
+ job('patch-prod', {
207
+ runsOn: 'default',
208
+ steps: [
209
+ step('apply-nginx-config', {
210
+ check: async (ctx) => ((await inSync(ctx)) ? null : { want: DESIRED_CONF }),
211
+ summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
212
+ run: async (ctx, drift) => {
213
+ await writeConfig(drift.want);
214
+ },
215
+ approval: { when: 'drift', approvers: [{ team: 'ops' }], reason: 'prod patch' },
216
+ }),
217
+ ],
218
+ });
219
+ ```
220
+
221
+ Behavior:
222
+
223
+ - The gate fires **only in apply mode**. In `--check` mode nothing applies, so nothing gates — drift is just reported.
224
+ - **Approve** → the step's `run(ctx, drift)` applies the change.
225
+ - **Reject** → fail-stop: the step fails, the job fails, and the `needs:` skip-cascade aborts everything downstream.
226
+
227
+ A `when: 'drift'` gate on a step without a `check` facet, or at job/workflow scope, is a compile error.
228
+
229
+ ## Mandatory vs. explicit gates
230
+
231
+ `approval` is the **explicit** gate — a deliberate "pause for a human here" written by the workflow author. It composes with the **mandatory** gate an operator can attach to a protected environment via required reviewers (see [Environments](https://docs.kici.dev/user/environments/#required-reviewers)). When both apply to the same job, all clauses from both sources must be satisfied before the job is released. The two funnel into one held-element mechanism, so the dashboard queue and `kici approve` work the same way regardless of which source held the element.
232
+
233
+ ## Approving from the CLI
234
+
235
+ Approve or reject a held element with the `kici` CLI:
236
+
237
+ ```bash
238
+ # Approve a workflow-level hold
239
+ kici approve <run-id>
240
+
241
+ # Approve a held job
242
+ kici approve <run-id> --job deploy-production
243
+
244
+ # Approve a held step
245
+ kici approve <run-id> --job migrate-and-deploy --step apply-migration
246
+
247
+ # Reject (a reason is required)
248
+ kici reject <run-id> --job deploy-production --reason "Wrong release branch"
249
+ ```
250
+
251
+ You must be eligible for at least one unsatisfied clause — being a member of a named team or being a named user. The orchestrator verifies eligibility against the operator-defined teams, so naming a team in your workflow can never let an ineligible person release the gate. The command reports whether the element was released, how many clauses remain, or that it was rejected. See [`kici approve`](https://docs.kici.dev/user/cli-reference/#kici-approve) for the full command reference.
252
+
253
+ ### Inline approval and `--approve-all` in `kici run remote`
254
+
255
+ When you trigger a run with `kici run remote` and it pauses on a gate, the CLI prints the gate (and, for a drift gate, the computed drift) and — in an interactive terminal — prompts you to approve or reject right there, without leaving the watch. In a non-interactive shell it prints how to approve out of band and keeps watching.
256
+
257
+ To auto-approve **every gate of the run you just dispatched**, pass `--approve-all` (alias `--yes`):
258
+
259
+ ```bash
260
+ kici run remote deploy-prod --approve-all
261
+ ```
262
+
263
+ `--approve-all` is **run-scoped** — it only auto-approves holds belonging to the run this invocation dispatched; there is no fleet-wide or account-wide auto-approve. Eligibility is still enforced per hold: if you are not eligible for a gate, that gate still blocks. Each auto-approved gate prints its payload before resolving and is recorded distinctly in the audit trail (`held_run.auto_approve`).
264
+
265
+ You can also approve from the dashboard approval queue. See [Dashboard](https://docs.kici.dev/user/dashboard/environments-and-secrets/#approval-queue).
266
+
267
+ ## See also
268
+
269
+ - [Idempotent steps](https://docs.kici.dev/user/idempotent-steps/) — the check/apply step facet that drift gates build on.
270
+ - [Environments](https://docs.kici.dev/user/environments/) — operator-required reviewers on protected environments.
271
+ - [Approval gates (operator guide)](https://docs.kici.dev/operator/approvals/) — teams, the approval queue, expiry, and self-approval.
272
+ - [Approval gates (architecture)](https://docs.kici.dev/architecture/approvals/) — the unified hold model and the step-level round-trip.
273
+
274
+ ---
275
+
276
+ ## Concurrency groups
277
+
278
+ Source: https://docs.kici.dev/user/concurrency/
279
+
280
+ 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.
281
+
282
+ ## Basic usage
283
+
284
+ ```typescript
285
+ import { workflow, job, step, push } from '@kici-dev/sdk';
286
+
287
+ export default workflow('deploy', {
288
+ on: push({ branches: ['main', 'staging'] }),
289
+ concurrency: {
290
+ group: (ctx) => `deploy-${ctx.branch}`,
291
+ cancelInProgress: true,
292
+ max: 1,
293
+ },
294
+ jobs: [
295
+ job('deploy', {
296
+ runsOn: 'linux',
297
+ steps: [
298
+ step('deploy', async ({ $ }) => {
299
+ await $`./deploy.sh`;
300
+ }),
301
+ ],
302
+ }),
303
+ ],
304
+ });
305
+ ```
306
+
307
+ ## Configuration
308
+
309
+ The `concurrency` option on a workflow accepts:
310
+
311
+ | Field | Type | Default | Description |
312
+ | ------------------ | -------- | -------- | ------------------------------------------ |
313
+ | `group` | Function | Required | Returns the concurrency group key string |
314
+ | `cancelInProgress` | boolean | `true` | Cancel older runs when a newer run arrives |
315
+ | `max` | number | `1` | Maximum concurrent runs in the same group |
316
+
317
+ ### Group key function
318
+
319
+ 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.
320
+
321
+ ```typescript
322
+ // Per-branch concurrency (most common)
323
+ group: (ctx) => `deploy-${ctx.branch}`;
324
+
325
+ // Global concurrency (across all branches)
326
+ group: () => 'deploy';
327
+
328
+ // Per-target-branch concurrency
329
+ group: (ctx) => `deploy-${ctx.event.targetBranch ?? 'default'}`;
330
+ ```
331
+
332
+ 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. This differs from job-level `concurrencyGroup` (see [Environments](https://docs.kici.dev/user/environments/#concurrency-groups)), where the compiler performs purity analysis and can inline pure functions for orchestrator-side evaluation.
333
+
334
+ ## cancelInProgress mode
335
+
336
+ When `cancelInProgress: true`, a newer run supersedes older runs in the same group:
337
+
338
+ ```
339
+ Run #1 starts deploying to main -> running
340
+ Run #2 arrives for deploy-main group -> Run #1 cancelled ("Superseded by run in concurrency group 'deploy-main'")
341
+ Run #2 continues -> running
342
+ ```
343
+
344
+ This is the most common mode for deploy workflows -- you want the latest code deployed, not an outdated version.
345
+
346
+ The cancelled run:
347
+
348
+ - Receives a cancellation with reason "Superseded by run in concurrency group 'deploy-main'"
349
+ - Goes through the normal cancel flow (grace period, hooks if graceful)
350
+ - GitHub Check status updated to `cancelled` with the superseded reason
351
+
352
+ ```typescript
353
+ workflow('deploy', {
354
+ concurrency: {
355
+ group: (ctx) => `deploy-${ctx.branch}`,
356
+ cancelInProgress: true,
357
+ },
358
+ jobs: [
359
+ /* ... */
360
+ ],
361
+ });
362
+ ```
363
+
364
+ ## Queue mode
365
+
366
+ When `cancelInProgress: false`, newer runs will wait until older runs complete:
367
+
368
+ ```
369
+ Run #1 starts deploying -> running
370
+ Run #2 arrives for same group -> queued ("Waiting for deploy-main (1 ahead)")
371
+ Run #1 completes -> success
372
+ Run #2 starts -> running
373
+ ```
374
+
375
+ 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).
376
+
377
+ ```typescript
378
+ workflow('migrate-db', {
379
+ concurrency: {
380
+ group: () => 'migrations',
381
+ cancelInProgress: false,
382
+ max: 1,
383
+ },
384
+ jobs: [
385
+ /* ... */
386
+ ],
387
+ });
388
+ ```
389
+
390
+ The dashboard will show a "Queued" badge with the reason: "Waiting for deploy-main (1 ahead)".
391
+
392
+ ## Max concurrent runs
393
+
394
+ The `max` field controls how many runs can execute simultaneously in the same group:
395
+
396
+ ```typescript
397
+ // Allow up to 3 parallel test runs per branch
398
+ workflow('test', {
399
+ concurrency: {
400
+ group: (ctx) => `test-${ctx.branch}`,
401
+ cancelInProgress: false,
402
+ max: 3,
403
+ },
404
+ jobs: [
405
+ /* ... */
406
+ ],
407
+ });
408
+ ```
409
+
410
+ When `max: 1` (default), runs are fully serialized within the group.
411
+
412
+ ## Group key examples
413
+
414
+ ### Deploy per environment
415
+
416
+ ```typescript
417
+ workflow('deploy', {
418
+ concurrency: {
419
+ group: (ctx) => `deploy-${ctx.branch}`,
420
+ cancelInProgress: true,
421
+ },
422
+ jobs: [
423
+ job('deploy-staging', {
424
+ runsOn: 'linux',
425
+ environment: 'staging',
426
+ steps: [
427
+ /* ... */
428
+ ],
429
+ }),
430
+ ],
431
+ });
432
+ ```
433
+
434
+ ### Global singleton
435
+
436
+ ```typescript
437
+ // Only one migration can run at a time, regardless of branch
438
+ workflow('migrate', {
439
+ concurrency: {
440
+ group: () => 'db-migration',
441
+ cancelInProgress: false,
442
+ },
443
+ jobs: [
444
+ /* ... */
445
+ ],
446
+ });
447
+ ```
448
+
449
+ ### Environment-aware groups
450
+
451
+ ```typescript
452
+ // Serialize deploys per environment
453
+ workflow('deploy', {
454
+ concurrency: {
455
+ group: (ctx) => {
456
+ const env = ctx.branch === 'main' ? 'production' : 'staging';
457
+ return `deploy-${env}`;
458
+ },
459
+ cancelInProgress: true,
460
+ },
461
+ jobs: [
462
+ /* ... */
463
+ ],
464
+ });
465
+ ```
466
+
467
+ ## Interaction with environment protection
468
+
469
+ When a workflow has both `concurrency` and `environment` protection rules:
470
+
471
+ 1. Environment protection gates (required reviewers, wait timer) apply first
472
+ 2. Concurrency group check happens after protection gates pass
473
+ 3. If the run is queued by concurrency, it keeps its protection approval
474
+
475
+ This means a run that passed approval won't need re-approval if it gets queued by concurrency.
476
+
477
+ ## Cancelling queued runs
478
+
479
+ 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.
480
+
481
+ ## Job-level concurrency groups
482
+
483
+ 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 [Environments — concurrency groups](https://docs.kici.dev/user/environments/#concurrency-groups) for details.
484
+
485
+ ## Local execution
486
+
487
+ `kici run local` honors workflow-level `concurrency` per-machine, per-user. The `group` callback is evaluated against the simulated event identically to the remote orchestrator path; `cancelInProgress` carries the same semantics — `true` interrupts the holder via `SIGTERM` (escalating to `SIGKILL` after a grace window) and proceeds with the new run, while `false` queues the new invocation in FIFO order until the holder finishes.
488
+
489
+ Coordination is local only. Running the same workflow on two different machines does not serialize across them — that requires the orchestrator. For full cross-host enforcement (queueing across agents, dashboard visibility, `max > 1`), use `kici run remote` against a deployed orchestrator.
490
+
491
+ Lock files live under `$XDG_RUNTIME_DIR/kici-local-locks/` on Linux, falling back to `os.tmpdir()/kici-local-locks-<uid>/`. A workflow whose `group` callback throws aborts the run with a clear error rather than running unprotected. See [`kici run local` — Concurrency enforcement](https://docs.kici.dev/user/cli-reference/#concurrency-enforcement) for the `KICI_LOCAL_LOCK_KILL_GRACE_MS` override and the diagnostic output emitted while contending on a busy lock.
492
+
493
+ The `kici run local --concurrency <n>` flag is a separate concept — it caps **job-level** parallelism within a single run (how many jobs from one workflow run at once), not cross-run serialization.
494
+
495
+ ---
496
+
497
+ _Source: `packages/sdk/src/types.ts` (WorkflowOptions.concurrency, JobOptions.concurrencyGroup)_
498
+
499
+ ---
500
+
501
+ ## Dashboard
502
+
503
+ Source: https://docs.kici.dev/user/dashboard/
504
+
505
+ The KiCI dashboard is the browser interface for monitoring workflow runs, inspecting jobs and logs, and managing your organization. It signs in via OIDC and talks to KiCI over its API.
506
+
507
+ This guide is split across the following pages:
508
+
509
+ | Page | Covers |
510
+ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
511
+ | [Getting started](https://docs.kici.dev/user/dashboard/getting-started/) | Onboarding checklist and your organizations list |
512
+ | [Navigation and layout](https://docs.kici.dev/user/dashboard/navigation/) | Sidebar, mobile nav, theme, time display, shortcuts, error pages |
513
+ | [Runs and logs](https://docs.kici.dev/user/dashboard/runs/) | Run list, run detail, the log viewer |
514
+ | [Settings](https://docs.kici.dev/user/dashboard/settings/) | Members, roles, teams, keys, sources, billing, security, support access |
515
+ | [Workflows, diagnostics, and orchestrators](https://docs.kici.dev/user/dashboard/workflows-and-diagnostics/) | Registered workflows, infra health, per-cluster views |
516
+ | [Environments, secrets, and approvals](https://docs.kici.dev/user/dashboard/environments-and-secrets/) | Environments, secret scopes, approval queue |
517
+ | [Activity and DLQ](https://docs.kici.dev/user/dashboard/activity-and-dlq/) | Forensic activity log and dead-letter queue |
518
+ | [Account](https://docs.kici.dev/user/dashboard/account/) | Personal account settings |
519
+
520
+ ---
521
+
522
+ ## Dynamic values
523
+
524
+ Source: https://docs.kici.dev/user/dynamic-values/
525
+
526
+ Dynamic values let you compute `environment`, `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.
527
+
528
+ ```typescript
529
+ job('deploy', {
530
+ runsOn: ['default'],
531
+ environment: (event) => event.targetBranch,
532
+ env: (event) => ({ BRANCH: event.targetBranch }),
533
+ concurrencyGroup: (event) => `deploy-${event.targetBranch}`,
534
+ steps: [
535
+ /* ... */
536
+ ],
537
+ });
538
+ ```
539
+
540
+ ```typescript
541
+ job('deploy', {
542
+ runsOn: 'default',
543
+ // One shape everywhere: branch on the normalized event type.
544
+ environment: (event) => (event.type === 'pull_request' ? 'preview' : 'production'),
545
+ steps: [
546
+ /* ... */
547
+ ],
548
+ });
549
+ ```
550
+
551
+ ## How it works
552
+
553
+ When you define a dynamic value as a function, the compiler analyzes it at compile time to determine whether it is **pure** (can be evaluated without cloning the repo or running an init job).
554
+
555
+ ### Pure functions (inline evaluation)
556
+
557
+ A pure function is one that:
558
+
559
+ - Is synchronous (no `async`/`await`)
560
+ - Only references its parameters and local variables
561
+ - Does not import or require external modules
562
+ - Does not access globals like `process`, `fetch`, `console`, `setTimeout`, etc.
563
+ - Uses only safe built-in constructors: `String`, `Number`, `Boolean`, `Array`, `Object`, `JSON`, `Math`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `encodeURIComponent`, `decodeURIComponent`, `encodeURI`, `decodeURI`
564
+ - Does not use `this`, `new`, `class`, `throw`, `try`/`catch`, `delete`, `var`, `yield`, or mutation operators (`++`, `--`, `+=`, etc.)
565
+
566
+ When the compiler detects a pure function, it serializes the function source directly into the lock file as an inline expression. At dispatch time, the orchestrator evaluates the expression in a sandboxed VM context (~0ms overhead) instead of dispatching an init job.
567
+
568
+ **Examples of pure functions:**
569
+
570
+ ```typescript
571
+ // Simple branch extraction
572
+ environment: (event) => event.targetBranch;
573
+
574
+ // Object literal with string operations
575
+ env: (event) => ({ BRANCH: event.targetBranch });
576
+
577
+ // Concatenation with event data
578
+ concurrencyGroup: (event) => `deploy-${event.targetBranch}`;
579
+
580
+ // Using safe globals
581
+ env: (event) => ({ UPPER: String(event.targetBranch).toUpperCase() });
582
+
583
+ // Local variables are fine
584
+ environment: (event) => {
585
+ const parts = event.targetBranch.split('/');
586
+ return parts[parts.length - 1];
587
+ };
588
+ ```
589
+
590
+ ### Impure functions (init-job evaluation)
591
+
592
+ If the compiler determines a function is impure, it emits a warning during compilation and falls back to the two-phase init model. This means:
593
+
594
+ 1. The orchestrator dispatches a special `__init__` job to a builder agent
595
+ 2. The builder agent clones the repository and evaluates the function
596
+ 3. The resolved values are sent back to the orchestrator
597
+ 4. The orchestrator dispatches the real execution job with the resolved values
598
+
599
+ This adds approximately 5-10 seconds of overhead for cloning and evaluation.
600
+
601
+ **Examples of impure functions (will use init job):**
602
+
603
+ ```typescript
604
+ // Async functions cannot be inlined
605
+ environment: async (event) => await lookupEnv(event.targetBranch);
606
+
607
+ // External module references
608
+ env: (event) => {
609
+ const config = require('./config');
610
+ return config.env;
611
+ };
612
+
613
+ // Process/global access
614
+ environment: (event) => process.env.DEFAULT_ENV || 'staging';
615
+
616
+ // Dynamic imports
617
+ env: async (event) => {
618
+ const m = await import('./config.js');
619
+ return m.default;
620
+ };
621
+ ```
622
+
623
+ ## Performance comparison
624
+
625
+ | Evaluation path | Overhead | When used |
626
+ | ------------------------------------ | -------- | ------------------------------------------------------------------- |
627
+ | Static value (string/object literal) | ~0ms | `environment: 'staging'` |
628
+ | Inline expression (pure function) | ~0ms | `environment: (event) => event.targetBranch` |
629
+ | Init job (impure function) | ~5-10s | `environment: async (event) => await lookupEnv(event.targetBranch)` |
630
+
631
+ ## Tips
632
+
633
+ - **Write pure functions whenever possible** to avoid the init-job delay. Most environment and env computations only need the event payload data.
634
+ - **Check compiler warnings** -- the compiler tells you when a function is classified as impure and explains why.
635
+ - **Runtime errors in inline expressions cause immediate job failure.** There is no fallback to the init-job path. If your pure function throws at runtime (e.g., accessing a property on `undefined`), the job fails immediately.
636
+ - **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`, …).
637
+
638
+ ---
639
+
640
+ ## Environment variables
641
+
642
+ Source: https://docs.kici.dev/user/env-vars/
643
+
644
+ 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 a self-hosted Platform or a testing environment.
645
+
646
+ ## Authentication
647
+
648
+ | Variable | Description | Default |
649
+ | --------------------- | -------------------------------------- | -------------------------------------------- |
650
+ | `KICI_OIDC_ISSUER` | OIDC issuer URL for authentication | `https://auth.kici.dev/realms/kici-internal` |
651
+ | `KICI_OIDC_CLIENT_ID` | OIDC client ID for the CLI application | `kici-cli` |
652
+ | `KICI_PLATFORM_URL` | Platform API base URL | `https://api.kici.dev` |
653
+ | `KICI_CONFIG_DIR` | Override the KiCI config directory | `~/.kici` |
654
+
655
+ ## Browser behavior
656
+
657
+ | Variable | Description | Default |
658
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
659
+ | `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 via the `open` package |
660
+ | `KICI_CALLBACK_PORT` | Fixed port for the OAuth PKCE callback server. Useful when firewall rules require a known port. | Random available port |
661
+
662
+ ## Development
663
+
664
+ | Variable | Description | Default |
665
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
666
+ | `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 |
667
+ | `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 |
668
+
669
+ ## Usage examples
670
+
671
+ ### CI/CD environment
672
+
673
+ Authenticate with a pre-existing API key (no browser needed):
674
+
675
+ ```bash
676
+ kici login --token <<< "$KICI_API_KEY"
677
+ ```
678
+
679
+ ### Self-hosted Platform or custom OIDC provider
680
+
681
+ `kici login` targets the hosted KiCI Platform by default. To point the CLI at a self-hosted Platform or a testing OIDC provider, override the defaults:
682
+
683
+ ```bash
684
+ export KICI_OIDC_ISSUER=https://your-idp.example.com
685
+ export KICI_OIDC_CLIENT_ID=your-client-id
686
+ export KICI_PLATFORM_URL=https://your-platform.example.com
687
+ kici login
688
+ ```
689
+
690
+ ### Headless SSH session
691
+
692
+ The CLI auto-detects headless environments and uses the device flow. To force PKCE with URL output instead:
693
+
694
+ ```bash
695
+ export KICI_BROWSER_CMD=none
696
+ kici login
697
+ ```
698
+
699
+ This prints the authorization URL to stdout as `KICI_AUTH_URL=<url>`. Open the URL in any browser to complete authentication.
700
+
701
+ ### Fixed callback port
702
+
703
+ When behind a firewall or using port forwarding:
704
+
705
+ ```bash
706
+ export KICI_CALLBACK_PORT=19876
707
+ kici login
708
+ ```
709
+
710
+ ### Custom config location
711
+
712
+ Store the KiCI config in a non-default location:
713
+
714
+ ```bash
715
+ export KICI_CONFIG_DIR=/tmp/kici-test
716
+ kici login
717
+ ```
718
+
719
+ ---
720
+
721
+ ## Environments
722
+
723
+ Source: https://docs.kici.dev/user/environments/
724
+
725
+ <!-- help:environments-list#overview -->
726
+
727
+ Environments are named deployment targets (like staging or production) that control where your workflow jobs run. Each environment can have its own variables, secrets, and protection rules to gate deployments.
728
+
729
+ <!-- /help:environments-list -->
730
+
731
+ <!-- help:environments-protection#protection-rules -->
732
+
733
+ Protection rules control when jobs targeting an environment can execute.
734
+
735
+ Available rules:
736
+
737
+ - **Branch restrictions** — only allow specific branches to deploy.
738
+ - **Required reviewer approvals** — gate the run on human sign-off.
739
+ - **Wait timers** — delay execution for a fixed period.
740
+ - **Concurrency limits** — prevent collisions between parallel deployments.
741
+
742
+ <!-- /help:environments-protection -->
743
+
744
+ Environments represent deployment targets like `staging`, `production`, or `review/PR-*`. Each environment can have its own variables, bound secrets, and protection rules that control when and how jobs targeting that environment can execute.
745
+
746
+ ## Overview
747
+
748
+ An environment in KiCI provides:
749
+
750
+ - **Variables** -- non-secret key-value configuration (e.g., `API_URL`, `CLUSTER_NAME`)
751
+ - **Scoped secrets** -- encrypted values bound to the environment via scope bindings
752
+ - **Protection rules** -- branch restrictions, required reviewers, wait timers, and concurrency limits
753
+ - **Per-source overrides** -- repositories can override unlocked variables for their own deployments
754
+
755
+ ## SDK API
756
+
757
+ ### Job-level environment property
758
+
759
+ The `environment` property is set on a job, not a workflow or step:
760
+
761
+ ```typescript
762
+ import { workflow, job, step, push } from '@kici-dev/sdk';
763
+
764
+ export default workflow('deploy', {
765
+ on: [push({ branches: ['main'] })],
766
+ jobs: [
767
+ job('deploy-staging', {
768
+ runsOn: 'default',
769
+ environment: 'staging',
770
+ steps: [
771
+ step('deploy', async (ctx) => {
772
+ // ctx.environment is the resolved environment name
773
+ console.log(`Deploying to ${ctx.environment}`);
774
+ // ctx.secrets provides async get/expose/has methods for environment-bound secrets
775
+ const dbPassword = await ctx.secrets.get('DB_PASSWORD');
776
+ // Environment variables are in ctx.env
777
+ const apiUrl = ctx.env.API_URL;
778
+ await ctx.$`deploy --target ${ctx.environment}`;
779
+ }),
780
+ ],
781
+ }),
782
+ ],
783
+ });
784
+ ```
785
+
786
+ ### Dynamic environments
787
+
788
+ The environment name can be a string or a function (sync or async) for dynamic environments (e.g., per-PR review environments). The function receives the normalized event envelope, with the raw provider body nested at `event.payload`:
789
+
790
+ ```typescript
791
+ job('deploy-review', {
792
+ runsOn: 'default',
793
+ environment: (event) => `review/PR-${event.payload.pull_request.number}`,
794
+ steps: [
795
+ step('deploy', async (ctx) => {
796
+ // ctx.environment is 'review/PR-123' (resolved at runtime)
797
+ await ctx.$`deploy-preview --env ${ctx.environment}`;
798
+ }),
799
+ ],
800
+ });
801
+ ```
802
+
803
+ A pure function like the one above (see [Dynamic values](https://docs.kici.dev/user/dynamic-values/)) is evaluated inline at dispatch with no init-job overhead. Dynamic environments that match a glob pattern (e.g., `review/*`) inherit the pattern's configuration, variables, and protection rules.
804
+
805
+ ### Multiple environments per job
806
+
807
+ A job can bind more than one environment with `environments`, an ordered array. This lets a single job draw secrets and variables from several environments at once — for example a shared `staging` environment plus a `my-testing` environment that carries test-only variables:
808
+
809
+ ```typescript
810
+ job('deploy', {
811
+ runsOn: 'default',
812
+ environments: ['staging', 'my-testing'],
813
+ steps: [
814
+ step('deploy', async (ctx) => {
815
+ // ctx.secrets and ctx.env carry the merged set from both environments
816
+ const dbUrl = await ctx.secrets.get('DB_URL');
817
+ }),
818
+ ],
819
+ });
820
+ ```
821
+
822
+ - `environment` (singular) and `environments` (array) are mutually exclusive — setting both is a compile error. `environment: 'staging'` is exactly equivalent to `environments: ['staging']`.
823
+ - Each array entry is a static name or a function of the event, resolved per element exactly like a single dynamic environment.
824
+
825
+ **Merge order — last wins.** All bound environments are resolved on every dispatch (webhook, scheduled, and test runs alike) and merged in array order. When the same secret or variable key is defined in more than one environment, the later entry in the array wins. With `environments: ['staging', 'my-testing']`, a key defined in both resolves to `my-testing`'s value; keys defined in only one are preserved. The longest-scope-path-wins rule still applies _within_ each environment.
826
+
827
+ **Protection rules combine all-must-pass.** A job must satisfy **every** bound environment's gates — adding an environment can never loosen access. Branch restrictions, trigger-type filters, and repo patterns must pass for all environments; the minimum trust tier is the most restrictive across them; required reviewers are the union of all environments' reviewers; the wait timer is the longest; and the hold expiry is the shortest. If a run is gated out, the rejection names which environment and which rule rejected it (visible via `kici status` and the run's rejection reason), so a mutually-exclusive set of rules surfaces as a clear failure rather than a silent perpetual rejection.
828
+
829
+ **Skip-on-test.** On a test or local run (`kici run remote`, `kici run local`), any bound environment that disallows local execution is skipped — its variables and secrets are omitted from the merge and its gates are not evaluated. This makes the test-only-variables pattern work: with `environments: ['staging', 'my-testing']` where only `my-testing` allows local execution, a test run resolves just `my-testing`'s variables. If every bound environment disallows test runs, the job runs with no environment variables and a clear warning.
830
+
831
+ **Unconfigured environments contribute nothing at dispatch.** At dispatch time a bound environment name with no matching configured environment (and no matching glob environment) simply adds no variables, secrets, or protection rules — the job still runs, exactly as a single dynamic environment resolving to an as-yet-unconfigured name does today.
832
+
833
+ **Registration rejects a provably-unsatisfiable binding.** When a workflow is registered, KiCI statically checks every multi-environment binding: a bound environment that does not exist, a disabled one, or two environments with mutually-exclusive fixed branch / trigger-type / repository restrictions (no value can satisfy both) makes the binding provably unsatisfiable, and the registration is rejected with a precise message naming the job, the environments, and the rule — for example `unsatisfiable environment binding: job 'deploy' binds environments [staging, my-testing] with mutually exclusive branch restrictions (no value satisfies all bound environments)`. Bindings whose restrictions use globs are undecidable at registration and fall through to the dispatch-time gate check instead.
834
+
835
+ ### Job-level environment variables
836
+
837
+ The `env` property on a job provides static or dynamic environment variables:
838
+
839
+ ```typescript
840
+ job('deploy', {
841
+ runsOn: 'default',
842
+ environment: 'production',
843
+ env: { DEPLOY_TARGET: 'us-east-1' },
844
+ // Or dynamic:
845
+ // env: (event) => ({ DEPLOY_SHA: event.payload.after?.slice(0, 7) }),
846
+ steps: [
847
+ step('deploy', async (ctx) => {
848
+ // DEPLOY_TARGET is available in ctx.env
849
+ await ctx.$`deploy --region ${ctx.env.DEPLOY_TARGET}`;
850
+ }),
851
+ ],
852
+ });
853
+ ```
854
+
855
+ ### Concurrency groups
856
+
857
+ Jobs can define their own concurrency groups to control concurrent execution within an environment. For workflow-level concurrency (which applies to all jobs in a workflow), see [Concurrency groups](https://docs.kici.dev/user/concurrency/).
858
+
859
+ Control concurrent deployments to the same environment:
860
+
861
+ ```typescript
862
+ job('deploy', {
863
+ runsOn: 'default',
864
+ environment: 'production',
865
+ concurrencyGroup: 'production-api',
866
+ // Or dynamic:
867
+ // concurrencyGroup: (event) => `review-${event.payload.pull_request.number}`,
868
+ steps: [
869
+ /* ... */
870
+ ],
871
+ });
872
+ ```
873
+
874
+ If no `concurrencyGroup` is specified, the environment name is used as the default concurrency group. For a job bound to multiple environments, the default is the **first** bound environment's name.
875
+
876
+ ### Step context
877
+
878
+ Inside a step, the `ctx` object provides:
879
+
880
+ | Property | Type | Description |
881
+ | ----------------- | ------------------------------------- | ---------------------------------------------------------------------------- |
882
+ | `ctx.environment` | `string \| undefined` | Resolved environment name (undefined for jobs without environment) |
883
+ | `ctx.env` | `Record<string, string \| undefined>` | Environment variables (merged from system, org, source, and job-level `env`) |
884
+ | `ctx.secrets` | `StepSecretsTyped` | Async accessor for bound secrets (get, expose, has, getMeta) |
885
+
886
+ | Method | Returns | Description |
887
+ | ------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
888
+ | `await ctx.secrets.get(key)` | `string` | Retrieve a secret value. Throws `SecretNotFoundError` if not found. |
889
+ | `await ctx.secrets.expose(key)` | `void` | Inject a secret into the step's environment variables (`ctx.env`). Throws `SecretNotFoundError` if not found. |
890
+ | `ctx.secrets.has(key)` | `boolean` | Check if a secret key exists. Synchronous, never throws. |
891
+ | `ctx.secrets.getMeta(key)` | `SecretMeta \| undefined` | Retrieve metadata (value, backend name, scope path) for a resolved secret. Returns `undefined` if not found. |
892
+ | `ctx.setSecretOutput(key, val)` | `void` | Publish an encrypted secret output from this job, consumable by downstream jobs via `needs`. Never logged or stored in plaintext. |
893
+
894
+ ## Environment variable merge precedence
895
+
896
+ When a job targets an environment, variables are merged in this order (last wins):
897
+
898
+ 1. **Allowed system vars** -- `PATH`, `HOME`, etc. from the agent process
899
+ 2. **Sandbox defaults** -- `FORCE_COLOR=1`
900
+ 3. **KICI\_\* system vars** -- orchestrator-generated metadata
901
+ 4. **Org-level environment vars** -- from the dashboard, managed per-environment
902
+ 5. **Source-level overrides** -- per-repository overrides (skips locked vars)
903
+ 6. **Job env** -- from the `env` property in the SDK
904
+ 7. **`setEnv()` calls** -- runtime modifications within steps
905
+
906
+ > **Note:** Secrets are NOT part of the environment variable merge. They are delivered to the step context via IPC and accessed through `ctx.secrets`, not through `process.env`. See the [step context](https://docs.kici.dev/user/environments/#step-context) section above.
907
+
908
+ ## Protection rules
909
+
910
+ Environments can have protection rules that gate job execution:
911
+
912
+ ### Branch restrictions
913
+
914
+ Limit which branches can deploy to an environment:
915
+
916
+ ```
917
+ Allowed branches: main, release/*
918
+ ```
919
+
920
+ Jobs from other branches are rejected immediately with an error message.
921
+
922
+ ### Required reviewers
923
+
924
+ Require manual approval before a job can proceed:
925
+
926
+ ```
927
+ Required reviewers: alice, bob
928
+ ```
929
+
930
+ When reviewers are required, the job enters a "held" state. Reviewers can approve or reject via the dashboard, the [`kici approve`](https://docs.kici.dev/user/cli-reference/#kici-approve) command, or the API. Held runs expire after a configurable timeout.
931
+
932
+ This operator-set rule is the **mandatory** form of an approval gate. Workflow authors can also declare gates in code with `approval` at step, job, or workflow level — see [Approval gates](https://docs.kici.dev/user/approvals/). Both forms use the same held-element mechanism and the same queue.
933
+
934
+ ### Wait timer
935
+
936
+ Add a mandatory delay before deployment starts:
937
+
938
+ ```
939
+ Wait timer: 300 seconds
940
+ ```
941
+
942
+ The job waits for the specified duration before proceeding. Useful for staged rollouts.
943
+
944
+ ### Minimum trust
945
+
946
+ Gate job execution based on the contributor's trust tier for PR-triggered runs:
947
+
948
+ ```
949
+ Minimum trust: known
950
+ ```
951
+
952
+ | Value | Effect |
953
+ | --------- | --------------------------------------------------- |
954
+ | `known` | Blocks unknown contributors; allows known + trusted |
955
+ | `trusted` | Blocks unknown + known; allows only trusted |
956
+
957
+ When a contributor does not meet the minimum trust level, the job is held in the security approval queue. Someone with `ci_trust:write` or higher must approve it before execution proceeds.
958
+
959
+ Trust tier is determined by the contributor's identity link and CI trust RBAC level:
960
+
961
+ - **Trusted** -- identity-linked org member with `ci_trust:write+` AND provider write access
962
+ - **Known** -- identity-linked member or verified collaborator via provider API
963
+ - **Unknown** -- no identity link and no provider access, fork PRs
964
+
965
+ The trust tier also affects which lock file is used for PR-triggered runs: trusted contributors use the PR head lock file, while known and unknown contributors use the base branch lock file. This prevents untrusted workflow modifications from affecting execution.
966
+
967
+ See the [CI security architecture docs](https://docs.kici.dev/architecture/security/ci-security/) for the full trust resolution flow.
968
+
969
+ ### Security approval queue
970
+
971
+ When a PR is held for security review (unknown contributor, workflow modification, or trust policy violation), it enters the security approval queue. This is separate from environment-level approval queues.
972
+
973
+ Held runs can be approved:
974
+
975
+ - Via the **dashboard** in Settings > CI trust > Approval queue
976
+ - Via a PR comment: `/kici approve` (commenter must have `ci_trust:write+`)
977
+
978
+ Security holds expire after a configurable timeout (default 1 hour).
979
+
980
+ ### Concurrency limits
981
+
982
+ Control how many jobs can run simultaneously in an environment:
983
+
984
+ ```
985
+ Concurrency limit: 1
986
+ Strategy: queue (or cancel-pending)
987
+ ```
988
+
989
+ - **queue** -- new jobs wait in a FIFO queue (with configurable timeout, default 1 hour)
990
+ - **cancel-pending** -- pending (queued) jobs are cancelled when the limit is reached
991
+
992
+ ## Dashboard management
993
+
994
+ ### Creating environments
995
+
996
+ Navigate to **Settings > Environments** in the dashboard. Click **New environment** to choose the environment name and type (Fixed or Glob).
997
+
998
+ - **Fixed** -- applies to jobs that declare exactly this environment name, like `staging` or `production`
999
+ - **Glob** -- applies to any environment name a job declares that matches the pattern, e.g. `review/*` matches a job with `environment: 'review/PR-123'`
1000
+
1001
+ The environments list shows each environment's type, whether test runs may use it (the `allowLocalExecution` flag -- see the [testing guide](https://docs.kici.dev/user/testing-guide/)), and whether it is enabled.
1002
+
1003
+ ### Environment detail page
1004
+
1005
+ Each environment has four tabs:
1006
+
1007
+ 1. **Variables** -- manage key-value pairs with lock toggles. Locked variables cannot be overridden by source-level overrides. Source overrides are managed in a sub-tab.
1008
+
1009
+ 2. **Secrets** -- view bound secret scopes and their resolved secret count. Add bindings by specifying scope glob patterns (e.g., `aws/prod/**`).
1010
+
1011
+ 3. **Protection** -- configure branch restrictions, required reviewers, wait timers, and concurrency limits with enable toggles for each section.
1012
+
1013
+ 4. **History** -- view filtered runs targeting this environment.
1014
+
1015
+ ### Bound environments on runs
1016
+
1017
+ A job's bound deployment environments are shown as chips on the run detail page (in the job metadata panel) in the order the job declared them, and the distinct set across a run's jobs appears as compact chips on the run list. For a multi-environment job the chips read left-to-right in merge order — later environments override earlier ones on key collisions. A `(dynamic)` chip marks an environment whose name is computed at runtime; it resolves to the real name once the run starts. A job that binds a single environment shows one chip; a job that binds none shows no chip.
1018
+
1019
+ If a multi-environment binding is gated out, the run's failure banner names which environment and which rule rejected it (the same all-must-pass detail surfaced by `kici status`).
1020
+
1021
+ ### Secrets management
1022
+
1023
+ Secrets are individual encrypted values organized by scope paths (e.g., `aws/prod`, `databases/postgres`). Scopes are bound to environments via bindings:
1024
+
1025
+ - **Scope-centric view** (Secrets page): tree view of scopes with per-scope environment binding checkboxes
1026
+ - **Environment-centric view** (inside environment detail): bound scopes, resolved secrets, add binding
1027
+
1028
+ When scope paths collide on the same key name, the longer (more specific) path wins.
1029
+
1030
+ ## Type generation
1031
+
1032
+ Running `kici types` generates two augmented interfaces: `KnownSecretKeys` (union of all secret keys across all environments) and `EnvironmentSecrets` (per-environment key unions):
1033
+
1034
+ ```typescript
1035
+ interface KnownSecretKeys {
1036
+ DB_PASSWORD: string;
1037
+ API_KEY: string;
1038
+ }
1039
+
1040
+ interface EnvironmentSecrets {
1041
+ production: 'DB_PASSWORD' | 'API_KEY';
1042
+ staging: 'DB_PASSWORD';
1043
+ }
1044
+ ```
1045
+
1046
+ `KnownSecretKeys` narrows `ctx.secrets.get()` and `ctx.secrets.expose()` key parameters to valid key names. `EnvironmentSecrets` maps each environment to its available secret key names as a string union. Dynamic environments fall back to the full `KnownSecretKeys` union.
1047
+
1048
+ ---
1049
+
1050
+ ## Event system
1051
+
1052
+ Source: https://docs.kici.dev/user/events/
1053
+
1054
+ 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.
1055
+
1056
+ ## Overview
1057
+
1058
+ 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.
1059
+
1060
+ 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:
1061
+
1062
+ - `kiciEvent()` -- custom events emitted from workflow steps
1063
+ - `workflowComplete()` -- fires when a workflow finishes
1064
+ - `jobComplete()` -- fires when a specific job finishes
1065
+ - `genericWebhook()` -- HTTP webhooks from external services
1066
+ - `schedule()` -- cron-based time triggers
1067
+ - `lifecycle()` -- orchestrator lifecycle events (workflow completion, job failure, registration updates)
1068
+
1069
+ All six require the **registration model** to function -- covered in detail below.
1070
+
1071
+ ## Event types
1072
+
1073
+ ### Custom events
1074
+
1075
+ Custom events are user-defined events emitted from workflow steps using `ctx.emit()`. Use `kiciEvent()` to listen for them.
1076
+
1077
+ ```typescript
1078
+ import { kiciEvent } from '@kici-dev/sdk';
1079
+
1080
+ // Listen for a custom event by name
1081
+ kiciEvent({ name: 'deploy-complete' });
1082
+
1083
+ // With payload matching (JSONPath)
1084
+ kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } });
1085
+
1086
+ // With negative filter
1087
+ kiciEvent({ name: 'deploy-complete', not: { '$.env': 'staging' } });
1088
+
1089
+ // From a specific repository
1090
+ kiciEvent({ name: 'deploy-complete', source: 'org/infra-repo' });
1091
+ ```
1092
+
1093
+ **Config options:** `name` (required), `match`, `not`, `source`, `description`.
1094
+
1095
+ ### System events
1096
+
1097
+ The orchestrator automatically emits completion events when workflows and jobs finish. No manual emission needed -- these fire automatically.
1098
+
1099
+ **Workflow completion:**
1100
+
1101
+ ```typescript
1102
+ import { workflowComplete } from '@kici-dev/sdk';
1103
+
1104
+ // Any workflow completion
1105
+ workflowComplete();
1106
+
1107
+ // Specific workflow by name
1108
+ workflowComplete({ name: 'build' });
1109
+
1110
+ // Only successful completions
1111
+ workflowComplete({ name: 'build', status: ['success'] });
1112
+ ```
1113
+
1114
+ **Config options:** `name`, `status` (`'success'`, `'failed'`, `'cancelled'`), `source`, `description`.
1115
+
1116
+ **Job completion:**
1117
+
1118
+ ```typescript
1119
+ import { jobComplete } from '@kici-dev/sdk';
1120
+
1121
+ // Any job completion
1122
+ jobComplete();
1123
+
1124
+ // Specific workflow + job
1125
+ jobComplete({ workflow: 'build', job: 'test' });
1126
+
1127
+ // Only failures
1128
+ jobComplete({ workflow: 'build', job: 'test', status: ['failed'] });
1129
+ ```
1130
+
1131
+ **Config options:** `workflow`, `job`, `status` (`'success'`, `'failed'`, `'cancelled'`, `'skipped'`), `source`, `description`.
1132
+
1133
+ ### External events
1134
+
1135
+ Generic webhooks let you trigger workflows from any HTTP service -- Stripe, ArgoCD, Slack, Grafana, or your own internal services.
1136
+
1137
+ ```typescript
1138
+ import { genericWebhook } from '@kici-dev/sdk';
1139
+
1140
+ // Match any event from a source
1141
+ genericWebhook({ source: 'stripe' });
1142
+
1143
+ // Match specific event types
1144
+ genericWebhook({ source: 'stripe', events: ['invoice.paid'] });
1145
+
1146
+ // With HMAC-SHA256 signature verification
1147
+ genericWebhook({
1148
+ source: 'stripe',
1149
+ events: ['invoice.paid'],
1150
+ auth: {
1151
+ method: 'hmac-sha256',
1152
+ secret: 'stripe-signing-key',
1153
+ signatureHeader: 'stripe-signature',
1154
+ },
1155
+ });
1156
+
1157
+ // With API key auth
1158
+ genericWebhook({
1159
+ source: 'slack',
1160
+ auth: { method: 'api-key', secret: 'slack-token' },
1161
+ });
1162
+ ```
1163
+
1164
+ **Config options:** `source` (required), `events`, `match`, `not`, `auth`, `path`, `description`.
1165
+
1166
+ 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.
1167
+
1168
+ ### Schedule events
1169
+
1170
+ Cron-based triggers evaluated by the orchestrator on a periodic interval. Only the Raft leader evaluates schedules in a clustered deployment.
1171
+
1172
+ ```typescript
1173
+ import { schedule } from '@kici-dev/sdk';
1174
+
1175
+ // Run every hour
1176
+ schedule({ cron: '0 * * * *' });
1177
+
1178
+ // Run daily at 2 AM UTC
1179
+ schedule({ cron: '0 2 * * *' });
1180
+
1181
+ // Run weekly on Mondays at 9 AM Eastern
1182
+ schedule({ cron: '0 9 * * 1', timezone: 'America/New_York' });
1183
+ ```
1184
+
1185
+ **Config options:** `cron` (required), `timezone` (defaults to `'UTC'`), `description`.
1186
+
1187
+ ### Lifecycle events
1188
+
1189
+ Lifecycle triggers listen for orchestrator-level events related to workflow execution and system state changes.
1190
+
1191
+ ```typescript
1192
+ import { lifecycle } from '@kici-dev/sdk';
1193
+
1194
+ // Trigger when any workflow completes
1195
+ lifecycle({ events: ['workflow_complete'] });
1196
+
1197
+ // Trigger on job failures from a specific repo
1198
+ lifecycle({ events: ['job_failed'], sources: ['org/deploy-repo'] });
1199
+
1200
+ // Trigger when registrations are updated
1201
+ lifecycle({ events: ['registration_updated'] });
1202
+ ```
1203
+
1204
+ **Available events:** `'workflow_complete'`, `'job_complete'`, `'job_failed'`, `'registration_updated'`.
1205
+
1206
+ **Config options:** `events` (required), `sources`, `description`.
1207
+
1208
+ ## The registration model
1209
+
1210
+ This is the most important concept for understanding event-based triggers.
1211
+
1212
+ ### Why registrations exist
1213
+
1214
+ 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.
1215
+
1216
+ 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.
1217
+
1218
+ ### How registration works
1219
+
1220
+ 1. You define a workflow with an event-based trigger (e.g., `schedule()`, `kiciEvent()`, `genericWebhook()`)
1221
+ 2. You compile the workflow (`kici compile`), which produces a lock file
1222
+ 3. You push the lock file to your repository's **default branch** (e.g., `main` or `master`)
1223
+ 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
1224
+ 5. Those workflows are stored in the orchestrator's registration database
1225
+ 6. From that point on, matching events will trigger those workflows
1226
+
1227
+ ### Key implications
1228
+
1229
+ - **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.
1230
+
1231
+ - **Registration is automatic.** There is no manual setup. Push your code, and the orchestrator handles the rest.
1232
+
1233
+ - **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.
1234
+
1235
+ - **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.
1236
+
1237
+ ### Practical example
1238
+
1239
+ You create a nightly build workflow:
1240
+
1241
+ ```typescript
1242
+ import { workflow, job, step, schedule } from '@kici-dev/sdk';
1243
+
1244
+ export default workflow('nightly-build', {
1245
+ on: schedule({ cron: '0 2 * * *' }),
1246
+ jobs: [
1247
+ job('build', {
1248
+ runsOn: 'linux',
1249
+ steps: [
1250
+ step('build', async ({ $ }) => {
1251
+ await $`pnpm build`;
1252
+ }),
1253
+ ],
1254
+ }),
1255
+ ],
1256
+ });
1257
+ ```
1258
+
1259
+ 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.
1260
+
1261
+ 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.
1262
+
1263
+ ## How events are matched
1264
+
1265
+ When an event arrives, the orchestrator follows this flow:
1266
+
1267
+ 1. **Event received** -- a custom event is emitted by a step, a cron timer fires, or a generic webhook arrives
1268
+ 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)
1269
+ 3. **Trigger evaluation** -- for each candidate workflow, the orchestrator evaluates the trigger conditions: event name patterns, payload matching, status filters, source filters
1270
+ 4. **Dispatch** -- matched workflows are dispatched to agents for execution, following the same job queue and agent routing as git-triggered workflows
1271
+
1272
+ This lookup is fast because the registration index is held in memory and refreshed only when the registry version changes (on default-branch pushes).
1273
+
1274
+ ### Cross-source webhook delivery
1275
+
1276
+ 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.
1277
+
1278
+ 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.
1279
+
1280
+ ## Circuit breaker
1281
+
1282
+ Events can trigger workflows that emit more events, creating chains. The circuit breaker prevents runaway event storms.
1283
+
1284
+ ### Chain depth limit
1285
+
1286
+ 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.
1287
+
1288
+ - **Default limit:** 10 levels deep
1289
+ - **What happens when hit:** the event is dropped and logged. It is not queued for later delivery.
1290
+
1291
+ 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.
1292
+
1293
+ ### Rate limiting
1294
+
1295
+ Each workflow is rate-limited on how many events it can process per minute, using a sliding window.
1296
+
1297
+ - **Default limit:** 100 events per workflow per minute
1298
+ - **What happens when hit:** additional events for that workflow are dropped and logged until the window clears.
1299
+
1300
+ These defaults are hardcoded in the orchestrator and are not currently configurable via environment variables.
1301
+
1302
+ ## Delivery guarantees
1303
+
1304
+ KiCI's event router delivers every accepted event with **at-least-once** semantics:
1305
+
1306
+ - An event that passes the circuit breaker (chain depth + rate limit) and commits
1307
+ to the `kici_events` table is guaranteed to dispatch to all matching workflows
1308
+ at least once.
1309
+ - Each dispatch attempt acquires a short-lived lease (default 60 s) on the row.
1310
+ If the dispatching node crashes or the handler throws, the lease expires (or
1311
+ is released on failure) and the event is automatically retried.
1312
+ - The retry policy is exponential backoff with full jitter: base 5 s, cap 5 min,
1313
+ up to 5 attempts before the event lands in the **DLQ** (dead-letter queue).
1314
+ Operators triage DLQ entries via `kici-admin event-dlq list / count / retry / discard`.
1315
+
1316
+ **What this means for workflow authors:**
1317
+
1318
+ - **Make event handlers idempotent.** A retried dispatch may run a handler more
1319
+ than once (e.g. if the first attempt threw after a partial side-effect).
1320
+ Workflows that mutate external state should use idempotency keys, conditional
1321
+ writes, or other deduplication patterns — same advice as for any distributed
1322
+ CI system.
1323
+ - **Schedule fires are at-least-once too.** A cron schedule that fires while a
1324
+ leader is being killed will commit (atomically with `cron_last_fired`) or roll
1325
+ back together — never half. Recovery on the new leader does not backfill
1326
+ multiple missed instants; if your workflow needs at-least-N guarantees across
1327
+ outages, drive it from a different mechanism (e.g. a workflow that runs more
1328
+ frequently and emits its own custom event).
1329
+ - **Drops are still possible — and visible.** Events rejected by the circuit
1330
+ breaker (chain depth or rate limit exceeded) are dropped and logged, not
1331
+ retried. That's a deliberate safety mechanism; the metric to watch is
1332
+ `kici_orch_events_dropped_total{reason}`.
1333
+
1334
+ ## Emitting custom events
1335
+
1336
+ Custom events are emitted from workflow steps using `ctx.emit()`. You can optionally define typed event schemas using `defineEvent()`.
1337
+
1338
+ ### Basic emission
1339
+
1340
+ ```typescript
1341
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1342
+
1343
+ export default workflow('build', {
1344
+ on: push({ branches: 'main' }),
1345
+ jobs: [
1346
+ job('build', {
1347
+ runsOn: 'linux',
1348
+ steps: [
1349
+ step('build', async ({ $ }) => {
1350
+ await $`pnpm build`;
1351
+ }),
1352
+ step('notify', async (ctx) => {
1353
+ await ctx.emit('build-complete', {
1354
+ version: '1.0.0',
1355
+ success: true,
1356
+ });
1357
+ }),
1358
+ ],
1359
+ }),
1360
+ ],
1361
+ });
1362
+ ```
1363
+
1364
+ ### Typed event definitions
1365
+
1366
+ Use `defineEvent()` with Zod schemas to create a typed contract for event payloads:
1367
+
1368
+ ```typescript
1369
+ import { defineEvent, z } from '@kici-dev/sdk';
1370
+
1371
+ export const deployComplete = defineEvent(
1372
+ 'deploy-complete',
1373
+ z.object({
1374
+ env: z.string(),
1375
+ version: z.string(),
1376
+ services: z.array(z.string()),
1377
+ }),
1378
+ );
1379
+ ```
1380
+
1381
+ Then emit using the definition's name:
1382
+
1383
+ ```typescript
1384
+ step('emit', async (ctx) => {
1385
+ await ctx.emit(deployComplete.name, {
1386
+ env: 'prod',
1387
+ version: '1.2.3',
1388
+ services: ['api', 'web'],
1389
+ });
1390
+ });
1391
+ ```
1392
+
1393
+ And consume in another workflow:
1394
+
1395
+ ```typescript
1396
+ import { workflow, job, step, kiciEvent } from '@kici-dev/sdk';
1397
+
1398
+ export default workflow('post-deploy', {
1399
+ on: kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }),
1400
+ jobs: [
1401
+ job('smoke-test', {
1402
+ runsOn: 'linux',
1403
+ steps: [
1404
+ step('test', async ({ $ }) => {
1405
+ await $`./scripts/smoke-test.sh`;
1406
+ }),
1407
+ ],
1408
+ }),
1409
+ ],
1410
+ });
1411
+ ```
1412
+
1413
+ 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.
1414
+
1415
+ ## See also
1416
+
1417
+ - [SDK reference: event triggers](https://docs.kici.dev/user/sdk/triggers/#event-triggers) -- complete API signatures for all trigger builders
1418
+ - [SDK reference: emitting events](https://docs.kici.dev/user/sdk/validation-events/#emitting-events) -- `ctx.emit()` and `defineEvent()` API
1419
+ - [Workflow patterns: workflow chaining](https://docs.kici.dev/user/patterns/integrations/#workflow-chaining) -- examples of event-driven workflow chains
1420
+ - [Operator guide: event routing](https://docs.kici.dev/operator/event-routing/) -- configuring generic webhook sources, trust relationships, and event routing
1421
+ - [Architecture: event system](https://docs.kici.dev/architecture/webhooks/event-system/) -- internal event routing design, registration model, cluster synchronization
1422
+
1423
+ ---
1424
+
1425
+ ## Global workflows
1426
+
1427
+ Source: https://docs.kici.dev/user/global-workflows/
1428
+
1429
+ 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."
1430
+
1431
+ 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.
1432
+
1433
+ ## Mental model
1434
+
1435
+ | Term | Meaning |
1436
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
1437
+ | Workflow repo | The repo whose `.kici/workflows/*.ts` file **declares** the global workflow. Holds the steps. Also known as the _authoring_ repo. |
1438
+ | 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. |
1439
+ | Global | A workflow whose trigger carries one or more `repos:` glob patterns. The presence of `repos:` is what classifies a workflow as global. |
1440
+ | Authoring axis | Policy that answers "which repos may **author** global workflows?" Controlled by the allow-list in the dashboard's _Workflow authors_ setting. |
1441
+ | Source axis | Policy that answers "which **source** repos' events are allowed to trigger global workflows?" Controlled by the deny-list in _Blocked source repos_. |
1442
+
1443
+ 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.
1444
+
1445
+ ## Declaring a global workflow
1446
+
1447
+ Add `repos:` to any trigger. Any workflow with at least one `repos:`-bearing trigger becomes global automatically; no separate flag is required.
1448
+
1449
+ ```ts
1450
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1451
+
1452
+ export default workflow('org-lint', {
1453
+ on: [
1454
+ push({
1455
+ repos: ['myorg/*', '!myorg/archived-*'],
1456
+ branches: ['main'],
1457
+ }),
1458
+ ],
1459
+ jobs: [
1460
+ job('lint', {
1461
+ steps: [
1462
+ step('lint-all', async ({ $, env }) => {
1463
+ await $`echo source=${env.KICI_SOURCE_REPO_PATH ?? 'unknown'}`;
1464
+ await $`npm run lint`;
1465
+ }),
1466
+ ],
1467
+ }),
1468
+ ],
1469
+ });
1470
+ ```
1471
+
1472
+ 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.
1473
+
1474
+ ### At a dual-repo checkout
1475
+
1476
+ The agent receives two sets of context during a global workflow execution:
1477
+
1478
+ | `env` var | Points to |
1479
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
1480
+ | `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. |
1481
+ | `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. |
1482
+
1483
+ Source repo secrets are **not** available to a global workflow's job by default — see _Elevated access_ below.
1484
+
1485
+ ## Enabling global workflows
1486
+
1487
+ Global workflows are **opt-in per org**. In a fresh org, `repos:`-bearing workflows are registered but never dispatched.
1488
+
1489
+ 1. Open the dashboard → **Settings → Global workflows**.
1490
+ 2. Turn on **Enable global workflows** (the master toggle). This is the kill-switch — every other toggle below is ignored while this is off.
1491
+ 3. Decide which authoring/source controls you need:
1492
+
1493
+ | Setting | What it controls | Typical use |
1494
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
1495
+ | Workflow authors | 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. |
1496
+ | 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-*`. |
1497
+ | Elevated access | Authoring repos listed here get **read access to source-repo secrets** during execution. Globs matched against the authoring repo identifier. | A `myorg/ci-deploy` repo that needs to read a source repo's `NPM_TOKEN` to publish releases. |
1498
+
1499
+ 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-*`).
1500
+
1501
+ ### Saving and reverting
1502
+
1503
+ 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.
1504
+
1505
+ ## Security model
1506
+
1507
+ ### Two independent axes
1508
+
1509
+ A global workflow fires only if:
1510
+
1511
+ 1. **The authoring repo is allowed.** If _Workflow authors_ 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:
1512
+ - At registration time (extraction from the lock file — non-matching globals are dropped with a warning).
1513
+ - At dispatch time (defense-in-depth — policy changes after registration still take effect).
1514
+ 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.
1515
+
1516
+ Both checks are logged to the orchestrator. Grep the logs for `Skipping global workflow` to see enforcement in action.
1517
+
1518
+ ### Elevated access (source-repo secrets)
1519
+
1520
+ By default a global workflow's job runs with credentials scoped to the **workflow** repo — it can clone both repos but cannot read the source repo's scoped secrets. That's the safe default: a random workflow in `myorg/ci-pipelines` does not get read access to secrets in `myorg/backend` just because it runs on a push there.
1521
+
1522
+ Adding the authoring repo to the _Elevated access_ list flips that: the job receives the source repo's secret context, so deploy and release flows that need `NPM_TOKEN` / `AWS_ROLE_ARN` / etc. from the source repo can read them. Treat elevated repos as effective owners of every source repo's CI secrets — only add repos you fully trust.
1523
+
1524
+ ## When does it fire?
1525
+
1526
+ 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.
1527
+
1528
+ 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:`.
1529
+
1530
+ ## Troubleshooting
1531
+
1532
+ | Symptom | Likely cause | Where to look |
1533
+ | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
1534
+ | 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` / `Skipping global workflow registration: not permitted` |
1535
+ | `repos:` has no effect — workflow only fires on its own repo | Master toggle OFF. Without opt-in, the orchestrator treats the workflow as per-repo-only. | Dashboard → Settings → Global workflows (top toggle) |
1536
+ | Source repo secrets unavailable in a global job | Expected default — elevate the authoring repo to grant access. | Dashboard → Settings → Global workflows → _Elevated access_ |
1537
+ | 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. |
1538
+
1539
+ ## See also
1540
+
1541
+ - [Architecture — global workflows](https://docs.kici.dev/architecture/global-workflows/) — dual-query dispatch flow, cross-provider auth, security model, lock-file schema.
1542
+ - [Universal-git provider](https://docs.kici.dev/user/providers/universal-git/#global-workflows) — how global workflows interact with `generic:<orgId>:<sourceId>` routing keys.
1543
+ - [SDK reference](https://docs.kici.dev/user/sdk-reference/) — the full set of triggers that accept `repos:`.
1544
+
1545
+ ---
1546
+
1547
+ ## Idempotent steps and check mode
1548
+
1549
+ Source: https://docs.kici.dev/user/idempotent-steps/
1550
+
1551
+ An **idempotent step** describes _desired state_ rather than a fixed sequence of
1552
+ commands. You give the step a `check` function that inspects the world and a
1553
+ `run` function that converges it. KiCI then executes the workflow in one of two
1554
+ modes:
1555
+
1556
+ - **Apply mode** (the default): for each step, `check()` runs first; on drift the
1557
+ step applies the change; when already in sync the step is skipped.
1558
+ - **Check mode** (`--check`): for each step, `check()` runs and KiCI reports what
1559
+ _would_ change — **without changing anything**. This is the same model as a
1560
+ dry-run plan: you see the drift before any side effect happens.
1561
+
1562
+ This turns a workflow into convergent configuration management: re-running an
1563
+ apply is safe (in-sync steps do nothing), and a check-mode run is a read-only
1564
+ preview you can gate a build on.
1565
+
1566
+ ## Authoring a checked step
1567
+
1568
+ Add a `check` facet to the existing `step()` factory. When `check` is present,
1569
+ `run` becomes the _apply_ function and receives the drift value `check`
1570
+ returned:
1571
+
1572
+ ```typescript
1573
+ import { step, z } from '@kici-dev/sdk';
1574
+
1575
+ const configureNginx = step('configure-nginx', {
1576
+ // optional schema for the drift value — gives the dashboard a typed shape
1577
+ drift: z.object({ want: z.string() }),
1578
+
1579
+ // read-only inspection; return null when already in the desired state
1580
+ check: async (ctx) => {
1581
+ const current = await ctx.$`nginx -T`;
1582
+ return current.stdout.includes(DESIRED) ? null : { want: DESIRED };
1583
+ },
1584
+
1585
+ // human-readable preview line — REQUIRED when check is set. It is the drift's
1586
+ // serializable face: it streams to the logs and persists for the dashboard.
1587
+ summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
1588
+
1589
+ // apply — runs only when check returned drift (apply mode); receives that drift
1590
+ run: async (ctx, drift) => {
1591
+ await writeConfig(drift.want);
1592
+ return { reloaded: true };
1593
+ },
1594
+
1595
+ // optional — runs when check returned null, to produce the step's outputs
1596
+ whenInSync: async () => ({ reloaded: false }),
1597
+ });
1598
+ ```
1599
+
1600
+ ### The facet fields
1601
+
1602
+ | Field | Required | Purpose |
1603
+ | ------------ | ---------------- | ---------------------------------------------------------------------- |
1604
+ | `check` | to opt in | Read-only inspection. Return a drift value, or `null` when in sync. |
1605
+ | `summarize` | when `check` set | Human-readable, serializable preview of the drift. Streams + persists. |
1606
+ | `run` | always | Apply function. With `check`, it receives the drift as its second arg. |
1607
+ | `whenInSync` | optional | Produces the step's outputs when `check` returned `null`. |
1608
+ | `drift` | optional | Schema that validates / shapes the drift value. |
1609
+
1610
+ `summarize` is **required** whenever `check` is declared. `run` and `whenInSync`
1611
+ both produce the same output type — one output shape per step, whichever path
1612
+ runs. Every other step facet (`cache`, `rules`, `continueOnError`, `timeout`,
1613
+ `approval`, `onCancel`, `cleanup`, `outputs`) composes unchanged.
1614
+
1615
+ A plain `step()` without `check` keeps its exact current behavior — the check
1616
+ facet is fully optional.
1617
+
1618
+ ## Run modes
1619
+
1620
+ A run carries one of three modes:
1621
+
1622
+ | Mode | CLI flags | Behavior |
1623
+ | --------------------- | ------------------------- | ------------------------------------------------------------------------------------------ |
1624
+ | `apply` | (default, no flags) | Converge: drift ⇒ apply ⇒ **applied**; null ⇒ **in sync** (skipped). |
1625
+ | `check` | `--check` | Preview only: drift ⇒ **would change**; null ⇒ **in sync**. Never applies. Always exits 0. |
1626
+ | `check-fail-on-drift` | `--check --fail-on-drift` | Same as check, but the run **fails** if any step reports drift. |
1627
+
1628
+ Per-step outcomes:
1629
+
1630
+ - **applied** — drift was found and the step applied the change (apply mode).
1631
+ - **in sync** — `check` returned `null`; nothing to do.
1632
+ - **would change** — drift was found in check mode; the change was previewed, not applied.
1633
+ - **no check** — a plain step (no `check`) reached under check mode. A
1634
+ side-effecting step can't be safely previewed, so it is skipped.
1635
+
1636
+ In check mode KiCI never invokes a checked step's `run` (apply) — the preview is
1637
+ guaranteed side-effect-free.
1638
+
1639
+ ## Running in check mode
1640
+
1641
+ `--check` and `--fail-on-drift` work on both local and remote runs:
1642
+
1643
+ ```bash
1644
+ # Apply (default): converge the workflow.
1645
+ kici run local push
1646
+ kici run remote my-fixture
1647
+
1648
+ # Check: report drift, change nothing. Always exits 0.
1649
+ kici run local push --check
1650
+ kici run remote my-fixture --check
1651
+
1652
+ # Check + fail on drift: exit non-zero (2) locally, or fail the run remotely,
1653
+ # when any step reports drift. Use this as a CI gate ("fail the build if prod
1654
+ # has drifted").
1655
+ kici run local push --check --fail-on-drift
1656
+ ```
1657
+
1658
+ `--fail-on-drift` only modifies check mode — passing it without `--check` is an
1659
+ error.
1660
+
1661
+ ## Where outcomes show up
1662
+
1663
+ A check-mode run is labeled in the dashboard with a **CHECK MODE — preview**
1664
+ badge on the run header. Each step shows its outcome chip — applied / in sync /
1665
+ would change / no check — and, when drift was detected, the `summarize` line
1666
+ describing what would change. The rendering is read-only.
1667
+
1668
+ ## See also
1669
+
1670
+ - [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.
1671
+ - [Core SDK reference](https://docs.kici.dev/user/sdk/core/) — the `step()`, `job()`, and `workflow()` factories the check facet extends.
1672
+ - [Lock file and drift](https://docs.kici.dev/user/lock-file-and-drift/) — how the lock file carries step capability flags.
1673
+
1674
+ ---
1675
+
1676
+ ## Private npm registries
1677
+
1678
+ Source: https://docs.kici.dev/user/private-registries/
1679
+
1680
+ A workflow's `.kici/package.json` may depend on packages published to a private registry — your org's internal CodeArtifact, a GitHub Packages scope, a self-hosted Verdaccio, JFrog, Cloudsmith, GitLab, etc. KiCI ships two ways to authenticate `npm install` against those registries from inside a job, plus an escape hatch for short-lived tokens.
1681
+
1682
+ ## Choose a path
1683
+
1684
+ | Path | When to pick it |
1685
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1686
+ | **Option A — `registries:` block in the workflow** | The token is a long-lived secret you rotate manually (GH Packages PAT, CodeArtifact IAM access key, Verdaccio service token). KiCI manages the `.npmrc` for you. |
1687
+ | **Option C — Committed `.kici/.npmrc` + `installEnv:`** | You already have an `.npmrc` you want to keep verbatim (e.g. it carries an `audit=false` line, a custom CA, or a complex multi-scope mapping). KiCI just supplies the env vars your `${VAR}` references need. |
1688
+ | **Setup-step pattern (short-lived tokens)** | The token is minted at workflow time (CodeArtifact authorization token, GCP Artifact Registry token). A `setup` job runs the cloud CLI, writes a fresh `.kici/.npmrc`, and the install jobs read it. |
1689
+
1690
+ The two channels (Option A and Option C) compose. If you declare both, the agent's auto-generated lines come **after** your committed `.npmrc`, so npm's last-wins semantics let agent-managed registries override committed ones — never the other way around.
1691
+
1692
+ ## Option A — `registries:` block
1693
+
1694
+ Declare the registry in your workflow file and point its `tokenSecret` at a scoped secret using the qualified `<environment>:<secret-name>` syntax. The orchestrator resolves the token at dispatch time and the agent applies it for one `npm install` only.
1695
+
1696
+ ```typescript
1697
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1698
+
1699
+ export default workflow('build', {
1700
+ on: [push({ branches: ['main'] })],
1701
+ registries: [
1702
+ {
1703
+ url: 'https://npm.pkg.github.com/',
1704
+ scope: '@my-org',
1705
+ tokenSecret: 'production:GITHUB_PACKAGES_TOKEN',
1706
+ },
1707
+ ],
1708
+ jobs: [
1709
+ job('build', {
1710
+ runsOn: 'default',
1711
+ environment: 'production',
1712
+ steps: [
1713
+ step('install-and-build', async (ctx) => {
1714
+ // .kici/package.json can now reference @my-org/* packages
1715
+ await ctx.$`npm run build`;
1716
+ }),
1717
+ ],
1718
+ }),
1719
+ ],
1720
+ });
1721
+ ```
1722
+
1723
+ Per-field rules:
1724
+
1725
+ - **`url`** — Must be HTTPS. HTTP is permitted only for `localhost` / `127.0.0.0/8` / `::1` / `*.local` hosts, or when an operator has flipped the org-level `allow_http_npm_registries` toggle (see [`kici-admin org-settings allow-http-npm`](https://docs.kici.dev/operator/kici-admin-cli#allow-http-npm--permit-non-https-private-npm-registries)).
1726
+ - **`scope`** — Optional. When present, the registry serves only that scope (`@my-org`). When absent, this entry becomes the **default** registry — at most one entry may omit `scope`.
1727
+ - **`tokenSecret`** — Mandatory `<environment>:<secret-name>`. The orchestrator looks up the secret in the named environment via the per-environment secret resolver. The bare name **must not** contain a colon.
1728
+ - **`alwaysAuth`** — Defaults to `true`. Forces npm to send the token on every request (even GETs), which is what most managed-registry providers require.
1729
+
1730
+ ### How tokens reach `npm install`
1731
+
1732
+ The agent never writes the token bytes to your `.kici/.npmrc`. Each registry token is exposed to the install subprocess as a job-scoped env var (`KICI_NPM_TOKEN_<jobIdShort>_<i>`), and the on-disk auth line carries a `${VAR}` reference that npm substitutes at read time. The job-scoped nonce makes the env var name unguessable from outside the install subprocess.
1733
+
1734
+ After the install completes (success or failure), the agent restores the original `.kici/.npmrc` — your committed file is never permanently modified.
1735
+
1736
+ ## Option C — committed `.kici/.npmrc` + `installEnv:`
1737
+
1738
+ If you'd rather hand-craft the `.npmrc`, commit it under `.kici/.npmrc` with `${VAR}` placeholders, then list each variable in the workflow's `installEnv:` block using the same qualified syntax as `tokenSecret`.
1739
+
1740
+ ```ini
1741
+ # .kici/.npmrc
1742
+ @my-org:registry=https://npm.example.com/
1743
+ //npm.example.com/:_authToken=${MY_NPM_TOKEN}
1744
+ //npm.example.com/:always-auth=true
1745
+ audit=false
1746
+ ```
1747
+
1748
+ ```typescript
1749
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1750
+
1751
+ export default workflow('build', {
1752
+ on: [push({ branches: ['main'] })],
1753
+ installEnv: ['production:MY_NPM_TOKEN'],
1754
+ jobs: [
1755
+ job('build', {
1756
+ runsOn: 'default',
1757
+ environment: 'production',
1758
+ steps: [step('build', async (ctx) => ctx.$`npm run build`)],
1759
+ }),
1760
+ ],
1761
+ });
1762
+ ```
1763
+
1764
+ The orchestrator resolves `MY_NPM_TOKEN` from the `production` environment's secret store and seeds it as `MY_NPM_TOKEN` (bare name) in the install subprocess. Your committed `.npmrc` reads it through `${MY_NPM_TOKEN}`.
1765
+
1766
+ This path is the right answer when:
1767
+
1768
+ - The `.npmrc` carries non-auth knobs (`audit=false`, `legacy-peer-deps=true`, custom CA bundles).
1769
+ - You want a single source of truth for registry topology that `npm` tooling outside KiCI can consume too.
1770
+ - The auth lines reference the **same** env var across multiple registries.
1771
+
1772
+ ## Short-lived tokens (CodeArtifact, GCP Artifact Registry)
1773
+
1774
+ AWS CodeArtifact authorization tokens expire after 12 hours; GCP Artifact Registry tokens after 60 minutes. Storing one as a long-lived `tokenSecret` does not work — by the time a build runs, the token may be expired.
1775
+
1776
+ The supported pattern is a **setup job** that mints a fresh token, writes `.kici/.npmrc`, and downstream jobs install with it.
1777
+
1778
+ ```typescript
1779
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1780
+
1781
+ export default workflow('build', {
1782
+ on: [push({ branches: ['main'] })],
1783
+ jobs: [
1784
+ job('mint-codeartifact-token', {
1785
+ runsOn: 'default',
1786
+ environment: 'production',
1787
+ steps: [
1788
+ step('mint', async (ctx) => {
1789
+ const awsKey = await ctx.secrets.get('AWS_ACCESS_KEY_ID');
1790
+ const awsSecret = await ctx.secrets.get('AWS_SECRET_ACCESS_KEY');
1791
+ process.env.AWS_ACCESS_KEY_ID = awsKey;
1792
+ process.env.AWS_SECRET_ACCESS_KEY = awsSecret;
1793
+
1794
+ const token = (
1795
+ await ctx.$`aws codeartifact get-authorization-token --domain my-domain --query authorizationToken --output text`
1796
+ ).stdout.trim();
1797
+
1798
+ // Write directly into the workspace's .kici/ — the next job reuses the same workspace.
1799
+ const npmrc = [
1800
+ '@my-org:registry=https://my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/',
1801
+ `//my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/:_authToken=${token}`,
1802
+ '//my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/:always-auth=true',
1803
+ '',
1804
+ ].join('\n');
1805
+ await ctx.$`tee .kici/.npmrc`.stdin(npmrc);
1806
+ }),
1807
+ ],
1808
+ }),
1809
+ job('build', {
1810
+ runsOn: 'default',
1811
+ environment: 'production',
1812
+ needs: ['mint-codeartifact-token'],
1813
+ steps: [step('build', async (ctx) => ctx.$`npm run build`)],
1814
+ }),
1815
+ ],
1816
+ });
1817
+ ```
1818
+
1819
+ The same pattern works for GCP Artifact Registry — replace the `aws codeartifact` call with `gcloud auth print-access-token`. The manual setup-step shown here is the supported path for these short-lived flows.
1820
+
1821
+ ## Provider-specific examples
1822
+
1823
+ ### GitHub Packages
1824
+
1825
+ ```typescript
1826
+ registries: [
1827
+ {
1828
+ url: 'https://npm.pkg.github.com/',
1829
+ scope: '@my-org',
1830
+ tokenSecret: 'production:GITHUB_PACKAGES_TOKEN',
1831
+ },
1832
+ ],
1833
+ ```
1834
+
1835
+ Mint the token from a fine-grained PAT with `read:packages` scope, store it as a scoped secret in the `production` environment.
1836
+
1837
+ ### GitLab Packages
1838
+
1839
+ ```typescript
1840
+ registries: [
1841
+ {
1842
+ url: 'https://gitlab.example.com/api/v4/projects/123/packages/npm/',
1843
+ scope: '@my-group',
1844
+ tokenSecret: 'production:GITLAB_DEPLOY_TOKEN',
1845
+ },
1846
+ ],
1847
+ ```
1848
+
1849
+ Use a project- or group-level deploy token with `read_package_registry` scope.
1850
+
1851
+ ### Verdaccio (self-hosted)
1852
+
1853
+ ```typescript
1854
+ registries: [
1855
+ {
1856
+ url: 'https://npm.internal.example.com/',
1857
+ tokenSecret: 'production:VERDACCIO_TOKEN',
1858
+ },
1859
+ ],
1860
+ ```
1861
+
1862
+ For local development against a Verdaccio container, point at `http://localhost:4873/` — the loopback exemption means the operator does NOT need to flip `allow_http_npm_registries`.
1863
+
1864
+ ### JFrog Artifactory
1865
+
1866
+ ```typescript
1867
+ registries: [
1868
+ {
1869
+ url: 'https://artifactory.example.com/artifactory/api/npm/npm-virtual/',
1870
+ scope: '@my-org',
1871
+ tokenSecret: 'production:JFROG_API_KEY',
1872
+ },
1873
+ ],
1874
+ ```
1875
+
1876
+ ### Cloudsmith
1877
+
1878
+ ```typescript
1879
+ registries: [
1880
+ {
1881
+ url: 'https://npm.cloudsmith.io/my-org/my-repo/',
1882
+ scope: '@my-org',
1883
+ tokenSecret: 'production:CLOUDSMITH_TOKEN',
1884
+ },
1885
+ ],
1886
+ ```
1887
+
1888
+ ## Security model
1889
+
1890
+ - **Per-environment scoping.** Every `tokenSecret` and `installEnv` entry is qualified with an environment name. The orchestrator runs the same protection-rule pipeline (branch / trust / concurrency / reviewer / wait-timer) against each named environment **before** resolving any secret, so a workflow that wants a `production` token from a feature branch is rejected exactly like a job that tries to deploy to `production` from a feature branch. A reviewer-gated install environment **pauses** the whole workflow dispatch as a workflow-scoped held run instead of resolving the token — see [Reviewer-gated installs](https://docs.kici.dev/user/private-registries/#reviewer-gated-installs) below.
1891
+ - **Untrusted contributors get no tokens.** When a fork PR is dispatched and the contributor-trust resolution returns anything other than `trusted`, the orchestrator strips both `npmRegistries` and `installEnvSecrets` out of the dispatch. The install runs without auth and fails naturally on the first private dep — fork PRs cannot ever observe a registry token, even if a misconfigured environment lacks an explicit `requiredTrustTier`.
1892
+ - **Lifecycle scripts disabled.** Whenever a private registry is in scope, the agent runs the install with `--ignore-scripts` (npm or pnpm alike). A malicious `preinstall` / `postinstall` hook in committed `package.json` cannot read the synthesized token env vars, even though they exist in the install subprocess. For a pnpm workspace, the agent builds your in-repo dependency closure as a separate step **after** the install's auth is torn down, so build scripts never see the tokens either.
1893
+ - **Stderr is redacted.** If the install fails, the agent masks every token literal out of the surfaced stderr / stdout chunks before logging.
1894
+ - **Job-scoped env-var names.** The synthesized auth env var is `KICI_NPM_TOKEN_<jobIdShort>_<i>` where `jobIdShort` is the first 8 chars of the dispatched job id. The name is unguessable from outside the install subprocess and not reused across jobs.
1895
+ - **`.npmrc` restored.** Whatever the agent appended for one install is stripped (or the file unlinked) on cleanup, so the workspace is never permanently modified.
1896
+
1897
+ ## Reviewer-gated installs
1898
+
1899
+ When the named install environment carries a protection rule that holds — a required reviewer (`hold`) or a wait timer (`wait`) — the install gate **pauses the whole workflow dispatch** instead of rejecting it. The run is created in the `held` state, no jobs are queued, and a workflow-scoped row appears on the held-runs page with a `Workflow` scope badge.
1900
+
1901
+ - **Reviewer hold:** the run waits for an approver. On approval the dispatch resumes from the install gate, resolves the token, and dispatches its jobs as a normal run. On rejection the run transitions to `cancelled` — no jobs ever run.
1902
+ - **Wait timer:** the run waits out the timer and resumes automatically when it elapses.
1903
+
1904
+ A `reject` protection outcome (for example a disabled environment or a branch the environment forbids) still fails the dispatch loudly with a clear reason, exactly as before — the orchestrator never dispatches a run with an unresolved install token.
1905
+
1906
+ ## Limitations
1907
+
1908
+ - **`registries:` is workflow-level only in v1.** Per-job overrides aren't supported — there is one shared `.kici/` per workspace, so a per-job `registries:` would be physically nonsensical.
1909
+ - **Container registries (Docker Hub, ECR, GHCR) are out of scope.** This feature covers **npm** registry auth only. Container image pulls travel through the executor backend's own credential paths.
1910
+
1911
+ ## Observability
1912
+
1913
+ The orchestrator exposes Prometheus counters and a histogram under the `kici_orch_install_secrets_*` prefix on its `/metrics` endpoint. They populate the **Install secrets resolution** Grafana dashboard and let operators graph install-secrets activity without digging through Loki.
1914
+
1915
+ | Metric | Type | Labels | What it tells you |
1916
+ | ------------------------------------------------------------- | --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1917
+ | `kici_orch_install_secrets_decisions_total` | Counter | `decision`, `reason` | Pass / reject / hold volume. `decision=hold` (reason `held`) counts dispatches paused at a reviewer-gated install environment. Reject reasons enumerate the failure mode: `malformed_ref`, `invalid_url_scheme`, `env_not_found`, `protection_rule_block`, `missing_token`, `missing_install_env`, etc. |
1918
+ | `kici_orch_install_secrets_npm_registry_used_total` | Counter | `channel`, `provider`, `scope` | Per-channel + per-scope usage. `channel=registries` is Option A, `channel=install_env` is Option C. `scope=default` marks a no-scope default registry; `scope=-` marks Option C entries. |
1919
+ | `kici_orch_install_secrets_contributor_stripped_total` | Counter | `trust_tier` | Number of dispatches where registry tokens were stripped because the contributor tier wasn't `trusted` (fork PRs from unknown / known contributors). Expected to be 0 in single-tenant orgs. |
1920
+ | `kici_orch_install_secrets_token_resolution_duration_seconds` | Histogram | `environment` | Latency of per-environment secret resolution. Pathological tails (>500ms) usually mean a Vault timeout or a slow Postgres replica. |
1921
+
1922
+ The dashboard JSON lives at `infra/terraform/modules/grafana/dashboards/install-secrets.json`; if you maintain your own monitoring stack, you can import it directly.
1923
+
1924
+ ## See also
1925
+
1926
+ - [Secrets](https://docs.kici.dev/user/secrets/) — how to seed the `<environment>:<secret-name>` values referenced by `tokenSecret` / `installEnv`.
1927
+ - [Environments](https://docs.kici.dev/user/environments/) — protection rules (`branch_restrictions`, `requires_review`, `minimum_trust`) that the install gate inherits.
1928
+ - [Operator: `kici-admin org-settings`](https://docs.kici.dev/operator/kici-admin-cli#org-settings----org-level-security-policy) — the `allow_http_npm_registries` toggle and other org-scoped knobs.
1929
+
1930
+ ---
1931
+
1932
+ ## Build provenance and attestations
1933
+
1934
+ Source: https://docs.kici.dev/user/provenance/
1935
+
1936
+ Build provenance is a signed, verifiable statement of **what produced an
1937
+ artifact** — the source repository, commit, ref, workflow path, and builder that
1938
+ ran. When a workflow step attests an artifact, KiCI records that statement,
1939
+ signs it, and makes it retrievable so anyone can later prove the artifact came
1940
+ from a specific KiCI run and was not swapped along the way.
1941
+
1942
+ This is the same idea behind supply-chain attestation systems like
1943
+ [SLSA](https://slsa.dev/spec/v1.0/provenance): a downstream consumer (a release
1944
+ gate, a security audit, a `"show me the provenance"` request) can verify the
1945
+ artifact's origin without trusting the person who handed it over.
1946
+
1947
+ ## What an attestation contains
1948
+
1949
+ An attestation is a self-contained bundle holding three things:
1950
+
1951
+ - An **in-toto SLSA v1.0 statement** describing the build: the subject artifact
1952
+ (name + content digest) and the provenance predicate (source repository,
1953
+ commit, ref, workflow, run/job identifiers, timestamps).
1954
+ - A **[DSSE](https://github.com/secure-systems-lab/dsse) signature** over that
1955
+ statement, made with an ephemeral signing key generated for the run.
1956
+ - A short-lived **OIDC identity token** issued by the KiCI platform that binds
1957
+ the signature to the build identity. The token's identity claims
1958
+ (`repository`, `ref`, `sha`, run/job ids) are derived by the platform from the
1959
+ run itself — a step cannot forge them.
1960
+
1961
+ Because the bundle carries the identity token and the public signing key, it is
1962
+ **offline-verifiable**: a verifier checks it against the platform's published
1963
+ signing keys with no per-attestation online lookup.
1964
+
1965
+ ## Attesting an artifact in a workflow
1966
+
1967
+ Call `ctx.attestProvenance({ subject })` from a step after you have produced the
1968
+ artifact:
1969
+
1970
+ ```typescript
1971
+ import { workflow, job, step } from '@kici-dev/sdk';
1972
+
1973
+ export default workflow('release', {
1974
+ on: { push: { branches: ['main'] } },
1975
+ jobs: [
1976
+ job('publish', {
1977
+ steps: [
1978
+ step('build', async (ctx) => {
1979
+ await ctx.$`npm pack`;
1980
+ }),
1981
+ step('attest', async (ctx) => {
1982
+ const result = await ctx.attestProvenance({
1983
+ subject: { name: 'my-pkg-1.2.3.tgz', path: 'my-pkg-1.2.3.tgz' },
1984
+ });
1985
+ ctx.log.info(`Attestation stored at ${result.storageKey}`);
1986
+ }),
1987
+ ],
1988
+ }),
1989
+ ],
1990
+ });
1991
+ ```
1992
+
1993
+ The **subject is caller-supplied** — you name the artifact and give KiCI either a
1994
+ path or a precomputed digest:
1995
+
1996
+ - `{ name, path }` — a path relative to the step working directory. KiCI reads
1997
+ the file and computes its SHA-256 digest.
1998
+ - `{ name, digest }` — a precomputed digest. For a container image, pass the OCI
1999
+ manifest digest your build tool emitted:
2000
+
2001
+ ```typescript
2002
+ await ctx.attestProvenance({
2003
+ subject: { name: 'ghcr.io/acme/app', digest: { sha256: '<manifest-digest>' } },
2004
+ });
2005
+ ```
2006
+
2007
+ The identity token is fetched and masked in logs automatically — you never
2008
+ handle it. The call returns `{ storageKey, subjectDigest, bundleMediaType }`
2009
+ identifying the stored bundle.
2010
+
2011
+ `ctx.attestProvenance` is only available inside a running job step. Calling it
2012
+ during local execution rejects with a clear error.
2013
+
2014
+ ### Requesting a raw identity token
2015
+
2016
+ `ctx.attestProvenance` builds on a lower-level primitive you can call directly
2017
+ when you need the identity token for a different tool:
2018
+
2019
+ ```typescript
2020
+ step('mint', async (ctx) => {
2021
+ const { token, expiresIn } = await ctx.kici.oidc.token({ audience: 'sigstore' });
2022
+ ctx.log.info(`Got an ID token valid for ${expiresIn}s`);
2023
+ // Hand `token` to a tool that exchanges it with a service trusting the issuer.
2024
+ });
2025
+ ```
2026
+
2027
+ The token is a short-lived (about 10 minutes) signed JWT scoped to the current
2028
+ run and job. Its identity claims (`repository`, `ref`, `sha`, `kici_run_id`,
2029
+ `kici_job_id`) are derived by the platform from the run context, so a step cannot
2030
+ spoof them. The returned token value is automatically masked in step logs, and
2031
+ the step never holds platform credentials — the request is relayed through the
2032
+ orchestrator, which mints the token on the step's behalf. Like
2033
+ `attestProvenance`, it is only available inside a running job step.
2034
+
2035
+ ## Verifying an attestation
2036
+
2037
+ Verify a bundle with the `kici verify-attestation` command. It establishes the
2038
+ full chain offline: the identity token verifies against the trusted issuer's
2039
+ JWKS, the DSSE signature verifies against the bundled signing key, and the
2040
+ statement's build context must match the token's identity claims (a mismatch is
2041
+ a hard failure).
2042
+
2043
+ ```bash
2044
+ kici verify-attestation [artifact] --bundle <path-or-url> [--trust-root <url-or-file>]
2045
+ ```
2046
+
2047
+ ### Which trust root do I use?
2048
+
2049
+ The trust root is the **KiCI platform's provenance issuer** — the same hosted
2050
+ KiCI platform you `kici login` against. KiCI attestations are issued by, and
2051
+ verified against, that one issuer; there are no competing "roots" to choose
2052
+ between. So the answer to "shouldn't I just use KiCI as the trust root?" is yes
2053
+ — and that's the **default**: omit `--trust-root` and the verifier checks the
2054
+ bundle against the hosted KiCI platform automatically. You only pass
2055
+ `--trust-root` to verify against a different environment or, more commonly, an
2056
+ offline `{ issuer, jwks }` file for air-gapped checks.
2057
+
2058
+ ### Why you supply it out-of-band
2059
+
2060
+ Given there's a single issuer, why pass it at all instead of letting the
2061
+ verifier read it from the token? Because the issuer named **inside** a token
2062
+ cannot be trusted: a forged bundle could carry a token that names
2063
+ `iss: https://attacker.example` _and_ bundle a key set that "verifies" it,
2064
+ making the whole signature chain circular and self-attesting. The verifier
2065
+ therefore pins to an issuer you supply out-of-band and checks the token against
2066
+ _that_ — the bundle is verified against a key set you trust, not one it shipped
2067
+ with. Naming the trust root is a security requirement, not a multiple-choice
2068
+ question.
2069
+
2070
+ To override the default, supply the trusted issuer via `--trust-root`, in one of
2071
+ two forms:
2072
+
2073
+ - **Online — an HTTPS issuer URL.** The verifier fetches
2074
+ `<url>/.well-known/openid-configuration`, reads its `issuer` and `jwks_uri`,
2075
+ and fetches the JWKS. The token's `iss` is pinned to the discovery document's
2076
+ `issuer`.
2077
+ - **Offline — a self-contained trust-root file.** A local JSON file with the
2078
+ issuer and JWKS inlined, for air-gapped verification:
2079
+
2080
+ ```json
2081
+ {
2082
+ "issuer": "https://platform.example/issuer",
2083
+ "jwks": {
2084
+ "keys": [
2085
+ { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "alg": "ES256", "kid": "..." }
2086
+ ]
2087
+ }
2088
+ }
2089
+ ```
2090
+
2091
+ Pass an optional `[artifact]` to also digest-check the file against the
2092
+ attestation subject — this is what binds the attestation to a specific set of
2093
+ bytes. Omit it to verify the signatures and identity only. Use `--json` for a
2094
+ machine-readable result. The command exits `0` when everything verifies and `1`
2095
+ when it does not (or on an error such as a missing flag or unreachable trust
2096
+ root).
2097
+
2098
+ ```bash
2099
+ # Default: verify against the hosted KiCI platform (no --trust-root needed):
2100
+ kici verify-attestation ./dist/app.tgz --bundle ./app.tgz.kici.json
2101
+
2102
+ # Override the trust root to verify against a specific issuer:
2103
+ kici verify-attestation ./dist/app.tgz \
2104
+ --bundle ./app.tgz.kici.json \
2105
+ --trust-root https://platform.example/issuer
2106
+
2107
+ # Air-gapped: verify against a self-contained trust-root file:
2108
+ kici verify-attestation ./dist/app.tgz \
2109
+ --bundle ./app.tgz.kici.json \
2110
+ --trust-root ./kici-trust-root.json
2111
+ ```
2112
+
2113
+ The full flag reference is in the [CLI reference](https://docs.kici.dev/user/cli-reference/#kici-verify-attestation).
2114
+
2115
+ ## Viewing attestations in the dashboard
2116
+
2117
+ <!-- help:run-attestations#viewing-attestations-in-the-dashboard -->
2118
+
2119
+ The run detail page has an **Attestations** tab listing each artifact a run's
2120
+ steps attested (via `ctx.attestProvenance`), one row per artifact.
2121
+
2122
+ Each row shows:
2123
+
2124
+ - **Status** — a **verified** badge computed in your browser. It checks the
2125
+ attestation's signature, the build identity, and the build context against the
2126
+ trusted provenance issuer. **verified** (green) means all of those pass;
2127
+ **failed** (red) shows why in a tooltip; **unverifiable** means the provenance
2128
+ issuer is not configured; **keys unavailable** (amber) means the issuer is
2129
+ configured but its verification keys could not be fetched.
2130
+ - **Job / Artifact / Digest / Created** — the producing job, the artifact name,
2131
+ its content digest, and when it was recorded.
2132
+ - **Download** — saves the signed bundle as a `.sigstore.json` file.
2133
+
2134
+ The badge does **not** re-hash the artifact bytes — the dashboard does not have
2135
+ the artifact. To bind the attestation to a specific file, run
2136
+ `kici verify-attestation <artifact> --bundle <bundle>`. A run with no
2137
+ attestations shows an empty state.
2138
+
2139
+ <!-- /help:run-attestations -->
2140
+
2141
+ ## Browsing attestations across runs
2142
+
2143
+ The **Attestations** page (in the org sidebar) lists every build-provenance
2144
+ attestation your organization has produced — not just one run's. It is the
2145
+ supply-chain audit surface: look up "who built `sha256:…`?" by digest, or browse
2146
+ and filter every attestation across all runs.
2147
+
2148
+ <!-- help:attestations-list#browsing-attestations-across-runs -->
2149
+
2150
+ The **Attestations** page lists every build-provenance attestation your organization has produced.
2151
+
2152
+ - **Search** by artifact digest (exact `sha256:…`) or name to trace a specific artifact.
2153
+ - **Filter** by verification status, repository, workflow, job, or date.
2154
+ - Each row's **status badge** is the verdict KiCI recorded when the attestation was produced (`verified`, `failed`, `unverifiable`, or `pending`).
2155
+
2156
+ Open a row for the parsed provenance statement and a live re-verification.
2157
+
2158
+ <!-- /help:attestations-list -->
2159
+
2160
+ The status badge here is the **server-side verdict**, computed once when the
2161
+ attestation was recorded (verify-at-ingest) — so the list stays fast at any
2162
+ size. `verified` means the signature, build identity, and build context all
2163
+ checked out against the provenance issuer; `failed` means verification ran and
2164
+ the bundle did not pass; `unverifiable` means no verdict could be computed (no
2165
+ provenance issuer configured, or its keys could not be read — not a forgery
2166
+ signal); `pending` means the verdict has not been computed yet.
2167
+
2168
+ Opening a row leads to the **attestation detail page**:
2169
+
2170
+ <!-- help:attestation-detail#browsing-attestations-across-runs -->
2171
+
2172
+ This page shows the parsed provenance for one attestation.
2173
+
2174
+ - **Builder identity, source, and build type** come from the signed SLSA statement.
2175
+ - The **stored badge** is the verdict recorded at build time; **Re-verify** runs the check live in your browser against the current signing keys.
2176
+ - **Download** exports the signed bundle for offline verification with `kici verify-attestation`.
2177
+ <!-- /help:attestation-detail -->
2178
+
2179
+ ## See also
2180
+
2181
+ - [SDK runtime reference](https://docs.kici.dev/user/sdk/runtime/) — the `ctx.attestProvenance` and
2182
+ `ctx.kici.oidc.token` step APIs in full.
2183
+ - [CLI reference](https://docs.kici.dev/user/cli-reference/#kici-verify-attestation) — every
2184
+ `kici verify-attestation` flag and exit code.
2185
+
2186
+ ---
2187
+
2188
+ ## Secrets
2189
+
2190
+ Source: https://docs.kici.dev/user/secrets/
2191
+
2192
+ KiCI provides an explicit secrets API that gives workflow steps controlled access to secrets stored in the orchestrator's secret store. Secrets are never auto-injected into `process.env` -- you must explicitly request each secret by name.
2193
+
2194
+ ## Overview
2195
+
2196
+ Secrets are managed per-environment in the orchestrator (see [operator docs](https://docs.kici.dev/operator/orchestrator/configuration) for setup). When a job runs with an `environment` binding, the agent receives the secret keys available for that environment but does **not** inject their values into the step's process environment. Instead, steps access secrets through the `ctx.secrets` API.
2197
+
2198
+ This design prevents accidental secret leakage through child processes, log output, or error messages. Only secrets you explicitly request are loaded into memory.
2199
+
2200
+ A job can bind several environments with `environments: ['staging', 'my-testing']`; the secret keys from all bound environments are merged in array order, with a later environment's value winning on a key collision. See [Multiple environments per job](https://docs.kici.dev/user/environments/#multiple-environments-per-job).
2201
+
2202
+ ## Where secret values come from
2203
+
2204
+ Secret values are written either through the dashboard or through `kici-admin` running against the orchestrator. The orchestrator operator decides — per organization — which surface accepts secret writes. From the workflow author's perspective, the resolution path at run time is identical either way; the difference is where you (or your ops team) **enter** the value.
2205
+
2206
+ ### Default — dashboard or CLI
2207
+
2208
+ A fresh orchestrator starts in **permissive** mode: both surfaces are available.
2209
+
2210
+ - **Dashboard:** Settings → Secrets → pick a scope → enter the secret name and value.
2211
+ - **CLI:** `kici-admin secret set --scope <scope> <KEY>` against the orchestrator's HTTP admin API.
2212
+
2213
+ Use whichever fits the workflow — most small teams stay on the dashboard; ops engineers and CI scripts use the CLI.
2214
+
2215
+ ### When the operator has disabled dashboard writes
2216
+
2217
+ The orchestrator operator can flip `secrets.set` (and `variables.set`) to **CLI-only** as part of the [dashboard-write policy](https://docs.kici.dev/operator/security/dashboard-write-policy). When that flip is on:
2218
+
2219
+ - The dashboard's "Add secret" / "Edit value" controls render with a lock icon. Clicking them shows a tooltip with the exact `kici-admin secret set` invocation needed.
2220
+ - The dashboard's secrets page still lists secret **names**, scopes, and bindings — only the value-entry path moves to the CLI.
2221
+ - `kici-admin secret set` becomes the single entry point for new and updated secret values.
2222
+
2223
+ This configuration is common for SOC2-prep and regulated workloads, where the customer requirement is "the SaaS control plane process never receives plaintext customer secret values." The dashboard remains usable for everything else (read paths, name CRUD, environment bindings).
2224
+
2225
+ ### CLI input modes
2226
+
2227
+ `kici-admin secret set` accepts five input modes — pick the one that fits your workflow:
2228
+
2229
+ ```bash
2230
+ # Interactive prompt (default when stdin is a TTY). No echo, no shell history.
2231
+ kici-admin secret set --scope production DB_PASSWORD --prompt
2232
+
2233
+ # Pipe from another tool (default when stdin is not a TTY).
2234
+ pass show prod/db | kici-admin secret set --scope production DB_PASSWORD --from-stdin
2235
+
2236
+ # Read from a file (handy after `sops -d` to a tmpfile).
2237
+ kici-admin secret set --scope production DB_PASSWORD --from-file ./db.pass
2238
+
2239
+ # Read from a named environment variable (CI-friendly).
2240
+ KICI_SECRET_VALUE=$(my-secrets-fetcher prod db) \
2241
+ kici-admin secret set --scope production DB_PASSWORD --from-env KICI_SECRET_VALUE
2242
+
2243
+ # Direct argv — discouraged. Prints a stderr warning ("visible in shell history").
2244
+ kici-admin secret set --scope production DB_PASSWORD --value "<plaintext>"
2245
+ ```
2246
+
2247
+ Two cross-cutting flags help every mode:
2248
+
2249
+ - `--confirm-fingerprint <hex>` — pre-compute SHA-256 of the value and pass it. The CLI rejects the call if the value's fingerprint doesn't match. Catches paste corruption.
2250
+ - `--dry-run` — parse and validate the value, print `[dry-run] would set <key> in scope <scope> sha256=<hex>`, exit without writing.
2251
+
2252
+ `kici-admin variable set` uses the same flags for non-encrypted variables, plus `--locked` to mark a variable as immutable from subsequent dashboard writes.
2253
+
2254
+ A full reference of input modes — including the default-mode resolution rules and the security trade-offs of each — lives in [Dashboard-write policy → CLI input modes](https://docs.kici.dev/operator/security/dashboard-write-policy#cli-input-modes-for-the-plaintext-path).
2255
+
2256
+ ## Accessing secrets
2257
+
2258
+ Use `ctx.secrets.get(key)` to retrieve a secret value. The method is async to support process-level step isolation in future versions.
2259
+
2260
+ ```typescript
2261
+ import { workflow, job, step } from '@kici-dev/sdk';
2262
+
2263
+ export default workflow('deploy', {
2264
+ on: [push({ branches: ['main'] })],
2265
+ jobs: [
2266
+ job('deploy', {
2267
+ runsOn: 'default',
2268
+ environment: 'production',
2269
+ steps: [
2270
+ step('deploy', async (ctx) => {
2271
+ const token = await ctx.secrets.get('DEPLOY_TOKEN');
2272
+ await ctx.$`deploy --token ${token}`;
2273
+ }),
2274
+ ],
2275
+ }),
2276
+ ],
2277
+ });
2278
+ ```
2279
+
2280
+ If the secret does not exist, `get()` throws a `SecretNotFoundError` with a descriptive message.
2281
+
2282
+ ## Exposing secrets to shell commands
2283
+
2284
+ When you need a secret available as an environment variable for shell commands (e.g., tools that read `$API_KEY` from the environment), use `ctx.secrets.expose(key)`:
2285
+
2286
+ ```typescript
2287
+ step('run-tool', async (ctx) => {
2288
+ // Injects MY_API_KEY into process.env for this step only
2289
+ await ctx.secrets.expose('MY_API_KEY');
2290
+
2291
+ // Now child processes can read it from the environment
2292
+ await ctx.$`some-tool --use-env-auth`;
2293
+ });
2294
+ ```
2295
+
2296
+ `expose()` sets `process.env[key]` to the secret value. This is scoped to the step's child process -- it does not leak to other steps or jobs.
2297
+
2298
+ ## Checking secret existence
2299
+
2300
+ Use `ctx.secrets.has(key)` to check whether a secret is available without retrieving its value:
2301
+
2302
+ ```typescript
2303
+ step('conditional-notify', async (ctx) => {
2304
+ if (ctx.secrets.has('SLACK_WEBHOOK')) {
2305
+ const webhook = await ctx.secrets.get('SLACK_WEBHOOK');
2306
+ await ctx.$`curl -X POST ${webhook} -d '{"text": "Deploy complete"}'`;
2307
+ } else {
2308
+ console.log('Slack webhook not configured, skipping notification');
2309
+ }
2310
+ });
2311
+ ```
2312
+
2313
+ `has()` is synchronous and does not load the secret value.
2314
+
2315
+ ## Mounting secrets as files
2316
+
2317
+ Some tools refuse to read credentials from environment variables and require a file path on disk (for example, `sops` reads `SOPS_AGE_KEY_FILE`, `kubectl` reads `KUBECONFIG`, and `gcloud` reads `GOOGLE_APPLICATION_CREDENTIALS`). The secrets API materialises one or more existing string secrets to a tmpfile for the lifetime of the step.
2318
+
2319
+ ### list()
2320
+
2321
+ `ctx.secrets.list()` returns every secret key available to the step, sorted alphabetically. It is synchronous, never throws, and returns names only — call `getMeta(key)` to inspect the backend and scope for a specific key.
2322
+
2323
+ ```typescript
2324
+ step('discover-keys', async (ctx) => {
2325
+ // Pick up every age key the operator has provisioned.
2326
+ const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
2327
+ ctx.log.info(`Found ${ageKeys.length} age keys`);
2328
+ });
2329
+ ```
2330
+
2331
+ ### mountFile(opts)
2332
+
2333
+ `ctx.secrets.mountFile(opts)` writes the concatenation of one or more existing secrets to a tmpfile inside a per-step tmpdir and returns the absolute path. The file is removed automatically when the step completes (success, failure, or timeout).
2334
+
2335
+ Options:
2336
+
2337
+ - `sources: string[]` — secret keys to concatenate (in order). Required.
2338
+ - `divider?: string` — separator written between concatenated values. Default: no divider.
2339
+ - `mode?: number` — permission bits to chmod the file to. Default: `0o600` (owner read/write only).
2340
+ - `name?: string` — filename inside the per-step tmpdir. Default: auto-generated.
2341
+
2342
+ If any source key is missing, `mountFile` rejects with `SecretNotFoundError` listing every missing key.
2343
+
2344
+ ```typescript
2345
+ step('decrypt', async (ctx) => {
2346
+ const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
2347
+ const keyFile = await ctx.secrets.mountFile({
2348
+ sources: ageKeys,
2349
+ divider: '\n',
2350
+ });
2351
+ await ctx.$`sops --age-key-file ${keyFile.path} -d secrets.enc.yaml`;
2352
+ });
2353
+ ```
2354
+
2355
+ ### exposeFile(envVar, opts)
2356
+
2357
+ `ctx.secrets.exposeFile(envVar, opts)` is `mountFile` plus `process.env[envVar] = path`. The env var is unset and the file is removed when the step completes. The customer controls every env var name — there is no implicit `KICI_SECRET_FILE_*` naming.
2358
+
2359
+ ```typescript
2360
+ step('deploy', async (ctx) => {
2361
+ await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', {
2362
+ sources: ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')),
2363
+ divider: '\n',
2364
+ });
2365
+
2366
+ // sops reads SOPS_AGE_KEY_FILE from the environment.
2367
+ await ctx.$`sops -d secret.enc.yaml`;
2368
+ });
2369
+ ```
2370
+
2371
+ ### Lifecycle and cleanup
2372
+
2373
+ - **Lazy allocation:** no tmpdir is created until the first `mountFile` / `exposeFile` call. Steps that never mount pay nothing.
2374
+ - **Per-step tmpdir:** allocated under the OS temp directory and bound to a single step. Two mounts in the same step share the same tmpdir; the runtime auto-suffixes filenames when no `name` is supplied.
2375
+ - **Automatic cleanup:** when the step returns (success), throws (failure), or times out, the runtime removes the tmpdir and unsets any env var set via `exposeFile`. There is nothing to clean up by hand.
2376
+ - **Sandbox container:** when the agent runs the step inside a container or microVM, the tmpdir lives on the sandbox's `/tmp` (a fresh tmpfs in the production sandbox profile). The file is gone when the sandbox is torn down.
2377
+
2378
+ ### Log masking
2379
+
2380
+ Mounted file contents are registered with the log masker, so a subprocess that echoes the credential (e.g. a tool that prints its loaded credential on `--debug`) sees `***` in the streamed log instead of the raw value. This covers the case where `mountFile` joins two source secrets into a brand-new byte sequence neither original value would mask on its own.
2381
+
2382
+ ### Canonical sops example
2383
+
2384
+ ```typescript
2385
+ import { workflow, job, step, push } from '@kici-dev/sdk';
2386
+
2387
+ export default workflow('deploy', {
2388
+ on: push({ branches: ['main'] }),
2389
+ jobs: [
2390
+ job('decrypt-and-deploy', {
2391
+ runsOn: 'default',
2392
+ environment: 'production',
2393
+ steps: [
2394
+ step('decrypt', async (ctx) => {
2395
+ const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
2396
+ await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', {
2397
+ sources: ageKeys,
2398
+ divider: '\n',
2399
+ });
2400
+ await ctx.$`sops -d secret.enc.yaml > config.yaml`;
2401
+ // No cleanup -- the tmpdir + the SOPS_AGE_KEY_FILE env var
2402
+ // are removed automatically when this step returns.
2403
+ }),
2404
+ ],
2405
+ }),
2406
+ ],
2407
+ });
2408
+ ```
2409
+
2410
+ ### Injecting decrypted sops values into the environment
2411
+
2412
+ KiCI does **not** scan your repository for `*.enc.yaml` files and auto-decrypt them into the environment at job init — nothing in a job runs `sops` on your behalf, and resolved secrets are never auto-injected as environment variables (see [Security notes](https://docs.kici.dev/user/sdk/runtime/#security-notes)). Decryption is always something your workflow does explicitly: provision the age (or other) decryption key as a KiCI secret, expose it for the step, run `sops -d`, and decide what to do with the output.
2413
+
2414
+ When you want the decrypted values available as environment variables — not just written to a file — decrypt early and export the values through `$KICI_ENV` (or `ctx.setEnv`). Anything appended to `$KICI_ENV` becomes an environment variable for every later step in the same job, so a single decrypt step can populate the environment for the whole job:
2415
+
2416
+ ```typescript
2417
+ import { workflow, job, step, push } from '@kici-dev/sdk';
2418
+
2419
+ export default workflow('deploy', {
2420
+ on: push({ branches: ['main'] }),
2421
+ jobs: [
2422
+ job('decrypt-and-deploy', {
2423
+ runsOn: 'default',
2424
+ environment: 'production',
2425
+ steps: [
2426
+ step('decrypt-to-env', async (ctx) => {
2427
+ await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', {
2428
+ sources: ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')),
2429
+ divider: '\n',
2430
+ });
2431
+ // Decrypt to dotenv format, then append every KEY=value line to
2432
+ // $KICI_ENV so subsequent steps see them as environment variables.
2433
+ await ctx.$`sops -d --output-type dotenv secrets.enc.yaml >> "$KICI_ENV"`;
2434
+ }),
2435
+ step('deploy', async (ctx) => {
2436
+ // Values decrypted above are now ordinary env vars here.
2437
+ await ctx.$`./deploy.sh`;
2438
+ }),
2439
+ ],
2440
+ }),
2441
+ ],
2442
+ });
2443
+ ```
2444
+
2445
+ Decrypted values exported this way follow the same rules as any other `$KICI_ENV` / `ctx.setEnv` export: last-write-wins on a repeated key, and a key that collides with an operator-injected secret is ignored (the operator value wins). See [Exporting env from shell commands](https://docs.kici.dev/user/sdk/runtime/#exporting-env-from-shell-commands-kici_env--kici_path) for the full `$KICI_ENV` contract.
2446
+
2447
+ If you only need the decrypted material as a file on disk (the common `kubectl` / `gcloud` case), skip the env hop and redirect to a file instead — see the [canonical sops example](https://docs.kici.dev/user/secrets/#canonical-sops-example) above.
2448
+
2449
+ ## API reference
2450
+
2451
+ | Method | Signature | Description |
2452
+ | ------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
2453
+ | `get` | `get(key: string): Promise<string>` | Retrieve a secret value. Throws `SecretNotFoundError` if not found. |
2454
+ | `expose` | `expose(key: string): Promise<void>` | Set `process.env[key]` to the secret value for child process access. |
2455
+ | `has` | `has(key: string): boolean` | Check if a secret key is available (synchronous). |
2456
+ | `getMeta` | `getMeta(key: string): SecretMeta \| undefined` | Get metadata (backend name, scope) for a secret. Returns `undefined` if not found. |
2457
+ | `list` | `list(): string[]` | Sorted array of every secret key available to the step. Synchronous, never throws. |
2458
+ | `mountFile` | `mountFile(opts: SecretFileOptions): Promise<{ path: string }>` | Materialise one or more secrets as a tmpfile. Auto-cleanup at step end. |
2459
+ | `exposeFile` | `exposeFile(envVar: string, opts: SecretFileOptions): Promise<{ path }>` | `mountFile` plus `process.env[envVar] = path`. Env var unset at step end. |
2460
+
2461
+ ## Migration from property access
2462
+
2463
+ If upgrading from a previous version that used property access (`ctx.secrets.KEY`), update your workflow code:
2464
+
2465
+ ```typescript
2466
+ // Before (old API)
2467
+ const token = ctx.secrets.DEPLOY_TOKEN;
2468
+
2469
+ // After (new API)
2470
+ const token = await ctx.secrets.get('DEPLOY_TOKEN');
2471
+ ```
2472
+
2473
+ For conditional access:
2474
+
2475
+ ```typescript
2476
+ // Before (old API)
2477
+ if (ctx.secrets.DEPLOY_TOKEN) { ... }
2478
+
2479
+ // After (new API)
2480
+ if (ctx.secrets.has('DEPLOY_TOKEN')) { ... }
2481
+ ```
2482
+
2483
+ Note that `get()` is async -- you must `await` the result.
2484
+
2485
+ ## Typed secrets
2486
+
2487
+ When you run `kici types`, the compiler generates a `.kici/secrets.d.ts` file that provides type-safe autocompletion for your secret keys. The generated types augment the `StepSecrets` interface so that `ctx.secrets.get('...')` and `ctx.secrets.has('...')` offer suggestions for known keys.
2488
+
2489
+ See [CLI reference](https://docs.kici.dev/user/cli) for the `kici types` command.
2490
+
2491
+ ---