Connecting Crowdin API to GitHub Pull Requests
Wiring Crowdin to GitHub means translated strings land back in your repo as a reviewable pull request on a dedicated branch, not as a manual export-and-commit. The hard part is not the API call — it is making the round trip deterministic: source strings flow up on every merge to main, finished translations flow down onto an l10n branch, and the resulting PR carries a stable identity so CI never opens a second one. Get the branch and token model wrong and you get duplicate PRs, force-push wars, and 403s from a token that cannot trigger downstream checks.
This page shows the recommended setup using the official Crowdin GitHub Action, why a GitHub App token beats a personal access token (PAT) for the download PR, and how to keep the l10n branch in sync with main without merge-conflict churn. It assumes you have already done the broader Crowdin Integration for Dev Teams wiring (project ID, crowdin.yml file mappings) and now want the two-way GitHub bridge specifically.
Root Cause: Why Naive Sync Produces Duplicate PRs and 403s
Three behaviours bite teams who script this by hand instead of using the official action.
The branch is not idempotent. The Crowdin GitHub Action keys its download PR off a fixed localization_branch_name (default l10n) and pull_request_title. If you instead generate a new branch per run, every sync opens a fresh PR and the old ones rot. The fix is to always target the same branch and let the action update it in place — it commits new translations onto that branch and reuses the existing open PR.
A GITHUB_TOKEN cannot trigger your CI. Pushes and PRs made with the default secrets.GITHUB_TOKEN deliberately do not fire push or pull_request workflow events — GitHub blocks this to prevent recursive runs. So your translation PR opens but its required status checks never start, and the PR can never satisfy branch protection. This is the single most common reason a Crowdin PR “looks stuck.” The fix is to authenticate the download with a GitHub App installation token (or, less ideally, a PAT). Events triggered by an App token do start workflows.
Sources and translations race. If you upload sources and download translations in the same job against main, a merge to main that happens mid-run leaves the l10n branch behind main, and the next PR carries phantom conflicts. Keep the l10n branch rebased on main and let the action manage the diff.
Minimal Reproducible Setup
A single scheduled job that both uploads source strings and pulls finished translations into a PR. This is the smallest config that exercises the full round trip and the token edge case.
name: Crowdin Sync
on:
push:
branches: [main] # upload sources whenever main changes
schedule:
- cron: '0 */6 * * *' # pull translations every 6h
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crowdin/github-action@v2
with:
upload_sources: true
download_translations: true
localization_branch_name: l10n
create_pull_request: true
pull_request_title: 'New Crowdin translations'
pull_request_base_branch_name: main
token: ${{ secrets.GITHUB_TOKEN }} # ← the bug: CI won't run on this PR
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
This works — the PR appears — but because it is created with GITHUB_TOKEN, none of your i18n CI gates (the kind described in GitHub Actions i18n CI Gates) ever run on it. Under branch protection, the PR is unmergeable.
The Fix: GitHub App Token + Idempotent l10n Branch
Mint a short-lived installation token from a GitHub App and hand that to the action’s token. An App token is treated as a first-class actor, so the PR it opens triggers pull_request workflows normally. CROWDIN_PERSONAL_TOKEN stays the Crowdin-side credential; the two are separate and must not be confused.
name: Crowdin Sync
on:
push:
branches: [main]
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
# Exchange App private key for a 1h installation token
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.L10N_APP_ID }}
private-key: ${{ secrets.L10N_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0 # full history so the l10n branch rebases cleanly
- uses: crowdin/github-action@v2
with:
upload_sources: true
download_translations: true
# one stable branch → one stable PR, updated in place every run
localization_branch_name: l10n
create_pull_request: true
pull_request_title: 'New Crowdin translations'
pull_request_labels: 'l10n,automated'
pull_request_base_branch_name: main
# commit only approved strings; leave fuzzy/untranslated out of the PR
skip_untranslated_strings: false
export_only_approved: true
# App token: PR now fires required CI checks
token: ${{ steps.app-token.outputs.token }}
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
Key points on the non-obvious lines:
actions/create-github-app-token@v1returns a token scoped to the App’s installation, expiring in ~1 hour — far safer than a long-lived PAT insecrets.fetch-depth: 0gives the action full history; without it thel10nbranch can fail to rebase ontomainand the PR shows spurious diffs.export_only_approved: truekeeps in-progress strings out of the PR, so a merge never ships half-translated UI. Pair it with the validation gate so placeholder integrity ({count},{{name}}, ICU{n, plural, …}) is checked before merge — see the related work on enforcing glossary terms in CI.
For the Crowdin App token specifically, the App needs the Contents and Pull requests repository permissions (read/write) and must be installed on the target repo. Anything less and the action 403s when it tries to push the l10n branch.
Configuration Reference
| Action input | Type | Description / default |
|---|---|---|
upload_sources |
boolean | Push source files to Crowdin per crowdin.yml. Default true. |
download_translations |
boolean | Pull translated files back into the repo. Default false. |
localization_branch_name |
string | The branch the download PR targets. Keep it stable (e.g. l10n) for idempotency. |
create_pull_request |
boolean | Open/update a PR for downloaded translations. Default true. |
pull_request_base_branch_name |
string | Base for the translation PR. Usually main. |
export_only_approved |
boolean | Commit only approved strings. Default false. |
token |
string | GitHub token used to push and open the PR. Use an App installation token so CI runs. |
CROWDIN_PROJECT_ID / CROWDIN_PERSONAL_TOKEN |
env | Crowdin-side numeric project ID and API token. Distinct from token. |
Verification
Confirm the round trip in three checks. First, prove the PR was opened by your App actor (not github-actions[bot]), which is what lets CI run:
gh pr view "New Crowdin translations" --json author,headRefName,labels
# expect: author.login = "<your-app>[bot]", headRefName = "l10n"
Second, assert the required check actually started on the PR — the symptom of the GITHUB_TOKEN bug is an empty list here:
gh pr checks l10n
# expect at least one "i18n-validate" (or similar) check, not "no checks reported"
Third, a fast placeholder-parity guard you can drop into the PR’s CI so a broken interpolation never merges:
# fail if any target file is missing a {placeholder} present in the source
node -e '
const fs=require("fs");
const src=JSON.parse(fs.readFileSync("locales/en/common.json"));
const ph=s=>[...String(s).matchAll(/\{[^}]+\}/g)].map(m=>m[0]).sort().join();
for (const lang of fs.readdirSync("locales")) {
if (lang==="en") continue;
const t=JSON.parse(fs.readFileSync(`locales/${lang}/common.json`));
for (const k in src) if (t[k] && ph(src[k])!==ph(t[k])) {
console.error(`placeholder mismatch ${lang}:${k}`); process.exit(1);
}
}'
When to Escalate
If translations still fail to land after switching to an App token, the problem has usually moved upstream to Crowdin itself — a webhook returning non-2xx on branch push, a project that is not actually building the export, or a crowdin.yml path that does not match your repo layout. A 422 on the inbound webhook in particular points at branch handling in the project, not the GitHub side; that path is covered in Crowdin webhook 422 on branch push. If you are weighing whether to keep this GitHub-hosted flow at all versus a self-hosted server, compare it against Crowdin vs Weblate for self-hosted teams. For the full project setup that this page builds on, return to Crowdin Integration for Dev Teams.
FAQ
Why does my Crowdin translation PR show no status checks?
Because it was created with the default secrets.GITHUB_TOKEN. GitHub intentionally does not fire push or pull_request events for activity from that token, so your required CI checks never start and branch protection blocks the merge. Re-run the action with a GitHub App installation token (via actions/create-github-app-token) or a PAT, and the PR will trigger workflows normally.
Should I use a GitHub App token or a personal access token?
Prefer a GitHub App token. It is minted per run, expires in about an hour, and is scoped to the App’s installation permissions (Contents + Pull requests), so a leak has a tiny blast radius. A PAT works but is long-lived, tied to a human account, and counts against that user’s rate limits — avoid it for shared CI.
How do I stop Crowdin from opening a new PR every sync?
Keep localization_branch_name and pull_request_title constant across runs. The action then commits new translations onto the same branch and updates the existing open PR in place instead of creating another. Generating a per-run branch name is what produces the pile of duplicate PRs.
Related
- Crowdin Integration for Dev Teams — the project, token, and
crowdin.ymlsetup this bridge sits on top of. - Crowdin webhook 422 on branch push — the upstream failure when the inbound webhook rejects a branch.
- GitHub Actions i18n CI Gates — the validation checks that must run on the translation PR.
- Failing the build on untranslated keys — gating merges on translation completeness.
- Crowdin vs Weblate for self-hosted teams — choosing the platform behind this pipeline.
Part of Crowdin Integration for Dev Teams.