Skip to main content

Recipe format — your CI/CD pipeline as code

Your repo opts in by shipping .slipstream.json at the root. The control plane validates it with the exact schema rendered below — also published as JSON Schema at /schema/recipe-v1.json, so you can point your editor's $schema at it for inline validation and autocomplete.

Rules the schema cannot express, enforced at validation time: at most 16 leaf stages, stage names must be unique, and stages may be empty only when extends names a preset.

FieldTypeRequiredConstraints
$schemastringno
versionconstant 1yes
extendsstringnopattern ^[a-z][a-z0-9-]*@\d+$
kind"ci" | "deploy" | "artifact"no
envobjectno
secretsarraynomin items 1, max items 8
disablearrayno
envKeystringnomin length 1, max length 128
toolReposarraynomax items 8
stagesarrayyesmin items 0
Raw JSON Schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://slipstream.dev/schema/recipe-v1.json",
  "title": "RunsGreen recipe v1",
  "type": "object",
  "properties": {
    "$schema": {
      "type": "string",
      "format": "uri"
    },
    "version": {
      "type": "number",
      "const": 1
    },
    "extends": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9-]*@\\d+$"
    },
    "kind": {
      "type": "string",
      "enum": [
        "ci",
        "deploy",
        "artifact"
      ]
    },
    "env": {
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {
        "type": "string",
        "maxLength": 4096
      }
    },
    "secrets": {
      "minItems": 1,
      "maxItems": 8,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "from": {
            "type": "string",
            "const": "infisical"
          },
          "projectId": {
            "type": "string",
            "minLength": 1
          },
          "env": {
            "type": "string",
            "minLength": 1
          },
          "path": {
            "type": "string",
            "minLength": 1
          },
          "tokenEnv": {
            "type": "string",
            "pattern": "^[A-Z0-9_]{1,64}$"
          }
        },
        "required": [
          "from",
          "projectId",
          "env",
          "path"
        ],
        "additionalProperties": false
      }
    },
    "disable": {
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1,
        "maxLength": 40
      }
    },
    "envKey": {
      "type": "string",
      "minLength": 1,
      "maxLength": 128
    },
    "toolRepos": {
      "maxItems": 8,
      "type": "array",
      "items": {
        "type": "string",
        "pattern": "^[\\w.-]+\\/[\\w.-]+$"
      }
    },
    "stages": {
      "minItems": 0,
      "type": "array",
      "items": {
        "anyOf": [
          {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 40
              },
              "cmd": {
                "type": "string",
                "minLength": 1,
                "maxLength": 500
              },
              "env": {
                "type": "object",
                "propertyNames": {
                  "type": "string"
                },
                "additionalProperties": {
                  "type": "string",
                  "maxLength": 4096
                }
              },
              "timeout_s": {
                "type": "integer",
                "minimum": 30,
                "maximum": 1800
              },
              "secrets": {
                "type": "boolean"
              },
              "affected": {
                "type": "boolean"
              },
              "if_changed": {
                "maxItems": 64,
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 4096
                }
              },
              "coverage_summary": {
                "type": "string",
                "minLength": 1,
                "maxLength": 256
              },
              "after": {
                "type": "string",
                "minLength": 1,
                "maxLength": 40
              }
            },
            "required": [
              "name",
              "cmd"
            ],
            "additionalProperties": false
          },
          {
            "type": "object",
            "properties": {
              "parallel": {
                "minItems": 1,
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 40
                    },
                    "cmd": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 500
                    },
                    "env": {
                      "type": "object",
                      "propertyNames": {
                        "type": "string"
                      },
                      "additionalProperties": {
                        "type": "string",
                        "maxLength": 4096
                      }
                    },
                    "timeout_s": {
                      "type": "integer",
                      "minimum": 30,
                      "maximum": 1800
                    },
                    "secrets": {
                      "type": "boolean"
                    },
                    "affected": {
                      "type": "boolean"
                    },
                    "if_changed": {
                      "maxItems": 64,
                      "type": "array",
                      "items": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 4096
                      }
                    },
                    "coverage_summary": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 256
                    },
                    "after": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 40
                    }
                  },
                  "required": [
                    "name",
                    "cmd"
                  ],
                  "additionalProperties": false
                }
              }
            },
            "required": [
              "parallel"
            ],
            "additionalProperties": false
          }
        ]
      }
    }
  },
  "required": [
    "version",
    "stages"
  ],
  "additionalProperties": false
}

Worked example

A typical recipe: install once, then test in parallel, with one secrets-gated integration stage at the end.

{
"version": 1,
"env": { "CI": "true" },
"stages": [
{ "name": "install", "cmd": "bun install --frozen-lockfile", "timeout_s": 600 },
{
"parallel": [
{ "name": "unit", "cmd": "bun test --timeout 20000" },
{ "name": "typecheck", "cmd": "bunx tsc --noEmit" }
]
},
{ "name": "integration", "cmd": "bun test integration/", "secrets": true, "timeout_s": 300 }
]
}

Each stage is a named shell command. Stages run in order; a parallel group runs its stages concurrently and the run proceeds when all of them pass. A stage with "secrets": true receives the secret bundle configured for your repo. See push to green for how a run executes these stages.

timeout_s is checked against your own run history

timeout_s (30–1800) is the wall the executor kills the stage at on every run. Agentic Pipeline refuses a value your repo's own successful runs prove is too tight: it compares your declared timeout against the p95 duration it has measured for a stage of that name, and if the timeout is below that p95, validation fails and names the value to set instead (ceil(p95 × 1.5)). You get the same answer from slipstream_recipe_validate before you push and from the check if you push anyway — a slightly-too-tight timeout otherwise fails intermittently, which costs several red runs to diagnose a number the platform already knew.

Two things worth knowing:

  • Lowering a timeout on an unchanged recipe never newly fails. A successful run can never have taken longer than the timeout it ran under, so a stage that has always had this timeout has no measurement above it and is never refused.
  • The history is keyed by stage NAME. If you repurpose a stage name for a different command, it is judged on the old command's durations. Rename the stage, or ask an operator to set slipstream.recipe_timeout_gate.<owner>/<repo> to 0 with a reason.

Per-run environment

Every stage — even "secrets": false ones — receives these non-secret coordinates for the run:

VariableValue
SLIPSTREAM_SHAThe commit SHA being built
SLIPSTREAM_REPOowner/repo
SLIPSTREAM_RUN_IDThe run's id (log key, dashboard)
SLIPSTREAM_TOOLS_DIRPath to the warm per-run tools cache

Each is also exported under the legacy NATIVE_* name (NATIVE_SHA, …) as a permanent alias, so existing recipes keep working; prefer the SLIPSTREAM_* spelling in new recipes.

Declared runtimes

A recipe may declare the interpreter runtimes it needs; the executor provisions them into a warm cache before the first stage runs and fails the run loudly if provisioning fails — no stage ever starts against a half-installed toolchain:

"runtimes": { "python": "3.12" }

Supported runtimes: python (via uv — the interpreter is shared per version across repos, so only the first run on a machine pays the install, while each repo keeps its own virtualenv) and node (installed per repo). At most 4 entries. Stages get the runtime on PATH plus the repo's .venv/bin, so uv syncruffpytest work with zero bootstrap.

Presets

Instead of writing stages, a recipe can extend a maintained preset:

{ "version": 1, "extends": "std/python-standard@1", "stages": [] }

std/python-standard@1 runs uv sync, ruff check ., and pytest -q; the contract is a pyproject.toml with ruff and pytest as dev dependencies. stages may be empty only when extends names a preset. Presets are versioned by major — the @1 pins the contract.

Coverage telemetry

A stage may declare where it writes a coverage summary; after that stage succeeds, the executor reads the file and stamps a lines-covered percentage on the run:

{ "name": "tests", "cmd": "bun run test:coverage", "coverage_summary": "artifacts/coverage/coverage-summary.json" }

Accepted shapes: istanbul/vitest json-summary ({"total":{"lines":{"pct":N}}}) or minimal {"lines_pct":N}. Coverage is telemetry, never a gate: a missing or unparsable file, a failed stage, or a skipped stage yields no coverage and never affects the run verdict. Declare it on one merged-summary stage per recipe; the value appears on the runs API and the dashboard's coverage column.

Skipping a stage intentionally

A stage that exits with code 250 is recorded as intentionally skipped: the run stays green and stages that depend on it still proceed. Use it for change-scoped work — for example, a summary stage that finds nothing to summarize, or a build that detects its inputs did not change.

Change-scoped stages (if_changed)

A stage may declare if_changed: an array of git pathspecs. When the run has a valid base/head pair, Agentic Pipeline diffs those paths — if nothing matched since the base, the stage is recorded skipped and does not run. Any doubt (no base, an unresolvable SHA, a git error) always means run, never a silent skip:

{ "name": "portal-e2e", "cmd": "bun run test:e2e:portal", "if_changed": ["portal/", "packages/"] }

A skipped stage still satisfies dependents in a needs graph (below) — it never blocks the rest of the run.

Parallel stages as a dependency graph (v2, needs)

Recipe v2 stages can declare needs: the names of stages that must complete first. Agentic Pipeline then schedules the whole recipe as a dependency graph instead of strict top-to-bottom order — a stage starts the moment its dependencies are done, even while an unrelated, slower stage is still running:

{
"version": 2,
"tenant": "k2k",
"stages": [
{ "name": "install", "cmd": "bun install --frozen-lockfile" },
{ "name": "backend-test", "cmd": "bun test backend/", "needs": ["install"] },
{ "name": "frontend-test", "cmd": "bun test frontend/", "needs": ["install"] },
{ "name": "e2e", "cmd": "bun run test:e2e", "needs": ["backend-test", "frontend-test"] }
]
}

A stage with no needs runs as soon as capacity allows. If a stage fails, only its transitive dependents are skipped (recorded skip_reason: "dependency-failed") — independent stages keep running and already-finished results are kept.

DAG stages may also declare resource hints that shape how ready stages share one machine: cpu (cores) and memory_mb are counted against machine capacity when known, stages sharing a resource_group never run concurrently, and exclusive: true runs a stage with nothing else in flight. Stages that build images with the machine's local Docker/Buildx are placed in an automatic docker-buildx group (one local builder — concurrent builds would just contend); setting an explicit resource_group overrides that placement, which is the escape hatch for builds targeting separate remote builders.

Deploy recipes

CI and CD are separate files with the same format family: .slipstream.json (this page) defines the CI pipeline, and .slipstream-deploy.json — recipe v2 with "kind": "deploy" — defines what ship on green executes after a green check on main. Both are repo-owned and fetched at the triggering commit, so a merged edit to either file is active on the very next run. The feature is opt-in per repo — and it opts itself in: landing a valid .slipstream-deploy.json on the default branch turns the ship key on for that repo (slipstream.ship_auto). To keep a deploy recipe without shipping on green, set slipstream.ship.<owner>/<repo>=0.

Deploy change scoping (skip_when_only, opt-in)

By default a deploy always runs. Two per-repo modes change that, set via PUT /api/slipstream/config on slipstream.deploy_scope.<owner>/<repo> (safe or prove) — both require this file to be repo-owned, and both are opt-in while the feature is under test.

safe mode reads a new top-level deploy-only key, the inverse of if_changed: paths that are safe to ignore, not paths that matter. The deploy predict-skips only when the whole diff since the last deploy that actually rolled fits entirely inside this list:

{ "version": 2, "tenant": "k2k", "kind": "deploy", "skip_when_only": ["docs/", "README.md"] }

Entries must be literal paths or directory prefixes — no glob characters (* ? [ ] { }), no leading !, no absolute path, no ./.. segment, and never one of the reserved names a deploy always depends on regardless of what changed: fly.toml, .dockerignore, package.json, bun.lock, bun.lockb, package-lock.json, yarn.lock, pnpm-lock.yaml, .slipstream.json, .slipstream-deploy.json, .slipstream-artifact.json, Dockerfile. slipstream_recipe_validate reports every violation by name. An omitted or empty list means the deploy never predict-skips on scope alone — an omission only ever costs an extra deploy, never a missed one. safe/prove also strip any per-stage if_changed from the deploy dispatch, so declaring both on the same recipe earns a validation warning: the per-stage list would silently stop doing anything.

prove mode never predicts from a declared list at all — every deploy dispatches, and the platform hashes each declared app's actual build context, skipping only the build+roll it can prove redundant (identical content, and the app already running the image that content would produce). A skip in either mode is recorded with a skip_reason your run's stage table shows verbatim: skip-when-only: <matched entries> for safe, unchanged-proven (optionally followed by a context-hash prefix) for prove. Whichever agent surface reports on your deploy (slipstream_follow, slipstream_deploy_history) states this same evidence as unchanged_because rather than a bare "production is unchanged" claim. Full design, honesty limits, and the adoption ceiling: docs/CONTROL-PLANE.md § Deploy change scoping.

Reading an unchanged_because verdict

A deploy that released nothing always names the evidence behind that outcome. Four values, and they are not equally strong:

  • scoped — every stage was skipped because nothing in the recipe's declared if_changed inputs changed. This is an allowlist, so an input nobody declared looks exactly like one that did not change; the verdict says so and offers both remedies. If the merge should have changed production, force it (slipstream_rerun {kind: deploy, confirm: true}). If the changed tree never deploys, declare it in skip_when_only and future merges report denylist instead. Where the control plane itself made the call before any executor booted, it also states how many files it compared.
  • denylist — the whole diff since the last deploy that actually rolled fell inside your declared-ignorable paths, and the matched entries are named. A missing entry costs an extra deploy, never a missed one.
  • content — the build ran and was proved identical to what is verified live; only the rollout was skipped. The proof covers image inputs, not base-image drift.
  • empty — the deploy concluded with no recorded stages. Nothing rolled, and nothing is claimed about scope lists.

A deploy whose stages recorded different bases reports the LEAST-PROVEN of them: the verdict covers the whole deploy, so one stage skipped on a trusted list makes the whole claim that good.

Artifact-first CD (opt-in)

An artifact-enabled repo adds a third file, .slipstream-artifact.json — recipe v2, "kind": "artifact" — that builds and pushes the repo's images to an immutable, SHA-addressed tag BEFORE the deploy runs. .slipstream-deploy.json then declares which of those images it promotes:

{ "version": 2, "tenant": "k2k", "kind": "deploy", "artifacts": [{ "app": "my-app-backend", "stage": "build-backend" }] }

Once a repo is artifact-enabled, deploy admission is strict: every declared app must already have a produced image for the deploy's exact commit, or the deploy is refused with a clear message naming the app and SHA — Agentic Pipeline never builds an image inside a deploy as a fallback. Scope which apps rebuild with the same stage-level if_changed used everywhere else, on the STAGES inside .slipstream-artifact.json; each declared app names its build stage via stage (one app per stage). An app whose build stage is skipped as unchanged still gets a digest for that commit: the artifact run carries it forward, preferring the run's own base commit — the exact commit the skip was computed against, so no history lookup is needed — and falling back to the nearest ancestor commit that genuinely built it, recorded as inherited with full provenance either way. Whenever that inheritance cannot be proven — a first build, a new app, unresolvable history — the stage is force-run instead, so ambiguity always means build, never skip.