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.

Four causes of a missing-file error in CI The CLI resolves source patterns relative to its base path, so a job started in a different directory finds nothing. A shallow or filtered checkout may not contain the catalogue at all. Extraction that runs after the upload step leaves nothing to upload. And a pattern differing in case or a leading slash matches no file on a case-sensitive runner. Why the CLI reports no files "No files to upload" or a path error the same run works locally Wrong working directory paths are relative to base_path Shallow checkout the files were never fetched Generated files not built yet extraction ran after the push Pattern does not match case, or a leading slash
Three are environmental and one is the configuration — check the environment first, it is faster.

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.

Four environment differences between a laptop and a runner A developer machine may have a case-insensitive filesystem while the runner is case-sensitive. Local clones are complete while CI checkouts are shallow by default. The local working directory is the repository root while a job step starts wherever it was configured. And a locally generated catalogue exists from an earlier command, whereas CI only has what a step produced. Why it works locally and not in CI Locally On the runner Filesystem case case-insensitive on macOS case-sensitive Checkout depth full clone shallow by default Working directory the repo root wherever the step starts Generated catalogue already built built only if a step says so
Every row is a difference nobody configured deliberately — which is why the failure is surprising.

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.

Five steps for a reliable CLI sync job Extraction runs in the same job as the upload so the catalogue exists. The base path is stated explicitly rather than inherited from the working directory. The checkout includes what the job needs. The step fails when zero files match, because an empty upload is always a defect. And the matched file list is printed before uploading so a failure explains itself. A sync step that cannot silently do nothing 1 Run extraction before upload, in the same job not in a separate workflow 2 Set base_path explicitly never rely on the working directory 3 Check out with the depth the job needs shallow is fine, empty is not 4 Fail on zero files an empty upload is a bug, not a no-op 5 Print what matched before uploading the list is the diagnosis
Step four turns the worst outcome — a silent no-op — into a red build.

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.

Part of Crowdin Integration for Dev Teams.