KiCI

2026-08-14 · KiCI

One repo, every repo: org-wide CI with global workflows

Every other tool makes you add a file to all forty repos. KiCI global workflows put one glob in one repo and run everywhere — with zero files added to the source repos.

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.