Use GitHub Actions minutes as burst capacity for your CI
A one-shot KiCI agent is a plain Node process that dials your orchestrator, runs one job and exits, so anything that can start a process can host one. GitHub Actions can: this post turns the Actions minutes you already have into burst capacity for KiCI jobs. You inherit GitHub's queue and limits, so it also covers which jobs belong there.
On this page
- Bursting CI usually means booting machines you own
- A KiCI agent is a process, not a server
- GitHub Actions as the pool
- Where each file lives
- The credential you can safely put in a dispatch input
- Teardown you get for free
- The trade-off: you inherit GitHub’s queue and limits
- The same pattern on GitLab CI, Nomad or a spare box
- Set it up
Bursting CI usually means booting machines you own
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.
A KiCI agent is a process, not a server
Here’s what a KiCI agent is: a plain Node process that opens one outbound WebSocket to your orchestrator and runs the jobs it’s handed. In one-shot mode it runs a single job 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, with the same kiciEvent()
trigger you’d use for anything else. The
workflow-driven autoscaling guide
covers the event contract. What comes up, and how, is the one thing left open on
purpose.
GitHub Actions as the pool
The pool most teams already have is GitHub Actions. Its runner queue is elastic, a run is already an ephemeral, one-shot sandbox (the exact shape of a scale-to-zero agent), and the minutes are free up to your plan’s quota on private repos, and free outright on public repos using standard runners.
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, with 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
# A one-shot agent registers, runs one job and exits, so a run that is still
# alive after this long is wedged (an agent the orchestrator refused, a job
# that never dispatched) and must release the runner rather than bill the
# default six hours. Raise it if your jobs legitimately run longer.
timeout-minutes: 15
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@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.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 rather than 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. The bundle vendors its own Node, so that path
skips the Node setup. KICI_SCALER_MANAGED and a zero KICI_SCALER_IDLE_TIMEOUT
are the whole contract: register, run one job, 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]
Where each file lives
The pieces live in different places, and the GitHub repos do different jobs:
- The workflow repo (
myorg/infra) holds the provisioning and teardown workflows in.kici/workflows/. They’re ordinary workflows, not global ones. The scaler’sprovisioningTargetsnames this repo, and KiCI delivers the scale-up and scale-down events only to workflows registered there. KiCI has to receive its pushes, like any repo it runs. - The runner repo (
myorg/ci-runners) holdskici-agent.ymlin.github/workflows/. Its Actions minutes are the ones you spend. KiCI never needs its pushes: the provisioning workflow dispatches runs in it through GitHub’s API. scalers.yamlsits with your orchestrator, not in either repo.
Your application repos don’t change, apart from the label: a job that may wait
in GitHub’s queue says runsOn: ['github-actions'].
The credential you can safely put in a dispatch input
There’s a real hazard hiding in “dispatch a workflow with inputs.” GitHub treats
workflow_dispatch inputs as ordinary event data: the run reads them from
github.event.inputs,
and GitHub’s security guide says to
mask any sensitive value
that isn’t a GitHub secret. Put an agent token in an input, and one stray echo
writes a live credential into the run’s log.
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 caps every job on a hosted runner at 6 hours as 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, because 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 trade-off: you inherit GitHub’s queue and limits
Compute you already pay for 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 before the job even starts, and GitHub discards a queued run that no hosted runner picks up within 45 minutes. If you need an agent up in under a second, this is the wrong pool.
- The minutes are GitHub’s, and so are the prices. Past your plan’s included minutes, a private repo pays GitHub’s per-minute rate: $0.006 a minute for a standard Linux runner, as of September 2026.
- Your fan-out ceiling is GitHub’s concurrency limit, not KiCI’s reservation math. Standard hosted runners run up to 20 concurrent jobs on Free, 60 on Team and 500 on Enterprise.
- You get best-effort placement. The example asks for
ubuntu-latest, with no say over instance class or region, and a GitHub incident is a KiCI capacity incident.
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 need to finish sometime) points at the GitHub pool. Same orchestrator, different label. You decide which jobs are allowed to wait in a queue.
The same pattern on GitLab CI, Nomad or a spare box
None of this is specific to 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 small 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 trade-off carries over too: each engine you borrow brings its own queue and limits.
Set it up
Let your coding agent do it. Every step below is a file to write or a
command to run, which is the kind of work a coding agent is good at. Give yours
this post’s URL and ask it to set up a GitHub Actions burst pool by following
these steps. For the SDK details, point it at
llms.txt or pipe kici docs llm sdk into its
context.
The steps use myorg/infra for the workflow repo and myorg/ci-runners for the
runner repo. Every file shown above is in
examples/github-actions-autoscale/,
ready to copy.
-
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 pushes from your GitHub App reach the orchestrator.
-
Create the workflow repo. Create a repo such as
myorg/infraand install the quickstart’s GitHub App on it, so KiCI receives its pushes. Clone it and runkici initin the clone. That creates.kici/, with thepackage.jsonthat declares@kici-dev/sdk. Delete any starter workflow it adds that you don’t want to run. -
Make the orchestrator reachable from GitHub’s runners. A hosted runner dials the orchestrator’s agent endpoint (
/ws) from GitHub’s network, so expose that endpoint at an address the runners can reach, such aswss://ci.example.com/ws. If it differs from the orchestrator’s ownKICI_ORCHESTRATOR_URL, set it asorchestratorUrlon the scaler entry in step 7. -
Pick the agent that runs the provisioning workflow. The provisioning workflow runs on an agent too, and it can’t wait for the pool it’s growing. The quickstart’s scaler already spawns one: in both KiCI workflows, you’ll change
runsOn: ['default']to that scaler’s labels,['linux', 'container']for the Docker quickstart or['linux', 'bare-metal']for bare metal. Those agents keep their default roles, so they also pack the workflow’s source before the job runs. -
Add the runner workflow to the runner repo. Commit
kici-agent.ymlto.github/workflows/kici-agent.ymlon the runner repo’s default branch. GitHub dispatches only a workflow file that’s on the default branch. The provisioning workflow dispatches refmain, so changeGH_REFin it if your default branch has another name. -
Store the token in a
github-actionscontext. Create a fine-grained GitHub token with Actions: read and write on the runner repo. Then, where you runkici-adminagainst your orchestrator, create the context, store the token in its scope, and point it at the runner repo:kici-admin context create --org <orgId> --name github-actions kici-admin secret set <orgId> github-actions GITHUB_DISPATCH_TOKEN --prompt kici-admin context bind --org <orgId> --env github-actions --scope github-actions kici-admin variable set <orgId> github-actions GITHUB_RUNNER_REPO --value myorg/ci-runnersLeave the context’s protection rules off. An approval hold on it would hold every scale-up, and the agents would never appear.
-
Declare the
eventscaler. Open thescalers.yamlthe quickstart created: next todocker-compose.yamlfor Docker, or~/.config/kici/scalers.yamlfor bare metal. Add thegithub-actionsentry shown above underscalers:, next to the quickstart’s own.provisioningTargetsis the workflow repo from step 2, written asowner/repo. Load it withkici-admin scaler reload. It checks the file and applies all of it or none of it, on the orchestrator it points at and every orchestrator connected to it, and prints what changed or why it refused the file. Here it printsadded: github-actions. -
Add the two KiCI workflows to the workflow repo. In the clone from step 2, save
provision.workflow.tsas.kici/workflows/github-actions-provision.tsandteardown.workflow.tsas.kici/workflows/github-actions-teardown.ts. Make therunsOnchange from step 4 in both. The workflow repo then looks like this:myorg/infra/ └── .kici/ ├── package.json ├── workflows/ │ ├── github-actions-provision.ts │ └── github-actions-teardown.ts └── kici.lock.json # written in the next step -
Compile and push to the default branch. From the root of the clone:
kici compile git add .kici/ git commit -m "ci: add GitHub Actions burst pool workflows" git push origin mainKiCI registers the scale-up and scale-down subscriptions only from the default branch’s lock file, so push to that branch (
mainhere). Until that push, a scale-up reaches no one. Confirm both workflows registered withkici-admin registration list --repo myorg/infra: it listsgithub-actions-autoscale-provisionandgithub-actions-autoscale-teardown. -
Send jobs to the pool and watch them. In any repo KiCI runs, give a job you’re happy to let wait in GitHub’s queue
runsOn: ['github-actions'], and push it. That repo needs no other change. Akici-agent <agent-id>run appears in the runner repo’s Actions tab. It registers, runs the job, and finishes. If no run appears, the provisioning workflow’s own run shows the dispatch error, andkici-admin diagnosenames the failure on thescaler:github-actionsrow.
The machine that runs a KiCI job can be anything that runs a process, and the TypeScript that brings it up is yours.
Burst your CI onto GitHub Actions minutes
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.