CI/CD

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:

JobRunsProducesWrites to your repository
.i18n-translatetranslateTranslated locale files, committed and pushed to the branchYes
.i18n-cleanupremove-orphansA Code Quality report of orphan keysNo — the scan previews by default and removes nothing
.i18n-checkcheckA Code Quality report of keys that render as their raw keyNo

Including the Template

.gitlab-ci.yml
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 lint stage. All three jobs set stage: lint. GitLab rejects the whole pipeline configuration when a job names a stage that does not exist, before running anything. Either declare stages: [lint] as above, or override stage: on each extending job to one you already have.
  • Pin the template. The URL above tracks main. Replace main with a release tag to freeze the job definitions, and pin the CLI alongside it with I18N_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.

ExitMeans
0The run succeeded and no gate tripped
1The run itself failed — an unusable API key, an unreadable project, a translate call that produced nothing
2The 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.

.gitlab-ci.yml
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.

VariableDefaultDescription
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_SIZE50Keys per LLM call
I18N_DRY_RUNfalse"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_VERSIONlatestnpm 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_PATHSi18n/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.

SituationExitBehavior
Required variable unset, or an unknown provider1Fails before calling the CLI
The run itself failed — unusable key, nothing translated1Fails immediately; there is nothing to commit
Keys failed, I18N_FAIL_ON_FAILED unset0Commits what succeeded; failed keys stay missing and the next run retries them
Keys failed, I18N_FAIL_ON_FAILED: "true"2Commits first, then exits 2
git push rejected1Fails 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.

.gitlab-ci.yml
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.

VariableDefaultDescription
I18N_LAYER(empty — all layers)Restrict the scan to one layer
I18N_FAIL_ON_ORPHANSfalse"true" adds --fail-on-orphans, so orphans trip a gate and the job exits 2
I18N_CLI_VERSIONlatestnpm 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.

VariableDefaultDescription
I18N_CLI_VERSIONlatestnpm 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.

The diff needs both sides. A job that runs only on merge requests produces the merge request's report and no baseline to compare it against, so the widget stays blank — which reads as a broken integration rather than a missing rule. Every job you adopt needs a second rule that also runs it on the default branch, and that rule must carry no 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.
.gitlab-ci.yml
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.

.gitlab-ci.yml
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-cli on the PATH. All three script blocks call the binary by name.
  • End the setup lines with || exit 1 in .i18n-cleanup and .i18n-check, for the allow_failure.exit_codes reason described above.
  • Install jq for .i18n-translate and .i18n-cleanup. Both parse the result for their log output. The stock before_script installs it with apk, guarded by command -v apk, so an image without apk gets no error and no jq.
  • Install git for .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 matching I18N_PROVIDER alongside it. If you only need extra packages rather than a different install procedure, I18N_INSTALL_PEER_DEPS appends to the stock npm install -g line 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_LAYER selects and where the names come from.
Copyright © 2026