Make your org-wide pipeline wait for each repo's own tests
An org-wide pipeline knows how to build and deploy, but not how each repo tests itself. invokeSource lets the org pipeline call each repo's own test workflow and wait for it, so a deploy never ships ahead of that repo's tests. A repo with no tests wired up fails the gate instead of passing it silently.
On this page
A shared pipeline and each repo’s tests end up racing
The org pipeline knows how to build and deploy. What it does not know is how any one repo tests itself: a service with a Postgres integration suite, a library with a fuzz run, a frontend with a Playwright pass. Those live in the repo, and they differ from repo to repo.
That leaves you two bad options. You let the repo’s own CI run independently, so the org’s deploy step and the repo’s tests fan out off the same push and race: the deploy can ship before the tests finish. Or you copy each repo’s test steps up into the org pipeline, and now the org pipeline is a pile of per-repo special cases that drifts the moment a repo changes how it tests.
The frame is right (one pipeline, every repo) but it has no extension point. The org owns the frame; each repo needs to fill in its own slot, and the frame needs to wait for it.
invokeSource: call each repo’s tests and wait
invokeSource is that extension point, and the
global workflows guide
covers it in full. A job in the org pipeline calls it, and instead of running
steps on an agent, the job emits a named event at the repo that triggered the
pipeline and waits for every workflow there that opted in. Each opted-in
workflow that fires becomes a job in the org run’s own graph, so you watch the
repo’s tests in the same run as the deploy, and the downstream jobs gate on them.
Here is the org side. The repo-tests job hands control back to the source repo,
and deploy needs it, so the deploy runs only after the repo’s tests pass:
export default workflow('org-ci-with-repo-tests', {
on: [push({ repos: ['myorg/*'], branches: ['main'] })],
jobs: [
// Hand control to the source repo: emit `org.repo-tests` there and gate on
// every workflow that subscribes. Required by default.
job('repo-tests', { invoke: invokeSource('org.repo-tests') }),
// Ships only after the source repo's own tests reported success.
job('deploy', {
needs: ['repo-tests'],
runsOn: ['kici:os:linux'],
run: async ({ $ }) => {
await $`./deploy.sh`;
},
}),
],
});
And here is a repo filling the slot. This workflow lives in an application repo,
not the org pipeline. It subscribes to the org’s event with kiciEvent, so when
the org pipeline invokes the repo, this runs and the gate waits for it:
export default workflow('repo-tests', {
on: [kiciEvent({ name: 'org.repo-tests' })],
jobs: [
job('unit', {
runsOn: ['kici:os:linux'],
run: async ({ $ }) => {
await $`pnpm test`;
},
}),
],
});
That’s the whole contract. The org names an event; a repo opts in by listening for it. Add the one file and your tests are in the org pipeline; the org pipeline never had to know they existed.
Required by default: a repo that forgot fails loud
Here is the part that matters at three in the morning. What happens when a repo never wired up its tests, with no subscriber listening for the event?
invokeSource is required by default. Zero subscribers is a failed gate, not a
pass. The deploy job needs the gate, so it does not run. A repo that quietly
dropped its test workflow does not sail through the org pipeline green; it stops,
and you find out because the pipeline is red, not because production is.
This is the opposite of the racing setup, where a repo with no tests looks exactly like a repo whose tests happened to pass. Here, “no tests ran” is a distinct, loud outcome.
Opt out on purpose with optional
Required-by-default is the safe default, but some repos have nothing to run: a
docs-only repo, a config repo. For those, the org pipeline sets
optional: true on the gate:
job('repo-tests', { invoke: invokeSource('org.repo-tests', { optional: true }) });
Now zero subscribers is a green skip, and the downstream still runs. The
difference is intent: with optional, “no tests” is a decision the org made, not
a repo that forgot. You choose which repos are allowed to have nothing to say.
Decide per repo at runtime
The gate can also be generated at runtime. A DynamicJobFn runs with the source
repo checked out, so it can read the repo and decide whether to invoke at all.
This variant reads the repo’s package.json and adds the gate only for a repo
that declares a ci:test script, so the org invokes each repo’s tests exactly
where they exist:
const gateWhereTestsExist: DynamicJobFn = async ({ sourceRepo }) => {
const root = sourceRepo?.path ?? '.';
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
const declaresTests = 'ci:test' in (pkg.scripts ?? {});
// A repo that declares its own tests gets an invoke gate; one that does not is
// left un-gated rather than failed.
return declaresTests ? [job('repo-tests', { invoke: invokeSource('org.repo-tests') })] : [];
};
export default workflow('org-ci-matrix-repo-tests', {
on: [push({ repos: ['myorg/*'], branches: ['main'] })],
jobs: [gateWhereTestsExist],
});
This is plain TypeScript against the tree on disk, so the rule can be anything you can express in code: gate the repos that ship a Dockerfile, invoke a different event for a repo tagged one way versus another, read a repo-local config file and branch on it. The org pipeline decides per repo what to summon, and the repos decide what to answer with.
Reading the results
An invoked run can report outputs, and they cross back to the downstream job as
ctx.needs['repo-tests'].result: coverage numbers, a build id, whatever the
repo’s tests emit. Secret outputs stay masked and never cross the boundary, so a
repo can report a token to its own steps without leaking it into the org pipeline.
The org gets exactly what the repo chose to publish, and nothing it did not.
When you don’t need invokeSource
- A single repo. A repo’s own workflow can already make its deploy job
needsits test job. The extra hop through an event buys nothing. - Tests that are the same in every repo. If every repo runs
npm testthe same way, the global workflow can run those tests itself, with jobs generated per source repo.invokeSourceearns its place when each repo tests itself differently. - A slow repo suite holds the org deploy. The gate waits for every run it
summoned, and a run held for an approval waits as long as the hold lasts. Give
the gate job a
timeout, in milliseconds, so the wait is bounded; the guide shows it next to the other job options.
Set it up
The steps use the event name org.repo-tests from the snippets above.
- 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 KiCI receives your GitHub org’s events. Install the GitHub App it uses on the whole org, or on the workflow repo plus every repo the org pipeline’s
repos:glob covers. - Set up global workflows. Follow the steps in the global-workflows post from step 2: switch global workflows on, and set up a workflow repo that holds the org pipeline.
- Pick the event name. It’s the contract between the org and every repo,
so choose one and keep it. Names that start with
kici.or__are reserved, andinvokeSource()rejects them when you compile. - Add the gate to the org pipeline. In the workflow repo’s
.kici/workflows/, add a job withinvoke: invokeSource('org.repo-tests')and make every job that must wait for the testsneedsit, as the first snippet does. Pass{ optional: true }if some repos may have nothing to run. Give the gate job atimeoutif a repo’s test run can be held for an approval. - Compile and push the org pipeline. Run
kici compile, commit the workflow with.kici/kici.lock.json, and push to the workflow repo’s default branch. - Opt each repo in. In each application repo that should report its tests,
run
npx kici initif it has no.kici/folder yet. Add a workflow that subscribes withkiciEvent({ name: 'org.repo-tests' }), like the second snippet. It’s an ordinary workflow in that repo, so it can bind the repo’s own contexts and secrets. Runkici compile, commit it with.kici/kici.lock.json, and push to that repo’s default branch. KiCI registers the subscription only from the default branch’s lock file, and until it does, the gate finds no subscriber in that repo. - Push to an opted-in repo and watch the run. Push to
mainin that repo. The org run shows the repo’s workflow as a job underrepo-tests, anddeploystarts only after it passes. A repo with no subscriber shows a failedrepo-testsgate instead, or a green skip when the gate is optional.
Gate your org pipeline on each repo's own tests
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.