GitLab CI
The template lives at the repository root as gitlab-ci.yml and defines three
hidden jobs you extend. Each one installs @the-i18n-kit/cli and runs one
command:
| Job | Runs | Produces | Writes to your repository |
|---|---|---|---|
.i18n-translate | translate | Translated locale files, committed and pushed to the branch | Yes |
.i18n-cleanup | remove-orphans | A Code Quality report of orphan keys | No — the scan previews by default and removes nothing |
.i18n-check | check | A Code Quality report of keys that render as their raw key | No |
Including the Template
include:
- remote: 'https://raw.githubusercontent.com/fabkho/the-i18n-kit/main/gitlab-ci.yml'
stages:
- lint
i18n-check:
extends: .i18n-check
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Two details that cost people a pipeline each:
- Declare a
lintstage. All three jobs setstage: lint. GitLab rejects the whole pipeline configuration when a job names a stage that does not exist, before running anything. Either declarestages: [lint]as above, or overridestage:on each extending job to one you already have. - Pin the template. The URL above tracks
main. Replacemainwith a release tag to freeze the job definitions, and pin the CLI alongside it withI18N_CLI_VERSION— otherwise both move under you.
Exit Codes: A Gate Is Not a Failure
Every job decides its outcome from the CLI's exit code, never by parsing counts out of the JSON result.
| Exit | Means |
|---|---|
| 0 | The run succeeded and no gate tripped |
| 1 | The run itself failed — an unusable API key, an unreadable project, a translate call that produced nothing |
| 2 | The run succeeded and a gate tripped: findings exist and the tool worked |
That split is what the jobs branch on. .i18n-cleanup and .i18n-check both
carry allow_failure.exit_codes: [2], so findings show as a yellow warning
that still reaches the merge request widget, while a scan that fell over fails
the job red. Set allow_failure: false on your extending job to make findings
block the merge instead.
allow_failure.exit_codes applies to the whole job, before_script included,
which is why the setup lines in those two jobs end in || exit 1: an installer
that happened to exit 2 would otherwise be read as findings and pass the job
yellow having never run a scan. Keep the || exit 1 if you replace those lines.
.i18n-translate
Finds missing keys, translates them through your provider, commits the changed locale files and pushes them to the branch that triggered the pipeline. On a merge request that means the translations appear on the MR itself.
i18n-translate:
extends: .i18n-translate
variables:
I18N_PROVIDER: google
I18N_MODEL: gemini-2.5-flash
I18N_API_KEY: $GEMINI_API_KEY
I18N_LOCALE_PATHS: "i18n/locales/ app-*/i18n/locales/"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- i18n/locales/en.json
- app-*/i18n/locales/en.json
I18N_PROVIDER, I18N_MODEL and I18N_API_KEY are required: the script checks
all three up front and exits 1 naming the ones that are unset, rather than
failing later inside the CLI. Everything else is optional.
| Variable | Default | Description |
|---|---|---|
I18N_LAYER | (empty — all layers) | The layer to translate. Empty translates every locale-backed layer in one run, one commit, no per-layer push races |
I18N_LOCALES | (all but the source) | Comma-separated target locales |
I18N_SOURCE_LOCALE | (project default) | Reference locale to translate from |
I18N_KEYS | (all missing) | Comma-separated keys, to translate a subset |
I18N_BATCH_SIZE | 50 | Keys per LLM call |
I18N_DRY_RUN | false | "true" previews without writing or committing |
I18N_FAIL_ON_FAILED | (off) | "true" adds --fail-on-failed, so any key that failed trips a gate and the job exits 2 |
I18N_CLI_VERSION | latest | npm version or dist-tag for the-i18n-cli |
I18N_INSTALL_PEER_DEPS | (empty) | Extra npm packages installed globally alongside the CLI |
I18N_PUSH_TOKEN | (empty) | Project access token used for the push instead of the job token — see below |
I18N_LOCALE_PATHS | i18n/locales/ | Space-separated globs for the locale directories the job stages and commits |
I18N_COMMIT_MESSAGE | (generated) | Replaces the default i18n: auto-translate missing keys (<layer>) via <provider> |
I18N_AUTOCOMMIT_SKIP_CI | (off) | "true" appends [skip ci] to the auto-commit |
A layer is a scoped locale directory together with the code that owns it — a Nuxt layer, a workspace package, a shared UI library, or an app in a monorepo. See Layers for how each framework's layer names are derived.
I18N_LOCALE_PATHS is what the job stages, and it defaults to a single
directory. On a layered project running with an empty I18N_LAYER, it must
cover every layer's locale directory — "i18n/locales/ app-*/i18n/locales/" —
or the translations for the layers it misses are written on the runner and then
never committed. The job reports a clean run and the keys are still missing on
the next one.When It Fails
.i18n-translate carries allow_failure: true, so by default nothing it does
blocks a merge. Set allow_failure: false on your extending job to make it
blocking.
| Situation | Exit | Behavior |
|---|---|---|
| Required variable unset, or an unknown provider | 1 | Fails before calling the CLI |
| The run itself failed — unusable key, nothing translated | 1 | Fails immediately; there is nothing to commit |
Keys failed, I18N_FAIL_ON_FAILED unset | 0 | Commits what succeeded; failed keys stay missing and the next run retries them |
Keys failed, I18N_FAIL_ON_FAILED: "true" | 2 | Commits first, then exits 2 |
git push rejected | 1 | Fails with the permission hint below |
The ordering in the last two rows is deliberate: a tripped gate is recorded and acted on only after the commit and push, so a red job never costs you the translations the run produced.
The raw JSON result is kept as .i18n-reports/translate-output.json, uploaded
when: always and expiring after 7 days.
Pushing Back to the Branch
This is the first thing to check when a translate job cannot push. The job pushes over HTTPS using a CI token, and a CI token has no write access to the repository unless you grant it. Pick one:
a) The project setting (GitLab 17.2 and later). Settings → CI/CD → Job token
permissions → Allow Git push requests to the repository. With it on, the
default CI_JOB_TOKEN push works and you set no variable at all.
b) A project access token. Create one with the write_repository scope, put
it in a masked CI/CD variable, and pass it as I18N_PUSH_TOKEN. The job then
authenticates as that token instead of the job token. Add the api scope too —
it is what lets the job repair the Code Quality widget after pushing, described
in Report widgets on translated merge requests.
When the push fails without I18N_PUSH_TOKEN set, the job log prints both
options before exiting 1. If the push fails with the token set, the token is
missing write_repository, expired, or belongs to a role below Developer.
.i18n-cleanup
Scans for orphan keys — keys defined in a locale file with no usage evidence in any app that consumes their layer — and writes them as a Code Quality report.
i18n-cleanup:
extends: .i18n-cleanup
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- components/**/*.vue
- i18n/locales/*.json
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The job never deletes anything: remove-orphans previews by default, and the
template passes no flag that changes that. Keys with ambiguous evidence are
reported as uncertain and are not part of the orphan set at all, so they never
reach the report.
| Variable | Default | Description |
|---|---|---|
I18N_LAYER | (empty — all layers) | Restrict the scan to one layer |
I18N_FAIL_ON_ORPHANS | false | "true" adds --fail-on-orphans, so orphans trip a gate and the job exits 2 |
I18N_CLI_VERSION | latest | npm version or dist-tag for the-i18n-cli |
I18N_INSTALL_PEER_DEPS | (empty) | Extra npm packages installed globally alongside the CLI |
Failure conditions: the scan itself failing (exit 1) fails the job red and dumps
the report to the log. Orphans found with I18N_FAIL_ON_ORPHANS: "true" exit 2,
which allow_failure.exit_codes: [2] surfaces as a yellow warning — set
allow_failure: false on your job to make orphans block the merge. Without the
variable the gate is not requested at all, so orphans are informational and the
job stays green.
Artifacts: .i18n-reports/orphans.json and gl-codequality.json, 7 days.
.i18n-check
Scans for keys that are referenced in code but defined in no locale file of the consuming app's layers. Those render as the raw key string in production.
| Variable | Default | Description |
|---|---|---|
I18N_CLI_VERSION | latest | npm version or dist-tag for the-i18n-cli |
I18N_INSTALL_PEER_DEPS | (empty) | Extra npm packages installed globally alongside the CLI |
check has no opt-in flag, because a key that renders raw in production is a
defect rather than a threshold you decide to care about. Its gate is always
evaluated: findings exit 2, and a scan that fell over exits 1. Both are visible
in the pipeline — allow_failure.exit_codes: [2] keeps findings yellow and
lets them reach the widget, while a broken scan goes red instead of passing for
a project that appears to have no findings. Set allow_failure: false on your
extending job to make findings block the merge.
Artifacts: .i18n-reports/check.json and gl-codequality.json, 7 days.
The Code Quality Widget
.i18n-cleanup and .i18n-check write gl-codequality.json and declare it
under artifacts:reports:codequality. GitLab renders those findings in the
merge request as a diff: it compares the report from the merge request
pipeline against the report from the latest default-branch pipeline, and shows
what the merge request introduced and what it resolved. The full list of
findings is always in the job artifact; the widget is only the delta.
changes: filter: a filtered baseline is skipped
whenever the merge request that merged last happened not to touch those paths,
leaving the next merge requests with nothing to diff against.i18n-cleanup:
extends: .i18n-cleanup
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- components/**/*.vue
- i18n/locales/*.json
# Baseline. No `changes:` filter, or the widget goes blank on the next MR.
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
The report is written on every run, including runs with nothing to report — an empty array on the default branch is a valid baseline, and it is what makes the first genuine finding show up as introduced by the merge request that introduced it.
Baselines also expire. The template sets expire_in: 7 days on the artifacts,
and the widget reads the newest unexpired default-branch report. On a default
branch quieter than a week, the widget goes blank again until the next
default-branch pipeline runs. Raise expire_in on your extending job if that
describes your project.
Report Widgets on Translated Merge Requests
There is one more way to end up with a blank widget, and it only happens on
merge requests where .i18n-translate pushed something.
GitLab compares reports at the merge request's current head, and a push made with a CI token — the job token or a project access token alike — never triggers a pipeline. The auto-commit therefore advances the head to a commit that has no report, and the widget disappears for that merge request.
The template heals this when I18N_PUSH_TOKEN carries the api scope: after a
successful push it creates a pipeline at the new head, which regenerates the
reports. A failed trigger warns rather than failing the job, since the push
already succeeded.
Without an api-scoped token, press Run pipeline on the merge request to
get the widget back. The same reasoning is why I18N_AUTOCOMMIT_SKIP_CI
defaults to off: [skip ci] guarantees the new head has no pipeline.
Overriding the Jobs
The jobs are ordinary GitLab job definitions, so image, before_script,
tags and cache can all be overridden on the job that extends them. That is
the supported path for runners behind a private registry, self-hosted mirrors,
or a package manager other than npm.
i18n-check:
extends: .i18n-check
image: registry.example.com/mirror/node:22-alpine
tags:
- internal-runners
cache:
key: i18n-npm
paths:
- .npm/
before_script:
- npm config set registry https://registry.example.com/npm/
- npm install -g "the-i18n-cli@${I18N_CLI_VERSION}" || exit 1
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
What a replacement before_script has to keep doing:
- Put
the-i18n-clion thePATH. All threescriptblocks call the binary by name. - End the setup lines with
|| exit 1in.i18n-cleanupand.i18n-check, for theallow_failure.exit_codesreason described above. - Install
jqfor.i18n-translateand.i18n-cleanup. Both parse the result for their log output. The stockbefore_scriptinstalls it withapk, guarded bycommand -v apk, so an image withoutapkgets no error and nojq. - Install
gitfor.i18n-translate, which commits and pushes. - Install the provider SDK for
.i18n-translate. The SDKs —openai,@anthropic-ai/sdk,@google/genai— are optional peer dependencies of the CLI, so the stock script installs the one matchingI18N_PROVIDERalongside it. If you only need extra packages rather than a different install procedure,I18N_INSTALL_PEER_DEPSappends to the stocknpm install -gline and needs no override at all.
Replacing image is supported and does not require a Node image built like the
default node:22-alpine, with one constraint the templates already respect: all
three script blocks are POSIX sh. No pipefail, no arrays, no
PIPESTATUS. Busybox ash in the default image tolerates set -o pipefail,
but dash rejects it outright and aborts the job before running a line, so
staying POSIX is what keeps the jobs working under whichever shell your image
provides.
Next
- GitHub Actions for the equivalent translate automation on GitHub, which opens a pull request instead of pushing.
- Exit codes and CI gates for the gate each command evaluates.
- Layers for what
I18N_LAYERselects and where the names come from.