Inngest CI reference
Options, triggers, command methods, and limits for @inngest/ci.
Inngest CI is an Inngest Labs project: early, moving fast, and shaped by your feedback. What's Labs? APIs may change between 0.x releases.
createCi
createCi(inngest, options) returns the client you use to define pipelines, jobs, and matrices.
| Option | Type | Required |
|---|---|---|
github | githubApp(), githubToken(), or consoleReporter() | No |
machine | { vcpu?: 1 | 2 | 4 } | No |
runUrl | (ctx: { runId, functionId }) => string | No |
githubsets how pipelines report to GitHub. It defaults toconsoleReporter(), which prints checks to the Inngest logger. PassgithubApp()orgithubToken()to report checks on GitHub.machineis the default machine for every job.runUrlbuilds the link shown on checks.
ci.functions() returns your pipelines plus the functions CI needs behind the scenes: machine cleanup, cache refreshes, and GitHub check re-runs. Pass it to serve() or createServer().
ci.pipeline
A pipeline runs when something happens and calls jobs. It is one Inngest function, so every Inngest flow control option works on it.
import { github } from "@inngest/ci";
import { ci } from "./client";
export const pr = ci.pipeline(
{
id: "pr",
on: github.pullRequest(),
singleton: { key: "event.data.pull_request.number", mode: "cancel" },
concurrency: {
key: "event.data.repository.owner.login",
limit: 20,
scope: "account",
},
},
async ({ event }) => {
await test();
await deploy(event.data.pull_request.head.sha);
},
);
The handler receives event, events, runId, pipelineId, repo, attempt, and logger. repo is undefined for triggers that carry no repository, such as a cron.
| Option | Type | Required |
|---|---|---|
id | string | Yes |
on | A trigger, or an array of triggers | Yes |
check | false, or { name?, jobs? } | No |
machine | { vcpu?: 1 | 2 | 4 } | No |
repo | string | No |
idis unique in the app. It names the function, the run, and the check.check: falseturns off all checks.check: { jobs: false }keeps the pipeline check and drops the job checks.machineis the default machine for the pipeline's jobs. A job's ownmachineoverrides it.repo("owner/name") gives crons and manual runs a repository to check out.ci.pipeline()throws if it is not in that form, and, with GitHub credentials, resolves the repository, its default branch, and its head commit for cron and manual runs.- Flow control options:
concurrency,throttle,rateLimit,debounce,priority,singleton,idempotency,batchEvents,timeouts,cancelOn,retries,name, anddescription. See flow control.
Return ci.skip(reason) to end a run early. The check completes as success with the reason, so a required check never waits.
const touched = await changed("src/**", "package.json");
if (touched === false) {
return ci.skip("nothing that affects the build changed");
}
changed() reads the pull request files, or the push range, before any machine starts. When it cannot tell what changed, it returns true so nothing is skipped by mistake, and notes it on the check. That happens when the run has no repository or no GitHub credentials, when it has no pull request or push range (a cron, a manual run, a merge group, or a push that creates a branch), when a local run cannot find its base branch, and when a compare hits GitHub's 300-file cap.
Pass patterns as arguments, or an object with include and ignore. A file counts when it matches an include pattern and no ignore pattern. Without include, every file is included. Patterns support **, *, ?, and {a,b}.
if (await changed({ include: ["docs/**"], ignore: ["docs/**/*.png"] })) {
await docsSite();
}
Triggers
A trigger starts a pipeline and types its event.
| Trigger | Runs when |
|---|---|
github.pullRequest({ branches, types, repo }) | A pull request opens, is pushed to, or reopens. types changes the actions. |
github.push({ branches, tags, repo }) | Commits are pushed. Deleted branches are excluded. |
github.comment({ command, minPermission, repo }) | A comment starts with command. |
github.mergeGroup({ repo }) | The merge queue asks for checks. |
github.checkSuite({ branch, repo }) | A check suite completes. |
{ cron: "0 3 * * *" } | The schedule fires. |
ci.manual({ schema, pipelineId }) | An event named ci/manual.<pipelineId> arrives. Without pipelineId, any ci/manual.* event. |
Pass an array for several triggers. event.data is typed by the triggers you pass:
export const merged = ci.pipeline(
{
id: "merged",
on: github.pullRequest({ types: ["closed"] }),
},
async ({ event }) => {
if (event.data.pull_request.merged === false) {
return ci.skip("closed without merging");
}
await release();
},
);
pullRequest()defaults toopened,synchronize, andreopened.push()matchesbranchesandtagsby exact name, sotags: ["v*"]does not matchv1.0.0.- A pipeline has at most 10 triggers, and
pullRequest()uses one for each type. comment({ minPermission })checks the author after the run starts. A user without the permission gets a reply and a neutral check. With severalcomment()triggers, each command keeps its ownminPermission.- Several triggers give a union type. Narrow it with
"pull_request" in event.data. - A cron has no typed
event.data.ci.manual({ schema })types it from any Standard Schema validator, such as Zod.
ci.job
A job is a unit of work with its own machine and its own check. Call it like a function.
const test = ci.job("test", async () => {
await checkout();
await $`pnpm install`;
await $`pnpm test`;
});
const build = ci.job({ id: "build", machine: { vcpu: 4 } }, async () => {
await checkout();
await $`pnpm build`;
});
- A job has no return value. Call it for its commands and its check.
- Each call runs the job again, on a new machine with its own check. A second call to
testshows astest (2). To share one build between jobs, usefrom(). - The machine starts on the job's first command. A job with no commands never gets one.
- Jobs never share a machine. To reuse work, use
from(). - When a command fails, the job's check fails and the pipeline ends.
| Option | Type | Required |
|---|---|---|
id | string | Yes |
machine | { vcpu?: 1 | 2 | 4 } | No |
cache | { key?, refresh?, scope? } | No |
check | false, or { name? } | No |
keepOnFailure | Duration, such as "24h" | No |
keepOnFailure snapshots the machine when the job fails. The snapshot ID appears in the pipeline check's summary, under Kept machines. It takes a duration, but the duration is ignored today: the snapshot keeps the platform's default retention.
Durations such as "10m" or "1h30m" are parsed strictly, with the units ms, s, m, h, d, and w. A malformed value throws CiUsageError.
Commands
$`…` runs a command on the job's machine. A non-zero exit code throws CommandFailedError.
await $`pnpm test`;
await $`pnpm --filter ${pkg} test`;
await $`pnpm test ${bail && ["--bail", "1"]}`;
Each interpolated value becomes one argument with no quoting, unless it follows text with no space, as in --filter=${pkg}, where it joins that argument. Arrays spread into several arguments, and false, null, and undefined are dropped.
Options chain:
await $`pnpm test`.retries(2);
await $`pnpm lint`.nothrow();
await $`pnpm test`.env({ CI: "true" });
await $`pnpm test`.cwd("/work/app");
await $`pnpm test`.timeout("10m");
await $`pnpm exec playwright test`.as("e2e");
const sha = await $`git rev-parse HEAD`.text();
const tracked = await $`git ls-files`.lines();
const meta = await $`cat package.json`.json<{ name: string }>();
await $.sh`pnpm build && pnpm test | tee test.log`;
| Method | Does |
|---|---|
.retries(n) | Runs the command up to n more times on the same machine. Each attempt is a step. |
.nothrow() | Returns the result with its exit code instead of throwing. |
.env(vars) | Sets environment variables for this command. |
.cwd(path) | Sets the directory. The default is /work. |
.timeout(duration) | Throws CommandTimeoutError after the duration. |
.onTimeout(fn) | Runs fn when the timeout hits, then throws. |
.background() | Starts the command and returns a process with id, exited(), kill(signal?), and output({ tailBytes? }). |
.as(name) | Replaces the command text in the step name. |
.text(), .lines(), .json() | Returns stdout as a trimmed string, an array of lines, or parsed JSON. |
$runs without a shell.$.shruns/bin/sh -cand escapes interpolated values.- A result holds
exitCode,stdout,stderr,truncated, anddurationMs.stdoutandstderrkeep the last 64 KiB.durationMsis missing when the Sandbox API does not time the command, which today is any command with a timeout of 5 minutes or less. $outside a job throwsCiUsageError..background()returns once the process starts.kill()sendsSIGTERMunless you pass a signal number,output()reads the last 64 KiB by default, andexited()polls for exit. It ignores.retries(),.timeout(), and.nothrow().- Output arrives when the command ends. A
.timeout()of 5 minutes or less is exact. A longer one is approximate because Inngest polls for exit.
Any function can run commands. It uses the calling job's machine:
export async function install() {
await checkout();
await $`pnpm install --frozen-lockfile`;
}
const test = ci.job("test", async () => {
await install();
await $`pnpm test`;
});
Machines
A machine is a Sandbox: an ephemeral Linux microVM. Each job gets one on its first command.
vcpu | Memory |
|---|---|
| 1 | 1 GiB |
| 2 (default) | 2 GiB |
| 4 | 4 GiB |
Set machine on a job, on its pipeline, or on createCi for every job. The first one set wins in that order, and the default is 2 vCPUs.
- Inngest pauses a machine when its job passes, so a later
from()can snapshot it, and destroys every machine when the pipeline ends. - Only
$runs on the machine. The rest of your handler runs in your app. - Commands run in
/workby default, which is wherecheckout()puts the repository. checkout()clones the commit that triggered the run with a short-lived token that never appears in the trace. Locally it uploads your working tree.
checkout() options are ref, submodules, history ("shallow" or "full"), and path. history defaults to "shallow", which skips file contents until they are needed, and path defaults to /work. Commands after a checkout({ path }) run in that path. A local checkout uploads your working tree without its .git folder and ignores ref, submodules, and history. checkout() throws CiUsageError when the run has no repository, so set repo on the pipeline. On a machine that already has a checkout, such as one started from() a cached job, it moves that checkout to this run's commit and keeps ignored files like node_modules. In a local run, files you deleted since the snapshot stay.
from
from(job) starts the current job on a copy of another job's machine, like a Docker layer. The parent runs once, however many jobs start from it, and each child gets its own isolated copy. If the pipeline already called the parent directly, from() uses that run. from() shares a job's machine within one run. To reuse it across runs, give the parent a cache key.
const base = ci.job("base", async () => {
await checkout();
await $`pnpm install`;
});
const build = ci.job("build", async () => {
await from(base);
await $`pnpm build`;
});
const test = ci.job("test", async () => {
await from(build);
await $`pnpm test`;
});
Each job builds on the snapshot of the one before it. When a job's cache key is unchanged, Inngest skips the job and restores its saved machine. When a cached job's key changes, it runs again, and so does every cached job that starts from it, directly or through other cached jobs.
from()resolves once the copy is ready. Pass the parent's input as the second argument when it takes one.- Call
from()before the job's first command, and once per job. await base()runsbaseon its own machine.await from(base)runs it and then starts this job from where it finished.- Choose the parent at runtime with
await from((await changed("docs/**")) ? docsBase : base). - If a snapshot is not available, the job runs the parent's commands again on its own machine. Every child repeats the parent's work in that case.
Extra machines
sandbox(name, { vcpu }) creates another machine for the job right away, with the job's machine settings unless you pass others. It is destroyed with the pipeline. Its steps are named <job> › <name> › machine.
const verify = ci.job("verify", async () => {
await checkout();
const clean = await sandbox("clean", { vcpu: 1 });
await Promise.all([
$`pnpm test`,
clean.$.sh`npm install -g my-cli@next && my-cli --version`,
]);
});
This runs the tests on the job's machine while a clean machine checks that the published next build installs.
- Plain
$still runs on the job's own machine. - An extra machine starts empty.
checkout()runs on the job's machine only. ExtraMachinehas$,$.sh,waitForPort(), andwaitForHttp().waitForPortandwaitForHttpare also top-level exports of@inngest/ci. They run on the job's own machine:import { waitForPort, waitForHttp } from "@inngest/ci".waitForPort(port, { timeout })andwaitForHttp(url, { timeout, status })wait up to2mby default, andwaitForHttpwaits for status200. When they give up they throwCommandFailedError.
Machines cannot reach each other yet. Run a server or database your tests call on the job's own machine, in the background, and use 127.0.0.1:
await $`pnpm start`.background();
await waitForPort(3000);
await $`pnpm exec playwright test`;
| If the work... | Use |
|---|---|
| Is independent | Separate jobs |
| Builds on earlier work | Separate jobs with from() |
| Needs a second machine alive at the same time | One job with sandbox() |
ci.matrix
ci.matrix runs a job for every combination of its axes. Each combination is its own job, with its own machine and check.
export const nightly = ci.pipeline(
{ id: "nightly", on: { cron: "0 3 * * *" }, repo: "my-org/my-app" },
async () => {
await compat();
},
);
const compat = ci.matrix(
{
id: "compat",
axes: { node: ["20", "22", "24"], db: ["sqlite", "postgres"] },
exclude: [{ node: "20", db: "postgres" }],
concurrency: 3,
},
async ({ node, db }) => {
await from(base);
await $`npx -y node@${node} --test`.env({ TEST_DATABASE: db });
},
);
Call compat() to run every combination, or compat({ node: "22" }) to run only the matching ones.
- Job IDs come from the values, such as
compat (node:22, db:sqlite). Adding a value does not change the others. excluderemoves combinations andincludeadds extra ones.concurrencylimits how many run at once. The default is all of them.failFastis off by default. When it is on, the first failure ends the matrix, and running combinations are not cancelled.machineandcacheaccept a value or a function of the combination.checkapplies to every combination.- With
failFastoff, failures are thrown together as anAggregateErrorafter every combination finishes.
Split files across jobs
shard({ total, index, files }, fn) calls fn with this shard's share of files: every total-th file, starting at index. Use it in a matrix to split a test suite across machines. It throws CiUsageError outside a job.
import { $, from, shard } from "@inngest/ci";
const unit = ci.matrix(
{ id: "unit", axes: { shard: ["0", "1", "2", "3"] } },
async ({ shard: index }) => {
await from(base);
const tests = await $.sh`find src -name '*.test.ts'`.lines();
await shard({ total: 4, index: Number(index), files: tests }, async (files) => {
await $`pnpm vitest run ${files}`;
});
},
);
Caching
A job's cache skips the job when nothing it depends on has changed. The input a job is called with is part of its cache identity, so the same key with a different input is a different entry.
const base = ci.job(
{
id: "base",
cache: {
key: files("pnpm-lock.yaml", ".nvmrc"),
refresh: [{ cron: "0 3 * * *" }],
},
},
async () => {
await checkout();
await $`pnpm install`;
},
);
keyis what the job depends on. If the key is unchanged since the last successful run, the job does not run. A job that started a machine is restored from its snapshot, andfrom(base)clones the saved machine.- A restored machine keeps the checkout from the commit it was built on. Call
checkout()again afterfrom()to move to this run's commit. It keeps installed dependencies and build output. refreshtakes triggers that rebuild the cache ahead of time, so pull requests do not pay for it. Setrepoon a pipeline so a cron has a repository to check out.scopeis"branch"by default. A pull request reads entries from its base branch and writes its own, and its scope can never collide with a branch name."global"shares one entry set.
A key is files(), a string, an array of them, or an async function that returns a string:
key: files("pnpm-lock.yaml")
key: files("migrations/**", "seeds/**")
key: [files("go.mod", "go.sum"), "go1.25"]
files()hashes the matched files in the git tree for the run's commit. Locally it hashes the working tree.- A cached job also depends on the cached jobs it starts
from(). When one of them changes its key, or the input it was called with, the child's entry is stale and the job runs again. This holds up the whole chain of cached parents. An uncached parent has no key and never invalidates its children, so add its files to the child's key when the child depends on them. - The first run of a job always misses.
- A cached job without a machine is skipped. Reused jobs show as passed.
- Do not cache tests that call the network.
Checks and reports
Checks are automatic: one for the pipeline and one for each job. Require the pipeline check in branch protection.
| State | Check |
|---|---|
| Running | In progress, with the current command |
| Retrying a command | In progress, with the attempt count and the last error |
| Run will be retried | In progress, with Retrying (attempt N of M) |
| Passed | Success, with the duration |
| Reused from cache | Success, with Restored, built … or Passed at <sha>, no changes since |
| Failed | Failure, with the command, the output tail, and annotations |
| Timed out | Timed out |
| Cancelled by a failure elsewhere | Cancelled |
Returned ci.skip() | Success, with the reason |
| Job never called | No check |
report adds to the current job's check. Outside a job it targets the pipeline check.
await report.summary(`Coverage: **${coverage}%**`);
await report.annotate([
{ path: "src/queue.ts", line: 42, message: "Flaky retry here" },
]);
report.summary(markdown)stacks sections. Inngest truncates a summary at 65,000 bytes, under GitHub's limit of 65,535 bytes, and adds a note pointing to the trace.report.annotate(annotations)puts annotations on the diff. Inngest sends them in batches of 50, the GitHub limit per request. Entries without apathor amessageare dropped.- While a run will be retried, its checks show
Retrying (attempt N of M)and complete only on success or the final attempt. Command failures, timeouts, usage errors, and matrix failures made only of those are deterministic, so they fail once. - A GitHub "Re-run" on a pipeline or job check, or "Re-run all" on the check suite, sends a new event for the checked commit, so pipelines on pull request and push triggers run again. On a pull request it sends a
pull_request.synchronize. Otherwise it sends apush, only when the branch's head is the checked commit. Pipelines on other triggers do not run again. Passed jobs are reused only when they have acachekey.
Run on GitHub
In production, pipelines start from GitHub webhooks and report checks as a GitHub App.
-
Create a GitHub App with these repository permissions: Checks (read and write), Commit statuses (read and write), Contents (read, or read and write to create releases or use
github.forcePushRef()), Actions (read, forgithub.waitForWorkflow()), Pull requests (read and write), Issues (read and write), Metadata (read). -
Subscribe the app to these events: push, pull request, check run, check suite, issue comment, merge group, and workflow run.
-
In the Inngest dashboard, create a webhook and paste the output of
githubWebhookTransforminto its transform. Use the webhook URL as the app's webhook URL.node --input-type=module -e "import { githubWebhookTransform as t } from '@inngest/ci'; console.log(t)" -
Install the app on your repositories.
-
Set
GITHUB_APP_IDandGITHUB_APP_PRIVATE_KEY, then pass the provider tocreateCi.
ci/client.ts
import { Inngest } from "inngest";
import { createCi, githubApp } from "@inngest/ci";
export const inngest = new Inngest({ id: "my-app" });
export const ci = createCi(inngest, {
github: githubApp({
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
}),
});
githubApp()reports checks with the Checks API.githubToken()reports commit statuses instead, with no summaries or annotations.githubApp()with no arguments readsGITHUB_APP_IDandGITHUB_APP_PRIVATE_KEY, and falls back toGITHUB_INSTALLATION_IDwhen an event carries no installation.githubToken()readsGITHUB_TOKEN.- In dev mode checks print to the terminal. Set
INNGEST_CI_GITHUB=liveto send real checks from the Dev Server.
Inngest does not verify webhook signatures yet. Keep the webhook URL secret.
GitHub API and helpers
github.rest exposes every Octokit REST method, with each call recorded as a step.
const release = ci.job("release", async () => {
const created = await github.rest.repos.createRelease({
tag_name: "v1.4.0",
generate_release_notes: true,
});
await github.rest.git.updateRef({
ref: "heads/next",
sha: github.repo().sha,
force: true,
});
console.log(created.html_url);
});
ownerandrepodefault to the run's repository.- A call returns the response
data. Results are JSON, so dates are strings. Keep them small. - A rate limit retries after the reset time, other 4xx errors do not retry, and 5xx errors retry.
- Streaming methods are not supported. Use
github.octokit()insidestep.run. - Inside
step.run, or outside a pipeline, calls run directly. - Use
github.rest.with({ id })to give a call a step ID of your choice.
| Helper | Does |
|---|---|
github.stickyComment(key, body) | Creates one pull request comment and updates it on later runs. |
github.upsertPullRequest({ head, base, title, body }) | Opens a pull request or updates the open one. |
github.forcePushRef(ref, sha) | Moves or creates a branch or tag. |
github.canUser(login, permission) | Checks a user's permission on the repository. |
github.waitForChecks({ names, sha, timeout }) | Waits for other checks on a commit, with no machine. The default timeout is 1h. |
github.waitForWorkflow({ workflow, sha, timeout }) | Waits for a GitHub Actions workflow run. |
github.paginate(method, params) | Fetches every page of a list method, typed. |
github.graphql(query, variables) | Runs a GraphQL query. |
github.repo() | Returns owner, repo, sha, number, and ref. |
github.token() | Returns a short-lived installation token. step.run only. |
github.octokit() | Returns a plain Octokit client. step.run only. |
github.waitForChecks() can miss a check that finishes between the lookup and the wait. That name then times out.
Run metadata
Inngest tags every pipeline run with metadata of the kind userland.inngest-ci, so it can tell a run is a CI run. Commands, output, and secrets are never recorded.
When the run starts:
{
"package": "@inngest/ci",
"version": "0.1.0",
"local": false,
"repo": "my-org/my-app",
"ref": "feature",
"sha": "abc1234",
"pullRequest": 7
}
local is true for any run on the Dev Server. repo, ref, sha, and pullRequest are left out when the run has none. apis holds every counter listed below, including zeros.
When the run ends:
{
"conclusion": "success",
"jobs": { "total": 3, "passed": 2, "failed": 0, "cached": 1, "skipped": 0, "cancelled": 0 },
"apis": { "from": 1, "matrix": 1, "cache": 1, "commands": 6, "githubRest": 0 }
}
apis counts the run's calls to from, matrix, cache, sandbox, checkout, changed, report, githubRest, githubHelpers, waitForChecks, waitForWorkflow, waitFor, commands, background, shard, and skip.
CI's own steps also carry a { job, kind } tag, where kind is job, check, or cache.
Steps inside jobs
A job is an Inngest function body, so the full Inngest step API works inside it. Import step from inngest.
import { step } from "inngest";
const migrations = ci.job("migrations", async () => {
const db = await step.run("create-db-branch", async () => {
return neon.branches.create({ parent: "main" });
});
await checkout();
await $`pnpm install`;
await $`pnpm db:migrate`.env({ DATABASE_URL: db.url });
await step.run("delete-db-branch", async () => {
return neon.branches.delete(db.id);
});
});
- Commands are steps and do not rerun. Other code in a job can run again when the run resumes, so wrap side effects in
step.run. - Step IDs are scoped to the job, so two jobs can use the same ID.
step.waitForEvent,step.sleep, andstep.invokework, and a wait holds no worker.- Throw
RetryAfterErrorfrominngestto retry a step after a delay.
Errors
| Error | Thrown when |
|---|---|
CommandFailedError | A command exits with a non-zero code and you did not call .nothrow(). |
CommandTimeoutError | A command passes its .timeout(). |
CiUsageError | The API is used in a way it does not support, such as $ outside a job or checkout() in a run with no repository. |
Import the classes from @inngest/ci to check for them with instanceof.
CommandFailedErrorhascommand(the arguments as an array),exitCode,stdoutTailandstderrTail(the end of each stream), andjobPath(the job that ran it). Its message is the command, its exit code, and the end of stderr.CommandTimeoutErrorhascommand,timeout(the duration you set), andjobPath.- A
CiUsageErrorfails the run without retrying, because a retry would fail the same way.
Next steps
- Concepts defines pipelines, triggers, jobs, commands, and machines.
- Recipes lists common tasks with the code for each.
- Quick start runs a pipeline on the Dev Server.
- Flow control covers the options a pipeline accepts.
- Sandboxes limits list the limits that apply to machines.