# CI/CD integration

Run the comvi CLI in CI: check generated types on every build, push translation files on merge, promote content between projects on release, and gate on coverage.

Canonical: https://comvi.io/docs/platform/recipes/ci-cd-integration

The CDN serves translations at runtime, so a pipeline only bundles them when the app reads local files. CI is for the
checks around the edges: proving generated types still match the project, uploading
translation files a developer changed, and promoting approved content on a release.

| Command | What it does | When to run it |
|---|---|---|
| `comvi typegen --check` | Compares the types file on disk with the project schema and exits non-zero when stale | Every build |
| `comvi typegen` | Regenerates the types file in place | When you regenerate instead of committing types |
| `comvi push` | Uploads the JSON files under `translationsPath` | After a developer edits translation files |
| `comvi pull` | Downloads translations into local JSON files | Builds that bundle translations, and promotions |

`@comvi/cli` installs `comvi` and `comvi-typegen`; both point at the same program. There is
no source-code extractor: `comvi push` reads translation files, not `t('...')` calls.

## Set up

1. **Create a CI key** under **Settings → API Keys** with the matching preset: **CLI pull**
   (`translations:read`), **CLI push/import** (`translations:read`, `translations:write`)
   or **Type generation** (`schema:read`). The dashboard sets no expiry (the REST API accepts `expiresAt`); rotate by creating a
   replacement, swapping the secret and deleting the old one. See
   [API keys](/docs/platform/api-keys).

2. **Store the key as a CI secret** named `COMVI_API_KEY`: GitHub under
   **Settings → Secrets and variables → Actions**, GitLab under **Settings → CI/CD →
   Variables** (masked). The CLI reads `COMVI_API_KEY` and `COMVI_API_BASE_URL` from the
   environment; both override the config file, and the config file has no variable
   substitution.

3. **Commit a `.comvirc.json`** with everything except the key:

   `.comvirc.json`

   ```json
   {
     "apiBaseUrl": "https://api.comvi.io",
     "translationsPath": "./src/locales",
     "fileTemplate": "{namespace}/{languageTag}.json",
     "format": "json",
     "outputPath": "src/types/i18n.d.ts"
   }
   ```

## Check generated types

`.github/workflows/ci.yml`

```yaml
- name: Check translation types
run: npx --package @comvi/cli comvi typegen --check
env:
    COMVI_API_KEY: ${{ secrets.COMVI_API_KEY }}

- run: npm run build
```

| Exit code | Meaning |
|---|---|
| `0` | The types file matches the project |
| `1` | The types file is outdated |
| `2` | The check could not run: connection, auth, timeout or an unreadable response |
| `4` | Invalid config, or a schema that cannot be emitted |

`--check` never writes. Exit 2 is separate from exit 1 so a flaky network reads differently
from real drift. To regenerate instead of committing the file, drop `--check` and run
`comvi typegen` before the build.

## Push translation files on merge
**GitHub Actions**

`.github/workflows/sync-translations.yml`

```yaml
name: Sync translation files
on:
    push:
      branches: [main]

jobs:
    push-files:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4
        - uses: actions/setup-node@v4
          with: { node-version: 24 }
        - run: npm ci
        - name: Preview the push
          run: npx --package @comvi/cli comvi push --dry-run
          env:
            COMVI_API_KEY: ${{ secrets.COMVI_API_KEY }}
        - name: Push
          run: npx --package @comvi/cli comvi push --force-mode keep
          env:
            COMVI_API_KEY: ${{ secrets.COMVI_API_KEY }}
```

**GitLab CI**

`.gitlab-ci.yml`

```yaml
push-files:
    stage: post-merge
    only:
      - main
    script:
      - npm ci
      - npx --package @comvi/cli comvi push --force-mode keep
    variables:
      COMVI_API_KEY: $COMVI_API_KEY
```

A local value that differs from the value in Comvi is a conflict, and `--force-mode`
decides: `override` uploads local values over remote ones, `keep` uploads only the values
that do not conflict, `abort` stops on the first conflict. The default, `ask`, prompts on
the terminal and throws in CI with `--force-mode ask requires an interactive terminal`.
`--dry-run` prints how many keys would be created, how many translations updated and how
many conflict, without writing.

> **A push writes Translated**
>
> Every value a push uploads gets the `Translated` status, with the key's owner recorded as
> reviewer. Review before the push, not after it.

## Promote staging to production on release

`.github/workflows/promote.yml`

```yaml
name: Promote translations to production
on:
release:
    types: [published]

jobs:
promote:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }

      - name: Pull from staging
        run: npx --package @comvi/cli comvi pull --path ./promote
        env:
          COMVI_API_KEY: ${{ secrets.COMVI_STAGING_KEY }}

      - name: Push to production
        run: npx --package @comvi/cli comvi push --path ./promote --force-mode keep
        env:
          COMVI_API_KEY: ${{ secrets.COMVI_PROD_KEY }}
```

The API key decides which project the CLI talks to. `comvi pull` has no status filter, so
an unreviewed draft in staging arrives in production as `Translated`: finish the review in
staging before the release runs, or promote by hand with **Export** filtered to `Translated`
and **Import**. See [Staging to production](/docs/platform/recipes/staging-production).

`Terminal`

```bash
comvi pull --path ./promote --locale de,fr --ns checkout
```

`--locale` and `--ns` take comma-separated lists and fully replace the `locales` and
`namespaces` arrays in `.comvirc.json`. A pull exits 4 if the server does not return a
locale or namespace you asked for, so a typo fails the job instead of writing empty files.

## Gate on coverage

`Terminal`

```bash
# PROJECT_ID: the id field of GET /v1/project
curl -sf -H "Authorization: Bearer $COMVI_API_KEY" \
"https://api.comvi.io/v1/projects/$PROJECT_ID/locales/stats" \
| jq -e '.[] | select(.localeCode == "de") | .translatedPct >= 95'
```

`GET /v1/projects/:id/locales/stats` returns one row per locale with counts and percentages
for `translated`, `notReviewed` and `notTranslated`; the key needs `translations:read`.
`jq -e` exits 1 when the expression is false. Percentages count only `Translated`, so a
language full of unreviewed drafts reads as low coverage. Locale codes in responses are
canonical BCP-47 (`pt-BR`, `zh-Hans`), so match on the hyphenated form.

## Pull on every build

`.github/workflows/build.yml`

```yaml
- run: npx --package @comvi/cli comvi pull
env:
    COMVI_API_KEY: ${{ secrets.COMVI_API_KEY }}
- run: npx --package @comvi/cli comvi typegen
- run: npm run build
```

For an app that bundles translation files instead of reading the CDN, pull before the build so every deploy carries the latest published values and the repository never stores them. See [comvi pull](/docs/cli/pull).

## Webhook or CLI on every build

Running the CLI on every build is deterministic, but a fresh copy lands only on the next
build. A [webhook](/docs/platform/integrations/webhooks) on `translation.updated` pointed at
GitHub's `repository_dispatch` API or a GitLab pipeline trigger token starts the build within
minutes of a change. Apps that read the CDN at runtime need neither.

## Troubleshooting

- **`comvi push` uploaded nothing.** Push reads files, not code. Check the key is in a JSON
file under `translationsPath` matching `fileTemplate`, and that every locale you pushed
exists in the project; the import endpoint skips a locale the project does not have and
still reports success. An unknown `--ns` or `--locale` on push exits `0` with "No
translation files found".
- **"requires an interactive terminal".** The run hit a conflict while `--force-mode` was
`ask`. Pass `override`, `keep` or `abort`, or set `push.forceMode` in `.comvirc.json`.
- **Exit 4.** Invalid configuration or failed validation: a `.comvirc.json` that is not
valid JSON, an empty `namespaces` or `locales` array (omit the field instead), a `--ns` or
`--locale` the project lacks on pull, an unrecognised `--force-mode`, or a `--dotenv` path
that does not exist. A missing `.comvirc.json` exits `1` instead, the same code as a stale
types file.
- **CI sees older translations than translators.** The CDN response is cached for 30
seconds and auto-deploy waits out a 5-second debounce; a CI cache in front of that can
hold a much older copy.

## Next steps

- [API keys](/docs/platform/api-keys): Create and rotate CI credentials.
- [Staging to production](/docs/platform/recipes/staging-production): The promotion pattern.
- [CLI](/docs/cli): pull, push and typegen reference.
