2026-11-06-crowdin-translation-step-3-crowdin-ci
Crowdin translation, step 3 (Crowdin connection): implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Keep the translations in Crowdin, so that Vianova local teams can correct them in the Crowdin web page, and bring each correction back to the repo through one merge request.
Architecture: The Crowdin CLI runs in GitLab CI (spec 6.2). The job crowdin-upload sends the English sources after each merge to v1.0. The job crowdin-download runs once a week, checks the downloaded files, and opens or updates one merge request "Crowdin translations". A one-writer check fails every other merge request that changes a translated file. The first import sends the translations of step 2 as not approved.
Tech Stack: @crowdin/cli 4 (exact version), Docusaurus 3.9.2, Node 20 with node:test, js-yaml, GitLab CI, a GitLab project access token.
Spec: docs/superpowers/specs/2026-11-04-crowdin-translation-design.md, section 8, step 3. Sections 6.2, 6.3 and 6.6.5 apply.
Global Constraints
- Crowdin target languages:
de,ar,no,fr,it. Repo folders:i18n/de,i18n/ar,i18n/no,i18n/fr,i18n/it. crowdin.ymlsends only/docs/**/*.md,/docs/**/*.mdx(typemdx_v2, fixed parser version) and an explicit list of JSON files ofi18n/en/. No recipes, no blog, no pages (spec 6.2.8).- Export: all translations, approved or not. "Export only approved" is off (spec 6.2.6).
- First import: translations are not approved (spec 6.2.3). The native reader approves them in step 4.
- Secrets: the Crowdin token is only in the GitLab variable
CROWDIN_PERSONAL_TOKEN(masked, protected) and in the Keychain itemcrowdin-token. The agent never prints, copies or puts it in a command line. Copy:~/.local/bin/gitlab-var-to-keychain vianova/knowledge-base CROWDIN_PERSONAL_TOKEN crowdin-token. - Project settings, CI variables, tokens and schedules are shared state: each change goes through an
agent-gatedscript and the user's typed1. - Never force-push
v1.0(knowledge-base)(the ReadMe Git sync pushes the old commits back). - Conventional commits, each with the trailer
Co-Authored-By: Claude Opus 5.5 <[email protected]>. - Never search the whole disk. Search only the repo.
Rulings (decisions that differ from the spec)
- No
with-secreton this laptop. Spec 6.2.2 runs the CLI withwith-secret kc:crowdin-token. That command does not exist in the shell of this laptop.crowdin-local.shreads the Keychain item into the environment of the CLI process instead, with the same rules: never in argv, never printed. Cost if wrong: none, whenwith-secretexists later, the script can call it.
Open fields (the user or the Crowdin admin fills them)
| Field | Value | Needed by |
|---|---|---|
| Crowdin admin (name) | open | Task 5 |
| Space in the Crowdin plan for about 49,000 more source words | open, the admin checks it | Task 5 |
| Crowdin project id | open, the admin creates the project | Task 5 |
| Day and time of the weekly download | open, default: Monday 06:00 Paris | Task 5 |
Checklist for the Crowdin admin (Task 5 sends it):
- Create the project "Vianova Help Center", source language English, target languages German, Arabic, Norwegian (Bokmål), French, Italian.
- In the export settings, set "Export only approved" to off, and "Skip untranslated strings" to off.
- Turn on the proofread step (spec 6.7.2).
- Create a personal access token restricted to this project, with the scopes "Projects" and "Source files and strings" and "Translations", and an expiry date of 1 year.
- Put the token in the GitLab variable
CROWDIN_PERSONAL_TOKEN(masked, protected) and the project id inCROWDIN_PROJECT_ID, invianova/knowledge-base.
Order with step 2
Tasks 1 to 4 can start before step 2 ends. Task 6 (first import) starts only after every language of step 2 is merged, so that the first import sends complete files.
Review Focus
- Crowdin exports an empty file, or a file with English text, for a page that the repo has in German. Expected: the download guard fails the job, and no merge request changes. Test in Task 3 (
guardDownload). - The Norwegian folder: Crowdin can export as
nborno. Expected: the files land ini18n/no/. Test in Task 1 (the language mapping) and proof in Task 4 (dry run). - An English page is renamed. Expected:
--delete-obsoleteremoves the old source in Crowdin, the next download removes the old translated file, and the check has noorphan. Checked in Task 7. - An editor fixes a German typo directly in git after the import. Expected: the one-writer check fails the merge request, with a message that names Crowdin as the place to fix it. Test in Task 2.
- The weekly job runs when nothing changed in Crowdin. Expected: the job ends green, with no new commit and no new merge request. Test in Task 3 (
no change).
File map
| File | Responsibility |
|---|---|
docusaurus-site/crowdin.yml | Which files go to Crowdin, and where the translations come back |
docusaurus-site/scripts/i18n/one-writer.mjs | Fails a merge request that changes translated files, unless it comes from the download job |
docusaurus-site/scripts/i18n/guard-download.mjs | Fails the download when a translated file becomes empty or goes back to English |
docusaurus-site/scripts/i18n/crowdin-mr.sh | Commits the download to branch crowdin/translations and opens or updates the merge request |
docusaurus-site/scripts/i18n/crowdin-local.sh | Runs the Crowdin CLI on this laptop with the token from the Keychain |
docusaurus-site/scripts/i18n/*.test.mjs | Tests of the above |
.gitlab-ci.yml | Jobs one-writer, crowdin-upload, crowdin-download |
docusaurus-site/scripts/i18n/TRANSLATING.md, docusaurus-site/CLAUDE.md | Update flow for editors and the agent |
Task 1: crowdin.yml
crowdin.ymlFiles:
-
Modify:
docusaurus-site/crowdin.yml,docusaurus-site/package.json(@crowdin/cliat an exact version, the current 4.x) -
Create:
docusaurus-site/scripts/i18n/crowdin-config.test.mjs -
Step 1: Write the failing tests (read the file with
js-yaml):sends only docs and English JSON: the set ofsourcevalues is exactly/docs/**/*.md,/docs/**/*.mdxand one entry for each JSON file underi18n/en/(the test lists them withreaddir, so a new JSON file makes the test fail until the config lists it).mdx uses the mdx_v2 parser: the.mdxentry hastype: mdx_v2and aparser_versionvalue.norwegian folder is no:languages_mapping.two_letters_codemaps the Crowdin Norwegian code tono.no recipes, blog or pages: nosourcecontainsrecipes,blogorsrc/pages.
-
Step 2: Run
npm run i18n:test. Expected: the 4 tests fail. -
Step 3: Write
crowdin.yml. Translation path for docs:/i18n/%two_letters_code%/docusaurus-plugin-content-docs/current/**/%original_file_name%. For each JSON file:/i18n/%two_letters_code%/<same relative path>. -
Step 4: Run
npm run i18n:test. Expected: all pass. -
Step 5: Commit
feat(i18n): send only the docs and the interface text to Crowdin.
Task 2: One-writer check
Files:
- Create:
docusaurus-site/scripts/i18n/one-writer.mjs,docusaurus-site/scripts/i18n/one-writer.test.mjs - Modify:
.gitlab-ci.yml
Interfaces:
-
oneWriter(changedFiles: string[], sourceBranch: string, triggerLogin: string, botLogin: string): {ok: boolean, files: string[]}. A file is a translated file when it matchesdocusaurus-site/i18n/(de|ar|no|fr|it)/.okis true when no translated file changed, or whensourceBranch === 'crowdin/translations'andtriggerLogin === botLogin. -
CLI: reads
git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA...HEAD,CI_MERGE_REQUEST_SOURCE_BRANCH_NAME,GITLAB_USER_LOGIN,CROWDIN_BOT_LOGIN. On failure it prints:Translated files change only through Crowdin. Fix the text in Crowdin. Files: <list>, exit 1. -
CI job
one-writer(stagelint, merge requests only,variables: GIT_DEPTH: 0, so that the diff base exists in the clone). It runs only when the CI variableCROWDIN_ONE_WRITER == "on". Task 6 sets this variable after the first import, so the step 2 merge requests are not blocked. -
Step 1: Write the failing tests: English file only → ok. German page from branch
fix/typo→ not ok,fileshas the page. German page fromcrowdin/translationsby the bot → ok. German page fromcrowdin/translationsby a person → not ok.i18n/en/code.jsonfrom any branch → ok. -
Step 2: Run
npm run i18n:test. Expected: 5 tests fail. -
Step 3: Implement the function, the CLI and the CI job. Add a test in
ci-config.test.mjs:one-writer runs only when CROWDIN_ONE_WRITER is on. -
Step 4: Run
npm run i18n:testandglab ci lint .gitlab-ci.yml. Expected: all pass, and the lint is valid. -
Step 5: Commit
feat(i18n): translated files change only through Crowdin.
Task 3: Download guard and merge request script
Files:
- Create:
docusaurus-site/scripts/i18n/guard-download.mjs,docusaurus-site/scripts/i18n/guard-download.test.mjs,docusaurus-site/scripts/i18n/crowdin-mr.sh,docusaurus-site/scripts/i18n/crowdin-mr.test.mjs
Interfaces:
-
guardDownload(siteDir: string, changed: Array<{locale: string, file: string, before: string | null, after: string}>): Problem[]. Rules:empty(the body ofafterhas no text),english(afterequals the English page andbeforedid not). A new file (before === null) that equals English is a problem too. -
CLI:
node scripts/i18n/guard-download.mjs, run fromdocusaurus-site/aftercrowdin download. It readsgit status --porcelain i18n/to find the changed translated files (noti18n/en/). For each file,beforeisgit show HEAD:<path>(nullwhen the file is new) andafteris the file on disk. It prints one line per problem and exits 1 when any problem exists. -
crowdin-mr.sh <site-dir>: whengit status --porcelain docusaurus-site/i18nis empty, printsNo change from Crowdin.and exits 0. Otherwise it commits on branchcrowdin/translations(messagechore(i18n): translations from Crowdin), pushes withCROWDIN_MR_TOKEN, and creates the merge request intov1.0with the title "Crowdin translations", or updates the existing open merge request. It callsgitandcurlthrough the variablesGIT_BINandCURL_BIN, so the test can use stubs. The token goes tocurlin a header read from a file, never in argv. -
Step 1: Write the failing tests: guard: a German page that becomes empty →
empty. A German page that becomes the English text →english. A German page with a new German sentence →[]. Merge request script with stubs: no change → outputNo change from Crowdin.and nogit push. A change and no open merge request → onePOST .../merge_requests. A change and one open merge request → no newPOST, one push to the same branch. -
Step 2: Run
npm run i18n:test. Expected: the new tests fail. -
Step 3: Implement both scripts.
-
Step 4: Run
npm run i18n:test. Expected: all pass. -
Step 5: Commit
feat(i18n): check the Crowdin download and open one merge request.
Task 4: CI jobs and the local dry run
Files:
- Create:
docusaurus-site/scripts/i18n/crowdin-local.sh - Modify:
.gitlab-ci.yml,docusaurus-site/scripts/i18n/ci-config.test.mjs
Interfaces:
-
crowdin-local.sh <crowdin args>: setsCROWDIN_PERSONAL_TOKENin the environment fromsecurity find-generic-password -s crowdin-token -a "$USER" -w, readsCROWDIN_PROJECT_IDfrom the environment (exit 1 with a clear message when it is not set), then runsnpx crowdin "$@". Every argument goes to the CLI. The token is never in argv and never printed. -
Job
crowdin-upload(stagedeploy,v1.0only, and only when$CROWDIN_PROJECT_IDis set,changes:docusaurus-site/docs/**/*,docusaurus-site/i18n/en/**/*,docusaurus-site/src/pages/index.tsx;allow_failure: true;needs: []):npx docusaurus write-translations --locale en, thennpx crowdin upload sources --delete-obsolete. The English deploy never waits for it. -
Job
crowdin-download(rules:$CI_PIPELINE_SOURCE == "schedule" && $CROWDIN_DOWNLOAD == "1", or$CI_PIPELINE_SOURCE == "web" && $CROWDIN_PROJECT_IDwithwhen: manual):npx crowdin download,node scripts/i18n/guard-download.mjs,npm run i18n:check, thensh scripts/i18n/crowdin-mr.sh .. -
Step 1: Write the failing tests in
ci-config.test.mjs:crowdin-upload runs only on v1.0, only when CROWDIN_PROJECT_ID is set, and never blocks the deploy;crowdin-download runs only on a schedule or by hand;deploy-staging does not need a crowdin job. -
Step 2: Run
npm run i18n:test. Expected: 3 tests fail. -
Step 3: Add the jobs and
crowdin-local.sh. -
Step 4: Run
npm run i18n:testandglab ci lint .gitlab-ci.yml. Expected: all pass. -
Step 5: Commit
ci(i18n): send sources to Crowdin and bring translations back each week. Tasks 1 to 4 go in one merge request:/mr-describe,/review-board, normal merge path. The jobs do nothing until Task 5 sets the variables.
Task 5: Crowdin project and GitLab settings (gated)
- Step 1: Send the admin checklist above to the Crowdin admin (open field). The message goes through
/slack-message, with a preview and the user's "post". - Step 2: After the admin confirms, copy the token to the Keychain:
~/.local/bin/gitlab-var-to-keychain vianova/knowledge-base CROWDIN_PERSONAL_TOKEN crowdin-token. - Step 3: Write one
agent-gatedscript with plan card (Blast radius, Reversible, Security) that:- creates a project access token
crowdin-bot(role Developer, scopesapiandwrite_repository, expiry 1 year), and stores it in the masked, protected CI variableCROWDIN_MR_TOKENthrough a pipe (never printed); - sets the CI variable
CROWDIN_BOT_LOGINto the bot user name; - sets "Pipelines must succeed" on the project (spec 6.6.5);
- creates the pipeline schedule on
v1.0withCROWDIN_DOWNLOAD=1at the open field time, inactive.
Undo line: revoke the token, delete the 2 variables and the schedule, set "Pipelines must succeed" back.
- creates a project access token
- Step 4: Show the card, wait for the user's
1, runagent-gated --yes <script>. Check each item through the API.
Task 6: First import
- Step 1: Dry run. With
CROWDIN_PROJECT_IDset to the project id of the open field, runsh scripts/i18n/crowdin-local.sh upload sources --dryrunandupload translations --dryrun. Expected: 109 docs files and every JSON file, and for each language a target path underi18n/<locale>/that the build reads,i18n/no/for Norwegian. Show the full output in the reply. - Step 2: Run
upload sources, thenupload translations(no auto-approve). Thencrowdin status. Expected: 100% translated and 0% approved for each language. - Step 3: Run
sh scripts/i18n/crowdin-local.sh downloadon a clean branch, thengit status --short docusaurus-site/i18n. Expected: no change, or only whitespace changes. A content change is a stop: find the cause before Step 4. - Step 4: Gated: set the CI variable
CROWDIN_ONE_WRITER=onand activate the schedule (agent-gated script, user's1).
Task 7: Round trip and runbook
- Step 1: Ask the Crowdin admin to change one sentence of one German page in the Crowdin editor.
- Step 2: Run the job
crowdin-downloadby hand. Expected: one merge request "Crowdin translations" with only that sentence changed, and green jobscheck-translationsandone-writer. - Step 3: Rename one English test page on a branch, merge it, and check:
crowdin-uploadis green, and the next download removes the old translated file. Then rename it back. - Step 4: Write the update flow with
/doc-page:TRANSLATING.md(agent), a short editor section inCONTRIBUTING.md, and the locale section ofdocusaurus-site/CLAUDE.md(it still says 4 locales).
Done condition (spec section 8, step 3)
The dry run paths are correct. An edit in Crowdin comes back in the crowdin-download merge request. No translated file goes back to English or becomes empty.
Not in this plan
- The native review and the approval in Crowdin (step 4). It needs the 5 reader names.
- The go-live rules of step 5. All 5 languages are already live on staging since !21(knowledge-base).
Updated about 11 hours ago
