@sdxc/ci
CI as a TypeScript script that runs the same on a laptop and a CI host, and signs a green commit off on GitHub
- Installs with
- @sdxc/cli@sdxc/result
- Depends on
remix- Source
- packages/ci
CI as a TypeScript script that runs the same on a laptop and a CI host, and signs a green commit off on GitHub.
Installation
npm add -D @sdxc/ciInstall it to type your script with defineCI. The command line also runs without installing,
through npx @sdxc/ci.
Usage
Write a script
import { defineCI } from "@sdxc/ci";
export default defineCI(async (ci) => {
await ci.step("Types", "tsc --noEmit", { halt: true });
await ci.parallel("Checks", [
() => ci.step("Lint", "npm run lint"),
() => ci.step("Test", "npm test", { cache: true }),
]);
if (ci.status === "passing") await ci.signoff("All systems go.");
else ci.failure("CI failed. Do not push.", "Fix the steps above and run it again.");
});await orders steps, ci.parallel runs them at once, and ordinary control flow does the rest.
A failed step does not stop the run unless it is a halt step; every later step then resolves
to skipped.
Run it
npx @sdxc/ci run ci.tsEach step prints one line as it finishes, a failed step's output under it, and a summary at the
end. The run exits 0 when every step passed and 1 otherwise. A green run on a clean tree signs
the commit off with an sdxc/ci GitHub commit status.
Fan out over workspaces
await ci.matrix("Workspaces", await ci.workspaces(), async (ws) => {
await ci.step(`Build ${ws.name}`, "npm run build", { cwd: ws.path, halt: true });
await ci.step(`Test ${ws.name}`, "npm test", { cwd: ws.path, cache: true });
});Each item is an entry addressed as Workspaces/<name>, with its own status; a halt inside it
stops only that entry.
API
defineCI(script)
Types a script; the default export of the file run loads. A plain function works too.
ci.step(name, run, options?)
Runs a shell command line, an argv without a shell, or a function, and resolves to
{ status, cached, duration, output }. A function step gets step.run(command),
step.log(...) and step.signal; its outcome is the Result it returns, or a failure when a
command inside it failed. Options: halt, cache, cwd, env, timeout (milliseconds).
ci.parallel(name, functions, options?) and ci.matrix(name, items, fn, options?)
Run functions, or one call per item, concurrently as a named group. Options: failFast
(default false) and concurrency (default os.availableParallelism()); a matrix also takes
key to name its entries. Only steps take a concurrency slot, so nesting never deadlocks.
ci.workspaces()
The workspaces of the root package.json, each with name, path, absolute, manifest,
dependencies, dependents, files(), has(glob) and inputs: the cache input globs for its
own files, every workspace it depends on and the root files, which a step extends with
cache: { inputs: [...ws.inputs, "fixtures/**"] }.
ci.files(glob), ci.root, ci.cores
files lists the tracked files matching a glob, relative to the project root, so a script finds
what to act on without reading the disk. root is the project root every step runs in, and
cores the machine's available parallelism, for sizing concurrency.
ci.status, ci.runner, ci.scope
status is pending, passing, failing or halted and never returns to passing.
runner is remote when the CI variable is set, else local. scope lists the workspaces
--scope names, or is null.
ci.success, ci.failure, ci.signoff
Closing messages. failure fails a run whose steps passed. success and signoff on a run
that is not passing are script errors. signoff posts the status only on a local run that ran
every step, started and ended on a clean tree, and kept the same HEAD.
cache
cache: true, or { inputs, env, outputs }, reuses an earlier pass while the key is
unchanged: the command, the contents of the input globs, the listed variables, the platform and
the Node and Bun versions. Inside a matrix entry over ci.workspaces() the default inputs are
the workspace, the workspaces it depends on and the files at the root.
Command line
npx @sdxc/ci run <script> [--only <pattern>] [--skip <pattern>] [--scope <workspace>] [--push] [--json]
npx @sdxc/ci list <script> [--matrix <name>] [--json]
npx @sdxc/ci signoff [<sha>]
npx @sdxc/ci verify <sha> [--wait 60s] [--repo owner/name]--only and --skip match step names, group names and entry addresses, with * spanning
/. verify exits 0 when someone with write access signed the commit off, 1 when nobody did,
and 2 when GitHub cannot be reached. The token comes from GITHUB_TOKEN (or --token-env),
then gh auth token.
Patterns
Pattern: Test, push and sign off in one command
npx @sdxc/ci run ci.ts --pushWhen every step passes, --push pushes exactly the tested commit to its upstream branch, then
posts the sdxc/ci status. A rejected push, because someone pushed first, exits 1: pull, then
run it again on the new commit.
Pattern: Skip the hosted run for a signed-off commit
jobs:
gate:
runs-on: ubuntu-latest
outputs:
signed: ${{ steps.verify.outputs.signed }}
steps:
- id: verify
env:
GITHUB_TOKEN: ${{ github.token }}
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
if npx @sdxc/ci verify "$SHA" --repo "$GITHUB_REPOSITORY" --wait 60s; then
echo "signed=true" >> "$GITHUB_OUTPUT"
fi
ci:
needs: gate
if: needs.gate.outputs.signed != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx @sdxc/ci run ci.tsThe gate needs no checkout: --repo names the repository. A skipped job counts as passing for
a required check, and a commit nobody with write access signed off runs in full.
Pattern: One CI job per workspace
jobs:
entries:
runs-on: ubuntu-latest
outputs:
keys: ${{ steps.list.outputs.keys }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- id: list
run: echo "keys=$(npx @sdxc/ci list ci.ts --runner remote --matrix Workspaces --json)" >> "$GITHUB_OUTPUT"
test:
needs: entries
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
key: ${{ fromJSON(needs.entries.outputs.keys) }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx @sdxc/ci run ci.ts --only "Workspaces/${{ matrix.key }}"list reads the same ci.workspaces() call the script makes, so a new workspace gets its own
job without editing the workflow.
Pattern: Run one package and everything that depends on it
npx @sdxc/ci run ci.ts --scope packages/uiOnly the matrix entries for packages/ui and every workspace depending on it, directly or
through others, run. The script reads the same list from ci.scope to narrow its own steps:
import { defineCI } from "@sdxc/ci";
export default defineCI(async (ci) => {
let all = await ci.workspaces();
let scoped = ci.scope === null ? all : all.filter((ws) => ci.scope?.includes(ws.path));
let paths = scoped.flatMap((ws) => [
ws.path,
...all.filter((other) => ws.dependents.includes(other.name)).map((other) => other.path),
]);
await ci.step("Lint", ["eslint", ...new Set(paths)]);
await ci.matrix("Workspaces", all, (ws) =>
ci.step(`Test ${ws.name}`, "npm test", { cwd: ws.path }),
);
});A scoped run never signs off; run the whole script before you push.
Pattern: Test only the workspaces that have tests
import { defineCI } from "@sdxc/ci";
export default defineCI(async (ci) => {
let tested = (await ci.workspaces()).filter((ws) => ws.has("**/*.test.{ts,tsx}"));
await ci.matrix("Tests", tested, async (ws) => {
await ci.step(`Test ${ws.name}`, "npm test", { cwd: ws.path, cache: true });
});
});has() matches the workspace's tracked files, so a workspace without a test file gets no entry,
and adding its first test adds one. With cache: true, a workspace is tested again only when it
or a workspace it depends on changed.
Pattern: Each workspace with its own linter
import { defineCI } from "@sdxc/ci";
export default defineCI(async (ci) => {
let linted = (await ci.workspaces()).filter((ws) => {
let scripts = ws.manifest.scripts as Record<string, string> | undefined;
return scripts?.lint !== undefined;
});
await ci.matrix("Lint", linted, (ws) =>
ci.step(`Lint ${ws.name}`, "npm run lint", { cwd: ws.path }),
);
});Each workspace declares its linter as its own lint script (ESLint in one, Stylelint in
another, a Markdown linter in the docs), and the script runs whichever one each declares.
Pattern: Apps in other languages
import { defineCI } from "@sdxc/ci";
const APPS = [
{ name: "api", path: "services/api", lint: "go vet ./...", test: "go test ./..." },
{ name: "worker", path: "services/worker", lint: "ruff check .", test: "pytest" },
{
name: "engine",
path: "crates/engine",
lint: "cargo clippy -- -D warnings",
test: "cargo test",
},
];
export default defineCI(async (ci) => {
await ci.matrix("Apps", APPS, async (app) => {
await ci.step(`Lint ${app.name}`, app.lint, { cwd: app.path, halt: true });
await ci.step(`Test ${app.name}`, app.test, {
cwd: app.path,
cache: { inputs: [`${app.path}/**`] },
});
});
});Steps are commands, so a Go service, a Python worker and a Rust crate run beside each other the same way a JavaScript workspace does. Only the script itself is TypeScript, run by Node.
Pattern: Time-box a step
await ci.step("End-to-end", "npm run e2e", { timeout: 10 * 60_000 });A step still running after timeout milliseconds is stopped and fails with "Timed out after
600000ms", so a hung browser or server never holds a run open.
Pattern: Fail on a check of your own
import { readFile } from "node:fs/promises";
import { defineCI } from "@sdxc/ci";
import { failure, success } from "@sdxc/result";
export default defineCI(async (ci) => {
await ci.step("Build", "npm run build", { halt: true });
await ci.step("Bundle size", async (step) => {
let bundle = await readFile("dist/index.js");
step.log(`dist/index.js is ${bundle.byteLength} bytes`);
if (bundle.byteLength > 50_000) return failure(new Error("The bundle is over 50 kB"));
return success(undefined);
});
});A function step's outcome is the @sdxc/result
it returns, and its message lands in the step's output. Call ci.failure instead when the check
reads the whole run rather than one step.
Pattern: Steps that run in one place only
import { defineCI } from "@sdxc/ci";
export default defineCI(async (ci) => {
if (ci.runner === "remote") {
for (let example of await ci.files("apps/*/.env.example")) {
await ci.step(`Secrets for ${example}`, [
"cp",
example,
example.replace(/example$/, "local"),
]);
}
}
await ci.step("Test", "npm test");
});On the CI host ci.runner is "remote", so the copies never overwrite a laptop's real
.env.local files.
--runner remote reproduces a CI run locally.
Pattern: Cache a step that writes files
await ci.step("Build", "npm run build", {
cache: { inputs: ["src/**", "package.json"], env: ["NODE_ENV"], outputs: ["dist"] },
});The build is skipped while src/, package.json and NODE_ENV are unchanged and dist/
still holds what the build left. Delete or edit dist/ and the next run builds again.
Pattern: Sign off a commit you pushed later
npx @sdxc/ci run ci.ts
git push
npx @sdxc/ci signoffEvery green, complete run on a clean tree keeps a record of the commit it tested. signoff
posts the status from that record once GitHub has the commit, and refuses a commit no green run
recorded.