# CI Builds



`ccp ci` runs a one-shot CI build in an isolated build machine, streams its logs live, and **exits with the build's own exit code**. That last part is the whole point: because a failing build exits non-zero, `ccp ci` composes directly into a shell gate.

```bash
ccp ci && ccp deploy --prod
```

The build runs in a Firecracker microVM cloned from the platform's `build-machine` template — an 8 GB memory ceiling with warm Go and Cargo toolchains. Each build clones your repo fresh and starts with no project dependencies; declare a [`cache` block](#dependency-caching) to reuse downloaded dependencies across builds. The VM is always torn down when the build finishes, whether it passed, failed, timed out, or was cancelled.

CI is the *produce-and-verify* half of the platform; [compute](/docs/ccp/compute) is the *run-the-artifact* half. Use `ccp ci` to prove a commit builds and its tests pass, then ship it with `ccp deploy` or `ccp compute deploy`.

## Quickstart [#quickstart]

Add a `.cluster-ci.yaml` to your repo describing the build steps:

```yaml
version: 1
timeout_sec: 1800
steps:
  - run: go build ./...
  - run: go test ./...
```

Commit and push it, then run the build from the repo root:

```bash
ccp ci
```

`ccp ci` streams each step's output to your terminal as it runs and, on completion, exits with the build's exit code — `0` when every step passed, non-zero when one failed.

## What Gets Built [#what-gets-built]

`ccp ci` builds &#x2A;*the pushed git remote, not your local working tree.** The build machine clones the repository fresh, so only commits you've pushed are part of the build.

From the target directory (default `.`) the CLI derives the job from git:

| Field        | Source                                                                                                                               |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `repo_url`   | `git remote get-url origin`, normalized from SSH to HTTPS — must be credential-free (no embedded username/token, query, or fragment) |
| `commit_sha` | `git rev-parse HEAD`, the full 40-character commit SHA                                                                               |
| `git_ref`    | the current branch — override with `--git-ref`; this is **display metadata only**, the server checks out `commit_sha` exactly        |

The build machine injects your credentials through Git's own credential mechanism rather than a token embedded in the clone URL, then clones the unmodified `repo_url` and checks out `commit_sha` in detached state — the branch is never used to resolve what gets built.

If your local `HEAD` differs from `origin/<ref>`, `ccp ci` prints a `⚠` warning (the build clones the remote, so commit and push first) but still submits — handy when you intentionally want to build a ref other than your working state. On a detached `HEAD` it errors cleanly instead of guessing a ref.

## Build Logs [#build-logs]

`ccp ci` streams each step's output to your terminal live, and that output is **secret-masked** before it reaches you. The build machine clones private repos using credentials injected through Git's credential mechanism (never embedded in the clone URL); if a build step ever echoes one of those values, it is redacted to `***` in the streamed logs rather than shown in the clear. Masking is chunk-split-safe, so a token split across two output chunks is still caught.

The public credential values the build machine uses — `github.com` and your git username — are **not** masked, so ordinary clone output reads normally. Masking covers only the token the platform injects on your behalf; you should still never print your own secrets from a build step.

## The `.cluster-ci.yaml` Spec [#the-cluster-ciyaml-spec]

Build steps live in `.cluster-ci.yaml` in the target directory, or in a file you point at with `-f`:

```yaml
version: 1
timeout_sec: 1800
steps:
  - run: cargo build --release
  - run: cargo test
  - run: cargo clippy -- -D warnings
```

Steps run in order. The **first non-zero step stops the run** — later steps are skipped and the build is reported as failed.

The CLI converts the YAML to JSON and ships it verbatim as the job spec; it does no schema validation of its own. The **server is the sole validator** — a malformed spec is rejected with a `400`, so a typo in `.cluster-ci.yaml` surfaces as a clear submit-time error rather than a confusing mid-build failure.

## Dependency Caching [#dependency-caching]

By default every build starts from the `build-machine` template with warm toolchains but **no project dependencies**, so it re-downloads its entire dependency set — usually the dominant cost of a cold build. Declare a `cache` block to persist those downloads and reuse them across builds:

```yaml
version: 1
cache:
  - ecosystem: go
  - ecosystem: cargo
steps:
  - run: go build ./...
  - run: go test ./...
```

Each entry names an `ecosystem`. The cache is keyed on your lockfile (plus the toolchain version and CPU architecture), so a build reuses a previous build's downloads whenever the lockfile is unchanged:

| Ecosystem               | Cached                   | Keyed on     |
| ----------------------- | ------------------------ | ------------ |
| `go`                    | yes                      | `go.sum`     |
| `cargo`                 | yes                      | `Cargo.lock` |
| `node`, `bun`, `python` | accepted, not yet cached | —            |

`node`, `bun`, and `python` are valid `ecosystem` values — they pass validation — but are a **no-op** for now: declaring them does nothing until caching lands for them. Any other value is rejected at submit time with a `400`.

Before the steps run, the platform restores the cache for each ecosystem whose lockfile is present in the repo; after a build **passes** on a cache miss, it saves the freshly-downloaded dependencies for the next build. Caching is **best-effort** — a miss, or a cache that's temporarily unavailable, never fails an otherwise-passing build; it just falls back to a cold download. The build log reports the outcome per ecosystem:

```
✓ dependency cache hit: go
• dependency cache miss: cargo (cold build, will populate)
```

An entry whose lockfile is absent from the repo is skipped silently — there's nothing to key the cache on.

## Command and Flags [#command-and-flags]

```bash
ccp ci [DIRECTORY] [-f FILE] [--org-id ORG] [--git-ref REF] [--no-wait] [--json]
```

| Flag            | Description                                                                                                                                                                   |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DIRECTORY`     | Directory holding `.cluster-ci.yaml` and the git repo. Default `.`.                                                                                                           |
| `-f` / `--file` | Spec file override. Default `<DIRECTORY>/.cluster-ci.yaml`.                                                                                                                   |
| `--org-id`      | Owning organization. Skips the org picker; **required in headless mode if you belong to more than one org** (or set `CCP_ORG_ID`).                                            |
| `--git-ref`     | Ref to build. Default: the current branch.                                                                                                                                    |
| `--no-wait`     | Submit the build and print its job id, then return immediately — don't stream logs or block.                                                                                  |
| `--json`        | Suppress the streamed logs and print a single machine-parseable result line on completion. The process still exits with the build's exit code. This is the agent-facing mode. |

## Exit Codes [#exit-codes]

`ccp ci` forwards the build's outcome as its own exit code, which is what makes it usable as a gate:

| Build status | Exit code                                  |
| ------------ | ------------------------------------------ |
| `passed`     | the steps' exit code (`0`)                 |
| `failed`     | the failing step's exit code (default `1`) |
| `timed_out`  | `124`                                      |
| `cancelled`  | `130`                                      |

So `ccp ci && ccp deploy --prod` deploys only when the build passes, and a CI runner that checks `$?` sees the real failure code.

## Failure Reasons [#failure-reasons]

When a build doesn't pass, the final line names *why*. In human mode the reason is appended in parentheses, and in `--json` it's the `error` field:

```bash
ccp ci
# ...
# build failed — 43def05c (checkout_credential_unavailable)
```

The reason is a stable, branchable code:

| Reason                            | Meaning                                                                                                                                                                                                                                                 |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `checkout_credential_unavailable` | The clone ran without a usable checkout credential and the repo rejected it — the owning user has no linked provider account (or its credential expired). Relink your provider account. This is the usual cause for a **private** repo that fails fast. |
| `clone_failed`                    | A checkout credential *was* available but the clone still failed: a missing repo, a commit not present at `commit_sha`, or the account lacks access to that specific repo.                                                                              |
| `no_capacity`                     | No build node had room (the `build-machine` needs 8 GB). Retry.                                                                                                                                                                                         |
| `setup_failed`                    | An infra-side setup failure (build VM / envd not reachable), not a problem with your repo or GitHub. Retry; if it persists it's an outage.                                                                                                              |
| `step N exited M`                 | Build step N failed (the common case); the exit code is `M`.                                                                                                                                                                                            |
| `spec_invalid`                    | The stored `.cluster-ci.yaml` spec was unparseable.                                                                                                                                                                                                     |

A reason that merely mirrors the status (`passed`, `timed_out`, `cancelled`) is suppressed, so only the informative cases get the parenthetical.

Submitting an unsafe or incomplete checkout (a credential embedded in `repo_url`, a non-40-character `commit_sha`, etc.) is rejected at submit time with a `400 invalid_checkout`, before a build is ever queued.

## Examples [#examples]

```bash
ccp ci                          # build + stream the current repo, gate on the exit code
ccp ci --json                   # agent mode: one JSON result line, real exit code
ccp ci --no-wait                # fire-and-forget — prints the job id and returns
ccp ci ../other-repo --git-ref main   # build a different repo + ref
```

## Headless / Agent Use [#headless--agent-use]

`ccp ci` is headless-safe. Set `CCP_HEADLESS=1` and provide a session token via `CCP_SESSION_TOKEN`:

```bash
export CCP_HEADLESS=1
export CCP_SESSION_TOKEN=$(... fetched from the operator's machine ...)

# Multi-org accounts must name the org; single-org auto-resolves:
ccp ci --org-id "$ORG_ID"
```

It needs a git repo with an `origin` remote and a `.cluster-ci.yaml` (or `-f FILE`). For AI agents and scripts, add `--json` to suppress the streamed logs and get one parseable result line:

```bash
ccp ci --json
# {"id":"...","status":"passed","exit_code":0,"error":null}
```

`--no-wait --json` instead returns as soon as the build is submitted, with just the id and status:

```bash
ccp ci --no-wait --json
# {"id":"...","status":"..."}
```

Either way the streaming run's process exits with the build's exit code, so an agent can branch on `$?` without parsing the JSON. A build can also be cancelled from another shell using its job id; the build VM is always torn down on completion regardless of how the build ends.

See [Headless mode](/docs/ccp/headless) for the full non-interactive pattern.

## Next Steps [#next-steps]

* [Compute Services](/docs/ccp/compute) — run the artifact a passing build produced
* [Deploying](/docs/ccp/deploying) — ship a function once `ccp ci` is green
* [Headless mode](/docs/ccp/headless) — wire `ccp ci` into CI pipelines and agent loops
