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.

Crowdin to GitHub two-way sync flow Merging to main uploads sources to Crowdin. Translators work. The Crowdin Action downloads translations onto the l10n branch and opens or updates a single pull request, which CI gates before merge. merge → main source strings change Crowdin Action upload_sources: true Crowdin project translators work download_translations approved strings l10n branch rebased on main Pull request single, idempotent CI gate on the PR JSON/ICU validation · placeholder parity · required check before merge → main
One branch, one PR: sources flow up on every merge, approved translations flow down and are gated by CI before they reach main.

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.

Three token types against three requirements The default workflow token can open a pull request but deliberately cannot trigger further workflow runs, and its scope is the whole repository. A personal access token can do both but carries the permissions of a human account. A GitHub App installation token can open the pull request, trigger continuous integration, and be scoped to a single repository. Which token can do what Opens a PR Triggers CI Fine-grained scope GITHUB_TOKEN yes no repo-wide Personal token yes yes user-wide GitHub App token yes yes per repository
Only the third row satisfies all three columns — which is why the App token is worth the setup.

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.

The token choice is the one that silently breaks the merge, so it is worth seeing side by side: the same PR, opened two different ways, ends up either stuck or mergeable.

Why GITHUB_TOKEN stalls the PR but an App token lets CI run Two lanes for the same translation PR. Left, opened with secrets.GITHUB_TOKEN: push and pull_request events are suppressed, required checks never start, and branch protection blocks the merge. Right, opened with a GitHub App installation token: events fire as the App actor, i18n CI runs, checks pass, and the PR is mergeable. Same download PR, two tokens secrets.GITHUB_TOKEN opens the l10n PR push / pull_request events suppressed by design required checks never start branch protection blocks PR stuck, unmergeable App installation token opens the l10n PR events fire as a first-class App actor required i18n checks run checks pass PR is mergeable
The default GITHUB_TOKEN deliberately suppresses the events that start CI, so the PR can never satisfy branch protection; an App installation token acts as a real actor and lets the required checks run.

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@v1 returns a token scoped to the App’s installation, expiring in ~1 hour — far safer than a long-lived PAT in secrets.
  • fetch-depth: 0 gives the action full history; without it the l10n branch can fail to rebase onto main and the PR shows spurious diffs.
  • export_only_approved: true keeps 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.

Keeping the l10n branch rebased on main

The third failure from the root-cause section — sources and translations racing — shows up as an l10n PR whose diff contains changes to files nobody translated, because the branch has drifted behind main. The action rebases l10n for you only when it can see full history, which is why fetch-depth: 0 above is not optional. When a long-lived l10n branch still accumulates drift (typical on repos that merge to main many times a day), reset it onto main before the action runs so the download diff is nothing but translation changes:

      - name: Rebase l10n onto main
        run: |
          git fetch origin main l10n
          # fast-forward l10n to main; the Crowdin Action re-applies translations on top
          git checkout -B l10n origin/main
          git push --force-with-lease origin l10n
        env:
          GIT_AUTHOR_NAME: l10n-bot
          GIT_COMMITTER_NAME: l10n-bot

--force-with-lease (never a bare --force) refuses to clobber the branch if someone pushed to it since your fetch, so a translator’s in-flight commit is never lost. Run this step before crowdin/github-action@v2; the action then commits the freshly downloaded strings onto a branch that already matches main, and the PR diff is pure translation.

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.

Part of Crowdin Integration for Dev Teams.