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.

OptionTypeRequired
githubgithubApp(), githubToken(), or consoleReporter()No
machine{ vcpu?: 1 | 2 | 4 }No
runUrl(ctx: { runId, functionId }) => stringNo
  • github sets how pipelines report to GitHub. It defaults to consoleReporter(), which prints checks to the Inngest logger. Pass githubApp() or githubToken() to report checks on GitHub.
  • machine is the default machine for every job.
  • runUrl builds 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.

TypeScript
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.

OptionTypeRequired
idstringYes
onA trigger, or an array of triggersYes
checkfalse, or { name?, jobs? }No
machine{ vcpu?: 1 | 2 | 4 }No
repostringNo
  • id is unique in the app. It names the function, the run, and the check.
  • check: false turns off all checks. check: { jobs: false } keeps the pipeline check and drops the job checks.
  • machine is the default machine for the pipeline's jobs. A job's own machine overrides 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, and description. 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.

TypeScript
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}.

TypeScript
if (await changed({ include: ["docs/**"], ignore: ["docs/**/*.png"] })) {
  await docsSite();
}

Triggers

A trigger starts a pipeline and types its event.

TriggerRuns 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:

TypeScript
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 to opened, synchronize, and reopened.
  • push() matches branches and tags by exact name, so tags: ["v*"] does not match v1.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 several comment() triggers, each command keeps its own minPermission.
  • 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.

TypeScript
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 test shows as test (2). To share one build between jobs, use from().
  • 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.
OptionTypeRequired
idstringYes
machine{ vcpu?: 1 | 2 | 4 }No
cache{ key?, refresh?, scope? }No
checkfalse, or { name? }No
keepOnFailureDuration, 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.

TypeScript
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:

TypeScript
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`;
MethodDoes
.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. $.sh runs /bin/sh -c and escapes interpolated values.
  • A result holds exitCode, stdout, stderr, truncated, and durationMs. stdout and stderr keep the last 64 KiB. durationMs is 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 throws CiUsageError.
  • .background() returns once the process starts. kill() sends SIGTERM unless you pass a signal number, output() reads the last 64 KiB by default, and exited() 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:

TypeScript
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.

vcpuMemory
11 GiB
2 (default)2 GiB
44 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 /work by default, which is where checkout() 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.

TypeScript
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() runs base on 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.

TypeScript
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.
  • ExtraMachine has $, $.sh, waitForPort(), and waitForHttp().
  • waitForPort and waitForHttp are also top-level exports of @inngest/ci. They run on the job's own machine: import { waitForPort, waitForHttp } from "@inngest/ci".
  • waitForPort(port, { timeout }) and waitForHttp(url, { timeout, status }) wait up to 2m by default, and waitForHttp waits for status 200. When they give up they throw CommandFailedError.

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 independentSeparate jobs
Builds on earlier workSeparate jobs with from()
Needs a second machine alive at the same timeOne 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.

TypeScript
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.
  • exclude removes combinations and include adds extra ones.
  • concurrency limits how many run at once. The default is all of them.
  • failFast is off by default. When it is on, the first failure ends the matrix, and running combinations are not cancelled.
  • machine and cache accept a value or a function of the combination. check applies to every combination.
  • With failFast off, failures are thrown together as an AggregateError after 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.

TypeScript
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.

TypeScript
const base = ci.job(
  {
    id: "base",
    cache: {
      key: files("pnpm-lock.yaml", ".nvmrc"),
      refresh: [{ cron: "0 3 * * *" }],
    },
  },
  async () => {
    await checkout();
    await $`pnpm install`;
  },
);
  • key is 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, and from(base) clones the saved machine.
  • A restored machine keeps the checkout from the commit it was built on. Call checkout() again after from() to move to this run's commit. It keeps installed dependencies and build output.
  • refresh takes triggers that rebuild the cache ahead of time, so pull requests do not pay for it. Set repo on a pipeline so a cron has a repository to check out.
  • scope is "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:

TypeScript
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.

StateCheck
RunningIn progress, with the current command
Retrying a commandIn progress, with the attempt count and the last error
Run will be retriedIn progress, with Retrying (attempt N of M)
PassedSuccess, with the duration
Reused from cacheSuccess, with Restored, built … or Passed at <sha>, no changes since
FailedFailure, with the command, the output tail, and annotations
Timed outTimed out
Cancelled by a failure elsewhereCancelled
Returned ci.skip()Success, with the reason
Job never calledNo check

report adds to the current job's check. Outside a job it targets the pipeline check.

TypeScript
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 a path or a message are 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 a push, 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 a cache key.

Run on GitHub

In production, pipelines start from GitHub webhooks and report checks as a GitHub App.

  1. 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, for github.waitForWorkflow()), Pull requests (read and write), Issues (read and write), Metadata (read).

  2. Subscribe the app to these events: push, pull request, check run, check suite, issue comment, merge group, and workflow run.

  3. In the Inngest dashboard, create a webhook and paste the output of githubWebhookTransform into 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)"
    
  4. Install the app on your repositories.

  5. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, then pass the provider to createCi.

ci/client.ts

TypeScript
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 reads GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY, and falls back to GITHUB_INSTALLATION_ID when an event carries no installation. githubToken() reads GITHUB_TOKEN.
  • In dev mode checks print to the terminal. Set INNGEST_CI_GITHUB=live to 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.

TypeScript
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);
});
  • owner and repo default 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() inside step.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.
HelperDoes
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.

TypeScript
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, and step.invoke work, and a wait holds no worker.
  • Throw RetryAfterError from inngest to retry a step after a delay.

Errors

ErrorThrown when
CommandFailedErrorA command exits with a non-zero code and you did not call .nothrow().
CommandTimeoutErrorA command passes its .timeout().
CiUsageErrorThe 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.

  • CommandFailedError has command (the arguments as an array), exitCode, stdoutTail and stderrTail (the end of each stream), and jobPath (the job that ran it). Its message is the command, its exit code, and the end of stderr.
  • CommandTimeoutError has command, timeout (the duration you set), and jobPath.
  • A CiUsageError fails 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.