Crowdin GitHub Action
crowdin/github-action runs Crowdin CLI inside a Docker container as a single workflow step: it uploads source files to Crowdin, downloads finished translations, commits them to a localization branch, and opens a pull request.
Because it is a Docker action it only runs on Linux runners — ubuntu-latest, or a self-hosted Linux runner with Docker available. It will not run on macos-* or windows-*.
Reference: README · examples · inputs and outputs · workflow recipes
The step is a pipeline, not a command
One step performs up to five operations, always in this fixed order, and each one is gated by its own input:
| Order | Input | Runs |
|---|---|---|
| 1 | upload_sources: true (default) |
crowdin upload sources |
| 2 | upload_translations: true |
crowdin upload translations |
| 3 | download_sources: true |
crowdin download sources, then the git half |
| 4 | download_translations: true |
crowdin download, then the git half |
| 5 | download_bundle: <id> |
crowdin bundle download <id>, then the git half |
The git half is a single unit of work: git add ., commit, force-push the localization branch, open the PR. Its parts are individually switchable (push_translations, push_sources, create_pull_request), and only rows 3–5 can reach it.
That order explains nearly every surprise the action produces:
upload_sourcesdefaults totrue. A step you intended as download-only also pushes your sources to Crowdin unless you writeupload_sources: false. Set every flag you care about explicitly rather than relying on defaults.- The git half runs only after a download.
create_pull_request: trueon an upload-only step is inert — there is nothing to commit. - Two downloads in one step run the git half twice — two commits to the same localization branch, under one PR.
- The git half commits with
git add .— everything dirty in the workspace goes in, including files earlier steps generated. Keep the Crowdin step ahead of any build steps, or give it its own job. - Every run with changes force-pushes, so an open translation PR is rewritten rather than appended to — which is why its checks start over each time.
Setting it up
Two of the five steps belong to a human: the repository secrets in step 2 and the pull-request setting in step 4 need someone with repository admin access clicking through the GitHub UI. Hand that list over explicitly rather than assuming it is already in place — a workflow that is perfect on disk still fails its first run without them.
1. Configure crowdin.yml
The action reads the same crowdin.yml as the CLI — the crowdin-cli skill covers files, placeholders, and language mapping. Three parts matter specifically here:
"project_id_env": "CROWDIN_PROJECT_ID"
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
"preserve_hierarchy": true
files:
- source: "/src/locales/en/**/*.json"
translation: "/src/locales/%two_letters_code%/**/%original_file_name%"
project_id_env/api_token_envpoint at the environment variables the workflow supplies, so no credential is ever committed.preserve_hierarchy: truekeeps Crowdin's file tree identical to the repository's. Without it Crowdin strips the common parent directory, and downloaded translations land in paths that don't match the repo — the usual cause of a PR full of files in the wrong place. It is also required when moving off the native GitHub (OAuth) integration.- Keep the file at the repository root, or pass its path via the
configinput (no leading/).
Repositories with a single source and translation pattern can skip the file entirely and pass source and translation as inputs instead — see no-crowdin.yml. Pick one or the other: a crowdin.yml in the repository conflicts with those inputs.
2. Create the repository secrets
In Settings → Secrets and variables → Actions, add:
CROWDIN_PROJECT_ID— the numeric project ID, from Tools → API in the Crowdin project.CROWDIN_PERSONAL_TOKEN— a Crowdin personal access token (crowdin.com, or Account Settings → Access Tokens in Enterprise).
Scope the token to what the workflow actually does — for a normal sync that is Projects → Read, Translation Status → Read, Source files & strings → Read and Write, Translations → Read and Write.
3. Write the workflow
.github/workflows/crowdin.yml:
name: Crowdin
on:
push:
branches: [ main ]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: crowdin
cancel-in-progress: false
jobs:
synchronize:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Synchronize with Crowdin
uses: crowdin/github-action@v3
with:
upload_sources: true
upload_translations: false
download_translations: true
localization_branch_name: l10n_crowdin
create_pull_request: true
pull_request_title: 'New Crowdin translations'
pull_request_base_branch_name: main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
Details worth keeping when you adapt it:
- Credentials go in
env:, notwith:— the CLI and the action's git code read them from the environment. (tokenandproject_idinputs exist as an alternative, but a token inwith:shows up in the step's rendered inputs.) workflow_dispatchis what lets you test the workflow without waiting for a push.concurrencywithcancel-in-progress: falsequeues runs instead of racing them. A second run that starts while Crowdin is still building the previous export fails with a 409.pull_request_base_branch_nameis worth setting explicitly: unset, the base branch is whatever ref triggered the run, which is correct for a push tomainbut broken for tag and release triggers.- A single sync step is the simplest shape, but the two directions have different natural rhythms — uploading on every push to
main, downloading on a schedule. See split workflows. actions/checkout@v7was current in August 2026; check its releases rather than copying a pin from an older example.
4. Grant the token write access
Pushing a branch and opening a PR needs contents: write and pull-requests: write. With the default GITHUB_TOKEN that means both the job-level permissions: block above and the repository setting Settings → Actions → General → Allow GitHub Actions to create and approve pull requests. Without the setting, PR creation fails even though the permissions look right.
The default token has one limitation no permission fixes: pull requests it opens do not trigger workflows, so the translation PR arrives with no CI checks. If checks must run on it, authenticate as something other than the Actions bot — a GitHub App (scoped, auto-rotating, the better default) or a classic PAT with the repo scope, passed as GH_TOKEN or GITHUB_TOKEN.
5. Prove it runs
Valid YAML is not the finish line. This action fails at runtime — on credentials, file patterns and permissions — so the work is done when a run has printed the stages you expected, not when the file is written:
- Set
dryrun_action: trueand dispatch the workflow. The CLI prints the files it would manage, so wrongsourcepatterns surface here, before anything reaches Crowdin. - Drop the flag, dispatch again, and read the log through to the end:
UPLOAD SOURCES, thenDOWNLOAD TRANSLATIONS, then either a PR URL orNOTHING TO COMMIT. - Re-run a misbehaving run with Enable debug logging — the action detects
RUNNER_DEBUG, dumps its environment, and adds--verbose --debugto every CLI call.
Dispatching needs the human who holds the secrets; ask for the run's log rather than stopping at the workflow file.
What breaks, and why
Read the step's log first: it prints UPLOAD SOURCES, DOWNLOAD TRANSLATIONS, PUSH TO BRANCH, NOTHING TO COMMIT, and CREATE PULL REQUEST as it goes, so the pipeline stage that failed is visible before you start guessing.
| Symptom | Cause | Fix |
|---|---|---|
Green run, no PR, log says NOTHING TO COMMIT |
Downloaded translations are byte-identical to what's committed | Working as intended — nothing to do |
| Green run, no PR, no download in the log | download_translations left at its false default |
Set download_translations: true |
ERROR: Either 'GITHUB_TOKEN' or 'GH_TOKEN' must be set |
Token missing from the step's env: |
Add GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
FAILED TO AUTHENTICATE WITH GITHUB, or 403 on push |
Job lacks write permissions, or the repo blocks Actions from creating PRs | Add the permissions: block and enable the repository setting (step 4) |
| PR opens but no CI checks run on it | PRs from the default GITHUB_TOKEN don't trigger workflows |
Use a GitHub App token or a PAT |
| Checks restart on every sync run | The localization branch is force-pushed each time | Pass a PAT to actions/checkout so the pushes come from a user |
| PR creation fails on a tag or release trigger | Base branch defaults to the triggering ref, which is refs/tags/... |
Set pull_request_base_branch_name |
| Later steps hit permission errors on downloaded files | The container runs as root, so the files it writes are root-owned | Add user: auto to detect the workspace owner |
409 / build already in progress |
Parallel runs against one Crowdin project | Serialize with concurrency, or max-parallel: 1 in a matrix |
| Build artifacts in the translation PR | git add . commits the whole workspace |
Move the Crowdin step before the build, or into its own job |
| Nothing downloads, project is string-based | download_translations doesn't apply to string-based projects |
Download a bundle: download_bundle: <id> |
crowdin.yml not found |
The action looks in the repository root | config: path/to/crowdin.yml, no leading / |
| Enterprise: 404 or auth failure from the CLI | Enterprise needs an organization-specific API host | base_url: 'https://{organization}.api.crowdin.com' |
command — running any CLI command
command (plus optional command_args) turns the step into an arbitrary CLI invocation: status, auto-translate, bundle, string, anything the CLI supports.
- name: Fail the build unless translations are complete
uses: crowdin/github-action@v3
with:
command: 'status translation'
command_args: '--fail-if-incomplete'
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
Two consequences of how it is implemented, neither of them documented upstream:
commandreplaces the pipeline. The step runs the command and exits — no upload, no download, no commit, no PR, whatever the other inputs say. Sequencing a command between an upload and a download takes three separate steps (AI pre-translation is the canonical example).- The action's config inputs don't reach it.
config,token,project_id,base_url,base_path,crowdin_branch_name, anddryrun_actionare not forwarded to acommandstep — onlycommand_argsis. Supply credentials throughenv:and put everything else incommand_args(--config path/to/crowdin.yml,--branch main,--dryrun).
The command's stdout is available to later steps as the command_output output.
Which version to pin
@v3 is the current stable line and runs Crowdin CLI 5.
@v2 is the legacy line, bundling the Java-based Crowdin CLI 4. Every input, output, and default is identical between the two, so upgrading a pipeline-style step is a one-line pin change; what changes is the CLI underneath, and therefore the strings you pass to command and *_args. Most notably v4's pre-translate became auto-translate, and --plain became -o plain; the migration reference lists the rest. Stay on @v2 only while a command step still depends on v4-specific CLI syntax.
Related skills
- crowdin-cli —
crowdin.ymlconfiguration and the commands and flags behind every input - crowdin-api-client — for automation the CLI doesn't cover
- croql — CroQL filters for
command_argson string commands