Translation Workflows with Git and Crowdin

Setting up a multilingual static site is a few days of engineering. Keeping it translated is a permanent process. The source language changes every week — new pages, rewritten steps, renamed options — and each change silently makes some translated pages wrong. Without a workflow, translations drift: the German install guide still describes a flag that was removed two releases ago, and nobody knows until a customer writes in.

This guide wires a Hugo or Astro documentation repository to Crowdin (the same pattern works with Lokalise, Phrase or a self-hosted Weblate) so that source changes are pushed to translators automatically, finished translations come back as ordinary pull requests with preview deploys, and CI reports every translated page whose source has moved on. It is part of Internationalization for Static Sites and builds on the review practices in Docs-as-Code Review Workflow for Writers.

Prerequisites

  • A repository with the source language in its own directory, for example content/en/, and target languages in sibling directories.
  • A Crowdin project with the target languages added, and a personal access token stored as the CROWDIN_TOKEN repository secret.
  • GitHub Actions with permission to open pull requests (contents: write, pull-requests: write).

Step 1: Describe the Files

A crowdin.yml at the repository root tells the CLI and the GitHub integration which files are sources and where translations go:

project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_TOKEN
preserve_hierarchy: true
files:
  - source: /content/en/**/*.md
    translation: /content/%two_letters_code%/**/%original_file_name%
    type: md
    update_option: update_as_unchanged
    excluded_target_languages: []
  - source: /i18n/en.toml
    translation: /i18n/%two_letters_code%.toml

update_as_unchanged keeps existing translations when a source segment changes only in whitespace or punctuation, which avoids re-translating a whole paragraph because a trailing space was removed. Frontmatter handling is configured in Crowdin's Markdown parser settings: mark title and description translatable, and exclude slug, translationKey, weight and sourceCommit.

Step 2: Push Sources on Merge

When English content is merged to main, upload changed sources:

# .github/workflows/crowdin-upload.yml
on:
  push:
    branches: [main]
    paths: ['content/en/**', 'i18n/en.toml']
jobs:
  upload:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: crowdin/github-action@v2
        with:
          upload_sources: true
          upload_translations: false
          download_translations: false
        env:
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}

Only merged content is uploaded. Pushing from pull request branches sends translators text that may change again during review, and on the documentation project it had wasted roughly 15% of translated words in the first month.

Sequence of a source edit through Crowdin and back A writer merges an English change to main. The upload workflow pushes changed segments to Crowdin. Translators and reviewers work there with translation memory. A scheduled download workflow opens a pull request with the finished translations. The pull request gets a preview deploy and checks, then a reviewer merges it and all locales deploy. One source change through the translation loop Writer English source GitHub main + Actions Crowdin TMS Reviewer per locale merge change to content/en upload changed segments translate and proofread nightly download of approved strings pull request with preview URL approve and merge
Translations come back through the same review gate as any other change, so broken markup or links cannot reach production directly.

Step 3: Pull Translations as Pull Requests

A scheduled workflow downloads approved translations and opens (or updates) one pull request:

# .github/workflows/crowdin-download.yml
on:
  schedule: [{ cron: '0 3 * * 1-5' }]
  workflow_dispatch:
jobs:
  download:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: crowdin/github-action@v2
        with:
          upload_sources: false
          download_translations: true
          export_only_approved: true
          localization_branch_name: l10n/crowdin
          create_pull_request: true
          pull_request_title: 'Translations from Crowdin'
          pull_request_labels: 'translations'
        env:
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

export_only_approved means only proofread strings reach the repository. The pull request triggers the normal build, preview deploy, HTML validation and link checks, so a translated page that breaks a table or a shortcode fails CI like any other change. Reviewers per language are assigned with a CODEOWNERS entry such as /content/de/ @docs-de-reviewers.

Step 4: Record the Source Commit

Crowdin knows which segments changed, but the site should know too, so readers and CI can see staleness without access to the translation system. After each download, a script stamps every translated file with the commit of its English source:

// scripts/stamp-source-commit.mjs
import { execSync } from 'node:child_process';
import { globby } from 'globby';
import matter from 'gray-matter';
import { readFile, writeFile } from 'node:fs/promises';

for (const file of await globby('content/{de,fr,ja}/**/*.md')) {
  const src = file.replace(/^content\/\w\w\//, 'content/en/');
  const sha = execSync(`git log -1 --format=%h -- ${src}`).toString().trim();
  const doc = matter(await readFile(file, 'utf8'));
  if (doc.data.sourceCommit !== sha) {
    doc.data.sourceCommit = sha;
    await writeFile(file, matter.stringify(doc.content, doc.data));
  }
}

A companion script run in CI does the reverse check — for each translated file, is there a newer commit on its source? — and writes a table to the job summary. The layout can also show a notice ("This page may be out of date; the English version was updated on 12 September") when a page is stale, which reduced "wrong instructions" support tickets on German pages from 11 to 2 per month.

How a stale translation is detected in CI A German page records sourceCommit 4f2a91c. The English source has a newer commit 9be04d2. CI compares the two, marks the German page stale, lists it in the job summary and the layout shows an out-of-date notice to readers until the next translation merge updates the stamp. Detecting a stale translation de/install.md sourceCommit 4f2a91c en/install.md latest commit 9be04d2 Compare in CI git log -1 per source Mark stale job summary table Reader notice until re-translated
The comparison needs nothing but Git history, so it works even when the translation system is unavailable.

Step 5: Keep Machine Translation in Its Place

Crowdin can pre-translate with DeepL or Google Translate. Used as suggestions for human translators, machine translation cut median turnaround for a batch of about 1,900 words from 6 days to 3.5 days on this project. Published without review, it produced the complaints that led the team to switch Japanese fallbacks to redirects. Keep export_only_approved: true so unreviewed machine output never reaches the site.

Glossaries, Screenshots and Who Owns What

Tooling moves text; people decide what it should say. Three agreements made the workflow run smoothly on this project, and each one prevented a recurring argument.

A glossary before the first batch. Product names, UI labels and key concepts need one agreed translation per language, stored as a Crowdin glossary so the editor highlights the approved term whenever the source term appears. Without it, the German documentation used three different words for "deployment" within the first month. The glossary also records terms that must never be translated — product names, CLI commands, configuration keys — which removed the most common reviewer correction.

Screenshots are content. A German page with an English screenshot is only partly translated. Where screenshots show UI text, generate them per locale from a script (Playwright can switch the application language and capture the same view in each) and treat them as build artefacts rather than files translators must handle. Where that is impossible, prefer annotated diagrams over screenshots in the source language, since diagram labels go through the normal string pipeline.

Clear ownership per locale. Each language has a named reviewer who approves the translation pull request, listed in CODEOWNERS. Engineers review only the build and checks; they do not approve wording in languages they do not read. Translators do not merge. That split meant translation pull requests stopped waiting for someone to feel qualified to press the button: median time from pull request to merge fell from four days to one.

Budget matters too. Translated word volume is predictable from the English merge history — about 1,900 new or changed words per week here — so the translation spend could be planned per quarter instead of approved batch by batch.

Measured Impact

Over two quarters on the four-language documentation site:

MeasureManual copy-paste (Q1)Git + Crowdin (Q2)
Median days from English merge to translated deploy194.5
Words re-translated because of unmerged source editsabout 15%under 1%
Stale pages known to the teamunknown118, then 23
Translation PRs failing checks before mergen/a14 (tables, shortcodes)
"Wrong instructions" tickets on German pages per month112
Translation lead time before and after automation Vertical bars of median lead time. Manual copy and paste took 19 days. Git plus Crowdin without machine suggestions took 6 days. Git plus Crowdin with machine translation suggestions reviewed by humans took 4.5 days. Days from English merge to translated deploy 19 d Manual copy and paste 6 d Git + Crowdin 4.5 d Git + Crowdin + reviewed MT
Median over all translated batches in each period, measured from merge timestamps in GitHub.

Pitfalls & Rollback

  • Uploading from branches. Translators work on text that is still under review; upload only from main.
  • Translatable slugs by accident. If slug is translatable, a translator can change a URL; exclude it or treat slug changes as a reviewed decision with a redirect.
  • Shortcodes mangled. Mark shortcode syntax as non-translatable with a custom placeholder regex in the parser settings, or translators will translate parameter names.
  • One giant pull request. A weekly batch across four languages can touch hundreds of files; split by language with separate branches if review stalls.
  • Rollback: translations are files in Git. Reverting the merge of a translation pull request restores the previous versions; the Crowdin project is unaffected and can re-export later.

Conclusion

A translation workflow is what keeps a multilingual site correct after launch. Upload merged sources automatically, bring approved translations back as pull requests with the same checks as English changes, and record the source commit on each translated page so staleness is visible in CI and to readers. On the documentation site that cut lead time from 19 days to 4.5 and turned an unknown number of outdated pages into a short, shrinking list.

FAQ

Why not let translators edit Markdown files directly in Git?

Some teams do, and it works for a few technical translators. Most professional translators work faster in a translation management system with translation memory, glossaries and machine-translation suggestions. Syncing through Git gives them that tooling while the repository stays the single source of truth.

How does Crowdin handle Markdown frontmatter and code blocks?

Crowdin's Markdown parser extracts prose segments and leaves code blocks untranslated by default. Frontmatter keys can be marked translatable or ignored in the file configuration, so a title is translated while a slug or translationKey is kept.

How do I know which translations are out of date?

Record the commit of the source file each translation was based on and compare it with the latest commit touching that source. Any page whose source has changed since is stale. A short script run in CI can list them in the job summary.

Should translation pull requests get preview deploys?

Yes. Translations break layouts more often than people expect, through longer words, different punctuation and right-to-left text. A preview deploy and the normal link and HTML checks catch those issues before merge.

Filed underHow-tos