Continuous integration
A translation file that no longer lines up does not break a build. Nothing crashes and no test fails, so the missing key ships and is only noticed when someone opens the app in that language. Running chki18n on every pull request catches it before then.
There is nothing to configure: give it a path and a target language, and the exit code does the rest. This page has jobs you can paste for GitHub Actions and Bitbucket Pipelines, in all three languages.
What CI reads
| Exit code | Meaning |
|---|---|
0 | No error level issue. Warnings may still have been printed. |
1 | At least one error level issue, or the directory could not be read. |
Warnings never fail a build. That is deliberate: a warning is worth fixing but not worth blocking a release, which is what makes the tool safe to add to an existing project without a day of cleanup first. Promote the ones your project treats as blockers with --levels.
Four reporters are useful here:
| Reporter | Where it is used |
|---|---|
github | GitHub Actions. Each issue becomes an annotation on the file itself. |
markdown | A job summary, or a report kept with the build. |
list | One line per issue, which suits a plain log. |
json | Another tool reads it: a dashboard, a bot, a gate of your own. |
GitHub Actions
The job
Put this in .github/workflows/translations.yml. It runs on every pull request and on every push to the main branch.
name: translations
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
name: Check translations
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Check translations
run: npx chki18n ./locales --target enname: translations
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
name: Check translations
steps:
- uses: actions/checkout@v5
- uses: dart-lang/setup-dart@v1
- name: Check translations
run: |
dart pub global activate chki18n
dart pub global run chki18n ./locales --target endart pub global run is used instead of the bare chki18n because the pub cache's bin is not reliably on a runner's path. If it is on yours, call the command by name.
name: translations
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
name: Check translations
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Check translations
run: |
pip install chki18n
chki18n ./locales --target enThat is the whole thing. A missing key now fails the pull request.
Annotating the files
--reporter github turns each issue into a workflow command, which GitHub renders as an annotation on the translation file itself rather than a line buried in a log:
chki18n ./locales --target en --reporter github::error file=locales/ko.json,title=chki18n NO_KEY::ko attr.folder The key exists in the target language but is missing here. (en: "Folder")
::warning file=locales/ko.json,title=chki18n EMPTY_VALUE::ko attr.open The key is defined but its value is an empty string. (en: "Open")An error becomes an error annotation, a warn a warning, an info a notice. An annotation points at the file rather than a line, because the checks work on parsed translations and the commonest finding is a key that is not in the file at all.
Annotations need permission to write checks when the workflow runs with a restricted token:
permissions:
contents: read
checks: writeA summary on the run
$GITHUB_STEP_SUMMARY is a file; anything Markdown written to it appears on the run's own page. That is exactly the shape of the markdown reporter:
chki18n ./locales --target en --output "$GITHUB_STEP_SUMMARY" --reporter markdownThe report is written before the command exits, so the summary is there whether the run passed or failed.
Keeping the report
- name: Check translations
run: chki18n ./locales --target en --output translation-report.md
- uses: actions/upload-artifact@v4
if: always()
with:
name: translation-report
path: translation-report.mdWithout if: always() the upload is skipped exactly when the report is worth reading.
Only when translations change
Scanning a folder takes milliseconds, so running it every time costs nothing. If you would rather not, a path filter is enough:
on:
pull_request:
paths:
- 'locales/**'
- '.github/workflows/translations.yml'Be careful making a required check conditional: a pull request that skips the job leaves the check pending rather than green, which blocks a merge on some branch protection settings.
Bitbucket Pipelines
The pipeline
Bitbucket reads one file, bitbucket-pipelines.yml, at the root of the repository. The image at the top decides what the step has available.
image: node:22
pipelines:
pull-requests:
'**':
- step:
name: Check translations
script:
- npx chki18n ./locales --target en
branches:
main:
- step:
name: Check translations
script:
- npx chki18n ./locales --target enimage: dart:stable
pipelines:
pull-requests:
'**':
- step:
name: Check translations
script:
- dart pub global activate chki18n
- dart pub global run chki18n ./locales --target en
branches:
main:
- step:
name: Check translations
script:
- dart pub global activate chki18n
- dart pub global run chki18n ./locales --target enimage: python:3.12-slim
pipelines:
pull-requests:
'**':
- step:
name: Check translations
script:
- pip install chki18n
- chki18n ./locales --target en
branches:
main:
- step:
name: Check translations
script:
- pip install chki18n
- chki18n ./locales --target enA step fails when a command in it exits non-zero, so the exit code is all the wiring there is.
To write the two blocks once rather than twice, define the step and point both at it:
definitions:
steps:
- step: &check-translations
name: Check translations
script:
- npx chki18n ./locales --target en
pipelines:
pull-requests:
'**':
- step: *check-translations
branches:
main:
- step: *check-translationsKeeping the report
- step:
name: Check translations
script:
- npx chki18n ./locales --target en --output translation-report.md
artifacts:
- translation-report.mdThe file is written before the command exits with 1, so it exists whether the check passed or failed.
--reporter list is what a Bitbucket log reads best. There are no annotations to produce here, so nothing is gained by the github reporter:
chki18n ./locales --target en --reporter listCaching the install
The install is small, so this is optional, but it takes a second off every run:
- step:
name: Check translations
caches:
- node
script:
- npx chki18n ./locales --target ennode is one of Bitbucket's own caches, so nothing has to be defined for it.
definitions:
caches:
pub: ~/.pub-cache
pipelines:
pull-requests:
'**':
- step:
name: Check translations
caches:
- pub
script:
- dart pub global activate chki18n
- dart pub global run chki18n ./locales --target en- step:
name: Check translations
caches:
- pip
script:
- pip install chki18n
- chki18n ./locales --target enpip is one of Bitbucket's own caches, so nothing has to be defined for it.
Only when translations change
A step can be told which paths it cares about:
- step:
name: Check translations
condition:
changesets:
includePaths:
- 'locales/**'
script:
- npx chki18n ./locales --target enAdopting it on a project that has translations already
Turning this on for the first time on a real project usually reports more than anyone can fix in one sitting. None of it has to block the build.
Start with the checks you already agree with, and add to the list as you clear them:
chki18n ./locales --target en --checks NO_KEY,NO_INTERPOLATION_KEYOr start with every check and drop the ones that are noisy in your project:
chki18n ./locales --target en --ignore-checks DUPLICATE_VALUEOr keep every check and decide what an error is:
chki18n ./locales --target en --levels EMPTY_VALUE=error,NOT_TRANSLATED_VALUE=infoEvery check, and what each one is for, is on Checks. All three flags are on Options.
When a command is not enough
If your project needs a finer gate than "did anything fail", such as a threshold, a per-language rule or a comment posted somewhere, read the result instead of the exit code:
import { checkTranslationFiles } from 'chki18n';
const result = await checkTranslationFiles('./locales', { target: 'en' });
const untranslated = result.summary.byCode.NOT_TRANSLATED_VALUE ?? 0;
if (untranslated > 50) {
console.error(`${untranslated} strings are still untranslated.`);
process.exit(1);
}import 'dart:io';
import 'package:chki18n/chki18n.dart';
final result = await checkTranslationFiles(
path: './locales',
options: const Chki18nOptions(target: 'en'),
);
final untranslated = result.summary.byCode[Chki18nCheckCode.notTranslatedValue] ?? 0;
if (untranslated > 50) {
stderr.writeln('$untranslated strings are still untranslated.');
exitCode = 1;
}import sys
from chki18n import Options, check_translation_files
result = check_translation_files("./locales", Options(target="en"))
untranslated = result.summary.by_code.get("NOT_TRANSLATED_VALUE", 0)
if untranslated > 50:
print(f"{untranslated} strings are still untranslated.", file=sys.stderr)
sys.exit(1)The library never exits the process and never prints unless asked, so a script of your own decides both. See checkTranslationFiles and The result object.
See also
- Command line — every flag, and the report the job prints.
- Options — the same options, from either side.
- Checks — what each check looks for, and its severity.