CI und Exit-Codes

Sichere eine Pipeline mit check oder diff gegen Übersetzungsdrift ab, lies den Exit-Code-Vertrag und verarbeite die JSON-Ausgabe.

Maschinell übersetzte Seite

Diese Seite wurde automatisch übersetzt und kann daher Fehler oder seltsame Formulierungen enthalten. Die englische Version ist die maßgebliche Quelle. Das englische Original lesen.

Diese Anleitung zeigt, wie du CI fehlschlagen lässt, wenn Übersetzungen auseinanderlaufen, und wie du liest, was verbatra dann meldet. Die Werkzeuge dafür sind der Exit-Code-Vertrag, dem jeder Befehl folgt, und die --json-Ausgabe, die deine Skripte parsen können.

verbatra ist eine Dev-Dependency, die Befehle laufen in CI also genauso wie lokal. Die übliche Aufteilung:

Die Exit-Codes

Der Exit-Code ist der Vertrag, auf den dein CI-Schritt verzweigt:

CodeBedeutung
0Erfolg: translate oder import schloss jedes Locale vollständig ab, check fand jedes Locale synchron, diff fand keine ausstehenden Änderungen, export schrieb seine Arbeitsmappe, init legte das Projekt an, watch oder studio wurde sauber beendet, oder --help beziehungsweise --version wurde ausgegeben
1der Lauf kam durch, aber das Ergebnis ist nicht sauber: translate oder import endete mit mindestens einem fehlgeschlagenen oder partial gebliebenen Locale, check fand ein Locale nicht synchron, diff fand einen fehlenden oder geänderten Key, oder studio scheiterte beim Herunterfahren seines Servers
2konnte nicht laufen: ein Fehler des gesamten Laufs (kaputte Konfiguration, unlesbare Quelle, kaputte Lock-Datei), ein Verwendungsfehler (ein leerer oder unbekannter --locales-Wert, ein ungültiges --debounce oder --port), init ohne auflösbaren Provider oder wenn es keine gültige Konfiguration anlegen kann, ein gescheiterter Start oder Stopp von watch, oder studio, das die Konfiguration nicht laden, @verbatra/studio nicht importieren oder seinen Server nicht starten kann
130watch oder studio wurde durch einen zweiten Interrupt zwangsbeendet

Einige Randfälle, die du kennen solltest:

  • Ein Locale mit dem Status partial zählt als Fehlschlag. Das heißt, die Datei wurde geschrieben, aber einige Keys fehlen weiterhin, meist weil ein Provider-Sub-Batch fehlschlug oder das Integritäts-Gate eine Übersetzung abgelehnt hat. Es endet mit 1 genau wie ein fehlgeschlagenes Locale, denn ein halb übersetztes Locale auf der Platte ist kein Zustand, den eine Pipeline durchwinken sollte. Um beide auseinanderzuhalten, lies partial in der Zusammenfassung.
  • Eine kaputte Lock-Datei ist bei translate wie bei import ein Fehler des gesamten Laufs, nie ein einzelnes fehlgeschlagenes Locale. Sie ist eine einzige gemeinsame Datei, deshalb bricht ein Lauf, dessen Lock-Datei mittendrin kaputtgeht, mit 2 ab, statt die restlichen Locales weiter abzuarbeiten. Was vor dem Abbruch geschrieben wurde, bleibt auf der Platte: Repariere die Lock-Datei und starte den Befehl erneut.
  • Ein einzelner Interrupt ist ein sauberer Stopp, und sowohl watch als auch studio enden dabei mit 0. Geh darüber hinaus nicht davon aus, dass sich beide gleich verhalten: scheitert der Stopp selbst, endet watch mit 2 und studio mit 1.
  • Ein fehlgeschlagener Lauf während watch erscheint als Datensatz im Ausgabestrom, nie als Exit-Code ungleich null.
  • export hat keinen Fehlermodus pro Locale: der Befehl endet mit 0 oder 2, nie mit 1.
  • Ein Fehlermodus liegt außerhalb des Vertrags: ein Parse-Fehler, der kein Verwendungsfehler ist, wird weitergeworfen, und die Binary fängt ihn nicht ab. Damit greift Nodes Standardverhalten für eine unbehandelte Rejection statt eines der vier Codes.

check oder diff: das Gate wählen

check und diff führen dieselbe schreibgeschützte Berechnung über deine Quelle, die Zieldateien und die Lock-Datei aus. Der Unterschied ist, was sie melden:

# 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

Nimm check, wenn der Exit-Code alles ist, was du brauchst. Nimm diff, wenn du die genauen Keys hinter der Drift willst, etwa um sie als Kommentar in einen Pull Request zu posten. Verwaiste Keys (in einer Zieldatei vorhanden, aber aus der Quelle verschwunden) erscheinen in der diff-Ausgabe, setzen aber nie allein Exit-Code 1.

Beide nehmen --locales de,fr, um nur eine Teilmenge abzusichern. --locales ohne gültiges Locale ist ein Verwendungsfehler und endet mit 2, ein Tippfehler kann das Gate also nie grün schalten. translate, watch und export nehmen dasselbe Flag; so übersetzt du gegen einen ratenbegrenzten Provider ein Locale nach dem anderen.

JSON-Ausgabe

Sechs Befehle akzeptieren --json für maschinenlesbare Ausgabe auf stdout: translate, watch, check, diff, export und import. Jeder Datensatz ist eine Zeile, und jeder Datensatz steckt im selben Envelope. Du verzweigst also auf ein einziges Feld und musst nie raten, von welchem Befehl die Daten stammen, die du gerade in der Hand hast:

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

version ist die Version dieser Envelope-Form, nicht die Version des Pakets. Es ist eine ganze Zahl, du vergleichst sie also mit ===, statt einen Bereich zu parsen, und sie ändert sich nur, wenn ein bestehendes Feld seine Bedeutung ändert oder verschwindet. Neue Felder können ohne Erhöhung dazukommen, ignoriere also die, die du nicht kennst.

Ein Lauf, der als Ganzes fehlschlägt, schreibt genau einen ok: false-Datensatz auf stdout und endet mit 2. Sein code ist derselbe stabile Fehlercode, den auch die stderr-Zeile trägt, und damit das, worauf du verzweigst:

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

command ist nur dann null, wenn der Fehlschlag eintrat, bevor ein Unterbefehl aufgelöst war.

Fehler gehen zusätzlich in beiden Modi unverändert als eine strukturierte Zeile (verbatra: error [CODE] message) auf stderr, ein Skript, das Exit-Code und stderr liest, braucht also keine Änderung. Ohne --json schreibt ein fehlgeschlagener Lauf weiterhin überhaupt nichts auf stdout. Datensätze zu Fortschritt und Lock-Wartezeit gehen immer auf stderr, auf stdout stehen also ausschließlich Envelopes.

Der Rest dieses Abschnitts beschreibt das result, das jeder Befehl in einen erfolgreichen Envelope legt.

verbatra translate --json und verbatra import --json tragen ein 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
}

Jedes LocaleSummary trägt die Key-Listen pro Locale (übersetzt, unverändert, verwaist, zurückgehalten, für Review markiert und mehr); die vollständige Anatomie steht in der SDK-Referenz.

verbatra watch --json gibt pro Lauf einen Envelope als NDJSON aus (ein JSON-Objekt pro Zeile), mit command: "watch". Ein erfolgreicher Lauf ist ein ok: true-Datensatz mit dem RunSummary dieses Laufs; ein fehlgeschlagener Lauf ist ein ok: false-Datensatz mit seinem Code und seiner Meldung. Ein fehlgeschlagener Lauf ist nur ein Datensatz im Strom: er stoppt weder den Watcher noch ändert er den Exit-Code.

verbatra check --json trägt ein Statusdokument. Das oberste inSync ist genau dann true, wenn der Befehl mit 0 endet:

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 trägt Key-Listen statt Zähler. Das oberste hasPendingChanges ist genau dann true, wenn der Befehl mit 1 endet:

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 trägt den Pfad, unter dem die Arbeitsmappe geschrieben wurde, plus die Zeilenzahlen pro Locale:

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

Ein GitHub-Actions-Job mit der CLI

Ein Drift-Gate für Pull Requests, das die CLI direkt ausführt. check ruft nie einen Provider auf, dieser Job braucht also gar keinen API-Key:

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

Um stattdessen in CI zu übersetzen, tausche den letzten Schritt gegen translate und übergib den Provider-Key aus deinem Secret-Store als die Umgebungsvariable, die dein Provider erwartet (siehe Provider):

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

Wenn du diesen Job lieber nicht selbst schreiben willst: die GitHub Action verpackt die translate-Variante mit Annotationen und einer Job-Zusammenfassung.

Eingefrorene Installationen und Keys

  • Installiere aus dem Lockfile. pnpm install --frozen-lockfile (oder npm ci) pinnt genau das @verbatra/cli-Release, das dein Lockfile festhält; ein CI-Lauf ist damit reproduzierbar und kann nie stillschweigend ein neueres Release ziehen. Die CLI braucht Node >=22.14.0.
  • Keys sind Umgebungsvariablen, nie Flags. Die CLI nimmt kein Key-Argument entgegen und liest keinen Key aus der Konfiguration; Provider lesen nur ihre Umgebungsvariable (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, DEEPL_API_KEY). Lege den Key im Secret-Store deiner CI ab und reiche ihn über env durch. Fehlermeldungen nennen die Variable, enthalten aber nie einen Key-Wert.
  • Schreibgeschützte Gates brauchen keinen Key. check, diff und export rufen nie einen Provider auf, halte Secrets also komplett aus diesen Jobs heraus.

translate, watch und studio laden außerdem .env.local und dann .env aus dem Arbeitsverzeichnis, bevor sie laufen, wobei echte Umgebungsvariablen immer gewinnen; check, diff, export und import laden keine .env-Dateien, sodass du dich in CI normalerweise allein auf env: verlässt.

Edit on GitHub