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.yml sends only /docs/**/*.md, /docs/**/*.mdx (type mdx_v2, fixed parser version) and an explicit list of JSON files of i18n/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 item crowdin-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-gated script and the user's typed 1.
  • 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)

  1. No with-secret on this laptop. Spec 6.2.2 runs the CLI with with-secret kc:crowdin-token. That command does not exist in the shell of this laptop. crowdin-local.sh reads the Keychain item into the environment of the CLI process instead, with the same rules: never in argv, never printed. Cost if wrong: none, when with-secret exists later, the script can call it.

Open fields (the user or the Crowdin admin fills them)

FieldValueNeeded by
Crowdin admin (name)openTask 5
Space in the Crowdin plan for about 49,000 more source wordsopen, the admin checks itTask 5
Crowdin project idopen, the admin creates the projectTask 5
Day and time of the weekly downloadopen, default: Monday 06:00 ParisTask 5

Checklist for the Crowdin admin (Task 5 sends it):

  1. Create the project "Vianova Help Center", source language English, target languages German, Arabic, Norwegian (Bokmål), French, Italian.
  2. In the export settings, set "Export only approved" to off, and "Skip untranslated strings" to off.
  3. Turn on the proofread step (spec 6.7.2).
  4. 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.
  5. Put the token in the GitLab variable CROWDIN_PERSONAL_TOKEN (masked, protected) and the project id in CROWDIN_PROJECT_ID, in vianova/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

  1. 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).
  2. The Norwegian folder: Crowdin can export as nb or no. Expected: the files land in i18n/no/. Test in Task 1 (the language mapping) and proof in Task 4 (dry run).
  3. An English page is renamed. Expected: --delete-obsolete removes the old source in Crowdin, the next download removes the old translated file, and the check has no orphan. Checked in Task 7.
  4. 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.
  5. 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

FileResponsibility
docusaurus-site/crowdin.ymlWhich files go to Crowdin, and where the translations come back
docusaurus-site/scripts/i18n/one-writer.mjsFails a merge request that changes translated files, unless it comes from the download job
docusaurus-site/scripts/i18n/guard-download.mjsFails the download when a translated file becomes empty or goes back to English
docusaurus-site/scripts/i18n/crowdin-mr.shCommits the download to branch crowdin/translations and opens or updates the merge request
docusaurus-site/scripts/i18n/crowdin-local.shRuns the Crowdin CLI on this laptop with the token from the Keychain
docusaurus-site/scripts/i18n/*.test.mjsTests of the above
.gitlab-ci.ymlJobs one-writer, crowdin-upload, crowdin-download
docusaurus-site/scripts/i18n/TRANSLATING.md, docusaurus-site/CLAUDE.mdUpdate flow for editors and the agent

Task 1: crowdin.yml

Files:

  • Modify: docusaurus-site/crowdin.yml, docusaurus-site/package.json (@crowdin/cli at 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 of source values is exactly /docs/**/*.md, /docs/**/*.mdx and one entry for each JSON file under i18n/en/ (the test lists them with readdir, so a new JSON file makes the test fail until the config lists it).
    • mdx uses the mdx_v2 parser: the .mdx entry has type: mdx_v2 and a parser_version value.
    • norwegian folder is no: languages_mapping.two_letters_code maps the Crowdin Norwegian code to no.
    • no recipes, blog or pages: no source contains recipes, blog or src/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 matches docusaurus-site/i18n/(de|ar|no|fr|it)/. ok is true when no translated file changed, or when sourceBranch === 'crowdin/translations' and triggerLogin === 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 (stage lint, merge requests only, variables: GIT_DEPTH: 0, so that the diff base exists in the clone). It runs only when the CI variable CROWDIN_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, files has the page. German page from crowdin/translations by the bot → ok. German page from crowdin/translations by a person → not ok. i18n/en/code.json from 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:test and glab 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 of after has no text), english (after equals the English page and before did not). A new file (before === null) that equals English is a problem too.

  • CLI: node scripts/i18n/guard-download.mjs, run from docusaurus-site/ after crowdin download. It reads git status --porcelain i18n/ to find the changed translated files (not i18n/en/). For each file, before is git show HEAD:<path> (null when the file is new) and after is the file on disk. It prints one line per problem and exits 1 when any problem exists.

  • crowdin-mr.sh <site-dir>: when git status --porcelain docusaurus-site/i18n is empty, prints No change from Crowdin. and exits 0. Otherwise it commits on branch crowdin/translations (message chore(i18n): translations from Crowdin), pushes with CROWDIN_MR_TOKEN, and creates the merge request into v1.0 with the title "Crowdin translations", or updates the existing open merge request. It calls git and curl through the variables GIT_BIN and CURL_BIN, so the test can use stubs. The token goes to curl in 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 → output No change from Crowdin. and no git push. A change and no open merge request → one POST .../merge_requests. A change and one open merge request → no new POST, 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>: sets CROWDIN_PERSONAL_TOKEN in the environment from security find-generic-password -s crowdin-token -a "$USER" -w, reads CROWDIN_PROJECT_ID from the environment (exit 1 with a clear message when it is not set), then runs npx crowdin "$@". Every argument goes to the CLI. The token is never in argv and never printed.

  • Job crowdin-upload (stage deploy, v1.0 only, and only when $CROWDIN_PROJECT_ID is 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, then npx 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_ID with when: manual): npx crowdin download, node scripts/i18n/guard-download.mjs, npm run i18n:check, then sh 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:test and glab 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-gated script with plan card (Blast radius, Reversible, Security) that:
    1. creates a project access token crowdin-bot (role Developer, scopes api and write_repository, expiry 1 year), and stores it in the masked, protected CI variable CROWDIN_MR_TOKEN through a pipe (never printed);
    2. sets the CI variable CROWDIN_BOT_LOGIN to the bot user name;
    3. sets "Pipelines must succeed" on the project (spec 6.6.5);
    4. creates the pipeline schedule on v1.0 with CROWDIN_DOWNLOAD=1 at the open field time, inactive.
      Undo line: revoke the token, delete the 2 variables and the schedule, set "Pipelines must succeed" back.
  • Step 4: Show the card, wait for the user's 1, run agent-gated --yes <script>. Check each item through the API.

Task 6: First import

  • Step 1: Dry run. With CROWDIN_PROJECT_ID set to the project id of the open field, run sh scripts/i18n/crowdin-local.sh upload sources --dryrun and upload translations --dryrun. Expected: 109 docs files and every JSON file, and for each language a target path under i18n/<locale>/ that the build reads, i18n/no/ for Norwegian. Show the full output in the reply.
  • Step 2: Run upload sources, then upload translations (no auto-approve). Then crowdin status. Expected: 100% translated and 0% approved for each language.
  • Step 3: Run sh scripts/i18n/crowdin-local.sh download on a clean branch, then git 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=on and activate the schedule (agent-gated script, user's 1).

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-download by hand. Expected: one merge request "Crowdin translations" with only that sentence changed, and green jobs check-translations and one-writer.
  • Step 3: Rename one English test page on a branch, merge it, and check: crowdin-upload is 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 in CONTRIBUTING.md, and the locale section of docusaurus-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).

Did this page help you?