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
bun add @canopy/ci# or: npm install @canopy/ciPublished 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:
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:latestis 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 }andtimeoutSeconds- 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 thecachehelper - persists across runs, keyed by path (or an explicit key), never shared between unrelated steps. - Secrets, never inlined:
secret('NAME')from thesecrethelper resolves a value stored in Vault at run time - the pipeline file itself never contains one. Manage them withcanopy 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:
bunx tsc -p tsconfig.pipelines.json --noEmitPoint 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
canopy pipelines check ./canopy.pipeline.tsCompiles, 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_divergedincident, runs the samevalidate()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 owncanopy.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:
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
| Command | Purpose |
|---|---|
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 →.