Scenario 3 of 3
CI is just a pipeline.
A CI pipeline written in TypeScript, run by Inngest on sandboxes. Each job gets a machine on its first command, completed work is saved, and every command appears in the trace.
| Status | Run ID | Trigger | App | Queued at | Started at | Ended at | Duration |
|---|---|---|---|---|---|---|---|
| queued | - | ci/manual.ci-pipeline | sandbox-playground | - | - | - | - |
- Run ID
- -
- App
- sandbox-playground
- Function
- ci-pipeline
- Duration
- -
- Queued at
- -
- Started at
- -
- Ended at
- -
Trace
Press Run to watch setup, parallel jobs, checks and machine cleanup.
- ○setup › machineCreate setup's machine and unpack the app
- ○setup › snapshotShare setup's filesystem with the other jobs
- ○lint › node lint.mjsLint on its own machine
- ○test › node --test #attempt-1Run tests on a copy of setup
- ○compat (runtime:node) › node --testTest the latest Node runtime
- ○compat (runtime:node-lts) › node-lts --testTest the LTS runtime
- ○summarize › write-summarySummarize the results without a machine
- ○cleanupDestroy all five machines
Checks
Command output
Run the pipeline to see its output.The real function this demo illustrates · 44 lines
import { $, from, report } from "inngest/ci";
import { ci } from "./client"; // createCi(inngest, { github: githubApp({ … }) })
export const setup = ci.job("setup", async () => {
// The first command creates this job's machine (a sandbox).
await $.sh`mkdir -p /work/app && printf %s "$CI_APP_TAR" | base64 -d | tar -x -C /work/app`
.env({ CI_APP_TAR: app }).as("unpack app"); // a real repo would checkout()
return { node: (await $`node --version`).stdout.trim() };
});
export const lint = ci.job("lint", async () => {
await from(setup); // a copy of setup's machine
await $`node lint.mjs`.cwd("/work/app");
});
export const test = ci.job("test", async () => {
await from(setup);
// Keep the exit status so the job can report it before failing.
const r = await $`node --test`.cwd("/work/app").retries(1).nothrow();
await report.summary(`node --test finished in ${r.durationMs} ms`);
if (r.exitCode !== 0) throw new Error(`node --test exited ${r.exitCode}`);
});
export const compat = ci.matrix(
{ id: "compat", axes: { runtime: ["node", "node-lts"] } },
async ({ runtime }) => {
await from(setup);
await $`${runtime} --test`.cwd("/work/app");
},
);
export const summarize = ci.job("summarize", async () =>
step.run("write-summary", () => ({ at: new Date().toISOString() })), // no commands, no machine
);
export const pr = ci.pipeline(
{ id: "ci-pipeline", on: ci.manual({ pipelineId: "ci-pipeline", schema }) },
async () => {
await Promise.all([lint(), test(), compat()]); // parallel, one machine each
await summarize();
},
);
// Machines are destroyed when the pipeline ends; a cleanup function catches runs that died first.
// With a GitHub App, each job's start and finish is a check run on the commit.Without inngest/ci this is five machines to create, poll and destroy by hand, plus a check per job to open and close on GitHub. Completed steps and their results must be saved to survive the CI process dying mid-run. The code below illustrates a pipeline without that durable state.
The same job without durable steps: every call, retry, poll and cleanup written by hand · 46 lines
const API = "https://api.inngest.com";
const headers = { authorization: `Bearer ${process.env.INNGEST_SIGNING_KEY}`, "content-type": "application/json" };
// Every job below needs its own VM, its own polling loop, its own retry loop,
// its own cleanup, and its own check run on GitHub. None of it survives a
// crash of this process: a retry starts from zero, machines included.
async function machine(name: string) {
const res = await fetch(`${API}/v2/sandboxes`, { method: "POST", headers, body: JSON.stringify({ name, vcpu: 2, memoryMb: 2048 }) });
const id = (await res.json()).data.id;
for (let i = 0; ; i++) {
const s = (await (await fetch(`${API}/v2/sandboxes/${id}`, { headers })).json()).data.status;
if (s === "RUNNING") return id;
if (s === "FAILED" || i > 60) throw new Error(`${name}: ${s}`);
await new Promise((r) => setTimeout(r, 1000));
}
}
async function exec(id: string, cmd: string, cwd = "/work/app") {
const res = await fetch(`${API}/v2/sandboxes/${id}/exec`, { method: "POST", headers, body: JSON.stringify({ command: ["/bin/sh", "-c", cmd], cwd, timeout: "60s" }) });
if (!res.ok) throw new Error(`exec failed: ${res.status}`); // …but did it run?
const out = (await res.json()).data;
return { exitCode: out.exitCode, stdout: Buffer.from(out.stdout, "base64").toString() };
}
async function destroy(id: string) { await fetch(`${API}/v2/sandboxes/${id}`, { method: "DELETE", headers }).catch(() => {}); }
export async function pipeline(app: string) {
const ids: string[] = [];
try {
// No snapshots here: every job repeats setup on a fresh machine.
const jobs = ["lint", "test", "compat-node", "compat-node-lts"].map(async (name) => {
const id = await machine(`ci-${name}`); ids.push(id);
await exec(id, `mkdir -p /work/app && printf %s '${app}' | base64 -d | tar -x -C /work/app`, "/");
const cmd = name === "lint" ? "node lint.mjs" : name.endsWith("lts") ? "node-lts --test" : "node --test";
let last;
for (let attempt = 0; attempt < 2; attempt++) { // hand-rolled retry, per job
last = await exec(id, cmd);
if (last.exitCode === 0) break;
}
if (last!.exitCode !== 0) throw new Error(`${name} failed`);
return last;
});
await Promise.all(jobs); // one failure hides the others
return { at: new Date().toISOString() };
} finally {
await Promise.all(ids.map(destroy)); // only runs if this process survives
}
}