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:
- Gate a pull request with
verbatra checkorverbatra diff. Both are read-only: no provider call, no API key needed, exit1on drift. - Translate on a push with
verbatra translate, either directly (this page) or through the GitHub Action.
The exit codes
The exit code is the contract your CI step branches on:
| Code | Meaning |
|---|---|
0 | success: 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 |
1 | it 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 |
2 | could 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 |
130 | watch 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
1exactly like a failed locale, because a half-translated locale on disk is not a state a pipeline should wave through. Readpartialon the summary to tell the two apart. - A corrupt lock file is a whole-run error in
translateandimportalike, 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 with2rather 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
watchandstudioexit0on it. Do not assume the two behave alike beyond that: if the stop itself fails,watchexits2andstudioexits1. - A failed run during
watchshows up as a record on the output stream, never as a non-zero exit code. exporthas no per-locale failure mode: it exits0or2, never1.- 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 diffUse 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 checkTo 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(ornpm ci) pins the exact@verbatra/clirelease 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 intoenv. Error messages name the variable but never contain a key value. - Read-only gates need no key.
check,diff, andexportnever 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.
Estimating cost
Size a translation run before you spend: count the keys with a dry run, turn them into requests and tokens, then price them with your provider's own rates.
GitHub Action
Run verbatra translate in GitHub Actions with the composite action: inputs, secret wiring, annotations, and the job summary.