Skip to content

Canopy Pipelines

Canopy Pipelines lets you describe your build and deploy logic in a real .ts file instead of a YAML dialect. @canopy/ci provides the authoring API and the IR (the Canopy Pipeline Graph, or CPG) it compiles to - functions, types, and imports, not a new language.

Status: early access. The compile/plan/graph surface below is real and shipped - every build with a repository already gets a CPG compiled and validated. Running that graph as a gate on your deploy (a failing step blocks the deploy the same way a failing test does) is live for a small number of allowlisted environments. Fully replacing your legacy build/deploy sequence with the graph (“driver mode”) is further along still - real, but only ever turned on for one environment at a time by a Canopy engineer today, not yet self-serve. Request early access → and we’ll get your environment allowlisted.

Install

Terminal window
bun add @canopy/ci
# or: npm install @canopy/ci

Published on Canopy’s own npm-compatible registry (see Package Registry) and installable anonymously, no Canopy account required just to pull the package.

Authoring a pipeline

Add a canopy.pipeline.ts file at your environment’s build path:

canopy.pipeline.ts
import { definePipeline } from '@canopy/ci';
export default definePipeline(({ step, when, canopy }) => {
const src = step.source();
const build = step('build', {
image: 'oven/bun:1-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0',
command: 'bun install --frozen-lockfile && bun run build',
inputs: [src],
egress: ['registry.npmjs.org'],
});
const test = step('test', {
image: 'oven/bun:1-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0',
command: 'bun test',
inputs: [build],
});
when.branch('main').then(() => {
canopy.environment('<your-environment-id>').deploy({ inputs: [test] });
});
});

Your file is never run to do the work - it’s run once, hermetically (frozen clock, no randomness, no network), to describe the work. That run produces a plain CpgGraph: a set of steps and their dependencies. Canopy schedules, caches, and executes that graph separately - the same separation a build tool like Bazel or Buck makes between defining a build and running one.

when.branch('main').then(...) runs its callback immediately, at compile time, if the predicate matches the real commit/ref this run is for - never deferred, never re-evaluated later. A graph compiled for a feature branch simply has no deploy node in it, not a skipped one. Other matchers: when.tag(pattern), when.event(name), when.pullRequest(), and when.untrustedPullRequest({ acknowledgeRisk: true }) for the one sanctioned way to let a fork-PR run reach a real secret or a deploy at all (policy-gated, rendered clearly in a plan diff).

What each step actually runs in

Every step (an ExecOp in the graph) runs in its own sandboxed container:

  • Digest-pinned images only. oven/bun:1-alpine:latest is rejected at validation time - a floating tag would make two builds of the same commit non-reproducible. Pin to a @sha256:... digest.
  • No network by default. Declare exactly the hosts a step needs via egress: ['registry.npmjs.org', ...] - plain hostnames or a single leading wildcard label (*.example.com). An undeclared host is not reachable, not silently allowed.
  • Declared resources. resources: { memoryMb, cpus, pidsLimit } and timeoutSeconds - a step that doesn’t declare enough for what it actually does fails clearly (OOM/timeout), rather than starving something else silently.
  • Cache mounts, not a shared filesystem: cache.mount('/root/.bun') from the cache helper - persists across runs, keyed by path (or an explicit key), never shared between unrelated steps.
  • Secrets, never inlined: secret('NAME') from the secret helper resolves a value stored in Vault at run time - the pipeline file itself never contains one. Manage them with canopy pipeline-secrets (see below).

A step that hasn’t changed - by actual content, not by name - doesn’t re-run; a step whose declared inputs are already known-cached is pruned from the plan before anything executes.

Editor/type support for a canopy.pipeline.ts outside a JS package

Working inside host/mcp/, cortex/api/, forest/, or the repo root - none of which have their own node_modules - your editor’s TypeScript server won’t resolve @canopy/ci on its own. This repo’s root tsconfig.pipelines.json maps it (and @canopy/docker) straight to the real sdk/packages/*/src for exactly this case:

Terminal window
bunx tsc -p tsconfig.pipelines.json --noEmit

Point your editor’s TypeScript server at that config for a canopy.pipeline.ts living outside a JS package, and you get real in-editor type checking with no package.json added to a Python/Go package just for this. canopy pipelines check (below) is self-sufficient and doesn’t need this - this is purely for editor/LSP support and a CI-independent tsc --noEmit pass.

Checking your pipeline before you push

Terminal window
canopy pipelines check ./canopy.pipeline.ts

Compiles, determinism-checks, and validates the file entirely offline

  • no network call, no Canopy account required. It reproduces the same double-compile Canopy itself runs server-side (frozen clock, seeded randomness) to catch nondeterministic authoring before it ever reaches a pipeline.generation_diverged incident, runs the same validate() the server runs, and prints the solved shape: topological order, critical path, dead nodes (no path to a deploy/promote/etc.), declared egress hosts, declared secret refs, and declared resources against the platform maximums. Exits non-zero on any failure, so it’s safe to use as a step in another CI system while you’re still on early access, or even inside your own canopy.pipeline.ts (step('pipeline-check', ...)) once it ships.

--ref, --commit, --commit-time, --event, --repository-id, and --untrusted let you simulate the context a real generation run would have (e.g. --untrusted --event pull_request to check what an untrustedPullRequest() branch compiles to for a fork PR); every one has a stub default so plain canopy pipelines check <file> works with no flags at all.

What this can’t check: plugin tarball integrity/yank status (needs the registry a real generation run verifies against - any canopy.plugins.lock entry is reported back labelled unverified) and server-side Stage-2 policy (untrusted-strip, promotion policy, plugin permission manifests). For those, push and use canopy pipelines validate/plan against the real generated graph.

Deploying, promoting, and the rest

canopy.environment(id) is the one place a pipeline reaches outside its own sandboxed steps, and it’s deliberately not a plugin surface - every method here emits a first-class PlatformOp node, gated by the exact same promotion-policy checks (require_evaluation_score, required approvals, etc.) that already govern these actions everywhere else in Canopy:

canopy.environment(id).deploy({ inputs: [test] });
canopy.environment(id).promote({ inputs: [test], fromEnvironmentId: stagingId });
canopy.environment(id).rollback({ inputs: [] });
canopy.environment(id).scale({ inputs: [] });
canopy.environment(id).invalidateCache({ inputs: [], cacheKey: 'edge' });

Importing an existing pipeline

Already have a CI config? canopy pipelines import converts it into a CPG instead of asking you to hand-author one from nothing:

Terminal window
canopy pipelines import <environment_id> --from github-actions --file .github/workflows/ci.yml --ref main --commit <sha>

Only --from github-actions is implemented today, and it’s deliberately partial: strategy.matrix, services:, if: conditionals, reusable workflows, and third-party marketplace actions have no CPG equivalent - these are reported as warnings in the output, never silently dropped.

Going the other way, canopy pipelines eject <graph_id> exports a stored graph back out as a plain GitHub Actions workflow (one job per node, preserving real parallelism) - the explicit anti-lock-in escape hatch, never the actual execution path Canopy itself uses.

CLI reference

CommandPurpose
canopy pipelines graph <build_id|graph_id>Show a compiled graph’s nodes, ops, and dependencies
canopy pipelines plan <graph_id|local-file>Solved graph (topo order, critical path, predicted cache hits); for a local file, the full validate → strip-untrusted → policy-check → cache-predict → sign pipeline. Executes nothing
canopy pipelines diff <graph_id> <against_graph_id>Structural diff - added/removed/changed nodes plus a policy delta (new egress hosts, new secret refs, new platform ops, removed gates)
canopy pipelines generate <environment_id> --ref <ref> --commit <sha>Compile that environment’s canopy.pipeline.ts hermetically and store the result
canopy pipelines import <environment_id> --from github-actions --file <path> --ref <ref> --commit <sha>Convert an existing CI config into a stored graph
canopy pipelines eject <graph_id> [--out <path>]Export a stored graph as a plain GitHub Actions workflow
canopy pipelines check <canopy.pipeline.ts>Offline: compile + determinism-check + validate a local .ts file, no network call. Prints the solved graph shape
canopy pipelines validate <local-file>Plan a local graph file, pass/fail only (exit code 1 on failure) - for a CI step
canopy pipelines run <graph_id>Real execution - an ExecOp runs in a real sandboxed container, a PlatformOp can trigger a real deploy/rollback/cache-invalidation. Asynchronous - poll with status
canopy pipelines status <graph_id>Per-node execution state for a graph’s most recent run
canopy pipelines approve-gate <graph_id> <node_id>Approve a blocked gate node and resume its run
canopy pipelines reject-gate <graph_id> <node_id>Reject a gate node - fails it and skips its transitive dependents
canopy pipeline-secrets set <name> <value> --team <team_id>Set (or rotate) a pipeline secret. Stored in Vault only, never in Postgres, never returned by any command
canopy pipeline-secrets list --team <team_id>List a team’s pipeline secret names (never values)
canopy pipeline-secrets rm <name> --team <team_id>Remove a pipeline secret

FAQ

Is this the same as Canopy Connect? No. Canopy Connect imports and correlates CI you already have - your existing GitHub Actions, GitLab CI, or Bitbucket Pipelines config, unmodified. Canopy Pipelines is for writing new pipeline logic yourself, in TypeScript.

Do I need a canopy.pipeline.ts file? No. It’s opt-in - without one, your project builds and deploys exactly as it does today.

What happens if a step fails? In gate mode (where every allowlisted environment runs today), the gate fails loudly and the deploy never reaches the legacy build path - not a warning buried in a log, a real stop, leaving your previous release untouched.

Is this available on every plan? Not yet - early access on a small number of allowlisted environments while the isolation and driver-mode work continues. Request early access →.