KiCI

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 needs its test job. The extra hop through an event buys nothing.
  • Tests that are the same in every repo. If every repo runs npm test the same way, the global workflow can run those tests itself, with jobs generated per source repo. invokeSource earns 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.

  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 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.
  2. 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.
  3. 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, and invokeSource() rejects them when you compile.
  4. Add the gate to the org pipeline. In the workflow repo’s .kici/workflows/, add a job with invoke: invokeSource('org.repo-tests') and make every job that must wait for the tests needs it, as the first snippet does. Pass { optional: true } if some repos may have nothing to run. Give the gate job a timeout if a repo’s test run can be held for an approval.
  5. 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.
  6. Opt each repo in. In each application repo that should report its tests, run npx kici init if it has no .kici/ folder yet. Add a workflow that subscribes with kiciEvent({ 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. Run kici 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.
  7. Push to an opted-in repo and watch the run. Push to main in that repo. The org run shows the repo’s workflow as a job under repo-tests, and deploy starts only after it passes. A repo with no subscriber shows a failed repo-tests gate 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.

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.