KiCI

Run one CI pipeline across every repo in your org

Rolling one CI policy out to forty repos usually means forty pull requests, one file in each. A KiCI global workflow lives in a single repo and runs on every repo its repos: glob matches, with nothing added to them. This post covers the guardrails that make that safe, and the ways to adapt the one pipeline to each repo, from a one-line filter to generated jobs.

On this page

Sharing CI today means a file in every repo

Say you run forty repositories and you want the same test-and-build policy on all of them. The usual answer is to factor the shared steps into a reusable unit, then go to each of the forty repos and add a file that calls it. A GitHub Actions workflow calls a reusable workflow with uses:, a GitLab pipeline pulls shared YAML in with include:, and a Buildkite pipeline adds plugins to its own steps. The shared logic lives in one place, but every repo still opts in by carrying a file that points at it.

That’s 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.

Some paid plans can push jobs from the top, as a compliance feature. GitLab Ultimate’s pipeline execution policies enforce CI jobs on projects, even ones with no CI file. GitHub rulesets can require a workflow from a central repo to pass before a pull request merges. Outside those settings, sharing CI still means one file per repo.

Global workflows: one repo pushes CI to all of them

A KiCI global workflow turns this around. 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’s live everywhere on the next push.

The guardrails are built for that blast radius. Global workflows stay off until the orchestrator’s operator switches them on. Each org then governs them on two independent axes: an allow-list of which repos may author org-wide automation, and a deny-list of which source repos may trigger it. Secrets follow the same logic. A global job binds contexts one job at a time, and only the jobs that ask for them get them. The context rules are checked as the workflow repo’s, not the repo that pushed, so a context you limited to another repo stays out of reach of your ci-pipelines repo. Here’s the catch: a job that binds a context also runs whatever the source repo’s code does, so keep the source repo’s npm install and tests in jobs that bind nothing, and save the secrets for the jobs that publish or deploy. If a global workflow fires on pull requests, set minimumTrust on the contexts it uses, so a fork’s pull request waits for a human before it gets anywhere near them.

One pipeline for every repo would be a blunt tool if it ran the same way everywhere. It doesn’t have to: a global workflow can adapt to each repo, at three levels, from a one-line declarative gate to jobs generated from the repo.

Skip the repos that don’t need it with a content filter

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 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 the orchestrator evaluates this filter from file contents alone, it never starts an agent for a non-matching repo. It’s the right tool when the gate is a simple “does this repo have X” question.

Decide per repo with a TypeScript predicate

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’s 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 content filter, the logic is arbitrary, and it works with any provider that can clone the repo.

Generate the jobs from each repo’s own scripts

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],
});

A static YAML matrix lists its entries up front. Here the set of jobs 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, with no configuration in the source repos and no fan-out you had to list by hand.

When a global workflow is the wrong tool

  • Repos that have little in common. If every repo builds and deploys in its own way, one shared pipeline turns into a pile of per-repo branches. Keep per-repo workflows there, and use a global workflow for the policy that’s the same everywhere: lint, a security scan, a license check.
  • One bad change breaks CI on every repo at once. That’s the other side of changing the policy in one place. Roll a change out on a narrow repos: glob first, such as a single repo named in full, then widen it. The narrowing guide covers the other ways to limit where a global workflow runs.

Set it up

The steps call the repo that holds the org’s pipelines the workflow repo, myorg/ci-pipelines.

  1. Sign up and bring up an orchestrator. Create a free account at app.kici.dev and follow the 5-minute quickstart through Part 2, so an orchestrator is running and receives your GitHub org’s events. Install the GitHub App it uses on the whole org, or on the workflow repo plus every repo your repos: glob should cover.
  2. Switch global workflows on. The orchestrator’s operator runs kici-admin cluster-settings set --global-workflows-enabled true. The switch is off by default, and while it’s off KiCI doesn’t register repos: workflows at all. Turn it on before step 5’s push, or push again after.
  3. Set up the workflow repo. Run npx kici init in myorg/ci-pipelines if it has no .kici/ folder yet. To keep other repos from shipping org-wide automation, add it under Settings → Global workflows → Allowed author repos in the dashboard.
  4. Write the workflow. Copy the level you need into the workflow repo’s .kici/workflows/ and change myorg/* to your org. None of the three examples needs a secret. If you add a job that does, bind the context on that job alone and keep the source repo’s install and test steps in jobs that bind nothing. Then limit the context to the workflow repo, so no source repo can bind it from its own workflows: kici-admin context set-policy --org <org-id> --env <context> --repo-patterns '["myorg/ci-pipelines"]'. If the workflow also fires on pull requests, add --minimum-trust trusted.
  5. Compile and push to the default branch. Run kici compile, commit the workflow with .kici/kici.lock.json, and push to the workflow repo’s default branch. KiCI registers global workflows only from that branch’s lock file, so a push to a feature branch changes nothing yet.
  6. Push to a covered repo and watch the run. Push to main in any repo the glob matches. The run shows up in the dashboard under that source repo, with a Defined in row naming the workflow repo. If nothing runs, kici-admin registration list shows whether the workflow registered, and the troubleshooting table maps each symptom to its cause.

That’s one pipeline on every repo in the org, with nothing to merge into any of them.

Put one pipeline on every repo in your org

Free plan. Full features on the hosted dashboard at no cost. You run the orchestrator and agents on your own machines, and the dashboard never receives your source or secrets. KiCI is in public beta. Pin versions for production.

About the author

· Founder of KiCI

Alberto builds KiCI and runs it for KiCI's own production deployments, after a career building and operating the deployment systems other teams depend on.