---
title: CI/CD Integration
description: Run chkit schema validation, migration deployment, and type checking in continuous integration pipelines.
---

import { Tabs, TabItem } from '@astrojs/starlight/components';

chkit is designed to run unattended in CI pipelines. Every command supports a `--json` flag for machine-readable output, and the CLI automatically detects non-interactive environments so it never blocks on prompts.

Three primary CI use cases:

1. **Schema validation gate** — run `chkit check` and `chkit codegen --check` on every pull request to catch drift, stale types, and pending migrations before merge.
2. **Automated migration deployment** — run `chkit migrate --apply` on merge to main to apply pending migrations to staging or production ClickHouse.
3. **JSON output scripting** — parse structured output with `jq` to build custom notifications, dashboards, or approval gates.

## CI-relevant commands

| Command | CI usage | Key flags | Exit codes |
|---|---|---|---|
| `chkit check` | Validate schema consistency | `--strict`, `--json` | 0 = pass, 1 = fail |
| `chkit codegen --check` | Verify generated types are current | `--json` | 0 = current, 1 = stale |
| `chkit migrate --apply` | Apply migrations non-interactively | `--allow-destructive`, `--json` | 0 = ok, 1 = error, 3 = destructive blocked |
| `chkit status` | Report migration state | `--json` | 0 |
| `chkit drift` | Compare snapshot vs live schema | `--json` | 0 |

## Environment variables

chkit reads ClickHouse connection details from the environment. Set these as CI secrets:

| Variable | Purpose |
|---|---|
| `CLICKHOUSE_URL` | ClickHouse HTTP(S) endpoint |
| `CLICKHOUSE_USER` | Username |
| `CLICKHOUSE_PASSWORD` | Password |
| `CLICKHOUSE_DB` | Target database |

### Non-interactive detection

The CLI detects CI environments automatically. A command is considered non-interactive when any of these are true:

- `process.env.CI` is `'1'` or `'true'` (set by GitHub Actions, GitLab CI, and most CI providers)
- `process.stdin.isTTY` is falsy
- `process.stdout.isTTY` is falsy

When non-interactive, `chkit migrate` without `--apply` prints the migration plan and exits without prompting. Pass `--apply` explicitly to execute.

### Dynamic config for CI

Your `clickhouse.config.ts` can export a function that receives a `ChxConfigEnv` object with `command` and `mode` fields. Use this to vary config per environment:

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    import { defineConfig } from '@chkit/core'

    export default defineConfig((env) => ({
      schema: './schema/**/*.ts',
      clickhouse: {
        url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123',
        username: process.env.CLICKHOUSE_USER ?? 'default',
        password: process.env.CLICKHOUSE_PASSWORD ?? '',
        database: process.env.CLICKHOUSE_DB ?? 'default',
      },
    }))
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    import os

    from chkit import define_config

    config = define_config(
        lambda env: {
            "schema": "./schema/**/*.py",
            "clickhouse": {
                "url": os.environ.get("CLICKHOUSE_URL", "http://localhost:8123"),
                "username": os.environ.get("CLICKHOUSE_USER", "default"),
                "password": os.environ.get("CLICKHOUSE_PASSWORD", ""),
                "database": os.environ.get("CLICKHOUSE_DB", "default"),
            },
        }
    )
    ```
  </TabItem>
</Tabs>

## Check policies

The `chkit check` command evaluates three policies:

| Policy | Default | Effect when enabled |
|---|---|---|
| `failOnPending` | `true` | Fail if unapplied migrations exist |
| `failOnChecksumMismatch` | `true` | Fail if applied migration files were modified |
| `failOnDrift` | `true` | Fail if live schema drifts from snapshot |
| `failOnExtraObjects` | `false` | Fail if ClickHouse has objects not in your schema (off by default so chkit coexists with unmanaged tables on a shared database) |

The first three default to `true`, so checks are strict out of the box. `failOnExtraObjects` defaults to `false` — enable it only when chkit owns the entire database. Override them in your config:

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```ts
    export default defineConfig({
      schema: './schema/**/*.ts',
      check: {
        failOnPending: true,
        failOnChecksumMismatch: true,
        failOnDrift: false, // disable drift checking
      },
    })
    ```
  </TabItem>
  <TabItem label="Python">
    ```python
    config = define_config(
        {
            "schema": "./schema/**/*.py",
            "check": {
                "failOnPending": True,
                "failOnChecksumMismatch": True,
                "failOnDrift": False,  # disable drift checking
            },
        }
    )
    ```
  </TabItem>
</Tabs>

The `--strict` flag forces all three policies to `true`, overriding any config. **Use `--strict` in CI** to ensure no permissive config setting leaks through.

Plugin checks (like `codegen`) are also evaluated automatically — a plugin is considered failed when it reports at least one finding with `severity: 'error'`.

## GitHub Actions: schema validation on PRs

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```yaml
    name: Schema Validation
    on: pull_request

    jobs:
      validate:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4

          - uses: oven-sh/setup-bun@v2
            with:
              bun-version: '1.3.5'

          - run: bun install --frozen-lockfile

          - name: Check schema consistency
            run: bunx chkit check --strict --json

          - name: Verify generated types
            run: bunx chkit codegen --check --json
    ```
  </TabItem>
  <TabItem label="Python">
    ```yaml
    name: Schema Validation
    on: pull_request

    jobs:
      validate:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4

          - uses: actions/setup-python@v5
            with:
              python-version: '3.12'

          - run: pip install chkit-py

          - name: Check schema consistency
            run: chkit check --strict --json

          - name: Verify generated models
            run: chkit codegen --check --json
    ```
  </TabItem>
</Tabs>

This workflow runs on every pull request. Both commands exit with code 1 on failure, which fails the GitHub Actions step.

## GitHub Actions: migration deployment on merge

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```yaml
    name: Deploy Migrations
    on:
      push:
        branches: [main]

    jobs:
      deploy:
        runs-on: ubuntu-latest
        env:
          CLICKHOUSE_URL: ${{ secrets.CLICKHOUSE_URL }}
          CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
          CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
          CLICKHOUSE_DB: ${{ secrets.CLICKHOUSE_DB }}

        steps:
          - uses: actions/checkout@v4

          - uses: oven-sh/setup-bun@v2
            with:
              bun-version: '1.3.5'

          - run: bun install --frozen-lockfile

          - name: Apply migrations
            run: bunx chkit migrate --apply --json
    ```
  </TabItem>
  <TabItem label="Python">
    ```yaml
    name: Deploy Migrations
    on:
      push:
        branches: [main]

    jobs:
      deploy:
        runs-on: ubuntu-latest
        env:
          CLICKHOUSE_URL: ${{ secrets.CLICKHOUSE_URL }}
          CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
          CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
          CLICKHOUSE_DB: ${{ secrets.CLICKHOUSE_DB }}

        steps:
          - uses: actions/checkout@v4

          - uses: actions/setup-python@v5
            with:
              python-version: '3.12'

          - run: pip install chkit-py

          - name: Apply migrations
            run: chkit migrate --apply --json
    ```
  </TabItem>
</Tabs>

:::caution
This workflow does **not** pass `--allow-destructive`. If a migration contains destructive operations (dropping tables or columns), the command exits with code 3 and the deployment stops. See [Handling destructive migrations](#handling-destructive-migrations-in-ci) for how to handle this safely.
:::

## GitLab CI

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```yaml
    stages:
      - validate
      - deploy

    .bun-setup: &bun-setup
      image: oven/bun:1.3.5
      before_script:
        - bun install --frozen-lockfile

    schema-check:
      <<: *bun-setup
      stage: validate
      script:
        - bunx chkit check --strict --json
        - bunx chkit codegen --check --json
      rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"

    deploy-migrations:
      <<: *bun-setup
      stage: deploy
      script:
        - bunx chkit migrate --apply --json
      variables:
        CLICKHOUSE_URL: $CLICKHOUSE_URL
        CLICKHOUSE_USER: $CLICKHOUSE_USER
        CLICKHOUSE_PASSWORD: $CLICKHOUSE_PASSWORD
        CLICKHOUSE_DB: $CLICKHOUSE_DB
      rules:
        - if: $CI_COMMIT_BRANCH == "main"
    ```
  </TabItem>
  <TabItem label="Python">
    ```yaml
    stages:
      - validate
      - deploy

    .py-setup: &py-setup
      image: python:3.12-slim
      before_script:
        - pip install chkit-py

    schema-check:
      <<: *py-setup
      stage: validate
      script:
        - chkit check --strict --json
        - chkit codegen --check --json
      rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"

    deploy-migrations:
      <<: *py-setup
      stage: deploy
      script:
        - chkit migrate --apply --json
      variables:
        CLICKHOUSE_URL: $CLICKHOUSE_URL
        CLICKHOUSE_USER: $CLICKHOUSE_USER
        CLICKHOUSE_PASSWORD: $CLICKHOUSE_PASSWORD
        CLICKHOUSE_DB: $CLICKHOUSE_DB
      rules:
        - if: $CI_COMMIT_BRANCH == "main"
    ```
  </TabItem>
</Tabs>

## Generic CI setup

For any CI system, use this shell script:

<Tabs syncKey="lang">
  <TabItem label="TypeScript">
    ```sh
    #!/usr/bin/env bash
    set -euo pipefail

    bun install --frozen-lockfile

    # Validate schema
    bunx chkit check --strict --json
    bunx chkit codegen --check --json

    # Deploy on main branch only
    if [ "${BRANCH:-}" = "main" ]; then
      bunx chkit migrate --apply --json
    fi
    ```
  </TabItem>
  <TabItem label="Python">
    ```sh
    #!/usr/bin/env bash
    set -euo pipefail

    pip install chkit-py

    # Validate schema
    chkit check --strict --json
    chkit codegen --check --json

    # Deploy on main branch only
    if [ "${BRANCH:-}" = "main" ]; then
      chkit migrate --apply --json
    fi
    ```
  </TabItem>
</Tabs>

:::note
The remaining examples on this page are shown in the TypeScript form. For Python, the swap is always the same: replace the Bun setup steps with `pip install chkit-py` and drop the `bunx` prefix — the `chkit` commands, flags, JSON output, and exit codes are identical.
:::

## Handling destructive migrations in CI

When `chkit migrate --apply` encounters migrations that drop tables, drop columns, or perform other destructive operations, it exits with **code 3** instead of applying them. This is a safety gate — destructive changes require explicit opt-in.

### What triggers exit code 3

The CLI scans pending migration SQL for dangerous operations. When found and `--allow-destructive` is not passed, the command:

- In `--json` mode: emits an error payload with `destructiveMigrations` and `destructiveOperations` arrays, then exits with code 3
- In text mode: prints destructive operation details and exits with an error

### Safety gate pattern

**Never pass `--allow-destructive` by default.** Instead, use a two-step workflow:

1. **PR check** — `chkit check --strict` surfaces destructive operations during review
2. **Deploy step** — `chkit migrate --apply` blocks on destructive ops, requiring manual approval

### Destructive operation JSON payload

Each entry in the `destructiveOperations` array contains:

| Field | Description |
|---|---|
| `migration` | Migration file name |
| `type` | Operation type (e.g., `drop_table`, `drop_column`) |
| `key` | Affected object identifier |
| `risk` | Risk level (`danger`) |
| `warningCode` | Machine-readable warning code |
| `reason` | Human-readable explanation |
| `impact` | Description of data impact |
| `recommendation` | Suggested action |
| `summary` | One-line summary |

### GitHub Actions manual approval gate

Add a manual approval step before allowing destructive migrations:

```yaml
name: Deploy Migrations
on:
  push:
    branches: [main]

jobs:
  migrate:
    runs-on: ubuntu-latest
    env:
      CLICKHOUSE_URL: ${{ secrets.CLICKHOUSE_URL }}
      CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
      CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
      CLICKHOUSE_DB: ${{ secrets.CLICKHOUSE_DB }}
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: '1.3.5'
      - run: bun install --frozen-lockfile

      - name: Apply safe migrations
        id: migrate
        run: bunx chkit migrate --apply --json
        continue-on-error: true

      - name: Check for destructive block
        if: steps.migrate.outcome == 'failure'
        run: |
          if [ "${{ steps.migrate.outputs.exitcode }}" = "3" ]; then
            echo "::error::Destructive migrations detected. Approve the environment deployment to proceed."
            exit 1
          fi
          echo "::error::Migration failed with a non-destructive error."
          exit 1

  migrate-destructive:
    needs: migrate
    if: failure()
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval in GitHub settings
    env:
      CLICKHOUSE_URL: ${{ secrets.CLICKHOUSE_URL }}
      CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
      CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
      CLICKHOUSE_DB: ${{ secrets.CLICKHOUSE_DB }}
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: '1.3.5'
      - run: bun install --frozen-lockfile

      - name: Apply with destructive allowed
        run: bunx chkit migrate --apply --allow-destructive --json
```

The `environment: production` line requires manual approval in GitHub's environment protection rules before the destructive migration job runs.

## JSON output for CI scripts

All commands wrap their output in a standard envelope when `--json` is passed:

```json
{
  "command": "check",
  "schemaVersion": 1,
  ...
}
```

The `command` field identifies which command produced the output. The `schemaVersion` field (currently `1`) tracks the output contract version for forward compatibility.

### Parsing with jq

**Check pass/fail:**

```bash
result=$(bunx chkit check --strict --json)
ok=$(echo "$result" | jq -r '.ok')
if [ "$ok" != "true" ]; then
  echo "Check failed:"
  echo "$result" | jq '.failedChecks'
  exit 1
fi
```

**Pending migration count:**

```bash
pending=$(bunx chkit status --json | jq '.pending')
echo "Pending migrations: $pending"
```

**Destructive operation details:**

```bash
bunx chkit migrate --apply --json 2>&1 || true
# If exit code 3, parse destructive details:
bunx chkit migrate --apply --json | jq '.destructiveOperations[] | {migration, type, key, impact}'
```

## Complete combined workflow

A production-ready GitHub Actions workflow with validation and deployment as separate jobs:

```yaml
name: Schema CI/CD
on:
  pull_request:
  push:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: '1.3.5'

      - run: bun install --frozen-lockfile

      - name: Schema check
        run: bunx chkit check --strict --json

      - name: Type check
        run: bunx chkit codegen --check --json

  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: validate
    runs-on: ubuntu-latest
    env:
      CLICKHOUSE_URL: ${{ secrets.CLICKHOUSE_URL }}
      CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
      CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
      CLICKHOUSE_DB: ${{ secrets.CLICKHOUSE_DB }}

    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: '1.3.5'

      - run: bun install --frozen-lockfile

      - name: Apply migrations
        run: bunx chkit migrate --apply --json
```

The `validate` job runs on both PRs and pushes. The `deploy` job runs only on pushes to `main` and waits for `validate` to pass first.

## Tips and troubleshooting

**No Docker required** — Bun runs natively on Linux CI runners. For Docker-based CI, use the `oven/bun:1.3.5` image.

**Non-interactive auto-detection** — The CLI never prompts in CI. If you see unexpected prompts, ensure `CI=true` is set or that stdin is not a TTY.

**Always use `--frozen-lockfile`** — Prevents `bun install` from modifying `bun.lock` during CI runs.

**Custom config path** — If your config file is not at the project root, pass `--config path/to/clickhouse.config.ts`.

**Recovering a failed migration** — A migration that fails part-way stays in progress, and the next run resumes it. If the fix is an edit to the migration file, the next `chkit migrate --apply` runs the file from statement 1 when no statement is recorded as completed. Otherwise run `chkit migrate --apply --retry <file>` once against that environment, for example from a manually triggered job, or reset the partial run with `chkit migrate --abandon <file> --apply`. See [failed migrations](/cli/migrate/#failed-migrations).

**Drift requires a live connection and snapshot** — `chkit drift` compares the snapshot file against the live ClickHouse schema. It needs both a snapshot (generated by `chkit generate`) and a reachable ClickHouse instance with credentials configured.
