# KiCI Workflow features: access and approval This bundle covers: Deployment contexts, scoped secrets, private registries, approvals, provenance, dashboard and account access. ## Account and sign-in Source: https://docs.kici.dev/user/account-and-login/ Your KiCI account is a single identity. It stays the same no matter how you sign in — whether you signed up with GitHub or with an email and password. Changing your sign-in method does not create a new account or move your data; your organizations, roles, and API keys stay attached to the same identity. ## Where sign-in methods are managed Sign-in methods and passwords are managed in your **account console**, provided by the identity provider that handles single sign-on for KiCI. The dashboard's **Linked accounts** page does not control how you sign in — see [Linked accounts vs sign-in methods](https://docs.kici.dev/user/account-and-login/#linked-accounts-vs-sign-in-methods) below. You can open the account console from the dashboard: go to your personal settings, open **Linked accounts**, and use the **Account console** link. ## Adding a password to a GitHub-created account If you registered by signing in with GitHub and now want to sign in with an email and password as well: 1. Open your account console. 2. Add a password (and, if prompted, confirm your email). After this, you can sign in either with GitHub or with your email and password — it is the same account. ## Removing GitHub as a sign-in method To stop using GitHub to sign in: 1. First add a password (see above). The identity provider will not let you remove your only sign-in method, so you must have another one first. 2. In your account console, remove the GitHub sign-in method. Your account, organizations, and data are unaffected — you simply sign in a different way afterward. ## Linked accounts vs sign-in methods The dashboard's **Linked accounts** page controls **run-attribution metadata** only — for example, showing your GitHub username on the runs you trigger and determining your contributor trust level. Unlinking a provider there removes that display link; it does **not** remove the provider as a way to sign in. To actually change how you sign in, use your account console as described above. --- ## Approval gates Source: https://docs.kici.dev/user/approvals/ 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. You declare a gate in your workflow with `approval`. It is available at three levels of granularity: - **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. - **Job** — hold the job before any of its steps run. - **Workflow** — hold the whole run before any job is dispatched. 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-when-drift) below. 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. ## Quick start Hold a deploy job until a member of the `leads` team approves: ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('deploy', { on: [push({ branches: ['main'] })], jobs: [ job('deploy-production', { runsOn: 'default', approval: [{ team: 'leads' }], steps: [step('deploy', async (ctx) => ctx.$`deploy --prod`)], }), ], }); ``` 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. ## The `approval` field `approval` accepts three forms. ### Shorthand: `true` ```typescript job('deploy', { runsOn: 'default', approval: true, steps: [/* ... */], }); ``` `approval: true` holds the element until **any** org member who can act on approvals signs off — anyone with `contexts:write`, since an `approval` gate always raises a reviewer hold. Use it when you want a manual gate without restricting who may release it. A **security** hold is different: it is raised by the CI trust pipeline (an unknown contributor, a fork PR, a workflow-modifying PR), never by `approval`, and releasing one requires `ci_trust:write`. See [Approval gates (operator guide)](https://docs.kici.dev/operator/approvals/#who-may-approve). ### Approver list (AND) ```typescript approval: [{ team: 'leads' }, { user: 'cto' }], ``` A list of approver clauses is an **AND** list: every clause must be satisfied before the element is released. - `{ team: 'leads' }` is satisfied once **any** member of the `leads` team approves. - `{ user: 'cto' }` is satisfied once the user `cto` approves. 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. There is no OR or nested logic — clauses are always a flat AND list. ### Object form: when, reason, and timeout ```typescript approval: { when: 'always', approvers: [{ team: 'security' }, { team: 'leads' }], reason: 'Production deploy requires security + leads sign-off', timeout: 7200, // seconds }, ``` | Field | Type | Description | | ----------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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-when-drift). | | `approvers` | `ApproverClause[]` | The AND list of `{ team }` / `{ user }` clauses. An empty list means "any approval-capable member". | | `reason` | `string` | A human-readable label shown in the dashboard queue and the held-for-approval status check. | | `timeout` | `number` | Per-gate expiry in **seconds**, overriding the org default. Must be a **positive integer** number of seconds; a non-positive or non-finite value is rejected at compile time. On expiry the element is rejected. | 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). A `timeout` that is zero, negative, or non-finite (for example a computed `minutes * 60` where `minutes` is `0`) fails `kici compile` with a clear author-facing error, so a misconfigured gate can never silently expire the moment it is created. If an orchestrator ever receives such a value from a hand-edited lock file, the run fails fast with an **Approval gate misconfigured** init-failure rather than dispatching ungated. ## Granularity The same `approval` field is accepted on a workflow, a job, and a step. ### Workflow-level A workflow-level gate holds the entire run before any job is dispatched: ```typescript export default workflow('release', { on: [push({ branches: ['main'] })], approval: [{ team: 'release-managers' }], jobs: [buildJob, publishJob], }); ``` ### Job-level A job-level gate holds just that job; other jobs in the run proceed normally: ```typescript job('publish', { runsOn: 'default', approval: [{ team: 'leads' }], steps: [/* ... */], }); ``` ### Step-level 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: ```typescript job('migrate-and-deploy', { runsOn: 'default', steps: [ step('build-plan', async (ctx) => ctx.$`./gen-migration-plan.sh`), step('apply-migration', { approval: [{ team: 'dba' }], run: async (ctx) => ctx.$`./apply-migration.sh`, }), step('deploy', async (ctx) => ctx.$`deploy --prod`), ], }); ``` 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. 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). ### Not available on organization-wide workflows An `approval` gate applies to per-repository workflows only. A workflow whose trigger carries `repos:` — an [organization-wide workflow](https://docs.kici.dev/user/global-workflows/) — is dispatched by a path that never consults the gate, so the gate would not hold anything. `kici compile` refuses it with `error [E124]` at the workflow level and on any static job, rather than accepting a security control the workflow does not have. Drop the `approval`, or move the gated jobs into a workflow whose triggers carry no `repos:`. A job produced by a `dynamicJob` generator never passes through the compiler, so that one is caught at dispatch instead: the orchestrator logs an error naming the workflow and job, and runs it ungated. ## Drift gates (`when: 'drift'`) 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. 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. ```typescript job('patch-prod', { runsOn: 'default', steps: [ step('apply-nginx-config', { check: async (ctx) => ((await inSync(ctx)) ? null : { want: DESIRED_CONF }), summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`, run: async (ctx, drift) => { await writeConfig(drift.want); }, approval: { when: 'drift', approvers: [{ team: 'ops' }], reason: 'prod patch' }, }), ], }); ``` Behavior: - The gate fires **only in apply mode**. In `--check` mode nothing applies, so nothing gates — drift is just reported. - **Approve** → the step's `run(ctx, drift)` applies the change. - **Reject** → fail-stop: the step fails, the job fails, and the `needs:` skip-cascade aborts everything downstream. A `when: 'drift'` gate on a step without a `check` facet, or at job/workflow scope, is a compile error. ## Mandatory vs. explicit gates `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 context via required reviewers (see [Contexts](https://docs.kici.dev/user/contexts/#required-reviewers)). When both apply to the same job, all clauses from both sources must be satisfied before the job is released. A workflow-level `approval` is one of those sources. It gates every root job, so a root job that also binds a context with required reviewers must satisfy the workflow clauses, its own job clauses, and the context's reviewers. The sources funnel into one held-element mechanism, so the dashboard queue and `kici approve` work the same way regardless of which source held the element. ## Approving from the CLI Approve or reject a held element with the `kici` CLI: ```bash # Approve a workflow-level hold kici approve # Approve a held job kici approve --job deploy-production # Approve a held step (--step takes the step's zero-based index within the job) kici approve --job migrate-and-deploy --step 1 # Reject (a reason is required) kici reject --job deploy-production --reason "Wrong release branch" ``` 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/runs-and-approvals/#kici-approve) for the full command reference. ### When a job is held twice A job can carry an `approval` gate **and** a [security hold](https://docs.kici.dev/user/contexts/#security-approval-queue) at the same time. Two setups reach it: a fork pull request on an organization whose fork policy is `hold`, and a context that sets both required reviewers and `minimumTrust`. The two are separate holds and **both** must be released before the job runs. They take different permissions: the approval hold needs `contexts:write` plus clause eligibility, the security hold needs `ci_trust:write`. `--job` names both at once, so pick one with `--hold-type`: ```bash kici approve --job deploy --hold-type reviewer kici approve --job deploy --hold-type security ``` Pass `--hold ` when the command's error lists two holds that `--hold-type` still cannot separate. The pull request's `KiCI Security` check stays pending until both holds have ended, and its description names the second gate and the permission that clears it. Each hold carries its own expiry, so the job is cancelled when the **first** one runs out. ### Inline approval and `--approve-all` in `kici run remote` 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. To auto-approve **every gate of the run you just dispatched**, pass `--approve-all` (alias `--yes`): ```bash kici run remote deploy-prod --approve-all ``` `--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`). `--approve-all` is honored in non-interactive runs too: with `--json` or `--quiet` (no TTY), the flag still auto-approves each eligible gate as it appears instead of hanging or printing out-of-band instructions. Hold notices are routed to stderr so `--json` stdout stays pure machine-readable output. You can also approve from the dashboard approval queue. See [Dashboard](https://docs.kici.dev/user/dashboard/contexts-and-secrets/#approval-queue). ## See also - [Idempotent steps](https://docs.kici.dev/user/idempotent-steps/) — the check/apply step facet that drift gates build on. - [Contexts](https://docs.kici.dev/user/contexts/) — operator-required reviewers on protected contexts. - [Approval gates (operator guide)](https://docs.kici.dev/operator/approvals/) — teams, the approval queue, expiry, and self-approval. - [Approval gates (architecture)](https://docs.kici.dev/architecture/approvals/) — the unified hold model and the step-level round-trip. - [Organization-wide workflows](https://docs.kici.dev/user/global-workflows/) — why an approval gate is refused there. --- ## Contexts Source: https://docs.kici.dev/user/contexts/ Contexts are named deployment targets (like staging or production) that control where your workflow jobs run. Each context can have its own variables, secrets, and protection rules to gate deployments. Protection rules control when jobs targeting a context can execute. Available rules: - **Branch restrictions** — only allow specific branches to deploy. - **Required reviewer approvals** — gate the run on human sign-off. - **Wait timers** — delay execution for a fixed period. - **Concurrency limits** — cap how many jobs run against the context at once. ## Overview A context in KiCI provides: - **Variables** -- non-secret key-value configuration (e.g., `API_URL`, `CLUSTER_NAME`) - **Scoped secrets** -- encrypted values bound to the context via scope bindings - **Protection rules** -- branch restrictions, required reviewers, wait timers, and concurrency limits - **Per-source overrides** -- repositories can override unlocked variables for their own deployments ## SDK API ### Job-level context property The `context` property is set on a job, not a workflow or step: ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('deploy', { on: [push({ branches: ['main'] })], jobs: [ job('deploy-staging', { runsOn: 'default', context: 'staging', steps: [ step('deploy', async (ctx) => { // ctx.context is the resolved context name console.log(`Deploying to ${ctx.context}`); // ctx.secrets provides async get/expose/has methods for context-bound secrets const dbPassword = await ctx.secrets.get('DB_PASSWORD'); // Environment variables are in ctx.env const apiUrl = ctx.env.API_URL; await ctx.$`deploy --target ${ctx.context}`; }), ], }), ], }); ``` ### Dynamic contexts The context name can be a string or a function (sync or async) for dynamic contexts (e.g., per-PR review contexts). The function receives the normalized event envelope, with the raw provider body nested at `event.payload`: ```typescript job('deploy-review', { runsOn: 'default', context: (event) => `review/PR-${event.payload.pull_request.number}`, steps: [ step('deploy', async (ctx) => { // ctx.context is 'review/PR-123' (resolved at runtime) await ctx.$`deploy-preview --env ${ctx.context}`; }), ], }); ``` A dynamic context function like the one above (see [Dynamic values](https://docs.kici.dev/user/dynamic-values/)) is resolved on the eval agent's init step before the job runs. Dynamic contexts that match a glob pattern (e.g., `review/*`) inherit the pattern's configuration, variables, and protection rules. ### Multiple contexts per job A job can bind more than one context with `contexts`, an ordered array. This lets a single job draw secrets and variables from several contexts at once — for example a shared `staging` context plus a `my-testing` context that carries test-only variables: ```typescript job('deploy', { runsOn: 'default', contexts: ['staging', 'my-testing'], steps: [ step('deploy', async (ctx) => { // ctx.secrets and ctx.env carry the merged set from both contexts const dbUrl = await ctx.secrets.get('DB_URL'); }), ], }); ``` - `context` (singular) and `contexts` (array) are mutually exclusive — setting both is a compile error. `context: 'staging'` is exactly equivalent to `contexts: ['staging']`. - Each array entry is a static name or a function of the event, resolved per element exactly like a single dynamic context. **Merge order — last wins.** All bound contexts are resolved on every dispatch (webhook, scheduled, and test runs alike) and merged in array order. When the same secret or variable key is defined in more than one context, the later entry in the array wins. With `contexts: ['staging', 'my-testing']`, a key defined in both resolves to `my-testing`'s value; keys defined in only one are preserved. The longest-scope-path-wins rule still applies _within_ each context. **Protection rules combine all-must-pass.** A job must satisfy **every** bound context's gates — adding a context can never loosen access. Branch restrictions, trigger-type filters, and repo patterns must pass for all contexts. The minimum trust tier is the most restrictive across them, and required reviewers are the union of all contexts' reviewers. The wait timer is the longest, and the hold expiry is the shortest. If a rule rejects a job, that job is not dispatched. It still appears on the run, as a failed job whose reason names the context and the rule that rejected it. Read the reason with `kici runs show `, or on the run detail page. **Skip-on-test (allow-and-warn).** On a test or local run (`kici run remote`, `kici run --local`), a bound context never rejects the run. Any bound context that disallows local execution (`allowLocalExecution: false`) — or that is not configured — is **skipped**: its variables and secrets are omitted from the merge and its gates are not evaluated. The run proceeds, and a user-visible warning naming the skipped context(s) is shown both on the `kici run remote` CLI output and on the dashboard run view. This makes the test-only-variables pattern work: with `contexts: ['staging', 'my-testing']` where only `my-testing` allows local execution, a test run resolves just `my-testing`'s variables and warns that `staging` was skipped. If every bound context is skipped, the job runs with no environment variables. This is intentionally different from a fixture `secrets:` mapping, which is fail-closed — see the [testing guide](https://docs.kici.dev/user/testing-guide/). **Unconfigured contexts contribute nothing at dispatch.** At dispatch time a bound context name with no matching configured context (and no matching glob context) adds no variables, secrets, or protection rules — the job still runs, exactly as a single dynamic context resolving to an as-yet-unconfigured name does today. **Registration rejects a provably-unsatisfiable binding.** When a workflow is registered, KiCI statically checks every multi-context binding: a bound context that does not exist, a disabled one, or two contexts with mutually-exclusive fixed branch / trigger-type / repository restrictions (no value can satisfy both) makes the binding provably unsatisfiable, and the registration is rejected with a precise message naming the job, the contexts, and the rule — for example `unsatisfiable context binding: job 'deploy' binds contexts [staging, my-testing] with mutually exclusive branch restrictions (no value satisfies all bound contexts)`. Bindings whose restrictions use globs are undecidable at registration and fall through to the dispatch-time gate check instead. ### Job-level environment variables The `env` property on a job provides static or dynamic environment variables: ```typescript job('deploy', { runsOn: 'default', context: 'production', env: { DEPLOY_TARGET: 'us-east-1' }, // Or dynamic: // env: (event) => ({ DEPLOY_SHA: event.payload.after?.slice(0, 7) }), steps: [ step('deploy', async (ctx) => { // DEPLOY_TARGET is available in ctx.env await ctx.$`deploy --region ${ctx.env.DEPLOY_TARGET}`; }), ], }); ``` ### Concurrency groups Jobs can define their own concurrency groups to control concurrent execution within a context. For workflow-level concurrency (which applies to all jobs in a workflow), see [Concurrency groups](https://docs.kici.dev/user/concurrency/). Control concurrent deployments to the same context: ```typescript job('deploy', { runsOn: 'default', context: 'production', concurrencyGroup: 'production-api', // Or dynamic: // concurrencyGroup: (event) => `review-${event.payload.pull_request.number}`, steps: [/* ... */], }); ``` If no `concurrencyGroup` is specified, the context name is used as the default concurrency group. For a job bound to multiple contexts, the default is the **first** bound context's name. ### Step context Inside a step, the `ctx` object provides: | Property | Type | Description | | ------------- | ------------------------------------- | ----------------------------------------------------------------------------------------- | | `ctx.context` | `string \| undefined` | Resolved context name (undefined for jobs without context) | | `ctx.env` | `Record` | Environment variables (merged from system, org, source, and job-level `env`) | | `ctx.secrets` | `StepSecretsTyped` | Async accessor for bound secrets (get, expose, has, getMeta, list, mountFile, exposeFile) | | Method | Returns | Description | | -------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `await ctx.secrets.get(key)` | `string` | Retrieve a secret value. Throws `SecretNotFoundError` if not found. | | `await ctx.secrets.expose(key)` | `void` | Set the secret as an environment variable for this step — visible via `ctx.env` and to child processes (`process.env`). Throws `SecretNotFoundError` if not found. | | `ctx.secrets.has(key)` | `boolean` | Check if a secret key exists. Synchronous, never throws. | | `ctx.secrets.getMeta(key)` | `SecretMeta \| undefined` | Retrieve metadata (value, backend name, scope path) for a resolved secret. Returns `undefined` if not found. | | `ctx.secrets.list()` | `string[]` | Every secret key available to the step, sorted alphabetically. Synchronous, never throws. | | `await ctx.secrets.mountFile(opts)` | `{ path }` | Materialise one or more secrets to a per-step tmpfile (auto-removed at step end). See [Secrets → Mounting secrets as files](https://docs.kici.dev/user/secrets/#mounting-secrets-as-files). | | `await ctx.secrets.exposeFile(envVar, opts)` | `{ path }` | `mountFile` plus `process.env[envVar] = path`; the env var is unset at step end. | | `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. | The full secrets API — including `SecretFileOptions`, log masking, and the canonical `sops` example — is documented in [Secrets](https://docs.kici.dev/user/secrets/). ## Environment variable merge precedence When a job targets a context, variables are merged in this order (last wins): 1. **Allowed system vars** -- `PATH`, `HOME`, etc. from the agent process 2. **Sandbox defaults** -- `FORCE_COLOR=1` 3. **KICI\_\* system vars** -- orchestrator-generated metadata 4. **Org-level context vars** -- from the dashboard, managed per-context 5. **Source-level overrides** -- per-repository overrides (skips locked vars) 6. **Job env** -- from the `env` property in the SDK 7. **`setEnv()` calls** -- runtime modifications within steps > **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/contexts/#step-context) section above. ## Protection rules Contexts can have protection rules that gate job execution: ### Branch restrictions Limit which branches can deploy to a context: ``` Allowed branches: main, release/* ``` Jobs from other branches are rejected immediately with an error message. **Most internally-triggered runs carry a branch.** KiCI starts these runs for itself: a [schedule](https://docs.kici.dev/user/sdk/triggers/) fire, a [custom event](https://docs.kici.dev/user/events/), a completion trigger, a failure batch, or an [invoke gate](https://docs.kici.dev/user/global-workflows/) summon. None comes from a branch push. A run with one source branch presents it: - A **schedule** fire executes the default branch's workflow, so it presents the repository's default branch. - A **`kiciEvent()` subscriber**, a **completion trigger** (`workflowComplete`, `jobComplete`) and an **invoke-gate summon** present the branch of the run behind them. That is the same branch the run behind them presented. For a pull-request run it is the branch the PR targets, not the contributor's own branch — so a subscriber of an event emitted by a PR against `main` presents `main`. A branch restriction compares that branch against your patterns like any other run. So a nightly deploy bound to a `production` context restricted to `main` runs. A branch restriction is not a trust control. It checks the branch a run presents, never where the code came from. A pull request against `main` presents `main`, whoever opened it. To gate on that, set a [minimum trust tier](https://docs.kici.dev/user/contexts/#minimum-trust) on the same context. A run with no single source branch presents no branch, and a branch restriction then rejects it: - A **failure batch** (`workflowsFailedBatch`). One accumulation window of failed runs causes it, on as many branches, so no one branch is its own. - A **scaler event** (`kici.scaler.scale-up`, `kici.scaler.scale-down`). The orchestrator mints these itself, with no run behind them. See [autoscaling workflows](https://docs.kici.dev/user/workflows/autoscaling-workflows/). - A **schedule** fire in a repository that has not pushed to its default branch since you upgraded KiCI. The default branch is captured when a push re-registers the workflows, so the first such push after the upgrade fixes it. - Any event whose emitting run is no longer on record. The rejection reason says so: ``` Context 'production' restricts branches: this internally-triggered run carries no branch, so no branch restriction can be satisfied - a scheduled run gains its branch after the next push to the default branch re-registers the workflow; alternatively bind a context without a branch restriction, or restrict by trigger type instead ``` To limit a context by how the run started instead of by branch, use a **trigger-type filter**: the trigger type is a real name (`schedule`, `kici_event`, `workflow_complete`, `job_complete`), so a filter that allows it works on these runs. ### Required reviewers Require manual approval before a job can proceed: ``` Required reviewers: alice, bob ``` 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/runs-and-approvals/#kici-approve) command, or the API. Held runs expire after a configurable timeout. 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. ### Wait timer Add a mandatory delay before deployment starts: ``` Wait timer: 300 seconds ``` The job waits for the specified duration before proceeding. Useful for staged rollouts. ### Minimum trust Hold a job whose run came from a fork: ``` Minimum trust: trusted ``` | Value | Effect | | --------- | ---------------------------------------------------------- | | `trusted` | Holds a run whose ref came from a fork | | `known` | Same effect; the value is deprecated and removed at v1.0.0 | | (unset) | No trust-based gating | Both values block the same thing. Trust comes from the git ref, and that judgement has two answers: a ref in your repository is `trusted`, a ref from a fork is `unknown`. The value you declare still decides the wording of the hold reason. When the gate holds a job, it enters the security approval queue. Someone with `ci_trust:write` or higher must approve it before execution proceeds. A run that resolved **no** tier passes the gate. A pull request from a source with no fork model resolves none, and so does an internal run whose inheritance lookup failed. See [trust tiers on internal triggers](https://docs.kici.dev/user/events/#trust-tiers-on-internal-triggers) for the full table. The trust tier also affects which lock file a pull-request run uses: a trusted ref evaluates the head lock file, a fork ref evaluates the base branch's. A fork run additionally carries no install or registry secrets, and its build-cache writes are confined to that run. So a fork pull request cannot change what CI does, and cannot read a private-registry token, whether or not you set this gate. Set the gate on any context that carries a credential a fork run must not reach. See the [deployment checklist](https://docs.kici.dev/operator/security/security/#deployment-checklist-which-contexts-need-it). An internally-triggered run carries a tier too. A schedule fire and the orchestrator's own lifecycle events are trusted. A `kiciEvent()` subscriber inherits the tier of the run that emitted the event, so a `minimumTrust` gate on the subscriber's context reads the **emitting** run's tier. See [trust tiers on internal triggers](https://docs.kici.dev/user/events/#trust-tiers-on-internal-triggers). See the [CI security architecture docs](https://docs.kici.dev/architecture/security/ci-security/) for the full trust resolution flow. ### Security approval queue A pull request held for security review enters the security approval queue. Two things put it there: the organization's fork switch set to `hold`, and a `minimumTrust` gate blocking a fork run. This is separate from the context approval queue: a security hold asks "is it safe to run this contributor's code at all?", while a context approval hold asks "should this job be promoted?". The two never cross — releasing a security hold needs `ci_trust:write` or higher, releasing a context approval hold needs `contexts:write` plus eligibility for one of the gate's clauses. See [Approval holds vs security holds](https://docs.kici.dev/architecture/approvals/#approval-holds-vs-security-holds) for the full comparison. Held runs can be approved: - Via the **dashboard** on the [Approval queue](https://docs.kici.dev/user/dashboard/contexts-and-secrets/#approval-queue) page, which lists security and context holds together - Via a PR comment: `/kici approve` (commenter must have `ci_trust:write+`) A hold raised by the fork switch covers the whole pull request and uses the org's approval expiry (default 72 hours). A `minimumTrust` hold is raised by a context, so it uses that context's own hold expiry (default one hour). A job carrying both a reviewer approval hold and a security hold carries both expiries, and whichever comes first cancels the run. While the fork switch is holding a pull request, your organization's global workflows do not run for it. Approving the hold releases that pull request's own workflows; it does not retroactively run the organization's global workflows for the event. ### Concurrency limits Control how many jobs can run simultaneously in a context: ``` Concurrency limit: 1 Strategy: queue (or cancel-pending) ``` The concurrency limit is a positive integer; leave it unset for unlimited concurrency. - **queue** -- new jobs wait in a FIFO queue (with configurable timeout, default 1 hour) - **cancel-pending** -- pending (queued) jobs are cancelled when the limit is reached The children of a matrix job count individually against the limit. A three-child matrix bound to a context with a limit of two dispatches two children and applies the strategy above to the third. A job counts against the limit from the moment it is dispatched until it finishes. It does not have to reach an agent first. #### What the limit does and does not guarantee A context concurrency limit is a **throughput control**. Treat it as a cap on how much work runs at once, not as a lock. Two events that arrive in the same instant read the limit before either job is recorded, so each can be admitted. The window is short — the time between one read and one write — but it is real, and it grows with the number of orchestrator processes serving the context. When a job must never run beside another copy of itself — a production deploy, a database migration — declare a workflow-level [concurrency group](https://docs.kici.dev/user/concurrency/) as well: ```typescript export default workflow('deploy', { on: push({ branches: ['main'] }), concurrency: { group: () => 'deploy-prod', max: 1 }, jobs: [/* ... */], }); ``` That mechanism claims its slot inside a single database transaction, so two runs arriving together cannot both take it. The two are complementary: the context limit caps throughput across every workflow bound to the context, and the concurrency group serializes one workflow against itself. ## Dashboard management ### Creating contexts Navigate to **Settings > Contexts** in the dashboard. Click **New context** to choose the context name and type (Fixed or Glob). - **Fixed** -- applies to jobs that declare exactly this context name, like `staging` or `production` - **Glob** -- applies to any context name a job declares that matches the pattern, e.g. `review/*` matches a job with `context: 'review/PR-123'` The contexts list shows each context'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. ### Context detail page Each context has four tabs: 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. 2. **Secrets** -- view bound secret scopes and their resolved secret count. Add bindings by specifying scope glob patterns (e.g., `aws/prod/**`). 3. **Protection** -- configure branch restrictions, required reviewers, wait timers, and concurrency limits with enable toggles for each section. Turning a section's toggle off and saving clears that rule on the context, so the gate stops applying to new runs. Emptying the hold expiry field clears it too, and held runs fall back to the default one-hour hold window. 4. **History** -- view filtered runs targeting this context. ### Bound contexts on runs A job's bound deployment contexts are shown as chips on the run detail page (in the job metadata panel) in the order the job declared them, and the distinct set across a run's jobs appears as compact chips on the run list. For a multi-context job the chips read left-to-right in merge order — later contexts override earlier ones on key collisions. A `(dynamic)` chip marks a context whose name is computed at runtime; it resolves to the real name once the run starts. A job that binds a single context shows one chip; a job that binds none shows no chip. A job a bound context rejects keeps its chips, and shows as failed because it never ran. Its failure reason names the context and the rule that rejected it. `kici runs show ` prints the same reason. ### Secrets management Secrets are individual encrypted values organized by scope paths (e.g., `aws/prod`, `databases/postgres`). Scopes are bound to contexts via bindings: - **Scope-centric view** (Secrets page): tree view of scopes with per-scope context binding checkboxes - **Context-centric view** (inside context detail): bound scopes, resolved secrets, add binding When scope paths collide on the same key name, the longer (more specific) path wins. ## Type generation Running `kici types` generates two augmented interfaces: `KnownSecretKeys` (union of all secret keys across all contexts) and `ContextSecrets` (per-context key unions): ```typescript interface KnownSecretKeys { DB_PASSWORD: string; API_KEY: string; } interface ContextSecrets { production: 'DB_PASSWORD' | 'API_KEY'; staging: 'DB_PASSWORD'; } ``` `KnownSecretKeys` narrows `ctx.secrets.get()` and `ctx.secrets.expose()` key parameters to valid key names. `ContextSecrets` maps each context to its available secret key names as a string union. Dynamic contexts fall back to the full `KnownSecretKeys` union. --- ## Dashboard Source: https://docs.kici.dev/user/dashboard/ 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. This guide is split across the following pages: | Page | Covers | | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | [Getting started](https://docs.kici.dev/user/dashboard/getting-started/) | Onboarding checklist and your organizations list | | [Navigation and layout](https://docs.kici.dev/user/dashboard/navigation/) | Sidebar, mobile nav, theme, time display, shortcuts, error pages | | [Runs and logs](https://docs.kici.dev/user/dashboard/runs/) | Run list, run detail, the log viewer | | [Settings](https://docs.kici.dev/user/dashboard/settings/) | Members, roles, teams, keys, sources, billing, security, support access | | [Workflows, diagnostics, and orchestrators](https://docs.kici.dev/user/dashboard/workflows-and-diagnostics/) | Registered workflows, infra health, per-cluster views | | [Contexts, secrets, and approvals](https://docs.kici.dev/user/dashboard/contexts-and-secrets/) | Contexts, secret scopes, approval queue | | [Activity and DLQ](https://docs.kici.dev/user/dashboard/activity-and-dlq/) | Forensic activity log and dead-letter queue | | [Notifications](https://docs.kici.dev/user/dashboard/notifications/) | Personal run notifications and org-wide channels, subscriptions, delivery log | | [Account](https://docs.kici.dev/user/dashboard/account/) | Personal account settings | --- ## Private npm registries Source: https://docs.kici.dev/user/private-registries/ 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. ## Choose a path | Path | When to pick it | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **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. | | **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. | | **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. | 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. ## Option A — `registries:` block Declare the registry in your workflow file and point its `tokenSecret` at a scoped secret using the qualified `:` syntax. The orchestrator resolves the token at dispatch time and the agent applies it for one `npm install` only. ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('build', { on: [push({ branches: ['main'] })], registries: [ { url: 'https://npm.pkg.github.com/', scope: '@my-org', tokenSecret: 'production:GITHUB_PACKAGES_TOKEN', }, ], jobs: [ job('build', { runsOn: 'default', context: 'production', steps: [ step('install-and-build', async (ctx) => { // .kici/package.json can now reference @my-org/* packages await ctx.$`npm run build`; }), ], }), ], }); ``` Per-field rules: - **`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/orchestrator/kici-admin/org-settings/#allow-http-npm--permit-non-https-private-npm-registries)). - **`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`. - **`tokenSecret`** — Mandatory `:`. The orchestrator looks up the secret in the named context via the per-context secret resolver. The bare name **must not** contain a colon. - **`alwaysAuth`** — Defaults to `true`. Forces npm to send the token on every request (even GETs), which is what most managed-registry providers require. ### How tokens reach `npm install` 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__`), 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. After the install completes (success or failure), the agent restores the original `.kici/.npmrc` — your committed file is never permanently modified. ### Package managers The agent detects the package manager from the cloned repo — npm, pnpm, or yarn (classic and berry are both supported) — and applies the auth overlay the detected manager actually reads: | Detected manager | Auth file the agent overlays | Notes | | ----------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | npm, pnpm, yarn classic | `.kici/.npmrc` | Auth lines carry `${VAR}` references to the job-scoped token env vars. | | yarn berry (v2+) | `.kici/.yarnrc.yml` | Berry reads `.yarnrc.yml` instead of `.npmrc`, so the overlay uses berry's own registry/scope/auth keys with the same `${VAR}` indirection. | Either file is restored on cleanup, exactly as described above. ## Option C — committed `.kici/.npmrc` + `installEnv:` 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`. ```ini # .kici/.npmrc @my-org:registry=https://npm.example.com/ //npm.example.com/:_authToken=${MY_NPM_TOKEN} //npm.example.com/:always-auth=true audit=false ``` ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('build', { on: [push({ branches: ['main'] })], installEnv: ['production:MY_NPM_TOKEN'], jobs: [ job('build', { runsOn: 'default', context: 'production', steps: [step('build', async (ctx) => ctx.$`npm run build`)], }), ], }); ``` The orchestrator resolves `MY_NPM_TOKEN` from the `production` context'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}`. This path is the right answer when: - The `.npmrc` carries non-auth knobs (`audit=false`, `legacy-peer-deps=true`, custom CA bundles). - You want a single source of truth for registry topology that `npm` tooling outside KiCI can consume too. - The auth lines reference the **same** env var across multiple registries. ## Short-lived tokens (CodeArtifact, GCP Artifact Registry) 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. The supported pattern is a **setup job** that mints a fresh token, writes `.kici/.npmrc`, and downstream jobs install with it. ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('build', { on: [push({ branches: ['main'] })], jobs: [ job('mint-codeartifact-token', { runsOn: 'default', context: 'production', steps: [ step('mint', async (ctx) => { const awsKey = await ctx.secrets.get('AWS_ACCESS_KEY_ID'); const awsSecret = await ctx.secrets.get('AWS_SECRET_ACCESS_KEY'); process.env.AWS_ACCESS_KEY_ID = awsKey; process.env.AWS_SECRET_ACCESS_KEY = awsSecret; const token = ( await ctx.$`aws codeartifact get-authorization-token --domain my-domain --query authorizationToken --output text` ).stdout.trim(); // Write directly into the workspace's .kici/ — the next job reuses the same workspace. const npmrc = [ '@my-org:registry=https://my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/', `//my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/:_authToken=${token}`, '//my-domain-1234567890.d.codeartifact.eu-central-1.amazonaws.com/npm/workflow-deps/:always-auth=true', '', ].join('\n'); await ctx.$`tee .kici/.npmrc`.stdin(npmrc); }), ], }), job('build', { runsOn: 'default', context: 'production', needs: ['mint-codeartifact-token'], steps: [step('build', async (ctx) => ctx.$`npm run build`)], }), ], }); ``` 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. ## Provider-specific examples ### GitHub Packages ```typescript registries: [ { url: 'https://npm.pkg.github.com/', scope: '@my-org', tokenSecret: 'production:GITHUB_PACKAGES_TOKEN', }, ], ``` Mint the token from a fine-grained PAT with `read:packages` scope, store it as a scoped secret in the `production` context. ### GitLab Packages ```typescript registries: [ { url: 'https://gitlab.example.com/api/v4/projects/123/packages/npm/', scope: '@my-group', tokenSecret: 'production:GITLAB_DEPLOY_TOKEN', }, ], ``` Use a project- or group-level deploy token with `read_package_registry` scope. ### Verdaccio (self-hosted) ```typescript registries: [ { url: 'https://npm.internal.example.com/', tokenSecret: 'production:VERDACCIO_TOKEN', }, ], ``` 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`. ### JFrog Artifactory ```typescript registries: [ { url: 'https://artifactory.example.com/artifactory/api/npm/npm-virtual/', scope: '@my-org', tokenSecret: 'production:JFROG_API_KEY', }, ], ``` ### Cloudsmith ```typescript registries: [ { url: 'https://npm.cloudsmith.io/my-org/my-repo/', scope: '@my-org', tokenSecret: 'production:CLOUDSMITH_TOKEN', }, ], ``` ## Security model - **Per-context scoping.** Every `tokenSecret` and `installEnv` entry is qualified with a context name. The orchestrator runs the same protection-rule pipeline (branch / trust / concurrency / reviewer / wait-timer) against each named context **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 context **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. - **Untrusted refs get no tokens.** When the trust resolution returns anything other than `trusted` — every fork pull request does — the orchestrator strips `npmRegistries`, `installEnvSecrets`, and a container job's [registry credentials](https://docs.kici.dev/user/container-jobs/#private-images) out of the dispatch. The install runs without auth and fails naturally on the first private dep, and a private base image fails to pull. A fork pull request cannot observe a registry token, even if a context lacks an explicit [minimum trust](https://docs.kici.dev/user/contexts/#minimum-trust) rule. - **Lifecycle scripts disabled.** Whenever a private registry is in scope, the agent runs the install with `--ignore-scripts` (npm, pnpm, and yarn classic alike; yarn berry gets the equivalent `enableScripts: false`). 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 or yarn 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. - **Stderr is redacted.** If the install fails, the agent masks every token literal out of the surfaced stderr / stdout chunks before logging. - **Job-scoped env-var names.** The synthesized auth env var is `KICI_NPM_TOKEN__` 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. - **`.npmrc` restored.** Whatever the agent appended for one install is stripped (or the file unlinked) on cleanup, so the workspace is never permanently modified. ## Reviewer-gated installs When the named install context 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. - **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. - **Wait timer:** the run waits out the timer and resumes automatically when it elapses. A `reject` protection outcome (for example a disabled context or a branch the context forbids) still fails the dispatch loudly with a clear reason, exactly as before — the orchestrator never dispatches a run with an unresolved install token. ## Limitations - **`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. - **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. ## Observability 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. | Metric | Type | Labels | What it tells you | | ------------------------------------------------------------- | --------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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 context. Reject reasons enumerate the failure mode: `malformed_ref`, `invalid_url_scheme`, `env_not_found`, `protection_rule_block`, `missing_token`, `missing_install_env`, etc. | | `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. | | `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. | | `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. | 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. ## See also - [Secrets](https://docs.kici.dev/user/secrets/) — how to seed the `:` values referenced by `tokenSecret` / `installEnv`. - [Contexts](https://docs.kici.dev/user/contexts/) — protection rules (branch restrictions, required reviewers, minimum trust) that the install gate inherits. - [Operator: `kici-admin org-settings`](https://docs.kici.dev/operator/orchestrator/kici-admin/org-settings/#org-settings----org-level-security-policy) — the `allow_http_npm_registries` toggle and other org-scoped knobs. --- ## Build provenance and attestations Source: https://docs.kici.dev/user/provenance/ Build provenance is a signed, verifiable statement of **what produced an artifact** — the source repository, commit, ref, workflow path, and builder that ran. When a workflow step attests an artifact, KiCI records that statement, signs it, and makes it retrievable so anyone can later prove the artifact came from a specific KiCI run and was not swapped along the way. This is the same idea behind supply-chain attestation systems like [SLSA](https://slsa.dev/spec/v1.0/provenance): a downstream consumer (a release gate, a security audit, a `"show me the provenance"` request) can verify the artifact's origin without trusting the person who handed it over. ## What an attestation contains An attestation is a self-contained bundle holding three things: - An **in-toto SLSA v1.0 statement** describing the build: the subject artifact (name + content digest) and the provenance predicate (source repository, commit, ref, workflow, run/job identifiers, timestamps). - A **[DSSE](https://github.com/secure-systems-lab/dsse) signature** over that statement, made with an ephemeral signing key generated for the run. - A short-lived **OIDC identity token** issued by your **orchestrator** that binds the signature to the build identity. The token's identity claims (`repository`, `ref`, `sha`, run/job ids) are derived by the orchestrator from the run itself — a step cannot forge them. The orchestrator owns the provenance root of trust: it holds its own long-lived ES256 signing key, mints and signs the identity token **locally** from its own run records, and publishes its own OIDC discovery + public key set (JWKS). Builds therefore produce verifiable provenance with **no dependency on the hosted KiCI platform** — the availability, sovereignty, and air-gap story all follow from this. Because the bundle carries the identity token and the public signing key, it is **offline-verifiable**: a verifier checks it against the orchestrator's published signing keys with no per-attestation online lookup. ## Attesting an artifact in a workflow Call `ctx.attestProvenance({ subject })` from a step after you have produced the artifact: ```typescript import { workflow, job, step } from '@kici-dev/sdk'; export default workflow('release', { on: { push: { branches: ['main'] } }, jobs: [ job('publish', { steps: [ step('build', async (ctx) => { await ctx.$`npm pack`; }), step('attest', async (ctx) => { const result = await ctx.attestProvenance({ subject: { name: 'my-pkg-1.2.3.tgz', path: 'my-pkg-1.2.3.tgz' }, }); ctx.log.info(`Attestation stored at ${result.storageKey}`); }), ], }), ], }); ``` The **subject is caller-supplied** — you name the artifact and give KiCI either a path or a precomputed digest: - `{ name, path }` — a path relative to the step working directory. KiCI reads the file and computes its SHA-256 digest. - `{ name, digest }` — a precomputed digest. For a container image, pass the OCI manifest digest your build tool emitted: ```typescript await ctx.attestProvenance({ subject: { name: 'ghcr.io/acme/app', digest: { sha256: '' } }, }); ``` The identity token is fetched and masked in logs automatically — you never handle it. The call returns `{ storageKey, subjectDigest, bundleMediaType }` identifying the stored bundle. `ctx.attestProvenance` is only available inside a running job step; calling it outside one rejects with a clear error. `kici run --local` runs are supported: the offline local dev plane signs with a dev identity under the clearly-non-production issuer `kici-local`, and those bundles verify against a trust root exported with `kici local trust-root`. ### Requesting a raw identity token `ctx.attestProvenance` builds on a lower-level primitive you can call directly when you need the identity token for a different tool: ```typescript step('mint', async (ctx) => { const { token, expiresIn } = await ctx.kici.oidc.token({ audience: 'sigstore' }); ctx.log.info(`Got an ID token valid for ${expiresIn}s`); // Hand `token` to a tool that exchanges it with a service trusting the issuer. }); ``` The token is a short-lived (about 10 minutes) signed JWT scoped to the current run and job. Its identity claims (`repository`, `ref`, `sha`, `kici_run_id`, `kici_job_id`) are derived by the orchestrator from the run context, so a step cannot spoof them. The returned token value is automatically masked in step logs, and the step never holds signing credentials — the orchestrator mints and signs the token on the step's behalf from its own run records. Like `attestProvenance`, it is only available inside a running job step. ## ID-token claims and cloud trust policies A cloud provider's OIDC trust policy decides which builds may assume a role. The token below is what your policy matches on, so read this section before you write one. ### The claim set | Claim | Value | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `iss` | Your orchestrator's provenance issuer | | `aud` | The audience you asked for | | `sub` | The build identity — see the two shapes below | | `repository` | `owner/repo` the run acted on | | `ref` | The branch or tag the run PRESENTS. For a pull request this is the **base** branch, not the contributor's branch | | `base_ref` | The same value as `ref`, named the way GitHub Actions names it | | `head_ref` | The pull request's HEAD branch; `''` for a non-PR run | | `head_repository` | `owner/repo` of the pull-request HEAD — the contributor's fork for a fork PR; `''` for a non-PR run | | `is_fork` | `'true'`, `'false'`, or `'unresolved'` | | `event_name` | The event that started the run (`push`, `pull_request:opened`, `schedule`, …) | | `trust_tier` | The resolved trust tier of the triggering actor, or `'unresolved'` | | `actor` | Provider login of the triggering actor | | `sha` | The run's commit | | `workflow_ref` | `@` | | `kici_run_id` / `kici_job_id` | The run and job this token was minted for | | `org_id` | Your organization id | Every claim in the table is **always present**. A value the run did not resolve is `''` or `'unresolved'`, never omitted and never guessed. That matters: an absent claim makes a `StringEquals` condition pass, which would silently remove a constraint you wrote expecting it to be enforced. ### The two `sub` shapes ``` push, tag, schedule, … repo::ref::workflow: pull request, review repo::pull_request ``` The pull-request shape carries **no ref segment**, mirroring GitHub Actions. A pull request's `ref` is its base branch. So a ref-bearing subject would be identical for a fork pull request targeting `main` and a trusted push to `main`. A policy pinning that subject would hand your cloud role to any contributor who opened a pull request running the same workflow. **A re-run keeps the shape of the run it repeats.** Re-running a pull-request run presents `repo::pull_request`, because it rebuilds the same commit from the same source. Its `event_name` claim still reads `rerun` — that claim says what started the run, while `sub` says which identity the run presents. A policy that pins the branch-shaped subject therefore does not match a re-run of a pull request, which is the same protection the first run gets. ### A worked AWS trust policy Pin `sub`, and pin the fork context too. `sub` alone tells you a pull request ran; it does not tell you whose code ran. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/orch.example.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "orch.example.com:aud": "sts.amazonaws.com", "orch.example.com:sub": "repo:acme/app:ref:main:workflow:deploy", "orch.example.com:is_fork": "false", "orch.example.com:head_repository": "acme/app", "orch.example.com:trust_tier": "trusted" } } } ] } ``` This grants the role only to a run on `main` in `acme/app`, from code in that same repository, triggered by an actor your orchestrator resolved as trusted. A fork pull request fails on all three of the extra conditions, and a run whose context did not resolve fails too — `'unresolved'` matches none of them, so the policy fails closed. To let a same-repo pull request assume the role, add a second statement pinning `"sub": "repo:acme/app:pull_request"` alongside `"is_fork": "false"` and `"head_repository": "acme/app"`. ### Migrating an existing policy If you already pin a ref-bearing `sub` for pull-request runs, that policy stops matching once you upgrade — which is the fix, because it was matching runs it should not have. Move it to `repo::pull_request` plus the fork conditions above. The same move covers a re-run of a pull request, which presents the pull-request subject too. While you migrate, `KICI_OIDC_LEGACY_PR_SUB=1` on the orchestrator restores the old subject. It restores the collision with it, so treat it as a short bridge, not a setting. See [deprecations](https://docs.kici.dev/user/deprecations/). ## Verifying an attestation Verify a bundle with the `kici verify-attestation` command. It establishes the full chain offline: the identity token verifies against the trusted issuer's JWKS, the DSSE signature verifies against the bundled signing key, and the statement's build context must match the token's identity claims (a mismatch is a hard failure). ```bash kici verify-attestation [artifact] --bundle [--trust-root ] ``` ### Which trust root do I use? The trust root is **your orchestrator's provenance issuer** — the orchestrator you `kici login` against, which owns the provenance signing key and publishes its own JWKS. That is the **default**: omit `--trust-root` and the verifier checks the bundle against your configured orchestrator automatically. There are three ways to verify, and offline is always the primary one: 1. **Offline against a JWKS / trust-root file (air-gap)** — export the `{ issuer, jwks }` file once with `kici-admin signing-key export --public` and verify against it with `--trust-root `. No network needed at verify time. 2. **Directly online against your orchestrator** — the default: the verifier resolves your orchestrator's discovery → JWKS. You can also POST a bundle to the orchestrator's native `POST /v1/verify-attestation` endpoint for a verdict against its live keys (fresh rotations / revocations included). 3. **Against the hosted KiCI platform** — bundles produced before your orchestrator owned signing were signed by the hosted platform; those keep verifying forever. When no orchestrator is configured, the default falls back to the hosted platform's issuer so those historical bundles still verify with no flag. You pass `--trust-root` to verify against a different environment or, most commonly, an offline `{ issuer, jwks }` file for air-gapped checks. ### Why you supply it out-of-band The verifier already resolves a sensible default, so why is naming the trust root a supported step at all -- why not let the verifier read the issuer from the token? Because the issuer named **inside** a token cannot be trusted. A forged bundle could carry a token that names `iss: https://attacker.example` _and_ bundle a key set that "verifies" it. That makes the whole signature chain circular and self-attesting. The verifier therefore pins to an issuer you supply out-of-band and checks the token against _that_ — the bundle is verified against a key set you trust, not one it shipped with. Naming the trust root is a security requirement, not a multiple-choice question. To override the default, supply the trusted issuer via `--trust-root`, in one of two forms: - **Online — an HTTPS issuer URL.** The verifier fetches `/.well-known/openid-configuration`, reads its `issuer` and `jwks_uri`, and fetches the JWKS. The token's `iss` is pinned to the discovery document's `issuer`. - **Offline — a self-contained trust-root file.** A local JSON file with the issuer and JWKS inlined, for air-gapped verification: ```json { "issuer": "https://platform.example/issuer", "jwks": { "keys": [ { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "alg": "ES256", "kid": "..." } ] } } ``` Pass an optional `[artifact]` to also digest-check the file against the attestation subject — this is what binds the attestation to a specific set of bytes. Omit it to verify the signatures and identity only. Use `--json` for a machine-readable result. The command exits `0` when everything verifies and `1` when it does not (or on an error such as a missing flag or unreachable trust root). ```bash # Default: verify against your configured orchestrator (no --trust-root needed): kici verify-attestation ./dist/app.tgz --bundle ./app.tgz.kici.json # Override the trust root to verify against a specific issuer: kici verify-attestation ./dist/app.tgz \ --bundle ./app.tgz.kici.json \ --trust-root https://platform.example/issuer # Air-gapped: verify against a self-contained trust-root file: kici verify-attestation ./dist/app.tgz \ --bundle ./app.tgz.kici.json \ --trust-root ./kici-trust-root.json ``` The full flag reference is in the [CLI reference](https://docs.kici.dev/user/cli/notifications-and-diagnostics/#kici-verify-attestation). ## Viewing attestations in the dashboard The run detail page has an **Attestations** tab listing each artifact a run's steps attested (via `ctx.attestProvenance`), one row per artifact. Each row shows: - **Status** — a **verified** badge computed in your browser. It checks the attestation's signature, the build identity, and the build context against the trusted provenance issuer. **verified** (green) means all of those pass; **failed** (red) shows why in a tooltip; **unverifiable** means the provenance issuer is not configured; **keys unavailable** (amber) means the issuer is configured but its verification keys could not be fetched. - **Job / Artifact / Digest / Created** — the producing job, the artifact name, its content digest, and when it was recorded. - **Download** — saves the signed bundle as a `.sigstore.json` file. The badge does **not** re-hash the artifact bytes — the dashboard does not have the artifact. To bind the attestation to a specific file, run `kici verify-attestation --bundle `. A run with no attestations shows an empty state. ## Browsing attestations across runs The **Attestations** page (in the org sidebar) lists every build-provenance attestation your organization has produced — not just one run's. It is the supply-chain audit surface: look up "who built `sha256:…`?" by digest, or browse and filter every attestation across all runs. The **Attestations** page lists every build-provenance attestation your organization has produced. - **Search** by artifact digest (exact `sha256:…`) or name to trace a specific artifact. - **Filter** by verification status, repository, workflow, job, or date. - Each row's **status badge** is the verdict KiCI recorded when the attestation was produced (`verified`, `failed`, `unverifiable`, or `pending`). - **Retry** on a `pending` row asks your orchestrator to mint that run's outstanding attestations now; **Retry pending** does the same for every pending run at once. Open a row for the parsed provenance statement and a live re-verification. The status badge here is the **server-side verdict**, computed once when the attestation was recorded (verify-at-ingest) — so the list stays fast at any size. `verified` means the signature, build identity, and build context all checked out against the provenance issuer; `failed` means verification ran and the bundle did not pass; `unverifiable` means no verdict could be computed (no provenance issuer configured, or its keys could not be read — not a forgery signal); `pending` means the verdict has not been computed yet. A `pending` row is one still waiting to be minted — the attestation was signed at build time, but attaching its identity token has not completed yet. Those rows carry a **Retry** button that asks your orchestrator to mint that run's outstanding attestations immediately, and the page header offers **Retry pending** to do the same across every pending run. Only one retry runs at a time — the other retry buttons are unavailable until it finishes. Retrying is safe to repeat while the mint is only temporarily unavailable: the row stays pending and the next retry tries again. A mint that is definitively rejected — for example the run's records are no longer there to bind the attestation to — is terminal: the row stops being retried, and re-arming it is an operator action (`kici-admin attestations retry --include-rejected`). Opening a row leads to the **attestation detail page**: This page shows the parsed provenance for one attestation. - **Builder identity, source, and build type** come from the signed SLSA statement. - The **stored badge** is the verdict recorded at build time; **Re-verify** runs the check live in your browser against the current signing keys. - **Download** exports the signed bundle for offline verification with `kici verify-attestation`. ## See also - [SDK runtime reference](https://docs.kici.dev/user/sdk/runtime/) — the `ctx.attestProvenance` and `ctx.kici.oidc.token` step APIs in full. - [CLI reference](https://docs.kici.dev/user/cli/notifications-and-diagnostics/#kici-verify-attestation) — every `kici verify-attestation` flag and exit code. --- ## Secrets Source: https://docs.kici.dev/user/secrets/ 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. ## Overview Secrets are managed per-context in the orchestrator (see [operator docs](https://docs.kici.dev/operator/orchestrator/configuration/) for setup). When a job runs with a `context` binding, the agent receives the secret keys available for that context but does **not** inject their values into the step's process environment. Instead, steps access secrets through the `ctx.secrets` API. This design prevents accidental secret leakage through child processes, log output, or error messages. Only secrets you explicitly request are loaded into memory. A job can bind several contexts with `contexts: ['staging', 'my-testing']`; the secret keys from all bound contexts are merged in array order, with a later context's value winning on a key collision. See [Multiple contexts per job](https://docs.kici.dev/user/contexts/#multiple-contexts-per-job). ## Where secret values come from 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. ### Default — dashboard or CLI A fresh orchestrator starts in **permissive** mode: both surfaces are available. - **Dashboard:** Settings → Secrets → pick a scope → enter the secret name and value. - **CLI:** `kici-admin secret set ` against the orchestrator's HTTP admin API. Use whichever fits the workflow — most small teams stay on the dashboard; ops engineers and CI scripts use the CLI. ### When the operator has disabled dashboard writes 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: - The dashboard's "Add secret" / "Edit value" controls render with a lock icon. Hovering or keyboard-focusing the lock shows a tooltip with the exact `kici-admin secret set` invocation needed. The control itself is inert, so there is nothing to click. - The dashboard's secrets page still lists secret **names**, scopes, and bindings — only the value-entry path moves to the CLI. - `kici-admin secret set` becomes the single entry point for new and updated secret values. 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, context bindings). ### CLI input modes `kici-admin secret set` takes the target as three positional arguments — ` ` — and accepts five input modes; pick the one that fits your workflow: ```bash # Interactive prompt (default when stdin is a TTY). No echo, no shell history. kici-admin secret set my-org production DB_PASSWORD --prompt # Pipe from another tool (default when stdin is not a TTY). pass show prod/db | kici-admin secret set my-org production DB_PASSWORD --from-stdin # Read from a file (handy after `sops -d` to a tmpfile). kici-admin secret set my-org production DB_PASSWORD --from-file ./db.pass # Read from a named environment variable (CI-friendly). KICI_SECRET_VALUE=$(my-secrets-fetcher prod db) \ kici-admin secret set my-org production DB_PASSWORD --from-env KICI_SECRET_VALUE # Direct argv — discouraged. Prints a stderr warning ("visible in shell history"). kici-admin secret set my-org production DB_PASSWORD --value "" ``` When the scope is a deployment context, a flag-based sugar form is also accepted: `kici-admin secret set --org my-org --context production --key DB_PASSWORD --prompt`. The two forms are mutually exclusive — mixing them is refused. Two cross-cutting flags help every mode: - `--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. - `--dry-run` — parse and validate the value, print `[dry-run] would set <key> in scope <scope> sha256=<hex>`, exit without writing. `kici-admin variable set` uses the same flags for non-encrypted variables, plus `--locked` to mark a variable as immutable from subsequent dashboard writes. 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). ## Accessing secrets Use `ctx.secrets.get(key)` to retrieve a secret value. The method is async to support process-level step isolation in future versions. ```typescript import { workflow, job, step } from '@kici-dev/sdk'; export default workflow('deploy', { on: [push({ branches: ['main'] })], jobs: [ job('deploy', { runsOn: 'default', context: 'production', steps: [ step('deploy', async (ctx) => { const token = await ctx.secrets.get('DEPLOY_TOKEN'); await ctx.$`deploy --token ${token}`; }), ], }), ], }); ``` If the secret does not exist, `get()` throws a `SecretNotFoundError` with a descriptive message. ## Exposing secrets to shell commands 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)`: ```typescript step('run-tool', async (ctx) => { // Injects MY_API_KEY into process.env for this step only await ctx.secrets.expose('MY_API_KEY'); // Now child processes can read it from the environment await ctx.$`some-tool --use-env-auth`; }); ``` `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. ## Checking secret existence Use `ctx.secrets.has(key)` to check whether a secret is available without retrieving its value: ```typescript step('conditional-notify', async (ctx) => { if (ctx.secrets.has('SLACK_WEBHOOK')) { const webhook = await ctx.secrets.get('SLACK_WEBHOOK'); await ctx.$`curl -X POST ${webhook} -d '{"text": "Deploy complete"}'`; } else { console.log('Slack webhook not configured, skipping notification'); } }); ``` `has()` is synchronous and does not load the secret value. ## Mounting secrets as files 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. ### list() `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. ```typescript step('discover-keys', async (ctx) => { // Pick up every age key the operator has provisioned. const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')); ctx.log.info(`Found ${ageKeys.length} age keys`); }); ``` ### mountFile(opts) `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). Options: - `sources: string[]` — secret keys to concatenate (in order). Required. - `divider?: string` — separator written between concatenated values. Default: no divider. - `mode?: number` — permission bits to chmod the file to. Default: `0o600` (owner read/write only). - `name?: string` — filename inside the per-step tmpdir. Default: auto-generated. If any source key is missing, `mountFile` rejects with `SecretNotFoundError` listing every missing key. ```typescript step('decrypt', async (ctx) => { const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')); const keyFile = await ctx.secrets.mountFile({ sources: ageKeys, divider: '\n', }); await ctx.$`sops --age-key-file ${keyFile.path} -d secrets.enc.yaml`; }); ``` ### exposeFile(envVar, opts) `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. ```typescript step('deploy', async (ctx) => { await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', { sources: ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')), divider: '\n', }); // sops reads SOPS_AGE_KEY_FILE from the environment. await ctx.$`sops -d secret.enc.yaml`; }); ``` ### Lifecycle and cleanup - **Lazy allocation:** no tmpdir is created until the first `mountFile` / `exposeFile` call. Steps that never mount pay nothing. - **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. - **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. - **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. ### Log masking 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. ### Canonical sops example ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('deploy', { on: push({ branches: ['main'] }), jobs: [ job('decrypt-and-deploy', { runsOn: 'default', context: 'production', steps: [ step('decrypt', async (ctx) => { const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')); await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', { sources: ageKeys, divider: '\n', }); await ctx.$`sops -d secret.enc.yaml > config.yaml`; // No cleanup -- the tmpdir + the SOPS_AGE_KEY_FILE env var // are removed automatically when this step returns. }), ], }), ], }); ``` ### Injecting decrypted sops values into the environment 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. 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: ```typescript import { workflow, job, step, push } from '@kici-dev/sdk'; export default workflow('deploy', { on: push({ branches: ['main'] }), jobs: [ job('decrypt-and-deploy', { runsOn: 'default', context: 'production', steps: [ step('decrypt-to-env', async (ctx) => { await ctx.secrets.exposeFile('SOPS_AGE_KEY_FILE', { sources: ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_')), divider: '\n', }); // Decrypt to dotenv format, then append every KEY=value line to // $KICI_ENV so subsequent steps see them as environment variables. await ctx.$`sops -d --output-type dotenv secrets.enc.yaml >> "$KICI_ENV"`; }), step('deploy', async (ctx) => { // Values decrypted above are now ordinary env vars here. await ctx.$`./deploy.sh`; }), ], }), ], }); ``` 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. 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. ## API reference | Method | Signature | Description | | ------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | | `get` | `get(key: string): Promise<string>` | Retrieve a secret value. Throws `SecretNotFoundError` if not found. | | `expose` | `expose(key: string): Promise<void>` | Set `process.env[key]` to the secret value for child process access. | | `has` | `has(key: string): boolean` | Check if a secret key is available (synchronous). | | `getMeta` | `getMeta(key: string): SecretMeta \| undefined` | Get metadata (backend name, scope) for a secret. Returns `undefined` if not found. | | `list` | `list(): string[]` | Sorted array of every secret key available to the step. Synchronous, never throws. | | `mountFile` | `mountFile(opts: SecretFileOptions): Promise<{ path: string }>` | Materialise one or more secrets as a tmpfile. Auto-cleanup at step end. | | `exposeFile` | `exposeFile(envVar: string, opts: SecretFileOptions): Promise<{ path }>` | `mountFile` plus `process.env[envVar] = path`. Env var unset at step end. | ## Migration from property access If upgrading from a previous version that used property access (`ctx.secrets.KEY`), update your workflow code: ```typescript // Before (old API) const token = ctx.secrets.DEPLOY_TOKEN; // After (new API) const token = await ctx.secrets.get('DEPLOY_TOKEN'); ``` For conditional access: ```typescript // Before (old API) if (ctx.secrets.DEPLOY_TOKEN) { ... } // After (new API) if (ctx.secrets.has('DEPLOY_TOKEN')) { ... } ``` Note that `get()` is async -- you must `await` the result. ## Typed secrets When you run `kici types`, the compiler generates a `.kici/types/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. This file is a local development aid, not source: `kici init` gitignores `.kici/types/`, so each team member generates their own copy with `kici types` rather than committing it. When the Platform is unreachable, `kici types` keeps any existing copy untouched, or writes an empty stub if none exists, so type checking degrades to "no known keys" instead of failing. See [CLI reference](https://docs.kici.dev/user/cli/authoring-and-local/#kici-types) for the `kici types` command. ---