The problem: sharing CI is pull-based
Say you run forty repositories and you want the same test-and-build policy on all
of them. Every mainstream CI tool solves this the same way: you factor the shared
steps into a reusable unit, and then you go to each of the forty repos and add a
file that calls it. GitHub reusable workflows, GitLab include:, Buildkite
plugins — all of them are pull. The shared logic exists in one place, but
every consuming repo still has to opt in by carrying a file that references it.
That is forty pull requests to roll out a policy, forty more to change it, and forty chances for a repo to drift out of sync or never adopt it at all.
The KiCI model: push
A KiCI global workflow inverts this. One workflow repo declares the pipeline,
its trigger carries a repos: glob, and the orchestrator runs it on events from
every matching repo in the org. Nothing is added to the source repos — no file,
no reference, no opt-in commit. You change the policy in one place and it is live
everywhere on the next push.
The guardrails are built for exactly this blast radius. Global workflows are
opt-in per org, and two independent axes govern them: an allow-list of which
repos may author org-wide automation, and a deny-list of which source
repos may trigger it. Source-repo secrets are not shared with a global
workflow by default — a pipeline in your ci-pipelines repo does not gain read
access to another repo’s secrets just because it runs on a push there. See the
global workflows guide for the
full security model.
What makes this genuinely useful — rather than a blunt “run this everywhere” — is that a global workflow can adapt to each repo. There are three levels, from a one-line declarative gate to full programmatic job generation.
Tier 1 — run only where it applies, with no wasted compute
The cheapest adaptivity is declarative. A requires content filter tells the
orchestrator to dispatch the workflow only for repos whose files match a
condition — here, repos whose package.json declares a ci:test script. A repo
without that script is dropped before any agent is dispatched, so you spend
zero compute on repos that opted out by simply not having the script.
export default workflow('org-ci-test', {
on: [
push({
repos: ['myorg/*'],
branches: ['main'],
requires: [{ file: 'package.json', exists: ["$.scripts['ci:test']"] }],
}),
],
jobs: [
job('test', {
runsOn: 'kici:os:linux',
steps: [
step('ci-test', async ({ $ }) => {
await $`pnpm run ci:test`;
}),
],
}),
],
});
Because this filter is evaluated by the orchestrator from file contents alone, it never starts an agent for a non-matching repo. It is the right tool when the gate is a simple “does this repo have X” question.
Tier 2 — a real predicate over the checked-out tree
When the decision needs more than a JSON-path check, a workflow-level filter
predicate runs on an evaluating agent with the source repo checked out. It is
plain TypeScript with the tree on disk, so it can inspect anything. This one
runs the build pipeline only for repos that ship both a Dockerfile and a
ci:build script.
export default workflow('org-ci-build', {
on: [push({ repos: ['myorg/*'], branches: ['main'] })],
filter: async ({ sourceRepo }) => {
const root = sourceRepo.path;
if (!existsSync(join(root, 'Dockerfile'))) return false;
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
return typeof pkg?.scripts?.['ci:build'] === 'string';
},
jobs: [
job('build', {
runsOn: 'kici:os:linux',
steps: [
step('ci-build', async ({ $ }) => {
await $`pnpm run ci:build`;
}),
],
}),
],
});
The predicate reads the source repo through sourceRepo.path and returns a
boolean. Unlike the tier-1 filter, the logic is arbitrary and works with any
provider that can clone the repo.
Tier 3 — generate the job set from the repo itself
The most expressive level generates the jobs at runtime from the repo’s own
state. This workflow reads each source repo’s package.json and emits one job
per ci:* script it finds — so every repo runs exactly the CI it declares, all
defined once, centrally.
const perScript: DynamicJobFn = async ({ sourceRepo }) => {
const root = sourceRepo?.path ?? '.';
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
const ciScripts = Object.keys(pkg.scripts ?? {}).filter((s) => s.startsWith('ci:'));
return ciScripts.map((name) =>
job(name.replace(':', '-'), {
runsOn: 'kici:os:linux',
steps: [
step('run', async ({ $ }) => {
await $`pnpm run ${name}`;
}),
],
}),
);
};
export default workflow('org-ci-matrix', {
on: [push({ repos: ['myorg/*'], branches: ['main'] })],
jobs: [perScript],
});
This is the pattern a YAML matrix can only approximate: the set of jobs is not a
fixed list, it is computed from the repository in front of you, in real code. A
repo with ci:lint, ci:test, and ci:build gets three jobs; a repo with one
gets one — no configuration in the source repos, no fan-out you had to enumerate
by hand.
That covers the org’s half — one pipeline on every repo. The other half is how
each repo plugs its own tests into that pipeline and makes the org pipeline wait
for them. That is invokeSource, and it is the subject of the follow-up post,
one pipeline, every repo’s own tests.
If you want to compare this model against how other tools share CI, the comparison pages lay out the differences point by point.