Back to the blog

Localization in CI/CD: fail the build on missing translations

Localization in CI/CD: fail the build on missing translations

Most localization problems are sync problems. A string added in a feature branch never reaches translators, finished translations sit on the platform because nobody exported them, and a release ships with half of German still in English. Localization in CI/CD moves that sync from a person's to-do list into the pipeline. This guide sets up three jobs with the Ownlate CLI: push source strings on every merge, pull translations on a schedule or before a release, and fail the build when a language is below a threshold.

The three jobs

Job When Command
Push source strings Every merge to the default branch ownlate push
Pull translations On a schedule, or right before a release build ownlate pull
Gate on progress Before a release ownlate status --min <N>

Pushing on merge means translators see a new string as soon as it lands. Pulling on a schedule or before a release keeps Ownlate the source of truth for translations while the build stays reproducible from the repository. The gate turns "is German ready?" from a question in a chat thread into an exit code.

Configure ownlate.yml for CI

The CLI reads ownlate.yml from the current directory. On your machine, ownlate login stores an API key in ~/.ownlate. A CI runner has no such file and should not need one. Instead, api_token_env names the environment variable that holds the token:

project_id: 00000000-0000-0000-0000-000000000000
api_token_env: OWNLATE_API_TOKEN

files:
  - source: locales/en.json
    translation: locales/{lang}.json

With this file committed, push, pull, status and translate need nothing else: no ownlate login, no ~/.ownlate. Commands that work across the whole workspace, such as init, project list and project create, still need a login, so keep those on your machine.

files[].translation must contain {lang}, the placeholder each target language code is written into. The same file is what Ownlate's GitHub and GitLab integration reads on sync, so one config serves the CLI, the pipeline and the Git integration.

Create an API key under Profile → API keys, then store it as a repository secret named OWNLATE_API_TOKEN. Every example below passes that secret to the CLI as an environment variable of the same name.

Localization in CI/CD with the GitHub Action

The official action, OwnLate/actions@v1, runs the CLI in a container and turns commands on with inputs: upload_sources runs push, translate runs translate, download_translations runs pull, and check_status runs status. Inside one step they always run in that order.

Push source strings on every merge

name: Push source strings

on:
  push:
    branches: [main]

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

      - uses: OwnLate/actions@v1
        with:
          upload_sources: true
        env:
          OWNLATE_API_TOKEN: ${{ secrets.OWNLATE_API_TOKEN }}

push uploads every files[].source and creates or updates segments from it, so a key added in a merged branch is waiting for translators a minute later.

The action also has an api_key input. It hands that value to the CLI as OWNLATE_API_KEY, so if you use it, set api_token_env: OWNLATE_API_KEY in ownlate.yml. Passing the secret through env, as above, keeps one config that behaves the same with the action, the Docker image and a local shell.

Fail the build when translations are missing

ownlate status prints a progress table for every target language:

  Project  My App  f4092389-…

  Lang     ████████████░░░░░░░░   %    Total  Untrans   Draft  Transl  Approved
  ────────────────────────────────────────────────────────────────────────────
  fr       ████████████████████ 100%     484        0       0       0       484
  de       ██████████░░░░░░░░░░  51%     484      237      12      89       146
  es       ████░░░░░░░░░░░░░░░░  20%     484      387       4      51        42
  ────────────────────────────────────────────────────────────────────────────
  3 langs                        57%    1452      624      16     140       672

With --min <percent>, the command exits with code 1 if any language is below that percentage. In the action, that is check_status plus min_progress:

      - uses: OwnLate/actions@v1
        with:
          check_status: true
          min_progress: 90
        env:
          OWNLATE_API_TOKEN: ${{ secrets.OWNLATE_API_TOKEN }}

Against the project above, de and es are below 90, so the step fails and the job fails with it. The table stays in the log, split into untranslated, draft, translated and approved, so whoever opens the failed run can tell missing work from work waiting for review.

Pull translations before a release

For a release build, pull and check in one step, then build:

name: Release

on:
  push:
    tags: ["v*"]

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

      - name: Pull translations and check progress
        uses: OwnLate/actions@v1
        with:
          download_translations: true
          check_status: true
          min_progress: 90
        env:
          OWNLATE_API_TOKEN: ${{ secrets.OWNLATE_API_TOKEN }}

      # your build steps follow

pull writes each language to the path from files[].translation, so the build that follows finds the files where it always does. If a language is below the threshold, the job stops before the build starts.

Pull on a schedule

To keep the translated files in the repository current between releases, run the pull on a schedule and let the action commit the result with push_translations:

name: Pull translations

on:
  schedule:
    - cron: "0 6 * * 1-5"
  workflow_dispatch:

permissions:
  contents: write

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

      - uses: OwnLate/actions@v1
        with:
          download_translations: true
          push_translations: true
          commit_message: "Update translations from Ownlate"
        env:
          OWNLATE_API_TOKEN: ${{ secrets.OWNLATE_API_TOKEN }}

If nothing changed, the action skips the commit. github_user_name and github_user_email set the commit author. If your default branch does not accept direct pushes, use the Git integration's Create PR instead: Ownlate exports the translations, writes them to the paths from ownlate.yml, pushes a new branch and opens a pull request, or a merge request on GitLab.

The same pipeline with the Docker image

The action is built on the public image globalartltd/ownlate-cli, and you can call the image directly:

- name: Push source strings
  run: >
    docker run --rm -v "$PWD:/app" -w /app
    -e OWNLATE_API_TOKEN globalartltd/ownlate-cli push
  env:
    OWNLATE_API_TOKEN: ${{ secrets.OWNLATE_API_TOKEN }}

- name: Fail if a language is behind
  run: >
    docker run --rm -v "$PWD:/app" -w /app
    -e OWNLATE_API_TOKEN globalartltd/ownlate-cli status --min 90
  env:
    OWNLATE_API_TOKEN: ${{ secrets.OWNLATE_API_TOKEN }}

-v "$PWD:/app" -w /app mounts the checkout so the CLI finds ownlate.yml, and -e OWNLATE_API_TOKEN forwards the token from the step's environment into the container. Pulling is the same command ending in pull, and pull --lang <code> fetches a single language. To see what a merge would upload without changing the project, run push --dry-run.

Nothing in these commands depends on GitHub. The image runs in any CI system that can start a container, GitLab CI included: store the token in that system's secret settings, expose it as OWNLATE_API_TOKEN, and run the same commands.

Choosing a threshold

A few decisions to make before you switch the gate on.

Where it runs. A string pushed a minute ago is untranslated in every language, so a strict gate straight after push on the default branch fails after most feature merges. The release workflow is where a failure means something: it stops a release that has gaps.

How strict. --min applies one threshold to every target language. At 100, nothing ships until every language is complete. A lower number lets a release go out with a known, bounded gap. Start where your languages are today and raise the number as the team catches up.

Pre-translation. ownlate translate, or translate: true in the action, fills untranslated segments from translation memory. An exact match (score 100) is applied and approved. Lower tm_score in the action, or --score on the CLI, to accept fuzzy matches too; those land as drafts for a person to check. Pre-translation runs in the background on the server, so a status check in the same run may not reflect it yet. It also writes translations that count against your plan's word allowance.

Further reading