Crowdin CLI file not found in CI
crowdin push sources runs green in the nightly job and uploads nothing. Or it fails with a path that plainly exists in the repository. The same command on a laptop, in the same branch, works exactly as expected.
The CLI resolves file patterns against a base path and a working directory, and both differ on a runner in ways nobody configured on purpose.
Root cause: patterns are relative to a base path, not to the repository
crowdin.yml declares source and translation patterns, and both resolve against base_path — which defaults to the directory the CLI was started in. Locally that is the repository root, because that is where a developer runs commands. In CI it is whatever directory the step began in, which depends on the job configuration and on any earlier step that changed directory.
When the base path is wrong, the pattern matches nothing. The CLI’s usual response is not an error but a report that there is nothing to upload, which reads as success — the job goes green and translators receive no new strings.
Three further environment differences produce the same symptom.
A shallow or filtered checkout may not contain the files at all. Most CI checkouts fetch a single commit by default, which is fine, but a sparse checkout configured for speed can exclude the locale directory entirely.
Case sensitivity differs. A pattern written as Locales/en/*.json matches on a case-insensitive developer machine and matches nothing on a Linux runner.
Generation order matters most on a repository that does not commit its source catalogue. If extraction runs in a different job — or after the upload step — there is nothing on disk to push, and the failure is not about paths at all.
Minimal reproducible example
# The catalogue is generated, but in a different job.
jobs:
extract:
steps:
- run: pnpm i18n:extract # writes locales/en/*.json
push:
steps:
- uses: actions/checkout@v4 # a fresh checkout: no generated files
- run: crowdin push sources # "no files to upload" — and exits 0
Two jobs, two workspaces. The second never saw the first’s output.
The fix: make the step self-contained and loud
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
# Generate in the same job that uploads.
- run: pnpm i18n:extract
# Fail loudly when the patterns match nothing.
- name: Verify sources exist
run: |
count=$(find locales/en -name '*.json' | wc -l)
echo "matched $count source file(s)"
[ "$count" -gt 0 ] || { echo 'no source catalogues to upload'; exit 1; }
- run: crowdin push sources --no-progress --verbose
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
# crowdin.yml — an explicit base path removes the working-directory dependency
"base_path": "."
"preserve_hierarchy": true
files:
- source: "/locales/en/**/*.json"
translation: "/locales/%two_letters_code%/**/%original_file_name%"
The leading slash in the patterns makes them relative to base_path rather than to wherever the process happens to be, which is the single change that fixes most of these failures.
Why an empty upload is worse than a failure
The check that counts files before uploading looks redundant. It is the most valuable line in the job.
A sync that fails is noticed within a day: someone looks at a red build. A sync that uploads nothing is invisible — the job is green, the dashboard shows no new strings, and the natural interpretation is that developers have not added any. Teams routinely discover this weeks later, when a release is blocked because a feature’s strings were never translated, and the pipeline reports no error at any point in that window.
The same argument applies to the pull direction. A crowdin pull that downloads nothing produces an empty commit or no commit at all, which looks exactly like “no translations were approved this week”. Asserting that the pull produced a change, or explicitly logging that it did not, distinguishes the two.
More generally, any step in a localization pipeline whose no-op outcome is indistinguishable from its success outcome should be made to assert. The pipeline moves content between systems, and content that does not move is precisely the failure nobody sees.
Making the sync job observable
A synchronisation step sits between two systems and is the place where localization problems are least visible, because a person only looks at it when something else has already gone wrong. Three cheap additions make it explain itself.
Log the counts on both sides of every run. How many source files matched, how many were uploaded, how many translations were pulled, how many files changed in the working tree. Four numbers, printed every night, turn a silent no-op into an obvious anomaly the first time someone reads the log — and they make the history searchable, so “when did this stop working?” has an answer.
Record the run in a summary that outlives the log. Continuous integration logs expire; a job summary or a comment on the standing localization pull request does not. The useful content is the same four numbers plus the branch and the project, which is enough to reconstruct what happened weeks later.
Alert on a run that changes nothing, repeatedly. A single empty pull is normal — nothing was approved that day. Five consecutive empty pulls, on a project with active translators, is a broken pipeline. That is a trivially detectable pattern and it catches the class of failure where every individual run looks fine.
None of this is specific to Crowdin, and the same three additions apply to a Weblate push, a machine-translation pre-fill job, or any other step that moves content between systems on a schedule. What they have in common is that their failure mode is inaction, and inaction produces no signal unless something is watching for the absence of one.
Verification
# What does the CLI think it will act on?
crowdin push sources --dry-run --verbose
# Fetching project info... OK
# Matched 6 file(s) under /locales/en
# Confirm the project actually received them
crowdin file list --project-id "$CROWDIN_PROJECT_ID" | head
Running the dry run in CI, on every sync, and printing the matched list turns a silent mismatch into a visible one. It costs a second and it is the log line you will want the next time this happens.
When to escalate
If files upload but land in the wrong place, the translation pattern rather than the source pattern is at fault, and the mapping rules in Crowdin integration for dev teams are the reference — particularly preserve_hierarchy, which decides whether directory structure survives.
If the CLI authenticates but reports the project as missing, the token is scoped to a different organisation. Personal tokens and organisation tokens differ in what they can see, and the error message is not always specific about which problem it is.
If uploads succeed and strings still do not appear for translators, check the branch. A push to a Crowdin branch that nobody is working in looks identical to a successful sync from the pipeline’s perspective — the mismatch is in the workflow, not in the transfer.
FAQ
Should the source catalogue be committed?
It simplifies CI considerably, because the push step then needs only a checkout. The cost is a generated file in version control, which invites hand-editing. A reasonable compromise is to commit it and gate on regeneration producing no diff, which keeps the convenience and removes the drift.
Does --dry-run need credentials?
Yes — it contacts the project to resolve the file list, so it exercises authentication as well as pattern matching. That makes it a genuinely useful smoke test rather than a purely local check.
Why does preserve_hierarchy matter for this?
Because it changes how matched paths map onto project paths. Turning it on after files already exist in a project produces a second set of files at different paths rather than moving the originals, and both then appear in listings — which looks like a duplication bug and is a configuration change.
Can the sync run on pull requests?
It can, and it usually should not push from them: every branch would create files or branches in the project. Pushing from the trunk on a schedule, and running only validation on pull requests, keeps the project’s contents predictable.
Does the same problem affect the Weblate and Lokalise CLIs?
Yes, in the same shape. Every client resolves patterns against some base and every CI runner differs from a laptop in working directory, checkout completeness and case sensitivity. The specific flags differ; the fix is identical — state the base explicitly, generate in the same job, and assert that the pattern matched something before the transfer runs.
Related
- Crowdin Integration for Dev Teams — the configuration these patterns come from.
- Crowdin webhook 422 on branch push — the neighbouring failure on the API side.
- Connecting the Crowdin API to GitHub pull requests — the pull direction of the same pipeline.
- GitHub Actions matrix jobs per locale — structuring the jobs this step lives beside.
Part of Crowdin Integration for Dev Teams.