@kici-dev/compiler 0.1.22 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +20 -6
- package/dist/commands/compile.d.ts +6 -0
- package/dist/commands/compile.js +6 -3
- package/dist/commands/docs.d.ts +8 -8
- package/dist/commands/docs.js +35 -16
- package/dist/commands/org.js +2 -2
- package/dist/commands/run.d.ts +16 -1
- package/dist/commands/run.js +86 -14
- package/dist/commands/test.d.ts +4 -0
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands/types.js +1 -1
- package/dist/fixtures/describe-event.d.ts +6 -0
- package/dist/fixtures/describe-event.js +18 -0
- package/dist/fixtures/picker.d.ts +19 -0
- package/dist/fixtures/picker.js +64 -0
- package/dist/llm-context/llms-architecture.txt +1440 -0
- package/dist/llm-context/llms-cli.txt +2386 -0
- package/dist/llm-context/llms-features.txt +2389 -0
- package/dist/llm-context/llms-full.txt +976 -317
- package/dist/llm-context/llms-getting-started.txt +519 -0
- package/dist/llm-context/llms-patterns.txt +1324 -0
- package/dist/llm-context/llms-providers.txt +805 -0
- package/dist/llm-context/llms-sdk.txt +3725 -0
- package/dist/llm-context/llms.txt +13 -0
- package/dist/local-executor/index.js +40 -3
- package/dist/local-executor/job-runner.d.ts +2 -0
- package/dist/local-executor/job-runner.js +36 -4
- package/dist/local-executor/types.d.ts +2 -0
- package/dist/lockfile/generator.js +13 -4
- package/dist/remote/platform-client.d.ts +6 -0
- package/dist/remote/uploader.js +1 -0
- package/dist/templates/package-json.js +1 -1
- package/dist/test-runner/rule-evaluator.d.ts +1 -1
- package/dist/test-runner/rule-evaluator.js +2 -1
- package/dist/test-runner/step-context.d.ts +1 -1
- package/dist/test-runner/step-context.js +7 -2
- package/dist/types.d.ts +6 -2
- package/package.json +4 -4
- package/sbom.spdx.json +35 -35
|
@@ -0,0 +1,2389 @@
|
|
|
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
|
+
### Job-level environment variables
|
|
806
|
+
|
|
807
|
+
The `env` property on a job provides static or dynamic environment variables:
|
|
808
|
+
|
|
809
|
+
```typescript
|
|
810
|
+
job('deploy', {
|
|
811
|
+
runsOn: 'default',
|
|
812
|
+
environment: 'production',
|
|
813
|
+
env: { DEPLOY_TARGET: 'us-east-1' },
|
|
814
|
+
// Or dynamic:
|
|
815
|
+
// env: (event) => ({ DEPLOY_SHA: event.payload.after?.slice(0, 7) }),
|
|
816
|
+
steps: [
|
|
817
|
+
step('deploy', async (ctx) => {
|
|
818
|
+
// DEPLOY_TARGET is available in ctx.env
|
|
819
|
+
await ctx.$`deploy --region ${ctx.env.DEPLOY_TARGET}`;
|
|
820
|
+
}),
|
|
821
|
+
],
|
|
822
|
+
});
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### Concurrency groups
|
|
826
|
+
|
|
827
|
+
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/).
|
|
828
|
+
|
|
829
|
+
Control concurrent deployments to the same environment:
|
|
830
|
+
|
|
831
|
+
```typescript
|
|
832
|
+
job('deploy', {
|
|
833
|
+
runsOn: 'default',
|
|
834
|
+
environment: 'production',
|
|
835
|
+
concurrencyGroup: 'production-api',
|
|
836
|
+
// Or dynamic:
|
|
837
|
+
// concurrencyGroup: (event) => `review-${event.payload.pull_request.number}`,
|
|
838
|
+
steps: [
|
|
839
|
+
/* ... */
|
|
840
|
+
],
|
|
841
|
+
});
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
If no `concurrencyGroup` is specified, the environment name is used as the default concurrency group.
|
|
845
|
+
|
|
846
|
+
### Step context
|
|
847
|
+
|
|
848
|
+
Inside a step, the `ctx` object provides:
|
|
849
|
+
|
|
850
|
+
| Property | Type | Description |
|
|
851
|
+
| ----------------- | ------------------------------------- | ---------------------------------------------------------------------------- |
|
|
852
|
+
| `ctx.environment` | `string \| undefined` | Resolved environment name (undefined for jobs without environment) |
|
|
853
|
+
| `ctx.env` | `Record<string, string \| undefined>` | Environment variables (merged from system, org, source, and job-level `env`) |
|
|
854
|
+
| `ctx.secrets` | `StepSecretsTyped` | Async accessor for bound secrets (get, expose, has, getMeta) |
|
|
855
|
+
|
|
856
|
+
| Method | Returns | Description |
|
|
857
|
+
| ------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
858
|
+
| `await ctx.secrets.get(key)` | `string` | Retrieve a secret value. Throws `SecretNotFoundError` if not found. |
|
|
859
|
+
| `await ctx.secrets.expose(key)` | `void` | Inject a secret into the step's environment variables (`ctx.env`). Throws `SecretNotFoundError` if not found. |
|
|
860
|
+
| `ctx.secrets.has(key)` | `boolean` | Check if a secret key exists. Synchronous, never throws. |
|
|
861
|
+
| `ctx.secrets.getMeta(key)` | `SecretMeta \| undefined` | Retrieve metadata (value, backend name, scope path) for a resolved secret. Returns `undefined` if not found. |
|
|
862
|
+
| `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. |
|
|
863
|
+
|
|
864
|
+
## Environment variable merge precedence
|
|
865
|
+
|
|
866
|
+
When a job targets an environment, variables are merged in this order (last wins):
|
|
867
|
+
|
|
868
|
+
1. **Allowed system vars** -- `PATH`, `HOME`, etc. from the agent process
|
|
869
|
+
2. **Sandbox defaults** -- `FORCE_COLOR=1`
|
|
870
|
+
3. **KICI\_\* system vars** -- orchestrator-generated metadata
|
|
871
|
+
4. **Org-level environment vars** -- from the dashboard, managed per-environment
|
|
872
|
+
5. **Source-level overrides** -- per-repository overrides (skips locked vars)
|
|
873
|
+
6. **Job env** -- from the `env` property in the SDK
|
|
874
|
+
7. **`setEnv()` calls** -- runtime modifications within steps
|
|
875
|
+
|
|
876
|
+
> **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.
|
|
877
|
+
|
|
878
|
+
## Protection rules
|
|
879
|
+
|
|
880
|
+
Environments can have protection rules that gate job execution:
|
|
881
|
+
|
|
882
|
+
### Branch restrictions
|
|
883
|
+
|
|
884
|
+
Limit which branches can deploy to an environment:
|
|
885
|
+
|
|
886
|
+
```
|
|
887
|
+
Allowed branches: main, release/*
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
Jobs from other branches are rejected immediately with an error message.
|
|
891
|
+
|
|
892
|
+
### Required reviewers
|
|
893
|
+
|
|
894
|
+
Require manual approval before a job can proceed:
|
|
895
|
+
|
|
896
|
+
```
|
|
897
|
+
Required reviewers: alice, bob
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
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.
|
|
901
|
+
|
|
902
|
+
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.
|
|
903
|
+
|
|
904
|
+
### Wait timer
|
|
905
|
+
|
|
906
|
+
Add a mandatory delay before deployment starts:
|
|
907
|
+
|
|
908
|
+
```
|
|
909
|
+
Wait timer: 300 seconds
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
The job waits for the specified duration before proceeding. Useful for staged rollouts.
|
|
913
|
+
|
|
914
|
+
### Minimum trust
|
|
915
|
+
|
|
916
|
+
Gate job execution based on the contributor's trust tier for PR-triggered runs:
|
|
917
|
+
|
|
918
|
+
```
|
|
919
|
+
Minimum trust: known
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
| Value | Effect |
|
|
923
|
+
| --------- | --------------------------------------------------- |
|
|
924
|
+
| `known` | Blocks unknown contributors; allows known + trusted |
|
|
925
|
+
| `trusted` | Blocks unknown + known; allows only trusted |
|
|
926
|
+
|
|
927
|
+
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.
|
|
928
|
+
|
|
929
|
+
Trust tier is determined by the contributor's identity link and CI trust RBAC level:
|
|
930
|
+
|
|
931
|
+
- **Trusted** -- identity-linked org member with `ci_trust:write+` AND provider write access
|
|
932
|
+
- **Known** -- identity-linked member or verified collaborator via provider API
|
|
933
|
+
- **Unknown** -- no identity link and no provider access, fork PRs
|
|
934
|
+
|
|
935
|
+
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.
|
|
936
|
+
|
|
937
|
+
See the [CI security architecture docs](https://docs.kici.dev/architecture/security/ci-security/) for the full trust resolution flow.
|
|
938
|
+
|
|
939
|
+
### Security approval queue
|
|
940
|
+
|
|
941
|
+
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.
|
|
942
|
+
|
|
943
|
+
Held runs can be approved:
|
|
944
|
+
|
|
945
|
+
- Via the **dashboard** in Settings > CI trust > Approval queue
|
|
946
|
+
- Via a PR comment: `/kici approve` (commenter must have `ci_trust:write+`)
|
|
947
|
+
|
|
948
|
+
Security holds expire after a configurable timeout (default 1 hour).
|
|
949
|
+
|
|
950
|
+
### Concurrency limits
|
|
951
|
+
|
|
952
|
+
Control how many jobs can run simultaneously in an environment:
|
|
953
|
+
|
|
954
|
+
```
|
|
955
|
+
Concurrency limit: 1
|
|
956
|
+
Strategy: queue (or cancel-pending)
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
- **queue** -- new jobs wait in a FIFO queue (with configurable timeout, default 1 hour)
|
|
960
|
+
- **cancel-pending** -- pending (queued) jobs are cancelled when the limit is reached
|
|
961
|
+
|
|
962
|
+
## Dashboard management
|
|
963
|
+
|
|
964
|
+
### Creating environments
|
|
965
|
+
|
|
966
|
+
Navigate to **Settings > Environments** in the dashboard. Click **New environment** to choose the environment name and type (Fixed or Glob).
|
|
967
|
+
|
|
968
|
+
- **Fixed** -- applies to jobs that declare exactly this environment name, like `staging` or `production`
|
|
969
|
+
- **Glob** -- applies to any environment name a job declares that matches the pattern, e.g. `review/*` matches a job with `environment: 'review/PR-123'`
|
|
970
|
+
|
|
971
|
+
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.
|
|
972
|
+
|
|
973
|
+
### Environment detail page
|
|
974
|
+
|
|
975
|
+
Each environment has four tabs:
|
|
976
|
+
|
|
977
|
+
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.
|
|
978
|
+
|
|
979
|
+
2. **Secrets** -- view bound secret scopes and their resolved secret count. Add bindings by specifying scope glob patterns (e.g., `aws/prod/**`).
|
|
980
|
+
|
|
981
|
+
3. **Protection** -- configure branch restrictions, required reviewers, wait timers, and concurrency limits with enable toggles for each section.
|
|
982
|
+
|
|
983
|
+
4. **History** -- view filtered runs targeting this environment.
|
|
984
|
+
|
|
985
|
+
### Secrets management
|
|
986
|
+
|
|
987
|
+
Secrets are individual encrypted values organized by scope paths (e.g., `aws/prod`, `databases/postgres`). Scopes are bound to environments via bindings:
|
|
988
|
+
|
|
989
|
+
- **Scope-centric view** (Secrets page): tree view of scopes with per-scope environment binding checkboxes
|
|
990
|
+
- **Environment-centric view** (inside environment detail): bound scopes, resolved secrets, add binding
|
|
991
|
+
|
|
992
|
+
When scope paths collide on the same key name, the longer (more specific) path wins.
|
|
993
|
+
|
|
994
|
+
## Type generation
|
|
995
|
+
|
|
996
|
+
Running `kici types` generates two augmented interfaces: `KnownSecretKeys` (union of all secret keys across all environments) and `EnvironmentSecrets` (per-environment key unions):
|
|
997
|
+
|
|
998
|
+
```typescript
|
|
999
|
+
interface KnownSecretKeys {
|
|
1000
|
+
DB_PASSWORD: string;
|
|
1001
|
+
API_KEY: string;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
interface EnvironmentSecrets {
|
|
1005
|
+
production: 'DB_PASSWORD' | 'API_KEY';
|
|
1006
|
+
staging: 'DB_PASSWORD';
|
|
1007
|
+
}
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
`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.
|
|
1011
|
+
|
|
1012
|
+
---
|
|
1013
|
+
|
|
1014
|
+
## Event system
|
|
1015
|
+
|
|
1016
|
+
Source: https://docs.kici.dev/user/events/
|
|
1017
|
+
|
|
1018
|
+
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.
|
|
1019
|
+
|
|
1020
|
+
## Overview
|
|
1021
|
+
|
|
1022
|
+
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.
|
|
1023
|
+
|
|
1024
|
+
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:
|
|
1025
|
+
|
|
1026
|
+
- `kiciEvent()` -- custom events emitted from workflow steps
|
|
1027
|
+
- `workflowComplete()` -- fires when a workflow finishes
|
|
1028
|
+
- `jobComplete()` -- fires when a specific job finishes
|
|
1029
|
+
- `genericWebhook()` -- HTTP webhooks from external services
|
|
1030
|
+
- `schedule()` -- cron-based time triggers
|
|
1031
|
+
- `lifecycle()` -- orchestrator lifecycle events (workflow completion, job failure, registration updates)
|
|
1032
|
+
|
|
1033
|
+
All six require the **registration model** to function -- covered in detail below.
|
|
1034
|
+
|
|
1035
|
+
## Event types
|
|
1036
|
+
|
|
1037
|
+
### Custom events
|
|
1038
|
+
|
|
1039
|
+
Custom events are user-defined events emitted from workflow steps using `ctx.emit()`. Use `kiciEvent()` to listen for them.
|
|
1040
|
+
|
|
1041
|
+
```typescript
|
|
1042
|
+
import { kiciEvent } from '@kici-dev/sdk';
|
|
1043
|
+
|
|
1044
|
+
// Listen for a custom event by name
|
|
1045
|
+
kiciEvent({ name: 'deploy-complete' });
|
|
1046
|
+
|
|
1047
|
+
// With payload matching (JSONPath)
|
|
1048
|
+
kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } });
|
|
1049
|
+
|
|
1050
|
+
// With negative filter
|
|
1051
|
+
kiciEvent({ name: 'deploy-complete', not: { '$.env': 'staging' } });
|
|
1052
|
+
|
|
1053
|
+
// From a specific repository
|
|
1054
|
+
kiciEvent({ name: 'deploy-complete', source: 'org/infra-repo' });
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
**Config options:** `name` (required), `match`, `not`, `source`, `description`.
|
|
1058
|
+
|
|
1059
|
+
### System events
|
|
1060
|
+
|
|
1061
|
+
The orchestrator automatically emits completion events when workflows and jobs finish. No manual emission needed -- these fire automatically.
|
|
1062
|
+
|
|
1063
|
+
**Workflow completion:**
|
|
1064
|
+
|
|
1065
|
+
```typescript
|
|
1066
|
+
import { workflowComplete } from '@kici-dev/sdk';
|
|
1067
|
+
|
|
1068
|
+
// Any workflow completion
|
|
1069
|
+
workflowComplete();
|
|
1070
|
+
|
|
1071
|
+
// Specific workflow by name
|
|
1072
|
+
workflowComplete({ name: 'build' });
|
|
1073
|
+
|
|
1074
|
+
// Only successful completions
|
|
1075
|
+
workflowComplete({ name: 'build', status: ['success'] });
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
**Config options:** `name`, `status` (`'success'`, `'failed'`, `'cancelled'`), `source`, `description`.
|
|
1079
|
+
|
|
1080
|
+
**Job completion:**
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
import { jobComplete } from '@kici-dev/sdk';
|
|
1084
|
+
|
|
1085
|
+
// Any job completion
|
|
1086
|
+
jobComplete();
|
|
1087
|
+
|
|
1088
|
+
// Specific workflow + job
|
|
1089
|
+
jobComplete({ workflow: 'build', job: 'test' });
|
|
1090
|
+
|
|
1091
|
+
// Only failures
|
|
1092
|
+
jobComplete({ workflow: 'build', job: 'test', status: ['failed'] });
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
**Config options:** `workflow`, `job`, `status` (`'success'`, `'failed'`, `'cancelled'`, `'skipped'`), `source`, `description`.
|
|
1096
|
+
|
|
1097
|
+
### External events
|
|
1098
|
+
|
|
1099
|
+
Generic webhooks let you trigger workflows from any HTTP service -- Stripe, ArgoCD, Slack, Grafana, or your own internal services.
|
|
1100
|
+
|
|
1101
|
+
```typescript
|
|
1102
|
+
import { genericWebhook } from '@kici-dev/sdk';
|
|
1103
|
+
|
|
1104
|
+
// Match any event from a source
|
|
1105
|
+
genericWebhook({ source: 'stripe' });
|
|
1106
|
+
|
|
1107
|
+
// Match specific event types
|
|
1108
|
+
genericWebhook({ source: 'stripe', events: ['invoice.paid'] });
|
|
1109
|
+
|
|
1110
|
+
// With HMAC-SHA256 signature verification
|
|
1111
|
+
genericWebhook({
|
|
1112
|
+
source: 'stripe',
|
|
1113
|
+
events: ['invoice.paid'],
|
|
1114
|
+
auth: {
|
|
1115
|
+
method: 'hmac-sha256',
|
|
1116
|
+
secret: 'stripe-signing-key',
|
|
1117
|
+
signatureHeader: 'stripe-signature',
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// With API key auth
|
|
1122
|
+
genericWebhook({
|
|
1123
|
+
source: 'slack',
|
|
1124
|
+
auth: { method: 'api-key', secret: 'slack-token' },
|
|
1125
|
+
});
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
**Config options:** `source` (required), `events`, `match`, `not`, `auth`, `path`, `description`.
|
|
1129
|
+
|
|
1130
|
+
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.
|
|
1131
|
+
|
|
1132
|
+
### Schedule events
|
|
1133
|
+
|
|
1134
|
+
Cron-based triggers evaluated by the orchestrator on a periodic interval. Only the Raft leader evaluates schedules in a clustered deployment.
|
|
1135
|
+
|
|
1136
|
+
```typescript
|
|
1137
|
+
import { schedule } from '@kici-dev/sdk';
|
|
1138
|
+
|
|
1139
|
+
// Run every hour
|
|
1140
|
+
schedule({ cron: '0 * * * *' });
|
|
1141
|
+
|
|
1142
|
+
// Run daily at 2 AM UTC
|
|
1143
|
+
schedule({ cron: '0 2 * * *' });
|
|
1144
|
+
|
|
1145
|
+
// Run weekly on Mondays at 9 AM Eastern
|
|
1146
|
+
schedule({ cron: '0 9 * * 1', timezone: 'America/New_York' });
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
**Config options:** `cron` (required), `timezone` (defaults to `'UTC'`), `description`.
|
|
1150
|
+
|
|
1151
|
+
### Lifecycle events
|
|
1152
|
+
|
|
1153
|
+
Lifecycle triggers listen for orchestrator-level events related to workflow execution and system state changes.
|
|
1154
|
+
|
|
1155
|
+
```typescript
|
|
1156
|
+
import { lifecycle } from '@kici-dev/sdk';
|
|
1157
|
+
|
|
1158
|
+
// Trigger when any workflow completes
|
|
1159
|
+
lifecycle({ events: ['workflow_complete'] });
|
|
1160
|
+
|
|
1161
|
+
// Trigger on job failures from a specific repo
|
|
1162
|
+
lifecycle({ events: ['job_failed'], sources: ['org/deploy-repo'] });
|
|
1163
|
+
|
|
1164
|
+
// Trigger when registrations are updated
|
|
1165
|
+
lifecycle({ events: ['registration_updated'] });
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
**Available events:** `'workflow_complete'`, `'job_complete'`, `'job_failed'`, `'registration_updated'`.
|
|
1169
|
+
|
|
1170
|
+
**Config options:** `events` (required), `sources`, `description`.
|
|
1171
|
+
|
|
1172
|
+
## The registration model
|
|
1173
|
+
|
|
1174
|
+
This is the most important concept for understanding event-based triggers.
|
|
1175
|
+
|
|
1176
|
+
### Why registrations exist
|
|
1177
|
+
|
|
1178
|
+
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.
|
|
1179
|
+
|
|
1180
|
+
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.
|
|
1181
|
+
|
|
1182
|
+
### How registration works
|
|
1183
|
+
|
|
1184
|
+
1. You define a workflow with an event-based trigger (e.g., `schedule()`, `kiciEvent()`, `genericWebhook()`)
|
|
1185
|
+
2. You compile the workflow (`kici compile`), which produces a lock file
|
|
1186
|
+
3. You push the lock file to your repository's **default branch** (e.g., `main` or `master`)
|
|
1187
|
+
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
|
|
1188
|
+
5. Those workflows are stored in the orchestrator's registration database
|
|
1189
|
+
6. From that point on, matching events will trigger those workflows
|
|
1190
|
+
|
|
1191
|
+
### Key implications
|
|
1192
|
+
|
|
1193
|
+
- **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.
|
|
1194
|
+
|
|
1195
|
+
- **Registration is automatic.** There is no manual setup. Push your code, and the orchestrator handles the rest.
|
|
1196
|
+
|
|
1197
|
+
- **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.
|
|
1198
|
+
|
|
1199
|
+
- **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.
|
|
1200
|
+
|
|
1201
|
+
### Practical example
|
|
1202
|
+
|
|
1203
|
+
You create a nightly build workflow:
|
|
1204
|
+
|
|
1205
|
+
```typescript
|
|
1206
|
+
import { workflow, job, step, schedule } from '@kici-dev/sdk';
|
|
1207
|
+
|
|
1208
|
+
export default workflow('nightly-build', {
|
|
1209
|
+
on: schedule({ cron: '0 2 * * *' }),
|
|
1210
|
+
jobs: [
|
|
1211
|
+
job('build', {
|
|
1212
|
+
runsOn: 'linux',
|
|
1213
|
+
steps: [
|
|
1214
|
+
step('build', async ({ $ }) => {
|
|
1215
|
+
await $`pnpm build`;
|
|
1216
|
+
}),
|
|
1217
|
+
],
|
|
1218
|
+
}),
|
|
1219
|
+
],
|
|
1220
|
+
});
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
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.
|
|
1224
|
+
|
|
1225
|
+
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.
|
|
1226
|
+
|
|
1227
|
+
## How events are matched
|
|
1228
|
+
|
|
1229
|
+
When an event arrives, the orchestrator follows this flow:
|
|
1230
|
+
|
|
1231
|
+
1. **Event received** -- a custom event is emitted by a step, a cron timer fires, or a generic webhook arrives
|
|
1232
|
+
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)
|
|
1233
|
+
3. **Trigger evaluation** -- for each candidate workflow, the orchestrator evaluates the trigger conditions: event name patterns, payload matching, status filters, source filters
|
|
1234
|
+
4. **Dispatch** -- matched workflows are dispatched to agents for execution, following the same job queue and agent routing as git-triggered workflows
|
|
1235
|
+
|
|
1236
|
+
This lookup is fast because the registration index is held in memory and refreshed only when the registry version changes (on default-branch pushes).
|
|
1237
|
+
|
|
1238
|
+
### Cross-source webhook delivery
|
|
1239
|
+
|
|
1240
|
+
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.
|
|
1241
|
+
|
|
1242
|
+
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.
|
|
1243
|
+
|
|
1244
|
+
## Circuit breaker
|
|
1245
|
+
|
|
1246
|
+
Events can trigger workflows that emit more events, creating chains. The circuit breaker prevents runaway event storms.
|
|
1247
|
+
|
|
1248
|
+
### Chain depth limit
|
|
1249
|
+
|
|
1250
|
+
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.
|
|
1251
|
+
|
|
1252
|
+
- **Default limit:** 10 levels deep
|
|
1253
|
+
- **What happens when hit:** the event is dropped and logged. It is not queued for later delivery.
|
|
1254
|
+
|
|
1255
|
+
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.
|
|
1256
|
+
|
|
1257
|
+
### Rate limiting
|
|
1258
|
+
|
|
1259
|
+
Each workflow is rate-limited on how many events it can process per minute, using a sliding window.
|
|
1260
|
+
|
|
1261
|
+
- **Default limit:** 100 events per workflow per minute
|
|
1262
|
+
- **What happens when hit:** additional events for that workflow are dropped and logged until the window clears.
|
|
1263
|
+
|
|
1264
|
+
These defaults are hardcoded in the orchestrator and are not currently configurable via environment variables.
|
|
1265
|
+
|
|
1266
|
+
## Delivery guarantees
|
|
1267
|
+
|
|
1268
|
+
KiCI's event router delivers every accepted event with **at-least-once** semantics:
|
|
1269
|
+
|
|
1270
|
+
- An event that passes the circuit breaker (chain depth + rate limit) and commits
|
|
1271
|
+
to the `kici_events` table is guaranteed to dispatch to all matching workflows
|
|
1272
|
+
at least once.
|
|
1273
|
+
- Each dispatch attempt acquires a short-lived lease (default 60 s) on the row.
|
|
1274
|
+
If the dispatching node crashes or the handler throws, the lease expires (or
|
|
1275
|
+
is released on failure) and the event is automatically retried.
|
|
1276
|
+
- The retry policy is exponential backoff with full jitter: base 5 s, cap 5 min,
|
|
1277
|
+
up to 5 attempts before the event lands in the **DLQ** (dead-letter queue).
|
|
1278
|
+
Operators triage DLQ entries via `kici-admin event-dlq list / count / retry / discard`.
|
|
1279
|
+
|
|
1280
|
+
**What this means for workflow authors:**
|
|
1281
|
+
|
|
1282
|
+
- **Make event handlers idempotent.** A retried dispatch may run a handler more
|
|
1283
|
+
than once (e.g. if the first attempt threw after a partial side-effect).
|
|
1284
|
+
Workflows that mutate external state should use idempotency keys, conditional
|
|
1285
|
+
writes, or other deduplication patterns — same advice as for any distributed
|
|
1286
|
+
CI system.
|
|
1287
|
+
- **Schedule fires are at-least-once too.** A cron schedule that fires while a
|
|
1288
|
+
leader is being killed will commit (atomically with `cron_last_fired`) or roll
|
|
1289
|
+
back together — never half. Recovery on the new leader does not backfill
|
|
1290
|
+
multiple missed instants; if your workflow needs at-least-N guarantees across
|
|
1291
|
+
outages, drive it from a different mechanism (e.g. a workflow that runs more
|
|
1292
|
+
frequently and emits its own custom event).
|
|
1293
|
+
- **Drops are still possible — and visible.** Events rejected by the circuit
|
|
1294
|
+
breaker (chain depth or rate limit exceeded) are dropped and logged, not
|
|
1295
|
+
retried. That's a deliberate safety mechanism; the metric to watch is
|
|
1296
|
+
`kici_orch_events_dropped_total{reason}`.
|
|
1297
|
+
|
|
1298
|
+
## Emitting custom events
|
|
1299
|
+
|
|
1300
|
+
Custom events are emitted from workflow steps using `ctx.emit()`. You can optionally define typed event schemas using `defineEvent()`.
|
|
1301
|
+
|
|
1302
|
+
### Basic emission
|
|
1303
|
+
|
|
1304
|
+
```typescript
|
|
1305
|
+
import { workflow, job, step, push } from '@kici-dev/sdk';
|
|
1306
|
+
|
|
1307
|
+
export default workflow('build', {
|
|
1308
|
+
on: push({ branches: 'main' }),
|
|
1309
|
+
jobs: [
|
|
1310
|
+
job('build', {
|
|
1311
|
+
runsOn: 'linux',
|
|
1312
|
+
steps: [
|
|
1313
|
+
step('build', async ({ $ }) => {
|
|
1314
|
+
await $`pnpm build`;
|
|
1315
|
+
}),
|
|
1316
|
+
step('notify', async (ctx) => {
|
|
1317
|
+
await ctx.emit('build-complete', {
|
|
1318
|
+
version: '1.0.0',
|
|
1319
|
+
success: true,
|
|
1320
|
+
});
|
|
1321
|
+
}),
|
|
1322
|
+
],
|
|
1323
|
+
}),
|
|
1324
|
+
],
|
|
1325
|
+
});
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
### Typed event definitions
|
|
1329
|
+
|
|
1330
|
+
Use `defineEvent()` with Zod schemas to create a typed contract for event payloads:
|
|
1331
|
+
|
|
1332
|
+
```typescript
|
|
1333
|
+
import { defineEvent, z } from '@kici-dev/sdk';
|
|
1334
|
+
|
|
1335
|
+
export const deployComplete = defineEvent(
|
|
1336
|
+
'deploy-complete',
|
|
1337
|
+
z.object({
|
|
1338
|
+
env: z.string(),
|
|
1339
|
+
version: z.string(),
|
|
1340
|
+
services: z.array(z.string()),
|
|
1341
|
+
}),
|
|
1342
|
+
);
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
Then emit using the definition's name:
|
|
1346
|
+
|
|
1347
|
+
```typescript
|
|
1348
|
+
step('emit', async (ctx) => {
|
|
1349
|
+
await ctx.emit(deployComplete.name, {
|
|
1350
|
+
env: 'prod',
|
|
1351
|
+
version: '1.2.3',
|
|
1352
|
+
services: ['api', 'web'],
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
And consume in another workflow:
|
|
1358
|
+
|
|
1359
|
+
```typescript
|
|
1360
|
+
import { workflow, job, step, kiciEvent } from '@kici-dev/sdk';
|
|
1361
|
+
|
|
1362
|
+
export default workflow('post-deploy', {
|
|
1363
|
+
on: kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }),
|
|
1364
|
+
jobs: [
|
|
1365
|
+
job('smoke-test', {
|
|
1366
|
+
runsOn: 'linux',
|
|
1367
|
+
steps: [
|
|
1368
|
+
step('test', async ({ $ }) => {
|
|
1369
|
+
await $`./scripts/smoke-test.sh`;
|
|
1370
|
+
}),
|
|
1371
|
+
],
|
|
1372
|
+
}),
|
|
1373
|
+
],
|
|
1374
|
+
});
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
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.
|
|
1378
|
+
|
|
1379
|
+
## See also
|
|
1380
|
+
|
|
1381
|
+
- [SDK reference: event triggers](https://docs.kici.dev/user/sdk/triggers/#event-triggers) -- complete API signatures for all trigger builders
|
|
1382
|
+
- [SDK reference: emitting events](https://docs.kici.dev/user/sdk/validation-events/#emitting-events) -- `ctx.emit()` and `defineEvent()` API
|
|
1383
|
+
- [Workflow patterns: workflow chaining](https://docs.kici.dev/user/patterns/integrations/#workflow-chaining) -- examples of event-driven workflow chains
|
|
1384
|
+
- [Operator guide: event routing](https://docs.kici.dev/operator/event-routing/) -- configuring generic webhook sources, trust relationships, and event routing
|
|
1385
|
+
- [Architecture: event system](https://docs.kici.dev/architecture/webhooks/event-system/) -- internal event routing design, registration model, cluster synchronization
|
|
1386
|
+
|
|
1387
|
+
---
|
|
1388
|
+
|
|
1389
|
+
## Global workflows
|
|
1390
|
+
|
|
1391
|
+
Source: https://docs.kici.dev/user/global-workflows/
|
|
1392
|
+
|
|
1393
|
+
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."
|
|
1394
|
+
|
|
1395
|
+
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.
|
|
1396
|
+
|
|
1397
|
+
## Mental model
|
|
1398
|
+
|
|
1399
|
+
| Term | Meaning |
|
|
1400
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
1401
|
+
| Workflow repo | The repo whose `.kici/workflows/*.ts` file **declares** the global workflow. Holds the steps. Also known as the _authoring_ repo. |
|
|
1402
|
+
| 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. |
|
|
1403
|
+
| Global | A workflow whose trigger carries one or more `repos:` glob patterns. The presence of `repos:` is what classifies a workflow as global. |
|
|
1404
|
+
| Authoring axis | Policy that answers "which repos may **author** global workflows?" Controlled by the allow-list in the dashboard's _Workflow authors_ setting. |
|
|
1405
|
+
| Source axis | Policy that answers "which **source** repos' events are allowed to trigger global workflows?" Controlled by the deny-list in _Blocked source repos_. |
|
|
1406
|
+
|
|
1407
|
+
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.
|
|
1408
|
+
|
|
1409
|
+
## Declaring a global workflow
|
|
1410
|
+
|
|
1411
|
+
Add `repos:` to any trigger. Any workflow with at least one `repos:`-bearing trigger becomes global automatically; no separate flag is required.
|
|
1412
|
+
|
|
1413
|
+
```ts
|
|
1414
|
+
import { workflow, job, step, push } from '@kici-dev/sdk';
|
|
1415
|
+
|
|
1416
|
+
export default workflow('org-lint', {
|
|
1417
|
+
on: [
|
|
1418
|
+
push({
|
|
1419
|
+
repos: ['myorg/*', '!myorg/archived-*'],
|
|
1420
|
+
branches: ['main'],
|
|
1421
|
+
}),
|
|
1422
|
+
],
|
|
1423
|
+
jobs: [
|
|
1424
|
+
job('lint', {
|
|
1425
|
+
steps: [
|
|
1426
|
+
step('lint-all', async ({ $, env }) => {
|
|
1427
|
+
await $`echo source=${env.KICI_SOURCE_REPO_PATH ?? 'unknown'}`;
|
|
1428
|
+
await $`npm run lint`;
|
|
1429
|
+
}),
|
|
1430
|
+
],
|
|
1431
|
+
}),
|
|
1432
|
+
],
|
|
1433
|
+
});
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
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.
|
|
1437
|
+
|
|
1438
|
+
### At a dual-repo checkout
|
|
1439
|
+
|
|
1440
|
+
The agent receives two sets of context during a global workflow execution:
|
|
1441
|
+
|
|
1442
|
+
| `env` var | Points to |
|
|
1443
|
+
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1444
|
+
| `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. |
|
|
1445
|
+
| `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. |
|
|
1446
|
+
|
|
1447
|
+
Source repo secrets are **not** available to a global workflow's job by default — see _Elevated access_ below.
|
|
1448
|
+
|
|
1449
|
+
## Enabling global workflows
|
|
1450
|
+
|
|
1451
|
+
Global workflows are **opt-in per org**. In a fresh org, `repos:`-bearing workflows are registered but never dispatched.
|
|
1452
|
+
|
|
1453
|
+
1. Open the dashboard → **Settings → Global workflows**.
|
|
1454
|
+
2. Turn on **Enable global workflows** (the master toggle). This is the kill-switch — every other toggle below is ignored while this is off.
|
|
1455
|
+
3. Decide which authoring/source controls you need:
|
|
1456
|
+
|
|
1457
|
+
| Setting | What it controls | Typical use |
|
|
1458
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
1459
|
+
| 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. |
|
|
1460
|
+
| 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-*`. |
|
|
1461
|
+
| 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. |
|
|
1462
|
+
|
|
1463
|
+
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-*`).
|
|
1464
|
+
|
|
1465
|
+
### Saving and reverting
|
|
1466
|
+
|
|
1467
|
+
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.
|
|
1468
|
+
|
|
1469
|
+
## Security model
|
|
1470
|
+
|
|
1471
|
+
### Two independent axes
|
|
1472
|
+
|
|
1473
|
+
A global workflow fires only if:
|
|
1474
|
+
|
|
1475
|
+
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:
|
|
1476
|
+
- At registration time (extraction from the lock file — non-matching globals are dropped with a warning).
|
|
1477
|
+
- At dispatch time (defense-in-depth — policy changes after registration still take effect).
|
|
1478
|
+
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.
|
|
1479
|
+
|
|
1480
|
+
Both checks are logged to the orchestrator. Grep the logs for `Skipping global workflow` to see enforcement in action.
|
|
1481
|
+
|
|
1482
|
+
### Elevated access (source-repo secrets)
|
|
1483
|
+
|
|
1484
|
+
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.
|
|
1485
|
+
|
|
1486
|
+
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.
|
|
1487
|
+
|
|
1488
|
+
## When does it fire?
|
|
1489
|
+
|
|
1490
|
+
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.
|
|
1491
|
+
|
|
1492
|
+
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:`.
|
|
1493
|
+
|
|
1494
|
+
## Troubleshooting
|
|
1495
|
+
|
|
1496
|
+
| Symptom | Likely cause | Where to look |
|
|
1497
|
+
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
1498
|
+
| 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` |
|
|
1499
|
+
| `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) |
|
|
1500
|
+
| Source repo secrets unavailable in a global job | Expected default — elevate the authoring repo to grant access. | Dashboard → Settings → Global workflows → _Elevated access_ |
|
|
1501
|
+
| 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. |
|
|
1502
|
+
|
|
1503
|
+
## See also
|
|
1504
|
+
|
|
1505
|
+
- [Architecture — global workflows](https://docs.kici.dev/architecture/global-workflows/) — dual-query dispatch flow, cross-provider auth, security model, lock-file schema.
|
|
1506
|
+
- [Universal-git provider](https://docs.kici.dev/user/providers/universal-git/#global-workflows) — how global workflows interact with `generic:<orgId>:<sourceId>` routing keys.
|
|
1507
|
+
- [SDK reference](https://docs.kici.dev/user/sdk-reference/) — the full set of triggers that accept `repos:`.
|
|
1508
|
+
|
|
1509
|
+
---
|
|
1510
|
+
|
|
1511
|
+
## Idempotent steps and check mode
|
|
1512
|
+
|
|
1513
|
+
Source: https://docs.kici.dev/user/idempotent-steps/
|
|
1514
|
+
|
|
1515
|
+
An **idempotent step** describes _desired state_ rather than a fixed sequence of
|
|
1516
|
+
commands. You give the step a `check` function that inspects the world and a
|
|
1517
|
+
`run` function that converges it. KiCI then executes the workflow in one of two
|
|
1518
|
+
modes:
|
|
1519
|
+
|
|
1520
|
+
- **Apply mode** (the default): for each step, `check()` runs first; on drift the
|
|
1521
|
+
step applies the change; when already in sync the step is skipped.
|
|
1522
|
+
- **Check mode** (`--check`): for each step, `check()` runs and KiCI reports what
|
|
1523
|
+
_would_ change — **without changing anything**. This is the same model as a
|
|
1524
|
+
dry-run plan: you see the drift before any side effect happens.
|
|
1525
|
+
|
|
1526
|
+
This turns a workflow into convergent configuration management: re-running an
|
|
1527
|
+
apply is safe (in-sync steps do nothing), and a check-mode run is a read-only
|
|
1528
|
+
preview you can gate a build on.
|
|
1529
|
+
|
|
1530
|
+
## Authoring a checked step
|
|
1531
|
+
|
|
1532
|
+
Add a `check` facet to the existing `step()` factory. When `check` is present,
|
|
1533
|
+
`run` becomes the _apply_ function and receives the drift value `check`
|
|
1534
|
+
returned:
|
|
1535
|
+
|
|
1536
|
+
```typescript
|
|
1537
|
+
import { step, z } from '@kici-dev/sdk';
|
|
1538
|
+
|
|
1539
|
+
const configureNginx = step('configure-nginx', {
|
|
1540
|
+
// optional schema for the drift value — gives the dashboard a typed shape
|
|
1541
|
+
drift: z.object({ want: z.string() }),
|
|
1542
|
+
|
|
1543
|
+
// read-only inspection; return null when already in the desired state
|
|
1544
|
+
check: async (ctx) => {
|
|
1545
|
+
const current = await ctx.$`nginx -T`;
|
|
1546
|
+
return current.stdout.includes(DESIRED) ? null : { want: DESIRED };
|
|
1547
|
+
},
|
|
1548
|
+
|
|
1549
|
+
// human-readable preview line — REQUIRED when check is set. It is the drift's
|
|
1550
|
+
// serializable face: it streams to the logs and persists for the dashboard.
|
|
1551
|
+
summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
|
|
1552
|
+
|
|
1553
|
+
// apply — runs only when check returned drift (apply mode); receives that drift
|
|
1554
|
+
run: async (ctx, drift) => {
|
|
1555
|
+
await writeConfig(drift.want);
|
|
1556
|
+
return { reloaded: true };
|
|
1557
|
+
},
|
|
1558
|
+
|
|
1559
|
+
// optional — runs when check returned null, to produce the step's outputs
|
|
1560
|
+
whenInSync: async () => ({ reloaded: false }),
|
|
1561
|
+
});
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
### The facet fields
|
|
1565
|
+
|
|
1566
|
+
| Field | Required | Purpose |
|
|
1567
|
+
| ------------ | ---------------- | ---------------------------------------------------------------------- |
|
|
1568
|
+
| `check` | to opt in | Read-only inspection. Return a drift value, or `null` when in sync. |
|
|
1569
|
+
| `summarize` | when `check` set | Human-readable, serializable preview of the drift. Streams + persists. |
|
|
1570
|
+
| `run` | always | Apply function. With `check`, it receives the drift as its second arg. |
|
|
1571
|
+
| `whenInSync` | optional | Produces the step's outputs when `check` returned `null`. |
|
|
1572
|
+
| `drift` | optional | Schema that validates / shapes the drift value. |
|
|
1573
|
+
|
|
1574
|
+
`summarize` is **required** whenever `check` is declared. `run` and `whenInSync`
|
|
1575
|
+
both produce the same output type — one output shape per step, whichever path
|
|
1576
|
+
runs. Every other step facet (`cache`, `rules`, `continueOnError`, `timeout`,
|
|
1577
|
+
`approval`, `onCancel`, `cleanup`, `outputs`) composes unchanged.
|
|
1578
|
+
|
|
1579
|
+
A plain `step()` without `check` keeps its exact current behavior — the check
|
|
1580
|
+
facet is fully optional.
|
|
1581
|
+
|
|
1582
|
+
## Run modes
|
|
1583
|
+
|
|
1584
|
+
A run carries one of three modes:
|
|
1585
|
+
|
|
1586
|
+
| Mode | CLI flags | Behavior |
|
|
1587
|
+
| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------ |
|
|
1588
|
+
| `apply` | (default, no flags) | Converge: drift ⇒ apply ⇒ **applied**; null ⇒ **in sync** (skipped). |
|
|
1589
|
+
| `check` | `--check` | Preview only: drift ⇒ **would change**; null ⇒ **in sync**. Never applies. Always exits 0. |
|
|
1590
|
+
| `check-fail-on-drift` | `--check --fail-on-drift` | Same as check, but the run **fails** if any step reports drift. |
|
|
1591
|
+
|
|
1592
|
+
Per-step outcomes:
|
|
1593
|
+
|
|
1594
|
+
- **applied** — drift was found and the step applied the change (apply mode).
|
|
1595
|
+
- **in sync** — `check` returned `null`; nothing to do.
|
|
1596
|
+
- **would change** — drift was found in check mode; the change was previewed, not applied.
|
|
1597
|
+
- **no check** — a plain step (no `check`) reached under check mode. A
|
|
1598
|
+
side-effecting step can't be safely previewed, so it is skipped.
|
|
1599
|
+
|
|
1600
|
+
In check mode KiCI never invokes a checked step's `run` (apply) — the preview is
|
|
1601
|
+
guaranteed side-effect-free.
|
|
1602
|
+
|
|
1603
|
+
## Running in check mode
|
|
1604
|
+
|
|
1605
|
+
`--check` and `--fail-on-drift` work on both local and remote runs:
|
|
1606
|
+
|
|
1607
|
+
```bash
|
|
1608
|
+
# Apply (default): converge the workflow.
|
|
1609
|
+
kici run local push
|
|
1610
|
+
kici run remote my-fixture
|
|
1611
|
+
|
|
1612
|
+
# Check: report drift, change nothing. Always exits 0.
|
|
1613
|
+
kici run local push --check
|
|
1614
|
+
kici run remote my-fixture --check
|
|
1615
|
+
|
|
1616
|
+
# Check + fail on drift: exit non-zero (2) locally, or fail the run remotely,
|
|
1617
|
+
# when any step reports drift. Use this as a CI gate ("fail the build if prod
|
|
1618
|
+
# has drifted").
|
|
1619
|
+
kici run local push --check --fail-on-drift
|
|
1620
|
+
```
|
|
1621
|
+
|
|
1622
|
+
`--fail-on-drift` only modifies check mode — passing it without `--check` is an
|
|
1623
|
+
error.
|
|
1624
|
+
|
|
1625
|
+
## Where outcomes show up
|
|
1626
|
+
|
|
1627
|
+
A check-mode run is labeled in the dashboard with a **CHECK MODE — preview**
|
|
1628
|
+
badge on the run header. Each step shows its outcome chip — applied / in sync /
|
|
1629
|
+
would change / no check — and, when drift was detected, the `summarize` line
|
|
1630
|
+
describing what would change. The rendering is read-only.
|
|
1631
|
+
|
|
1632
|
+
## See also
|
|
1633
|
+
|
|
1634
|
+
- [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.
|
|
1635
|
+
- [Core SDK reference](https://docs.kici.dev/user/sdk/core/) — the `step()`, `job()`, and `workflow()` factories the check facet extends.
|
|
1636
|
+
- [Lock file and drift](https://docs.kici.dev/user/lock-file-and-drift/) — how the lock file carries step capability flags.
|
|
1637
|
+
|
|
1638
|
+
---
|
|
1639
|
+
|
|
1640
|
+
## Private npm registries
|
|
1641
|
+
|
|
1642
|
+
Source: https://docs.kici.dev/user/private-registries/
|
|
1643
|
+
|
|
1644
|
+
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.
|
|
1645
|
+
|
|
1646
|
+
## Choose a path
|
|
1647
|
+
|
|
1648
|
+
| Path | When to pick it |
|
|
1649
|
+
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1650
|
+
| **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. |
|
|
1651
|
+
| **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. |
|
|
1652
|
+
| **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. |
|
|
1653
|
+
|
|
1654
|
+
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.
|
|
1655
|
+
|
|
1656
|
+
## Option A — `registries:` block
|
|
1657
|
+
|
|
1658
|
+
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.
|
|
1659
|
+
|
|
1660
|
+
```typescript
|
|
1661
|
+
import { workflow, job, step, push } from '@kici-dev/sdk';
|
|
1662
|
+
|
|
1663
|
+
export default workflow('build', {
|
|
1664
|
+
on: [push({ branches: ['main'] })],
|
|
1665
|
+
registries: [
|
|
1666
|
+
{
|
|
1667
|
+
url: 'https://npm.pkg.github.com/',
|
|
1668
|
+
scope: '@my-org',
|
|
1669
|
+
tokenSecret: 'production:GITHUB_PACKAGES_TOKEN',
|
|
1670
|
+
},
|
|
1671
|
+
],
|
|
1672
|
+
jobs: [
|
|
1673
|
+
job('build', {
|
|
1674
|
+
runsOn: 'default',
|
|
1675
|
+
environment: 'production',
|
|
1676
|
+
steps: [
|
|
1677
|
+
step('install-and-build', async (ctx) => {
|
|
1678
|
+
// .kici/package.json can now reference @my-org/* packages
|
|
1679
|
+
await ctx.$`npm run build`;
|
|
1680
|
+
}),
|
|
1681
|
+
],
|
|
1682
|
+
}),
|
|
1683
|
+
],
|
|
1684
|
+
});
|
|
1685
|
+
```
|
|
1686
|
+
|
|
1687
|
+
Per-field rules:
|
|
1688
|
+
|
|
1689
|
+
- **`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)).
|
|
1690
|
+
- **`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`.
|
|
1691
|
+
- **`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.
|
|
1692
|
+
- **`alwaysAuth`** — Defaults to `true`. Forces npm to send the token on every request (even GETs), which is what most managed-registry providers require.
|
|
1693
|
+
|
|
1694
|
+
### How tokens reach `npm install`
|
|
1695
|
+
|
|
1696
|
+
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.
|
|
1697
|
+
|
|
1698
|
+
After the install completes (success or failure), the agent restores the original `.kici/.npmrc` — your committed file is never permanently modified.
|
|
1699
|
+
|
|
1700
|
+
## Option C — committed `.kici/.npmrc` + `installEnv:`
|
|
1701
|
+
|
|
1702
|
+
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`.
|
|
1703
|
+
|
|
1704
|
+
```ini
|
|
1705
|
+
# .kici/.npmrc
|
|
1706
|
+
@my-org:registry=https://npm.example.com/
|
|
1707
|
+
//npm.example.com/:_authToken=${MY_NPM_TOKEN}
|
|
1708
|
+
//npm.example.com/:always-auth=true
|
|
1709
|
+
audit=false
|
|
1710
|
+
```
|
|
1711
|
+
|
|
1712
|
+
```typescript
|
|
1713
|
+
import { workflow, job, step, push } from '@kici-dev/sdk';
|
|
1714
|
+
|
|
1715
|
+
export default workflow('build', {
|
|
1716
|
+
on: [push({ branches: ['main'] })],
|
|
1717
|
+
installEnv: ['production:MY_NPM_TOKEN'],
|
|
1718
|
+
jobs: [
|
|
1719
|
+
job('build', {
|
|
1720
|
+
runsOn: 'default',
|
|
1721
|
+
environment: 'production',
|
|
1722
|
+
steps: [step('build', async (ctx) => ctx.$`npm run build`)],
|
|
1723
|
+
}),
|
|
1724
|
+
],
|
|
1725
|
+
});
|
|
1726
|
+
```
|
|
1727
|
+
|
|
1728
|
+
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}`.
|
|
1729
|
+
|
|
1730
|
+
This path is the right answer when:
|
|
1731
|
+
|
|
1732
|
+
- The `.npmrc` carries non-auth knobs (`audit=false`, `legacy-peer-deps=true`, custom CA bundles).
|
|
1733
|
+
- You want a single source of truth for registry topology that `npm` tooling outside KiCI can consume too.
|
|
1734
|
+
- The auth lines reference the **same** env var across multiple registries.
|
|
1735
|
+
|
|
1736
|
+
## Short-lived tokens (CodeArtifact, GCP Artifact Registry)
|
|
1737
|
+
|
|
1738
|
+
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.
|
|
1739
|
+
|
|
1740
|
+
The supported pattern is a **setup job** that mints a fresh token, writes `.kici/.npmrc`, and downstream jobs install with it.
|
|
1741
|
+
|
|
1742
|
+
```typescript
|
|
1743
|
+
import { workflow, job, step, push } from '@kici-dev/sdk';
|
|
1744
|
+
|
|
1745
|
+
export default workflow('build', {
|
|
1746
|
+
on: [push({ branches: ['main'] })],
|
|
1747
|
+
jobs: [
|
|
1748
|
+
job('mint-codeartifact-token', {
|
|
1749
|
+
runsOn: 'default',
|
|
1750
|
+
environment: 'production',
|
|
1751
|
+
steps: [
|
|
1752
|
+
step('mint', async (ctx) => {
|
|
1753
|
+
const awsKey = await ctx.secrets.get('AWS_ACCESS_KEY_ID');
|
|
1754
|
+
const awsSecret = await ctx.secrets.get('AWS_SECRET_ACCESS_KEY');
|
|
1755
|
+
process.env.AWS_ACCESS_KEY_ID = awsKey;
|
|
1756
|
+
process.env.AWS_SECRET_ACCESS_KEY = awsSecret;
|
|
1757
|
+
|
|
1758
|
+
const token = (
|
|
1759
|
+
await ctx.$`aws codeartifact get-authorization-token --domain my-domain --query authorizationToken --output text`
|
|
1760
|
+
).stdout.trim();
|
|
1761
|
+
|
|
1762
|
+
// Write directly into the workspace's .kici/ — the next job reuses the same workspace.
|
|
1763
|
+
const npmrc = [
|
|
1764
|
+
'@my-org:registry=https://my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/',
|
|
1765
|
+
`//my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/:_authToken=${token}`,
|
|
1766
|
+
'//my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/:always-auth=true',
|
|
1767
|
+
'',
|
|
1768
|
+
].join('\n');
|
|
1769
|
+
await ctx.$`tee .kici/.npmrc`.stdin(npmrc);
|
|
1770
|
+
}),
|
|
1771
|
+
],
|
|
1772
|
+
}),
|
|
1773
|
+
job('build', {
|
|
1774
|
+
runsOn: 'default',
|
|
1775
|
+
environment: 'production',
|
|
1776
|
+
needs: ['mint-codeartifact-token'],
|
|
1777
|
+
steps: [step('build', async (ctx) => ctx.$`npm run build`)],
|
|
1778
|
+
}),
|
|
1779
|
+
],
|
|
1780
|
+
});
|
|
1781
|
+
```
|
|
1782
|
+
|
|
1783
|
+
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.
|
|
1784
|
+
|
|
1785
|
+
## Provider-specific examples
|
|
1786
|
+
|
|
1787
|
+
### GitHub Packages
|
|
1788
|
+
|
|
1789
|
+
```typescript
|
|
1790
|
+
registries: [
|
|
1791
|
+
{
|
|
1792
|
+
url: 'https://npm.pkg.github.com/',
|
|
1793
|
+
scope: '@my-org',
|
|
1794
|
+
tokenSecret: 'production:GITHUB_PACKAGES_TOKEN',
|
|
1795
|
+
},
|
|
1796
|
+
],
|
|
1797
|
+
```
|
|
1798
|
+
|
|
1799
|
+
Mint the token from a fine-grained PAT with `read:packages` scope, store it as a scoped secret in the `production` environment.
|
|
1800
|
+
|
|
1801
|
+
### GitLab Packages
|
|
1802
|
+
|
|
1803
|
+
```typescript
|
|
1804
|
+
registries: [
|
|
1805
|
+
{
|
|
1806
|
+
url: 'https://gitlab.example.com/api/v4/projects/123/packages/npm/',
|
|
1807
|
+
scope: '@my-group',
|
|
1808
|
+
tokenSecret: 'production:GITLAB_DEPLOY_TOKEN',
|
|
1809
|
+
},
|
|
1810
|
+
],
|
|
1811
|
+
```
|
|
1812
|
+
|
|
1813
|
+
Use a project- or group-level deploy token with `read_package_registry` scope.
|
|
1814
|
+
|
|
1815
|
+
### Verdaccio (self-hosted)
|
|
1816
|
+
|
|
1817
|
+
```typescript
|
|
1818
|
+
registries: [
|
|
1819
|
+
{
|
|
1820
|
+
url: 'https://npm.internal.example.com/',
|
|
1821
|
+
tokenSecret: 'production:VERDACCIO_TOKEN',
|
|
1822
|
+
},
|
|
1823
|
+
],
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
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`.
|
|
1827
|
+
|
|
1828
|
+
### JFrog Artifactory
|
|
1829
|
+
|
|
1830
|
+
```typescript
|
|
1831
|
+
registries: [
|
|
1832
|
+
{
|
|
1833
|
+
url: 'https://artifactory.example.com/artifactory/api/npm/npm-virtual/',
|
|
1834
|
+
scope: '@my-org',
|
|
1835
|
+
tokenSecret: 'production:JFROG_API_KEY',
|
|
1836
|
+
},
|
|
1837
|
+
],
|
|
1838
|
+
```
|
|
1839
|
+
|
|
1840
|
+
### Cloudsmith
|
|
1841
|
+
|
|
1842
|
+
```typescript
|
|
1843
|
+
registries: [
|
|
1844
|
+
{
|
|
1845
|
+
url: 'https://npm.cloudsmith.io/my-org/my-repo/',
|
|
1846
|
+
scope: '@my-org',
|
|
1847
|
+
tokenSecret: 'production:CLOUDSMITH_TOKEN',
|
|
1848
|
+
},
|
|
1849
|
+
],
|
|
1850
|
+
```
|
|
1851
|
+
|
|
1852
|
+
## Security model
|
|
1853
|
+
|
|
1854
|
+
- **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.
|
|
1855
|
+
- **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`.
|
|
1856
|
+
- **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.
|
|
1857
|
+
- **Stderr is redacted.** If the install fails, the agent masks every token literal out of the surfaced stderr / stdout chunks before logging.
|
|
1858
|
+
- **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.
|
|
1859
|
+
- **`.npmrc` restored.** Whatever the agent appended for one install is stripped (or the file unlinked) on cleanup, so the workspace is never permanently modified.
|
|
1860
|
+
|
|
1861
|
+
## Reviewer-gated installs
|
|
1862
|
+
|
|
1863
|
+
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.
|
|
1864
|
+
|
|
1865
|
+
- **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.
|
|
1866
|
+
- **Wait timer:** the run waits out the timer and resumes automatically when it elapses.
|
|
1867
|
+
|
|
1868
|
+
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.
|
|
1869
|
+
|
|
1870
|
+
## Limitations
|
|
1871
|
+
|
|
1872
|
+
- **`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.
|
|
1873
|
+
- **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.
|
|
1874
|
+
|
|
1875
|
+
## Observability
|
|
1876
|
+
|
|
1877
|
+
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.
|
|
1878
|
+
|
|
1879
|
+
| Metric | Type | Labels | What it tells you |
|
|
1880
|
+
| ------------------------------------------------------------- | --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1881
|
+
| `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. |
|
|
1882
|
+
| `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. |
|
|
1883
|
+
| `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. |
|
|
1884
|
+
| `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. |
|
|
1885
|
+
|
|
1886
|
+
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.
|
|
1887
|
+
|
|
1888
|
+
## See also
|
|
1889
|
+
|
|
1890
|
+
- [Secrets](https://docs.kici.dev/user/secrets/) — how to seed the `<environment>:<secret-name>` values referenced by `tokenSecret` / `installEnv`.
|
|
1891
|
+
- [Environments](https://docs.kici.dev/user/environments/) — protection rules (`branch_restrictions`, `requires_review`, `minimum_trust`) that the install gate inherits.
|
|
1892
|
+
- [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.
|
|
1893
|
+
|
|
1894
|
+
---
|
|
1895
|
+
|
|
1896
|
+
## Build provenance and attestations
|
|
1897
|
+
|
|
1898
|
+
Source: https://docs.kici.dev/user/provenance/
|
|
1899
|
+
|
|
1900
|
+
Build provenance is a signed, verifiable statement of **what produced an
|
|
1901
|
+
artifact** — the source repository, commit, ref, workflow path, and builder that
|
|
1902
|
+
ran. When a workflow step attests an artifact, KiCI records that statement,
|
|
1903
|
+
signs it, and makes it retrievable so anyone can later prove the artifact came
|
|
1904
|
+
from a specific KiCI run and was not swapped along the way.
|
|
1905
|
+
|
|
1906
|
+
This is the same idea behind supply-chain attestation systems like
|
|
1907
|
+
[SLSA](https://slsa.dev/spec/v1.0/provenance): a downstream consumer (a release
|
|
1908
|
+
gate, a security audit, a `"show me the provenance"` request) can verify the
|
|
1909
|
+
artifact's origin without trusting the person who handed it over.
|
|
1910
|
+
|
|
1911
|
+
## What an attestation contains
|
|
1912
|
+
|
|
1913
|
+
An attestation is a self-contained bundle holding three things:
|
|
1914
|
+
|
|
1915
|
+
- An **in-toto SLSA v1.0 statement** describing the build: the subject artifact
|
|
1916
|
+
(name + content digest) and the provenance predicate (source repository,
|
|
1917
|
+
commit, ref, workflow, run/job identifiers, timestamps).
|
|
1918
|
+
- A **[DSSE](https://github.com/secure-systems-lab/dsse) signature** over that
|
|
1919
|
+
statement, made with an ephemeral signing key generated for the run.
|
|
1920
|
+
- A short-lived **OIDC identity token** issued by the KiCI platform that binds
|
|
1921
|
+
the signature to the build identity. The token's identity claims
|
|
1922
|
+
(`repository`, `ref`, `sha`, run/job ids) are derived by the platform from the
|
|
1923
|
+
run itself — a step cannot forge them.
|
|
1924
|
+
|
|
1925
|
+
Because the bundle carries the identity token and the public signing key, it is
|
|
1926
|
+
**offline-verifiable**: a verifier checks it against the platform's published
|
|
1927
|
+
signing keys with no per-attestation online lookup.
|
|
1928
|
+
|
|
1929
|
+
## Attesting an artifact in a workflow
|
|
1930
|
+
|
|
1931
|
+
Call `ctx.attestProvenance({ subject })` from a step after you have produced the
|
|
1932
|
+
artifact:
|
|
1933
|
+
|
|
1934
|
+
```typescript
|
|
1935
|
+
import { workflow, job, step } from '@kici-dev/sdk';
|
|
1936
|
+
|
|
1937
|
+
export default workflow('release', {
|
|
1938
|
+
on: { push: { branches: ['main'] } },
|
|
1939
|
+
jobs: [
|
|
1940
|
+
job('publish', {
|
|
1941
|
+
steps: [
|
|
1942
|
+
step('build', async (ctx) => {
|
|
1943
|
+
await ctx.$`npm pack`;
|
|
1944
|
+
}),
|
|
1945
|
+
step('attest', async (ctx) => {
|
|
1946
|
+
const result = await ctx.attestProvenance({
|
|
1947
|
+
subject: { name: 'my-pkg-1.2.3.tgz', path: 'my-pkg-1.2.3.tgz' },
|
|
1948
|
+
});
|
|
1949
|
+
ctx.log.info(`Attestation stored at ${result.storageKey}`);
|
|
1950
|
+
}),
|
|
1951
|
+
],
|
|
1952
|
+
}),
|
|
1953
|
+
],
|
|
1954
|
+
});
|
|
1955
|
+
```
|
|
1956
|
+
|
|
1957
|
+
The **subject is caller-supplied** — you name the artifact and give KiCI either a
|
|
1958
|
+
path or a precomputed digest:
|
|
1959
|
+
|
|
1960
|
+
- `{ name, path }` — a path relative to the step working directory. KiCI reads
|
|
1961
|
+
the file and computes its SHA-256 digest.
|
|
1962
|
+
- `{ name, digest }` — a precomputed digest. For a container image, pass the OCI
|
|
1963
|
+
manifest digest your build tool emitted:
|
|
1964
|
+
|
|
1965
|
+
```typescript
|
|
1966
|
+
await ctx.attestProvenance({
|
|
1967
|
+
subject: { name: 'ghcr.io/acme/app', digest: { sha256: '<manifest-digest>' } },
|
|
1968
|
+
});
|
|
1969
|
+
```
|
|
1970
|
+
|
|
1971
|
+
The identity token is fetched and masked in logs automatically — you never
|
|
1972
|
+
handle it. The call returns `{ storageKey, subjectDigest, bundleMediaType }`
|
|
1973
|
+
identifying the stored bundle.
|
|
1974
|
+
|
|
1975
|
+
`ctx.attestProvenance` is only available inside a running job step. Calling it
|
|
1976
|
+
during local execution rejects with a clear error.
|
|
1977
|
+
|
|
1978
|
+
### Requesting a raw identity token
|
|
1979
|
+
|
|
1980
|
+
`ctx.attestProvenance` builds on a lower-level primitive you can call directly
|
|
1981
|
+
when you need the identity token for a different tool:
|
|
1982
|
+
|
|
1983
|
+
```typescript
|
|
1984
|
+
step('mint', async (ctx) => {
|
|
1985
|
+
const { token, expiresIn } = await ctx.kici.oidc.token({ audience: 'sigstore' });
|
|
1986
|
+
ctx.log.info(`Got an ID token valid for ${expiresIn}s`);
|
|
1987
|
+
// Hand `token` to a tool that exchanges it with a service trusting the issuer.
|
|
1988
|
+
});
|
|
1989
|
+
```
|
|
1990
|
+
|
|
1991
|
+
The token is a short-lived (about 10 minutes) signed JWT scoped to the current
|
|
1992
|
+
run and job. Its identity claims (`repository`, `ref`, `sha`, `kici_run_id`,
|
|
1993
|
+
`kici_job_id`) are derived by the platform from the run context, so a step cannot
|
|
1994
|
+
spoof them. The returned token value is automatically masked in step logs, and
|
|
1995
|
+
the step never holds platform credentials — the request is relayed through the
|
|
1996
|
+
orchestrator, which mints the token on the step's behalf. Like
|
|
1997
|
+
`attestProvenance`, it is only available inside a running job step.
|
|
1998
|
+
|
|
1999
|
+
## Verifying an attestation
|
|
2000
|
+
|
|
2001
|
+
Verify a bundle with the `kici verify-attestation` command. It establishes the
|
|
2002
|
+
full chain offline: the identity token verifies against the trusted issuer's
|
|
2003
|
+
JWKS, the DSSE signature verifies against the bundled signing key, and the
|
|
2004
|
+
statement's build context must match the token's identity claims (a mismatch is
|
|
2005
|
+
a hard failure).
|
|
2006
|
+
|
|
2007
|
+
```bash
|
|
2008
|
+
kici verify-attestation [artifact] --bundle <path-or-url> --trust-root <url-or-file>
|
|
2009
|
+
```
|
|
2010
|
+
|
|
2011
|
+
You supply the trusted issuer out-of-band via `--trust-root` — the verifier
|
|
2012
|
+
never trusts the issuer named inside the token. There are two forms:
|
|
2013
|
+
|
|
2014
|
+
- **Online — an HTTPS issuer URL.** The verifier fetches
|
|
2015
|
+
`<url>/.well-known/openid-configuration`, reads its `issuer` and `jwks_uri`,
|
|
2016
|
+
and fetches the JWKS. The token's `iss` is pinned to the discovery document's
|
|
2017
|
+
`issuer`.
|
|
2018
|
+
- **Offline — a self-contained trust-root file.** A local JSON file with the
|
|
2019
|
+
issuer and JWKS inlined, for air-gapped verification:
|
|
2020
|
+
|
|
2021
|
+
```json
|
|
2022
|
+
{
|
|
2023
|
+
"issuer": "https://platform.example/issuer",
|
|
2024
|
+
"jwks": {
|
|
2025
|
+
"keys": [
|
|
2026
|
+
{ "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "alg": "ES256", "kid": "..." }
|
|
2027
|
+
]
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
```
|
|
2031
|
+
|
|
2032
|
+
Pass an optional `[artifact]` to also digest-check the file against the
|
|
2033
|
+
attestation subject — this is what binds the attestation to a specific set of
|
|
2034
|
+
bytes. Omit it to verify the signatures and identity only. Use `--json` for a
|
|
2035
|
+
machine-readable result. The command exits `0` when everything verifies and `1`
|
|
2036
|
+
when it does not (or on an error such as a missing flag or unreachable trust
|
|
2037
|
+
root).
|
|
2038
|
+
|
|
2039
|
+
```bash
|
|
2040
|
+
# Verify a bundle against a deployed issuer, digest-checking the artifact:
|
|
2041
|
+
kici verify-attestation ./dist/app.tgz \
|
|
2042
|
+
--bundle ./app.tgz.kici.json \
|
|
2043
|
+
--trust-root https://platform.example/issuer
|
|
2044
|
+
|
|
2045
|
+
# Air-gapped: verify against a self-contained trust-root file:
|
|
2046
|
+
kici verify-attestation ./dist/app.tgz \
|
|
2047
|
+
--bundle ./app.tgz.kici.json \
|
|
2048
|
+
--trust-root ./kici-trust-root.json
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
The full flag reference is in the [CLI reference](https://docs.kici.dev/user/cli-reference/#kici-verify-attestation).
|
|
2052
|
+
|
|
2053
|
+
## Viewing attestations in the dashboard
|
|
2054
|
+
|
|
2055
|
+
<!-- help:run-attestations#viewing-attestations-in-the-dashboard -->
|
|
2056
|
+
|
|
2057
|
+
The run detail page has an **Attestations** tab listing each artifact a run's
|
|
2058
|
+
steps attested (via `ctx.attestProvenance`), one row per artifact.
|
|
2059
|
+
|
|
2060
|
+
Each row shows:
|
|
2061
|
+
|
|
2062
|
+
- **Status** — a **verified** badge computed in your browser. It checks the
|
|
2063
|
+
attestation's signature, the build identity, and the build context against the
|
|
2064
|
+
trusted provenance issuer. **verified** (green) means all of those pass;
|
|
2065
|
+
**failed** (red) shows why in a tooltip; **unverifiable** means the provenance
|
|
2066
|
+
issuer is not configured; **keys unavailable** (amber) means the issuer is
|
|
2067
|
+
configured but its verification keys could not be fetched.
|
|
2068
|
+
- **Job / Artifact / Digest / Created** — the producing job, the artifact name,
|
|
2069
|
+
its content digest, and when it was recorded.
|
|
2070
|
+
- **Download** — saves the signed bundle as a `.sigstore.json` file.
|
|
2071
|
+
|
|
2072
|
+
The badge does **not** re-hash the artifact bytes — the dashboard does not have
|
|
2073
|
+
the artifact. To bind the attestation to a specific file, run
|
|
2074
|
+
`kici verify-attestation <artifact> --bundle <bundle>`. A run with no
|
|
2075
|
+
attestations shows an empty state.
|
|
2076
|
+
|
|
2077
|
+
<!-- /help:run-attestations -->
|
|
2078
|
+
|
|
2079
|
+
## See also
|
|
2080
|
+
|
|
2081
|
+
- [SDK runtime reference](https://docs.kici.dev/user/sdk/runtime/) — the `ctx.attestProvenance` and
|
|
2082
|
+
`ctx.kici.oidc.token` step APIs in full.
|
|
2083
|
+
- [CLI reference](https://docs.kici.dev/user/cli-reference/#kici-verify-attestation) — every
|
|
2084
|
+
`kici verify-attestation` flag and exit code.
|
|
2085
|
+
|
|
2086
|
+
---
|
|
2087
|
+
|
|
2088
|
+
## Secrets
|
|
2089
|
+
|
|
2090
|
+
Source: https://docs.kici.dev/user/secrets/
|
|
2091
|
+
|
|
2092
|
+
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.
|
|
2093
|
+
|
|
2094
|
+
## Overview
|
|
2095
|
+
|
|
2096
|
+
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.
|
|
2097
|
+
|
|
2098
|
+
This design prevents accidental secret leakage through child processes, log output, or error messages. Only secrets you explicitly request are loaded into memory.
|
|
2099
|
+
|
|
2100
|
+
## Where secret values come from
|
|
2101
|
+
|
|
2102
|
+
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.
|
|
2103
|
+
|
|
2104
|
+
### Default — dashboard or CLI
|
|
2105
|
+
|
|
2106
|
+
A fresh orchestrator starts in **permissive** mode: both surfaces are available.
|
|
2107
|
+
|
|
2108
|
+
- **Dashboard:** Settings → Secrets → pick a scope → enter the secret name and value.
|
|
2109
|
+
- **CLI:** `kici-admin secret set --scope <scope> <KEY>` against the orchestrator's HTTP admin API.
|
|
2110
|
+
|
|
2111
|
+
Use whichever fits the workflow — most small teams stay on the dashboard; ops engineers and CI scripts use the CLI.
|
|
2112
|
+
|
|
2113
|
+
### When the operator has disabled dashboard writes
|
|
2114
|
+
|
|
2115
|
+
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:
|
|
2116
|
+
|
|
2117
|
+
- 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.
|
|
2118
|
+
- The dashboard's secrets page still lists secret **names**, scopes, and bindings — only the value-entry path moves to the CLI.
|
|
2119
|
+
- `kici-admin secret set` becomes the single entry point for new and updated secret values.
|
|
2120
|
+
|
|
2121
|
+
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).
|
|
2122
|
+
|
|
2123
|
+
### CLI input modes
|
|
2124
|
+
|
|
2125
|
+
`kici-admin secret set` accepts five input modes — pick the one that fits your workflow:
|
|
2126
|
+
|
|
2127
|
+
```bash
|
|
2128
|
+
# Interactive prompt (default when stdin is a TTY). No echo, no shell history.
|
|
2129
|
+
kici-admin secret set --scope production DB_PASSWORD --prompt
|
|
2130
|
+
|
|
2131
|
+
# Pipe from another tool (default when stdin is not a TTY).
|
|
2132
|
+
pass show prod/db | kici-admin secret set --scope production DB_PASSWORD --from-stdin
|
|
2133
|
+
|
|
2134
|
+
# Read from a file (handy after `sops -d` to a tmpfile).
|
|
2135
|
+
kici-admin secret set --scope production DB_PASSWORD --from-file ./db.pass
|
|
2136
|
+
|
|
2137
|
+
# Read from a named environment variable (CI-friendly).
|
|
2138
|
+
KICI_SECRET_VALUE=$(my-secrets-fetcher prod db) \
|
|
2139
|
+
kici-admin secret set --scope production DB_PASSWORD --from-env KICI_SECRET_VALUE
|
|
2140
|
+
|
|
2141
|
+
# Direct argv — discouraged. Prints a stderr warning ("visible in shell history").
|
|
2142
|
+
kici-admin secret set --scope production DB_PASSWORD --value "<plaintext>"
|
|
2143
|
+
```
|
|
2144
|
+
|
|
2145
|
+
Two cross-cutting flags help every mode:
|
|
2146
|
+
|
|
2147
|
+
- `--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.
|
|
2148
|
+
- `--dry-run` — parse and validate the value, print `[dry-run] would set <key> in scope <scope> sha256=<hex>`, exit without writing.
|
|
2149
|
+
|
|
2150
|
+
`kici-admin variable set` uses the same flags for non-encrypted variables, plus `--locked` to mark a variable as immutable from subsequent dashboard writes.
|
|
2151
|
+
|
|
2152
|
+
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).
|
|
2153
|
+
|
|
2154
|
+
## Accessing secrets
|
|
2155
|
+
|
|
2156
|
+
Use `ctx.secrets.get(key)` to retrieve a secret value. The method is async to support process-level step isolation in future versions.
|
|
2157
|
+
|
|
2158
|
+
```typescript
|
|
2159
|
+
import { workflow, job, step } from '@kici-dev/sdk';
|
|
2160
|
+
|
|
2161
|
+
export default workflow('deploy', {
|
|
2162
|
+
on: [push({ branches: ['main'] })],
|
|
2163
|
+
jobs: [
|
|
2164
|
+
job('deploy', {
|
|
2165
|
+
runsOn: 'default',
|
|
2166
|
+
environment: 'production',
|
|
2167
|
+
steps: [
|
|
2168
|
+
step('deploy', async (ctx) => {
|
|
2169
|
+
const token = await ctx.secrets.get('DEPLOY_TOKEN');
|
|
2170
|
+
await ctx.$`deploy --token ${token}`;
|
|
2171
|
+
}),
|
|
2172
|
+
],
|
|
2173
|
+
}),
|
|
2174
|
+
],
|
|
2175
|
+
});
|
|
2176
|
+
```
|
|
2177
|
+
|
|
2178
|
+
If the secret does not exist, `get()` throws a `SecretNotFoundError` with a descriptive message.
|
|
2179
|
+
|
|
2180
|
+
## Exposing secrets to shell commands
|
|
2181
|
+
|
|
2182
|
+
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)`:
|
|
2183
|
+
|
|
2184
|
+
```typescript
|
|
2185
|
+
step('run-tool', async (ctx) => {
|
|
2186
|
+
// Injects MY_API_KEY into process.env for this step only
|
|
2187
|
+
await ctx.secrets.expose('MY_API_KEY');
|
|
2188
|
+
|
|
2189
|
+
// Now child processes can read it from the environment
|
|
2190
|
+
await ctx.$`some-tool --use-env-auth`;
|
|
2191
|
+
});
|
|
2192
|
+
```
|
|
2193
|
+
|
|
2194
|
+
`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.
|
|
2195
|
+
|
|
2196
|
+
## Checking secret existence
|
|
2197
|
+
|
|
2198
|
+
Use `ctx.secrets.has(key)` to check whether a secret is available without retrieving its value:
|
|
2199
|
+
|
|
2200
|
+
```typescript
|
|
2201
|
+
step('conditional-notify', async (ctx) => {
|
|
2202
|
+
if (ctx.secrets.has('SLACK_WEBHOOK')) {
|
|
2203
|
+
const webhook = await ctx.secrets.get('SLACK_WEBHOOK');
|
|
2204
|
+
await ctx.$`curl -X POST ${webhook} -d '{"text": "Deploy complete"}'`;
|
|
2205
|
+
} else {
|
|
2206
|
+
console.log('Slack webhook not configured, skipping notification');
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2209
|
+
```
|
|
2210
|
+
|
|
2211
|
+
`has()` is synchronous and does not load the secret value.
|
|
2212
|
+
|
|
2213
|
+
## Mounting secrets as files
|
|
2214
|
+
|
|
2215
|
+
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.
|
|
2216
|
+
|
|
2217
|
+
### list()
|
|
2218
|
+
|
|
2219
|
+
`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.
|
|
2220
|
+
|
|
2221
|
+
```typescript
|
|
2222
|
+
step('discover-keys', async (ctx) => {
|
|
2223
|
+
// Pick up every age key the operator has provisioned.
|
|
2224
|
+
const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
|
|
2225
|
+
ctx.log.info(`Found ${ageKeys.length} age keys`);
|
|
2226
|
+
});
|
|
2227
|
+
```
|
|
2228
|
+
|
|
2229
|
+
### mountFile(opts)
|
|
2230
|
+
|
|
2231
|
+
`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).
|
|
2232
|
+
|
|
2233
|
+
Options:
|
|
2234
|
+
|
|
2235
|
+
- `sources: string[]` — secret keys to concatenate (in order). Required.
|
|
2236
|
+
- `divider?: string` — separator written between concatenated values. Default: no divider.
|
|
2237
|
+
- `mode?: number` — permission bits to chmod the file to. Default: `0o600` (owner read/write only).
|
|
2238
|
+
- `name?: string` — filename inside the per-step tmpdir. Default: auto-generated.
|
|
2239
|
+
|
|
2240
|
+
If any source key is missing, `mountFile` rejects with `SecretNotFoundError` listing every missing key.
|
|
2241
|
+
|
|
2242
|
+
```typescript
|
|
2243
|
+
step('decrypt', async (ctx) => {
|
|
2244
|
+
const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
|
|
2245
|
+
const keyFile = await ctx.secrets.mountFile({
|
|
2246
|
+
sources: ageKeys,
|
|
2247
|
+
divider: '\n',
|
|
2248
|
+
});
|
|
2249
|
+
await ctx.$`sops --age-key-file ${keyFile.path} -d secrets.enc.yaml`;
|
|
2250
|
+
});
|
|
2251
|
+
```
|
|
2252
|
+
|
|
2253
|
+
### exposeFile(envVar, opts)
|
|
2254
|
+
|
|
2255
|
+
`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.
|
|
2256
|
+
|
|
2257
|
+
```typescript
|
|
2258
|
+
step('deploy', async (ctx) => {
|
|
2259
|
+
await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', {
|
|
2260
|
+
sources: ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')),
|
|
2261
|
+
divider: '\n',
|
|
2262
|
+
});
|
|
2263
|
+
|
|
2264
|
+
// sops reads SOPS_AGE_KEY_FILE from the environment.
|
|
2265
|
+
await ctx.$`sops -d secret.enc.yaml`;
|
|
2266
|
+
});
|
|
2267
|
+
```
|
|
2268
|
+
|
|
2269
|
+
### Lifecycle and cleanup
|
|
2270
|
+
|
|
2271
|
+
- **Lazy allocation:** no tmpdir is created until the first `mountFile` / `exposeFile` call. Steps that never mount pay nothing.
|
|
2272
|
+
- **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.
|
|
2273
|
+
- **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.
|
|
2274
|
+
- **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.
|
|
2275
|
+
|
|
2276
|
+
### Log masking
|
|
2277
|
+
|
|
2278
|
+
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.
|
|
2279
|
+
|
|
2280
|
+
### Canonical sops example
|
|
2281
|
+
|
|
2282
|
+
```typescript
|
|
2283
|
+
import { workflow, job, step, push } from '@kici-dev/sdk';
|
|
2284
|
+
|
|
2285
|
+
export default workflow('deploy', {
|
|
2286
|
+
on: push({ branches: ['main'] }),
|
|
2287
|
+
jobs: [
|
|
2288
|
+
job('decrypt-and-deploy', {
|
|
2289
|
+
runsOn: 'default',
|
|
2290
|
+
environment: 'production',
|
|
2291
|
+
steps: [
|
|
2292
|
+
step('decrypt', async (ctx) => {
|
|
2293
|
+
const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
|
|
2294
|
+
await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', {
|
|
2295
|
+
sources: ageKeys,
|
|
2296
|
+
divider: '\n',
|
|
2297
|
+
});
|
|
2298
|
+
await ctx.$`sops -d secret.enc.yaml > config.yaml`;
|
|
2299
|
+
// No cleanup -- the tmpdir + the SOPS_AGE_KEY_FILE env var
|
|
2300
|
+
// are removed automatically when this step returns.
|
|
2301
|
+
}),
|
|
2302
|
+
],
|
|
2303
|
+
}),
|
|
2304
|
+
],
|
|
2305
|
+
});
|
|
2306
|
+
```
|
|
2307
|
+
|
|
2308
|
+
### Injecting decrypted sops values into the environment
|
|
2309
|
+
|
|
2310
|
+
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.
|
|
2311
|
+
|
|
2312
|
+
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:
|
|
2313
|
+
|
|
2314
|
+
```typescript
|
|
2315
|
+
import { workflow, job, step, push } from '@kici-dev/sdk';
|
|
2316
|
+
|
|
2317
|
+
export default workflow('deploy', {
|
|
2318
|
+
on: push({ branches: ['main'] }),
|
|
2319
|
+
jobs: [
|
|
2320
|
+
job('decrypt-and-deploy', {
|
|
2321
|
+
runsOn: 'default',
|
|
2322
|
+
environment: 'production',
|
|
2323
|
+
steps: [
|
|
2324
|
+
step('decrypt-to-env', async (ctx) => {
|
|
2325
|
+
await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', {
|
|
2326
|
+
sources: ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')),
|
|
2327
|
+
divider: '\n',
|
|
2328
|
+
});
|
|
2329
|
+
// Decrypt to dotenv format, then append every KEY=value line to
|
|
2330
|
+
// $KICI_ENV so subsequent steps see them as environment variables.
|
|
2331
|
+
await ctx.$`sops -d --output-type dotenv secrets.enc.yaml >> "$KICI_ENV"`;
|
|
2332
|
+
}),
|
|
2333
|
+
step('deploy', async (ctx) => {
|
|
2334
|
+
// Values decrypted above are now ordinary env vars here.
|
|
2335
|
+
await ctx.$`./deploy.sh`;
|
|
2336
|
+
}),
|
|
2337
|
+
],
|
|
2338
|
+
}),
|
|
2339
|
+
],
|
|
2340
|
+
});
|
|
2341
|
+
```
|
|
2342
|
+
|
|
2343
|
+
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.
|
|
2344
|
+
|
|
2345
|
+
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.
|
|
2346
|
+
|
|
2347
|
+
## API reference
|
|
2348
|
+
|
|
2349
|
+
| Method | Signature | Description |
|
|
2350
|
+
| ------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
|
|
2351
|
+
| `get` | `get(key: string): Promise<string>` | Retrieve a secret value. Throws `SecretNotFoundError` if not found. |
|
|
2352
|
+
| `expose` | `expose(key: string): Promise<void>` | Set `process.env[key]` to the secret value for child process access. |
|
|
2353
|
+
| `has` | `has(key: string): boolean` | Check if a secret key is available (synchronous). |
|
|
2354
|
+
| `getMeta` | `getMeta(key: string): SecretMeta \| undefined` | Get metadata (backend name, scope) for a secret. Returns `undefined` if not found. |
|
|
2355
|
+
| `list` | `list(): string[]` | Sorted array of every secret key available to the step. Synchronous, never throws. |
|
|
2356
|
+
| `mountFile` | `mountFile(opts: SecretFileOptions): Promise<{ path: string }>` | Materialise one or more secrets as a tmpfile. Auto-cleanup at step end. |
|
|
2357
|
+
| `exposeFile` | `exposeFile(envVar: string, opts: SecretFileOptions): Promise<{ path }>` | `mountFile` plus `process.env[envVar] = path`. Env var unset at step end. |
|
|
2358
|
+
|
|
2359
|
+
## Migration from property access
|
|
2360
|
+
|
|
2361
|
+
If upgrading from a previous version that used property access (`ctx.secrets.KEY`), update your workflow code:
|
|
2362
|
+
|
|
2363
|
+
```typescript
|
|
2364
|
+
// Before (old API)
|
|
2365
|
+
const token = ctx.secrets.DEPLOY_TOKEN;
|
|
2366
|
+
|
|
2367
|
+
// After (new API)
|
|
2368
|
+
const token = await ctx.secrets.get('DEPLOY_TOKEN');
|
|
2369
|
+
```
|
|
2370
|
+
|
|
2371
|
+
For conditional access:
|
|
2372
|
+
|
|
2373
|
+
```typescript
|
|
2374
|
+
// Before (old API)
|
|
2375
|
+
if (ctx.secrets.DEPLOY_TOKEN) { ... }
|
|
2376
|
+
|
|
2377
|
+
// After (new API)
|
|
2378
|
+
if (ctx.secrets.has('DEPLOY_TOKEN')) { ... }
|
|
2379
|
+
```
|
|
2380
|
+
|
|
2381
|
+
Note that `get()` is async -- you must `await` the result.
|
|
2382
|
+
|
|
2383
|
+
## Typed secrets
|
|
2384
|
+
|
|
2385
|
+
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.
|
|
2386
|
+
|
|
2387
|
+
See [CLI reference](https://docs.kici.dev/user/cli) for the `kici types` command.
|
|
2388
|
+
|
|
2389
|
+
---
|