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.
- 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. - 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 registerrepos:workflows at all. Turn it on before step 5’s push, or push again after. - Set up the workflow repo. Run
npx kici initinmyorg/ci-pipelinesif 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. - Write the workflow. Copy the level you need into the workflow repo’s
.kici/workflows/and changemyorg/*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. - 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. - Push to a covered repo and watch the run. Push to
mainin 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 listshows 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.