The problem: bursting CI means owning the machines
You want CI capacity that grows when the queue backs up and shrinks when it drains. Today you pick between two costs. Reserve the machines and you pay for the peak all month, even at 3am when nothing runs. Or wire up a cloud-provisioning integration — an API to boot a VM, credentials to inject, a lifecycle to babysit, a reaper to make sure a crash doesn’t leave an instance billing you for a week.
Both cost you the same assumption: bursting means booting a server you own and operate. That assumption is the expensive part.
The reframe: a KiCI agent is a process, not a server
Here’s what a KiCI agent actually is: a plain Node process that opens one outbound WebSocket to your orchestrator, runs the job it’s handed, and exits. It needs a Node runtime, a network path out, and nothing else. It does not care what started it.
So “add capacity” doesn’t have to mean “boot a VM.” It can mean “ask something that can already run a process to run one.” And you have systems that do exactly that, sitting idle between builds: your CI runners.
KiCI’s event scaler is built for this. It makes no cloud calls of its own. When
demand rises it emits a reserved kici.scaler.scale-up event; when an agent
should go away it emits kici.scaler.scale-down. You consume those in a
provisioning workflow you write in TypeScript — the same kiciEvent() trigger
you’d use for anything else. What comes up, and how, is the one thing left open
on purpose.
GitHub Actions as the pool
The cheapest pool most teams already have is GitHub Actions. Its runner queue is elastic, its minutes are free up to a point, and a run is already an ephemeral, one-shot sandbox — the exact shape of a scale-to-zero agent.
Here’s the provisioning workflow. It subscribes to the scale-up event and
dispatches a kici-agent.yml run in a GitHub repo:
import {
workflow,
job,
step,
kiciEvent,
SCALER_EVENT_NAMES,
ScalerScaleUpPayload,
} from '@kici-dev/sdk';
/** Must match the `name:` of the `event` scaler in your `scalers.yaml`. */
const SCALER_NAME = 'github-actions';
/** The one-shot runner workflow in your runner repo, and the ref to dispatch. */
const GH_WORKFLOW = 'kici-agent.yml';
const GH_REF = 'main';
export default workflow('github-actions-autoscale-provision', {
on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleUp, match: { '$.scalerName': SCALER_NAME } })],
jobs: [
job('provision', {
runsOn: ['default'],
// Binds the `github-actions` context, which carries both the dispatch
// token and this integration's two settings. Load-bearing: the job option
// is `context`, and an unrecognised key is DROPPED at compile time rather
// than rejected — so a typo here fails at run time on an unresolved
// secret, never at compile time.
context: 'github-actions',
steps: [
step('dispatch', async (ctx) => {
const p = ScalerScaleUpPayload.parse(ctx.rawPayload);
// Your runner repo, as `owner/repo`. Set it once on the context:
// kici-admin variable set <orgId> github-actions \
// GITHUB_RUNNER_REPO --value myorg/ci-runners
// or replace the fallback below in your own copy.
const runnerRepo = ctx.env.GITHUB_RUNNER_REPO ?? 'myorg/ci-runners';
// Optional. A release tag holding a `kici-admin agent package` tarball
// the runner installs instead of the published npm agent. Leave it
// unset unless you pin exact builds or your runners cannot reach npm.
const agentBundleRelease = ctx.env.GITHUB_AGENT_BUNDLE_RELEASE;
const inputs: Record<string, string> = {
claim_code: p.claimCode,
orchestrator_url: p.orchestratorUrl,
agent_id: p.agentId,
labels: p.labels.join(','),
};
if (agentBundleRelease) inputs.agent_bundle_release = agentBundleRelease;
const token = await ctx.secrets.get('GITHUB_DISPATCH_TOKEN');
const res = await fetch(
`https://api.github.com/repos/${runnerRepo}/actions/workflows/${GH_WORKFLOW}/dispatches`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
body: JSON.stringify({ ref: GH_REF, inputs }),
},
);
// GitHub answers a successful dispatch with 204 and no body.
if (!res.ok && res.status !== 204) {
throw new Error(`dispatch failed: ${res.status} ${await res.text()}`);
}
ctx.log.info(`Dispatched ${GH_WORKFLOW} in ${runnerRepo} for agent ${p.agentId}`);
}),
],
}),
],
});
That’s the whole KiCI side. The step body speaks plain HTTP to GitHub’s REST API — there’s no SDK to install. Everything specific to “how a machine comes up” lives there; swap it and you’ve pointed KiCI at a different pool.
The run it dispatches is a normal GitHub Actions workflow. It installs the agent and runs it once:
on:
workflow_dispatch:
inputs:
claim_code:
description: Single-use claim code the agent exchanges for its token
required: true
orchestrator_url:
description: Orchestrator WebSocket URL the agent connects back to
required: true
agent_id:
description: Agent id the orchestrator correlates the spawn with
required: true
labels:
description: Comma-separated label set the pending job needs
required: false
default: ''
agent_bundle_release:
description: >-
Release tag in this repo holding a `kici-admin agent package` tarball.
Leave empty to install the published agent from npm.
required: false
default: ''
jobs:
agent:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# Default path: the published agent, from npm.
- name: Set up Node.js
if: ${{ inputs.agent_bundle_release == '' }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Install the KiCI agent
if: ${{ inputs.agent_bundle_release == '' }}
run: npm install -g kici-admin
# Pinned path: a self-contained bundle your orchestrator produced with
# `kici-admin agent package`. It vendors its own Node, so it needs no
# setup-node. Use it to pin an exact agent build, or for runners that
# cannot reach npm. Prepending it to PATH keeps the run step below uniform.
- name: Install the KiCI agent (packaged bundle)
if: ${{ inputs.agent_bundle_release != '' }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE: ${{ inputs.agent_bundle_release }}
run: |
gh release download "$RELEASE" --repo "$GITHUB_REPOSITORY" \
--pattern 'kici-agent-linux-x64.tar.gz*' --clobber
sha256sum -c kici-agent-linux-x64.tar.gz.sha256
mkdir -p kici-agent-dist
tar xzf kici-agent-linux-x64.tar.gz -C kici-agent-dist
echo "$PWD/kici-agent-dist" >> "$GITHUB_PATH"
- name: Run the one-shot KiCI agent
env:
KICI_ORCHESTRATOR_URL: ${{ inputs.orchestrator_url }}
KICI_SCALER_CLAIM_CODE: ${{ inputs.claim_code }}
KICI_AGENT_ID: ${{ inputs.agent_id }}
KICI_LABELS: ${{ inputs.labels }}
# Managed mode + zero idle timeout: register, run one job, exit.
KICI_SCALER_MANAGED: '1'
KICI_EXECUTION_MODE: bare-metal
KICI_SCALER_IDLE_TIMEOUT: '0'
run: exec kici-agent
The agent runs directly on the runner — installed with npm, not wrapped in a
container — so job steps that need Docker use the runner’s own daemon. If your
runners can’t reach npm, or you want to pin an exact build, set
agent_bundle_release to a release holding a kici-admin agent package tarball
and it installs that instead — it vendors its own Node, so that path skips the
Node setup. Three env vars are the whole contract: managed mode, one job, then
exit.
And the scaler config that ties it together — an event scaler, no new backend
type:
version: 1
scalers:
- name: github-actions
type: event
maxAgents: 20
# Repo identifiers, NOT workflow names: the reserved events are delivered
# with `target.repos = provisioningTargets`, and the router filters
# registrations by repo. A workflow name here matches no registration and
# the scale-up reaches no subscriber at all.
provisioningTargets:
- myorg/infra
labelSets:
- labels: [github-actions]
The credential you can safely put in a log
There’s a real hazard hiding in “dispatch a workflow with inputs”: GitHub logs
workflow_dispatch inputs. Put an agent token in there and you’ve written a live
credential to a log a lot of people can read.
So KiCI never passes the token. The scale-up event carries a single-use claim code, and the agent redeems it itself — over the same WebSocket it has to open anyway — for a short-lived token that never leaves that connection. The claim code is safe to log: it’s one-time, short-lived, and useless once redeemed. Pass it through GitHub’s inputs, a cloud-init file, anywhere. The thing worth stealing never travels.
Teardown you get for free
The scary part of “boot compute on demand” is the instance nobody cleaned up. Here you mostly don’t have that problem: a GitHub Actions run ends when its one-shot agent exits, and GitHub’s own job timeout is a hard backstop. The run can’t outlive its job.
There’s still a teardown workflow, but it does less than the cloud version’s reaper. It only cancels a run GitHub has not yet marked finished, and only when the agent will never do useful work — it never started, or it went silent:
import {
workflow,
job,
step,
kiciEvent,
SCALER_EVENT_NAMES,
ScaleDownReason,
ScalerScaleDownPayload,
} from '@kici-dev/sdk';
/** Must match the `name:` of the `event` scaler in your `scalers.yaml`. */
const SCALER_NAME = 'github-actions';
const GH_WORKFLOW = 'kici-agent.yml';
/**
* The only two reasons that mean the run will never do useful work:
*
* spawn-timeout no agent ever registered against the spawn
* heartbeat-timeout the agent registered, then went silent
*
* Every other reason is left alone. `shutdown` in particular is what a HEALTHY
* one-shot agent emits when it exits after finishing its job — cancelling on it
* would kill a run that is already succeeding. An unrecognised reason falls
* through to doing nothing, which is always the safe default here: the run
* reaps itself.
*/
export const CANCELABLE: ScaleDownReason[] = ['spawn-timeout', 'heartbeat-timeout'];
export default workflow('github-actions-autoscale-teardown', {
on: [kiciEvent({ name: SCALER_EVENT_NAMES.scaleDown, match: { '$.scalerName': SCALER_NAME } })],
jobs: [
job('teardown', {
runsOn: ['default'],
// Binds the `github-actions` context. Required for BOTH halves: the
// dispatch token this job reads, and `GITHUB_RUNNER_REPO` below — context
// variables resolve only from the contexts a job binds, so an unbound
// teardown reads `undefined` for the repo.
context: 'github-actions',
steps: [
step('cancel-stranded-run', async (ctx) => {
const p = ScalerScaleDownPayload.parse(ctx.rawPayload);
if (!CANCELABLE.includes(p.reason)) {
ctx.log.info(`teardown reason=${p.reason} agent=${p.agentId} action=skip`);
return;
}
const runnerRepo = ctx.env.GITHUB_RUNNER_REPO ?? 'myorg/ci-runners';
const token = await ctx.secrets.get('GITHUB_DISPATCH_TOKEN');
const headers = {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
// `kici-agent.yml` sets its `run-name` to `kici-agent {agent_id}`,
// which is how a scale-down finds the run its agent belongs to. One
// page bounds it for most pools: the run is at most one spawn-timeout
// window old, so a pool creating fewer than 100 runs of this workflow
// inside that window always finds it here. A busier pool needs
// pagination. What decides is the age of the RUN — the list is newest
// first — not how fast the teardown follows the scale-down.
const listed = await fetch(
`https://api.github.com/repos/${runnerRepo}/actions/workflows/${GH_WORKFLOW}/runs?per_page=100`,
{ headers },
);
if (!listed.ok) {
throw new Error(`listing runs failed: ${listed.status} ${await listed.text()}`);
}
const body = (await listed.json()) as {
workflow_runs: Array<{ id: number; name?: string; status: string }>;
};
const run = body.workflow_runs.find((r) => r.name === `kici-agent ${p.agentId}`);
// Anything GitHub has not marked `completed` is still live — that
// covers `queued` and `in_progress` plus the pre-start states
// (`requested`, `waiting`, `pending`), which is exactly where a
// spawn-timeout run sits. Listing the live states instead would
// decline to cancel the case this workflow exists for.
if (!run || run.status === 'completed') {
ctx.log.info(
`teardown reason=${p.reason} agent=${p.agentId} action=skip (no live run)`,
);
return;
}
const cancelled = await fetch(
`https://api.github.com/repos/${runnerRepo}/actions/runs/${run.id}/cancel`,
{ method: 'POST', headers },
);
// 409 is GitHub refusing the cancel, most often because the run
// finished between the list above and this call. A teardown the
// orchestrator could not deliver is retried, so treating that as a
// failure would fail the workflow forever over a run that is already
// in the state the teardown wanted.
if (cancelled.status === 409) {
ctx.log.info(
`teardown reason=${p.reason} agent=${p.agentId} action=skip ` +
`(run ${run.id} not cancelable: 409, most often already completed)`,
);
return;
}
if (!cancelled.ok) {
throw new Error(`cancel failed: ${cancelled.status} ${await cancelled.text()}`);
}
ctx.log.info(
`teardown reason=${p.reason} agent=${p.agentId} action=cancel run=${run.id}`,
);
}),
],
}),
],
});
For a job that already finished, there’s nothing to cancel — the run is gone. Compare that to a cloud VM, where teardown is a delete call you’d better not miss.
The honest part: you inherit the pool’s SLA
Free compute you already have is a real win, and it comes with a real cost you should see clearly. When you borrow someone else’s pool, you inherit that pool’s guarantees. For GitHub Actions that means:
- Queue latency becomes your scheduling latency. A dispatched run waits for a hosted runner — seconds, sometimes minutes, before the job even starts. If you need an agent up in under a second, this is the wrong pool.
- The minutes are GitHub’s, and so are the caps. “Free” is the included tier. Past it you’re on GitHub’s per-minute pricing and your plan’s ceiling, and a big enough burst stops scaling when the cap hits.
- You get best-effort placement.
ubuntu-latest, no control over instance class or region, and a GitHub incident is a KiCI capacity incident. - Your fan-out ceiling is GitHub’s concurrency limit, not KiCI’s reservation math.
KiCI doesn’t paper over this. When a provision fails — GitHub down, dispatch rejected, minutes exhausted — the scaler reports the failure and backs off a pool that keeps failing, instead of hammering it. You see the problem; you don’t get a silent stall.
So this is a choice, and it’s per scaler. Latency-critical jobs point at a cloud scaler or a warm pool you keep hot. Bursty, tolerant work — nightly matrices, PR fan-out, background jobs that just need to finish — points at the free GitHub pool. Same orchestrator, different label. You decide which jobs are allowed to wait in a queue.
Not just GitHub Actions
None of this is really about GitHub. Look at what each piece needs:
- What KiCI brings is fixed and engine-independent: noticing demand, minting the claim code, handing the pending job to whatever registers, spawn and idle timeouts, and the backoff when a pool keeps failing.
- What you write is the one part that changes: the step body that asks your
engine to start a process with a few env vars. For GitHub it’s a
workflow_dispatch. For GitLab CI it’s a pipeline trigger. For Nomad it’s a batch job. For a spare box in the corner it’s a queueddocker run. - What the agent needs is tiny and the same everywhere: a Node runtime, a way out to the orchestrator, and a handful of env vars.
Any system that can run a process on request is a burst pool for KiCI. GitHub Actions is the one almost everyone already pays for. And the honesty carries over too: each engine you borrow brings its own SLA — same trade, different numbers.
Try it
The full pattern — the event scaler, the reserved scale-up and scale-down
events, and the provisioning and teardown workflows — is in the
workflow-driven autoscaling guide.
It builds on the same idea as the
cloud-autoscaling post:
the thing that used to be a driver you waited for is TypeScript you write — and
the machine that runs it can be anything that runs a process.