Run Lingo.dev inside GitLab CI so every merge request that adds or changes source strings comes back with translations already filled in. The pipeline runs lingo push, the engine translates only the new or changed keys, and the result is committed straight onto the MR branch — translations show up in the MR diff and get reviewed before a human merges. Nothing lands on your default branch unseen.
Working example
A complete, runnable setup lives at gitlab.com/lingo.dev/gitlab-cicd-example.
Prerequisites#
A Lingo.dev organization and engine, plus a service API key (Dashboard → API Keys → create, type service).
A project configured for Lingo.dev. Generate it once with:
bashnpx @lingo.dev/cli@latest init # scaffolds .lingo/config.json npx @lingo.dev/cli@latest link # connects the project to your org + engine.lingo/config.jsondeclares the source/target locales and source globs:json{ "sourceLocale": "en", "targetLocales": ["es", "fr", "de", "zh"], "files": [{ "pattern": "locales/en.json" }], "orgId": "org_...", "engineId": "eng_..." }A committed baseline. The very first time, translate everything and commit it so CI has a lockfile to diff against:
bashnpx @lingo.dev/cli@latest push --backfill-missing --wait git add locales .lingo && git commit -m "chore(i18n): baseline translations"
Access tokens#
Add two masked CI/CD variables under Settings → CI/CD → Variables:
LINGO_API_KEY— your Lingo.dev service key (lingo_sk_...). The CLI reads it automatically to authenticate.GITLAB_PUSH_TOKEN— a Project Access Token with thewrite_repositoryscope (role Developer). This lets CI commit the translations back onto the MR branch.
Create the Project Access Token under Settings → Access tokens. CI_JOB_TOKEN can't push commits, so a dedicated token is required for the commit-back step. Project access tokens require a paid GitLab plan.
Pipeline#
Commit this .gitlab-ci.yml. It runs on merge requests targeting the default branch and pushes the translations back onto the MR's source branch:
stages:
- localize
localize:
stage: localize
image: node:22-alpine
rules:
# Only on merge requests that target the default branch.
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH'
before_script:
- apk add --no-cache git
script:
# Pin the CLI version — never @latest; bump deliberately after testing.
# --wait blocks until the engine finishes and writes files: since 1.6.0
# `push` is async by default (it submits the run and exits), so CI must
# wait to have something to commit.
- npx -y @lingo.dev/cli@1.6.0 push --wait
- |
if [ -z "$(git status --porcelain)" ]; then
echo "Translations already up to date — nothing to commit."
exit 0
fi
git config user.name "lingo-bot"
git config user.email "bot@lingo.dev"
git add locales .lingo/lock.json
# [skip ci] keeps the bot's own commit from re-triggering this pipeline.
git commit -m "chore(i18n): sync translations [skip ci]"
git push "https://oauth2:${GITLAB_PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "HEAD:${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"Try it#
git checkout -b feat/new-strings
# add or change a key in locales/en.json
git commit -am "feat: add strings" && git push -u origin feat/new-strings
# open an MR feat/new-strings -> main (UI, or: glab mr create --fill --target-branch main)The MR pipeline runs lingo push --wait, commits locales/{...}.json plus the updated .lingo/lock.json to the MR branch, and the translations appear in the diff. A reviewer adjusts any value, then merges.
How human edits survive#
lingo push preserves manual edits per key:
- Edit a target string (its English source unchanged) → that string is kept; every other key keeps getting translated.
- The English source behind an edited key changes → a fresh translation is generated for that key (the meaning changed).
- A new source key is added → translated and added, even into files that contain manual edits.
So a reviewer's fix in the MR survives every later pipeline run, while new and changed keys flow in automatically.
push modes#
lingo push— incremental; the CI default. Translates only new/changed keys, preserves everything else. Add--waitin CI so it blocks until outputs are written (1.6.0+ is async by default).lingo push --backfill-missing— first-push / new-locale bootstrap; fills target files that don't exist yet. Not for ongoing changes.lingo push --force --yes— re-translate everything from scratch (overwrites manual edits). Rare.
Customization#
- Auto-commit to the default branch instead of MRs: trigger on
$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHand push back to$CI_DEFAULT_BRANCH. Simpler, but AI output lands on the default branch without review. - Pin specific strings: use
preservedKeys/lockedKeysin.lingo/config.jsonto keep chosen keys fixed even when their source changes. - Self-hosted GitLab: works unchanged. On gitlab.com, new accounts must pass identity verification before shared runners execute CI jobs.
