CI and exit codes

Gate a pipeline on translation state with check or diff, read the exit-code contract, and consume the JSON output.

This guide shows how to make CI fail when translations drift out of sync, and how to read what verbatra reports when it does. The tools are the exit-code contract every command follows and the --json output your scripts can parse.

verbatra is a dev dependency, so the commands you run locally run the same in CI. The usual split:

The exit codes

The exit code is the contract your CI step branches on:

CodeMeaning
0success: translate or import finished every locale complete, check found every locale in sync, diff found no pending changes, export wrote its workbook, init scaffolded the project, watch or studio stopped cleanly, or --help or --version was printed
1it ran, but the result is not clean: translate or import finished with at least one failed or partial locale, check found a locale out of sync, diff found a missing or changed key, or studio failed while shutting its server down
2could not run: a whole-run error (bad config, unreadable source, corrupt lock file), a usage error (an empty or unknown --locales value, an invalid --debounce or --port), init without a resolvable provider or unable to scaffold a valid config, watch failing to start or to stop, or studio unable to load the config, import @verbatra/studio, or start its server
130watch or studio was force-stopped by a second interrupt

Some edge cases worth knowing:

  • A partial locale counts as failure. It means the file was written but some keys are still missing, usually because a provider sub-batch failed or the integrity gate refused a translation. It exits 1 exactly like a failed locale, because a half-translated locale on disk is not a state a pipeline should wave through. Read partial on the summary to tell the two apart.
  • A corrupt lock file is a whole-run error in translate and import alike, never one failed locale. It is a single shared file, so a lock file that turns corrupt while the run is under way stops the run with 2 rather than carrying on through the remaining locales. Whatever was written before the abort stays on disk; fix the lock file and re-run the command.
  • A single interrupt is a clean stop, and both watch and studio exit 0 on it. Do not assume the two behave alike beyond that: if the stop itself fails, watch exits 2 and studio exits 1.
  • A failed run during watch shows up as a record on the output stream, never as a non-zero exit code.
  • export has no per-locale failure mode: it exits 0 or 2, never 1.
  • One failure mode sits outside the contract: a parse failure that is not a usage error is re-thrown, and the binary does not catch it, so Node's default handling of an unhandled rejection applies instead of any of the four codes.

check or diff: picking the gate

check and diff run the same read-only computation over your source, target files, and the lock file. The difference is what they report:

# counts per locale: exit 1 if any locale is missing or stale
verbatra check

# key lists per locale: exit 1 if any locale has keys to add or re-translate
verbatra diff

Use check when the exit code is all you need. Use diff when you want the exact keys behind the drift, say, to post them in a pull request comment. Orphaned keys (in a target file but gone from source) appear in diff output but never set exit code 1 on their own.

Both take --locales de,fr to gate a subset. Passing --locales with no valid locale is a usage error and exits 2, so a typo can never turn the gate green. translate, watch and export take the same flag, which is how you translate one locale at a time against a rate-limited provider.

JSON output

Six commands accept --json for machine-readable output on stdout: translate, watch, check, diff, export, and import. Every record is one line, and every record has the same envelope around it, so you branch on one field and never have to guess which command's payload you are holding:

type Envelope<TResult> =
  | { ok: true; version: 1; command: string; result: TResult }
  | { ok: false; version: 1; command: string | null; code: string; message: string };

version is the version of this envelope shape, not the package version. It is an integer so you compare it with === rather than range-parsing it, and it changes only when an existing field changes meaning or disappears. New fields can appear without a bump, so ignore the ones you do not recognize.

A run that fails as a whole writes exactly one ok: false record to stdout and exits 2. Its code is the same stable error code the stderr line carries, so it is what you branch on:

{ "ok": false, "version": 1, "command": "translate", "code": "CONFIG_INVALID", "message": "..." }

command is null only when the failure happened before a subcommand was resolved.

Errors also go to stderr as one structured line (verbatra: error [CODE] message) in both modes, unchanged, so a script that reads the exit code and stderr needs no update. Without --json, a failing run still writes nothing at all to stdout. Progress and lock-wait records always go to stderr, so stdout carries nothing but envelopes.

The rest of this section describes the result each command puts inside a successful envelope.

verbatra translate --json and verbatra import --json carry one RunSummary:

interface RunSummary {
  dryRun: boolean;          // whether this was a dry run (no provider calls, no writes)
  locales: LocaleSummary[]; // one entry per target locale, in config order
  succeeded: string[];      // locales whose run succeeded
  partial: string[];        // locales written with keys still missing; these exit 1 too
  failed: string[];         // locales whose run failed
  usage?: UsageSummary;     // summed input/output tokens; absent when no call reported usage
  budget?: RunBudget;       // the token-budget outcome; present only when maxTokens is configured
}

Each LocaleSummary carries the per-locale key lists (translated, unchanged, orphaned, withheld, flagged for review, and more); see the SDK reference for the full anatomy.

verbatra watch --json prints one envelope per run as NDJSON (one JSON object per line), with command: "watch". A succeeded run is an ok: true record carrying that run's RunSummary; a failed run is an ok: false record carrying its code and message. A failed run is just a record on the stream: it neither stops the watcher nor changes the exit code.

verbatra check --json carries one status document. The top-level inSync is true exactly when the command exits 0:

interface CheckSummary {
  inSync: boolean;             // true exactly when the command exits 0
  locales: LocaleCheckSummary[];
}

interface LocaleCheckSummary {
  locale: string;
  missing: number;             // in source, absent from target
  stale: number;               // source changed since last translated
  upToDate: number;            // target matches the recorded baseline
  inSync: boolean;             // missing === 0 && stale === 0
}

verbatra diff --json carries key lists instead of counts. The top-level hasPendingChanges is true exactly when the command exits 1:

interface DiffSummary {
  hasPendingChanges: boolean;  // true exactly when the command exits 1
  locales: LocaleDiff[];
}

interface LocaleDiff {
  locale: string;
  missing: string[];           // in source, absent from target: would be added
  changed: string[];           // source changed since last translated: would be re-translated
  orphaned: string[];          // in target, absent from source: reported only
  hasPendingChanges: boolean;  // missing.length > 0 || changed.length > 0
}

verbatra export --json carries where the workbook went and the per-locale row counts:

{
  path: string;                // absolute path of the written workbook
  locales: { locale: string; rows: number }[];
}

A GitHub Actions job with the CLI

A drift gate on pull requests, running the CLI directly. check never calls a provider, so this job needs no API key at all:

name: i18n
on: pull_request

permissions:
  contents: read

jobs:
  check-translations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<commit-sha>
      - uses: pnpm/action-setup@<commit-sha>
      - uses: actions/setup-node@<commit-sha>
        with:
          node-version: 22
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec verbatra check

To translate in CI instead, swap the last step for translate and pass the provider key from your secret store as the environment variable your provider expects (see Providers):

      - run: pnpm exec verbatra translate --json
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

If you would rather not write this job yourself, the GitHub Action wraps the translate variant with annotations and a job summary.

Frozen installs and keys

  • Install from the lockfile. pnpm install --frozen-lockfile (or npm ci) pins the exact @verbatra/cli release your lockfile records, so a CI run is reproducible and cannot silently pull a newer release. The CLI requires Node >=22.14.0.
  • Keys are environment variables, never flags. The CLI takes no key argument and reads no key from config; providers read only their environment variable (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, DEEPL_API_KEY). Store the key in your CI secret store and map it into env. Error messages name the variable but never contain a key value.
  • Read-only gates need no key. check, diff, and export never call a provider, so keep secrets out of those jobs entirely.

translate, watch, and studio also load .env.local and then .env from the working directory before they run, with real environment variables always winning; check, diff, export, and import load no .env files, so in CI you will normally rely on env: alone.

Edit on GitHub