Skip to content

Global workflows

Global workflows allow a single workflow repository to define CI/CD pipelines that trigger on events from any other repository under the same organization (routing key). This enables centralized CI policy enforcement, shared build pipelines, and org-wide automation without duplicating workflow definitions across repositories.

In the standard KiCI model, each repository defines its own workflows in .kici/workflows/. When a push or PR event arrives, the orchestrator fetches that repository’s lock file and matches triggers. Global workflows extend this model: a workflow repo defines workflows with repos patterns (including !-prefixed exclusions), and those workflows fire when events occur in source repos that match the patterns.

Workflow repo (e.g. myorg/ci-pipelines)
.kici/workflows/lint-all.ts
on.push({ repos: ['myorg/*'], branches: ['main'] })
Source repo (e.g. myorg/backend)
git push to main
--> triggers lint-all from ci-pipelines
--> agent clones both repos
--> executes lint-all with dual-repo context

When a webhook event arrives, the orchestrator runs two independent matching passes:

Webhook event (push to myorg/backend)
|
v
[1] Per-repo matching (existing path)
Fetch lock file from myorg/backend
Match triggers against event
Dispatch matched jobs (source repo only)
|
v
[2] Global matching (new path)
Query RegistrationIndex for global workflows
matching this trigger type + routing key
For each global registration:
Skip if same repo as event source (dedup)
Check GlobalWorkflowPolicy
Match trigger patterns (repos, branches, etc.)
Dispatch with dual-repo context

Both passes run within the same processWebhook call. Global dispatches are additive — they never replace per-repo dispatches.

import { workflow, job, step, push } from '@kici-dev/sdk';
export default workflow('org-lint', {
on: [
push({
repos: ['myorg/*'],
branches: ['main', 'develop'],
}),
],
jobs: [
job('lint', {
steps: [
step('run-lint', async ({ $ }) => {
await $`npm run lint`;
}),
],
}),
],
});
push({
repos: ['myorg/*', '!myorg/legacy-*', '!myorg/archived-*'],
branches: ['main'],
});
push({
repos: ['myorg/*'],
branches: ['main'],
paths: ['src/**', 'package.json'],
});

The lock file carries a repos field on a trigger entry to mark it for global workflow matching. A leading ! on a pattern is a negation:

{
"workflows": [
{
"name": "org-lint",
"source": ".kici/workflows/org-lint.ts",
"triggers": [
{
"_type": "push",
"branches": [{ "type": "literal", "pattern": "main" }],
"repos": [{ "type": "glob", "pattern": "myorg/*" }]
}
],
"jobs": [...]
}
]
}

Workflows with repos patterns are classified as global workflows and stored in the workflow_registrations table with is_global = true.

Before any global-workflow policy decision is consulted, the event must clear the org’s trust policy (packages/orchestrator/src/security/trust-policy-gate.ts). Both org-global dispatch paths — the fallback that runs globals when the source repo has no lock file, and the pass that dispatches globals authored in other repos — return without dispatching unless the verdict is pass. So a hold or ignore verdict from the policy’s fork switch stops the organization’s global workflows too, not only the pull request’s own workflows. This matters because globals run with org credentials against the event’s head SHA: dispatching them for an event the policy refused would hand an untrusted head SHA the org’s credentials.

A skipped global creates no run row, so there is nothing to approve — approving the event’s security hold releases the pull request’s own workflows and does not retroactively run the organization’s globals for that event.

The skip is recorded as a neutral informational check named KiCI: Organization workflows, posted through the inbound event’s own bundle and credentials (a cross-provider lock-file fallback swaps the dispatch bundle for another source’s, which must not write this check). It is deliberately a different check from KiCI Security: that name owns the single security check run per commit, which the hold posts as pending and approve / reject later complete, so writing the notice through it would resolve a still-held run’s check and could unblock a branch-protection rule that requires it. The notice stays neutral in the reject case as well, because the same-source path already posts the failure for that event and a second failure would double-report one decision.

See Approvals for the trust policy’s hold / reject vocabulary.

A global workflow that declares a filter, or whose jobs come from a generator, cannot be decided from the lock file alone — only an agent may run that code. So the orchestrator dispatches one pre-run evaluation job per (event × workflow repo) and waits for its verdicts before deciding which of those workflows apply.

An evaluation that produces no usable verdicts is retried once. That covers both shapes it can take: the job failed outright (the agent went away, the job was rejected, the wait ceiling below was reached), and the job finished but decided nothing — every workflow came back undecided, which is what an evaluation that runs out of its own time budget reports. An evaluation that decided even one workflow is a real result: its decided workflows run, and it is not retried — retrying it would re-dispatch what it already decided. Its undecided workflows are still recorded, as a partial failure carrying only their names, so the two records below are produced for a partial evaluation as well as a total one.

If the second attempt still produces no usable verdicts, none of the workflows it was deciding on run for that commit, and the outcome is recorded in two places:

  • One errored run, for the whole evaluation rather than one per workflow — the evaluation exists to collapse several candidate workflows into a single pre-run job, so fanning its failure back out would undo that. Its failure reason names every workflow the failure suppressed.
  • One failing commit check on the event’s commit, named KiCI: Organization workflow evaluation. Its own name, for the same reason as the skip notice above: KiCI Security owns the single security check run per commit, so writing through it could resolve a still-held run’s check.

The orchestrator also applies its own ceiling on how long it waits (--global-eval-wait-timeout-ms, 4 minutes by default). The evaluation’s own budgets are enforced by the agent and only start once the job is running, so neither covers an evaluation still waiting for a free agent, or an agent that stops responding — without the ceiling the delivery would wait indefinitely and never be logged at all. See Cluster settings for the knob.

Global workflows require explicit opt-in. The master switch is fleet-wide: cluster_settings.global_workflows_enabled (a single id='default' row, BOOLEAN nullable — NULL resolves to the orchestrator’s configured default KICI_GLOBAL_WORKFLOWS_ENABLED, off by default). It is held by the operator through kici-admin cluster-settings and gates every org before any per-org list is consulted; an unreadable row fails closed.

The three per-org lists live in the org_settings table, which is org-scoped — one row per customer_id, regardless of how many webhook sources the org has registered. A missing org_settings row means “no per-org restrictions” (the repo and source axes pass), not a denial:

ColumnTypePurpose
customer_idtext (PK)Organization identifier
global_workflow_allowed_reposjsonb[] (nullable)Authoring axis. Entries {routingKey?, pattern}; repos allowed to register global workflows (null/empty = any author)
global_workflow_denied_reposjsonb[] (nullable)Source axis. Entries {routingKey?, pattern}; source repos whose events must never trigger global workflows (null/empty = none)

Each list element is an object: {routingKey?: string, pattern: string}. When routingKey is absent, the entry applies to events from / workflows authored on any source in the org. When set, the entry only applies to that one webhook source — a deny pinned to github:42 does not block events delivered on a Forgejo generic:* source in the same org. This is how the same org/repo identifier can appear under multiple sources without policy collisions.

The GlobalWorkflowPolicy class (packages/orchestrator/src/security/global-workflow-policy.ts) encodes two decisions:

  1. isWorkflowRepoAllowed(workflowRoutingKey, workflowRepo, customerId) — consults the allow-list. Each entry matches when entry.routingKey is absent OR equals workflowRoutingKey, AND the pattern matches the workflow repo. Applied at registration extraction (filters which workflows get stored) and at dispatch time (filters which authored workflows may run).
  2. isSourceRepoAllowed(eventRoutingKey, sourceRepo, customerId) — consults the deny-list. Each entry matches when entry.routingKey is absent OR equals the event’s routing key, AND the pattern matches the source repo. Applied at dispatch time. Used to block events from untrusted repos (forks, public-contrib repos) before any global workflow is considered.

Allow-list and deny-list are orthogonal: the allow-list restricts authors, the deny-list restricts event sources. Both can be active simultaneously — they answer different questions.

If an admin deletes a webhook source whose routing key still appears in some entry’s routingKey, that entry becomes an orphan: its routing key cannot equal any current event’s routing key, so the entry never matches. This is the safe default — orphans silently stop applying rather than re-binding to some unrelated source. The dashboard surfaces orphans inline with an “Unknown source” badge so an operator can rebind them or delete them.

A run of an organization-wide workflow that executed against another repository is never re-run. Two tiers refuse it independently. The orchestrator’s re-run pipeline (packages/orchestrator/src/pipeline/rerun.ts) refuses it at the tier that holds the credentials and the lock file. The hosted Platform enforces the same refusal on its own mirrored run row, ahead of every path that exposes re-run.

One exception: a failed evaluation round. Both tiers admit a run that records a global evaluation round, ahead of the cross-repository comparison. Each tier reads a structural marker on the run row (is_global_eval_round), never the workflow name, so no repository enters the exception by naming a workflow a certain way. The exception is necessary, and it is narrow. A round is cross-repository by definition — it decides one repository’s global workflows against another repository’s event — so the comparison would refuse every round. The round path also resolves no workflow out of the acted-on repository’s lock file, so the workflow substitution the refusal prevents cannot happen. A re-run of a round re-evaluates the original event against the workflow repository’s current state, and dispatches what that evaluation admits.

The refusal is an authorization boundary, not a correctness workaround: it is what keeps the either-repository grant narrow. RBAC lets a member scoped to either of a global run’s two repositories read and cancel that run. The basis is that no caller re-executes an organization-wide workflow from the repository the run acted on. A failed evaluation round is the one run such a caller re-executes. It runs the same evaluation the original event ran, through the same policy axes, and it does not let the caller select which workflow runs. Lifting one refusal alone does not widen the grant. Lifting both requires answering the authorization question first: which of the two repositories may re-execute the run, and with whose credentials.

Global workflow jobs use provider credentials from the webhook event (source repo), not from the registration. The workflow repo’s secrets are not automatically shared with the source repo’s execution context.

When the source bundle and the workflow bundle differ (e.g., a Forgejo universal-git source delivers a push and the authored workflow lives in a GitHub App source, or two distinct universal-git Forgejo sources in the same org), the dispatch carries two independent auth bundles on jobDispatchSchema:

FieldMinted fromUsed for
sourceAuthInbound bundle’s cloneTokenProviderCloning the source repo
workflowAuthRegistration bundle’s cloneTokenProviderCloning the workflow repo

For same-bundle globals (both repos under the same GitHub App) workflowAuth mirrors sourceAuth. A single-token field is still emitted alongside the split fields for callers that consume the simpler shape.

The in-memory RegistrationIndex.globalByOrgAndTriggerType index (keyed by ${customerId}|${triggerType}) is what makes this cross-source lookup work — the routing-key-scoped globalByTriggerType only surfaces globals on the inbound routing key, which would hide every cross-provider author.

Policy decisions look up a single org row (one per customer_id). The allow axis runs against the registration’s routing key — the authoring source is the one whose qualifier governs whether a given authored workflow may fire. The deny axis runs against the event’s routing key — events are filtered by the source they actually arrived on.

Universal-git sources (Forgejo / Gitea / Gogs / GitLab / plain-GitHub webhooks, routing key generic:<orgId>:<sourceId>) share the same org-level row as the org’s other sources. The policy code is purely string-based with no hardcoded provider checks, so a universal-git routing key works as a per-entry qualifier just like a github:* routing key. Enable cluster-wide via kici-admin cluster-settings set --global-workflows-enabled true, then tune the per-org lists via kici-admin org-settings global-workflows {allow-add, deny-add} --customer-id <orgId> [--source generic:<orgId>:<sourceId>]. See the user guide for the operator surface.

The global dispatch path resolves no secrets at all: it binds no secret contexts and writes no secret material into a job config. A global workflow’s job runs with neither the source repo’s secrets nor the workflow repo’s own. Secrets are stored (org_id, scope, key) with no repository dimension, so “the source repo’s secrets” is not a set the orchestrator can name — a grant would first need a repository-to-secret-context model that does not exist.

When an agent receives a global workflow dispatch, the jobConfig includes:

FieldValuePurpose
isGlobalWorkflowtrueSignals dual-repo context
workflowRepoUrlClone URL for workflow repoAgent clones this for workflow source
workflowRefGit ref at registration timePinned version of the workflow
workflowShaCommit SHA at registrationFor reproducibility
workflowRepoIdentifierowner/repo of workflow repoFor logging and context

The agent clones both repositories into a workspace directory:

/workspace/
source/ <-- Source repo (where the event happened)
workflow/ <-- Workflow repo (where the workflow is defined)

One writer sets all seven, so the pre-dispatch evaluation round and the sandbox present the same ambient environment to a job generator. A generator that saw a key on one call and not the other would be a determinism failure.

VariableValueDescription
KICI_IS_GLOBAL_WORKFLOWtrueIndicates global workflow execution
KICI_WORKFLOW_REPO_PATH<workdir>/workflowPath to workflow repo clone
KICI_SOURCE_REPO_PATH<workdir>/sourcePath to source repo clone
KICI_WORKFLOW_REPOowner/repoWorkflow repo identifier
KICI_SOURCE_REPOowner/repoSource repo identifier
KICI_SOURCE_BRANCHref, or ""Source repo ref; empty when the event carries no ref
KICI_SOURCE_SHAsha, or ""Source repo commit; empty when the event carries none

<workdir> is a per-job temporary directory, not a fixed path — read the variable rather than reconstructing it.

A missing ref or sha writes an empty string rather than leaving the key unset: assigning undefined to a process.env key stringifies to "undefined", which is worse than either.

Global workflows are disabled by default. To enable them:

  1. Turn the fleet-wide master switch on (operator, once per cluster):
Terminal window
kici-admin cluster-settings set --global-workflows-enabled true
kici-admin cluster-settings show # confirm: Global workflows enabled: true
  1. Optionally restrict which repos can register global workflows. Pass --source to pin an entry to one webhook source, or omit it for “any source in the org”:
Terminal window
kici-admin org-settings global-workflows allow-add 'myorg/ci-*' --org <customerId>
kici-admin org-settings global-workflows allow-add 'myorg/automation' \
--org <customerId> --source github:42
kici-admin org-settings global-workflows show --org <customerId>

Both are also reachable from the dashboard tab below, except the master switch, which is operator-only by design.

The org settings page exposes these knobs through the Global workflows tab (/orgs/:customerId/settings/global-workflows), visible to any user with org_settings:read. Editing requires org_settings:write. The tab surfaces:

  • A read-only master-switch badge showing the effective fleet-wide state (cluster_settings.global_workflows_enabled). It is set with kici-admin cluster-settings, not from the dashboard.
  • An Allowed author repos section with its own enable toggle and editable list bound to global_workflow_allowed_repos (the authoring axis). When the toggle is off, any repo in the org may author global workflows.
  • A Blocked source repos section with its own enable toggle and editable list bound to global_workflow_denied_repos (the source axis). Use this to protect forks and public-contrib repos from silently triggering org-wide automation.

Every list row pairs a source picker with the existing pattern input. The source picker defaults to “Any source” — leaving it as such stores an unqualified entry. Selecting a specific source pins the entry’s routingKey so it only applies to events / workflows on that source. Stored entries whose source has since been deleted render with an “Unknown source” badge.

The Platform proxies reads and writes to the orchestrator via the existing dashboard WS channel (dashboard.global-workflows.get/update).

Operators enable the fleet-wide switch with kici-admin cluster-settings, then manage the per-org lists with kici-admin org-settings global-workflows:

Terminal window
# Fleet-wide master switch (once per cluster):
kici-admin cluster-settings set --global-workflows-enabled true
kici-admin org-settings global-workflows show --customer-id kiciStg00001
kici-admin org-settings global-workflows allow-add 'myorg/ci-*' --customer-id kiciStg00001
kici-admin org-settings global-workflows deny-add 'myorg/fork-*' --customer-id kiciStg00001
# Pin an entry to one webhook source (qualified by routingKey):
kici-admin org-settings global-workflows allow-add 'myorg/deploy' \
--customer-id kiciStg00001 --source github:42
kici-admin org-settings global-workflows deny-add 'myorg/main' \
--customer-id kiciStg00001 --source generic:kiciStg00001:src-b

--org is accepted as an alias for --customer-id. Omitting --source on *-add stores an unqualified entry that applies to any source in the org; omitting it on *-remove targets the unqualified entry. To remove a source-qualified entry, pass the same --source value used when it was added.

The CLI talks directly to the orchestrator admin API (/api/v1/admin/org-settings/global-workflows) so policy management remains available even when the Platform relay is unreachable.

A global workflow can hand control back to the source repo it runs against and gate on the repo’s own work. A job that carries an invoke: action — built with invokeSource('event.name') — is a gate: it runs no steps on an agent. Instead, when the gate becomes ready the orchestrator:

  1. Emits the named kici event at the source repo (ctx.sourceRepo).
  2. Matches the repo’s opt-in subscribers — workflows that declare on: [ kiciEvent({ name }) ] — and dispatches each as a normal in-repo run, capturing the run ids it created.
  3. Creates one proxy job per spawned run, tracked in the global run’s graph as a fan-out child of the gate. A proxy runs no steps; its status mirrors the spawned run.
  4. As each spawned run reaches a terminal state, the orchestrator maps it back to its proxy and sets the proxy’s status, carrying the run’s non-secret outputs.
  5. Once every proxy is terminal, the gate aggregates a status and the downstream needs release.
repo-tests ─┬─ repo-tests (myorg/backend:unit) ← proxy, mirrors the spawned run
├─ repo-tests (myorg/backend:lint) ← proxy
└─ repo-tests (myorg/backend:e2e) ← proxy
│ (all terminal)
deploy

Required by default. An emit that matches zero subscribers fails the gate, with a message naming the event and repo. A repo that never wired up its tests must not silently pass the org gate. Pass invokeSource(event, { optional: true }) to let a repo opt out: a zero-subscriber gate then succeeds immediately with no proxies.

Dynamic invocation. Because invoke: is a job shape, a generator job can inspect the source repo at runtime and return only the invoke gates that apply — e.g. a docker-test gate when the repo has a Dockerfile, a node-test gate when it has a package.json. The generator decides whether to create a gate; optional decides what a created gate does when nothing subscribes.

Failure, timeout, concurrency — the standard job vocabulary. The gate is a standard job. continueOnError tolerates a failed invoked run. A downstream needs when: on-failure reacts to a failed gate. The job timeout bounds the wait — the orchestrator enforces it, since the gate has no agent. maxParallel and failFast bound the fan-out over the proxies.

Security. The invoked workflow runs as the repo’s own run, with the repo’s own secrets and its own dashboard run — the global never gains the repo’s secrets, only pass/fail plus plain declared outputs. The opt-in is the subscription: a global cannot invoke a repo that did not subscribe. The invoke path reuses the same trust-policy and global-workflow-policy gates as the rest of the global dispatch path, and a bounded chain depth stops an invoke chain from looping.

ComponentPath
Invoke-gate executorpackages/orchestrator/src/pipeline/invoke-gate.ts
GlobalWorkflowPolicypackages/orchestrator/src/security/global-workflow-policy.ts
Registration extractorpackages/orchestrator/src/registration/extractor.ts
Registration indexpackages/orchestrator/src/registration/registration-index.ts
Processor (dispatch)packages/orchestrator/src/pipeline/processor.ts
SDK trigger typespackages/sdk/src/triggers/
Engine trigger matcherpackages/engine/src/trigger/matcher.ts
Org settings tablepackages/orchestrator/src/db/types.ts (OrgSettingsTable)
E2E teste2e/tests/global-workflow.test.ts