Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/** linguist-generated=true
36 changes: 35 additions & 1 deletion .github/workflows/test-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,53 @@ on:
- '*'

jobs:
test:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- run: npm ci

- run: npm run typecheck

- run: npm test

- name: Check that dist/ is up to date
run: |
npm run build
if [ -n "$(git status --porcelain dist)" ]; then
echo "::error::dist/ is out of date. Run 'npm run build' and commit the result."
git --no-pager diff --stat dist
exit 1
fi

test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-15-intel, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

- name: Crowdin Action
uses: ./
with:
command: 'push'
command_args: '-h'

- name: Get CLI version
id: version
uses: ./
with:
command: 'init'
command_args: '-V'

- name: Check command output
shell: bash
run: test -n "${{ steps.version.outputs.command_output }}"
1 change: 1 addition & 0 deletions .github/workflows/update-main-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
type: choice
description: The main version to update
options:
- v4
- v3
- v2
- v1
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.idea
node_modules
coverage
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,16 @@ Before sending your pull requests, make sure you followed the list below:
- Ensure that your code adheres to standard conventions, as used in the rest of the project.
- Ensure that your changes are well tested.

#### Development

The action is written in TypeScript and bundled into `dist/index.js`, which must be committed together with the source changes.

```sh
npm ci
npm run typecheck # type-check the sources and tests
npm test # run the unit tests
npm run build # bundle src/ into dist/index.js
```

> **Note**
> This project uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification for commit messages and PR titles.
8 changes: 0 additions & 8 deletions Dockerfile

This file was deleted.

22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ This action allows you to easily integrate and automate the localization of your
> - Redundant negatable flags were removed - only the form that changes the default behavior is kept (e.g. `--auto-update` was removed while `--no-auto-update` stays; `--no-auto-approve-imported`, `--no-import-eq-suggestions`, `--no-translate-hidden`, and `--no-auto-tag` were removed). The defaults are unchanged, so simply drop the removed form.
>

## Upgrading to v4

> [!IMPORTANT]
> Starting with `v4`, the action is a JavaScript (`node24`) action instead of a Docker container action. Inputs and outputs are unchanged, so it is a drop-in replacement for `v3`, with the following differences:
>
> - It runs on Linux, macOS, and Windows runners (x64 and arm64), and no longer requires Docker (e.g. on ARC runners in Kubernetes mode).
> - Files are created by the runner user instead of `root`, so the `user` input is no longer needed and has no effect.
> - Git and GPG are used from the runner. `git` is required; `gpg` is only required when `gpg_private_key` is set. The commit identity and signing key are passed to `git commit` directly, so the global git configuration and GPG keyring of the runner are not modified.
> - Arguments in `command`, `command_args`, and `*_args` inputs are split like a shell would, so quoted values (e.g. `--label "my label"`) are passed as a single argument.
> - The `pull_request_url` output no longer contains surrounding double quotes.

## Usage

Set up a workflow in *.github/workflows/crowdin.yml* (or add a job to your existing workflows).
Expand Down Expand Up @@ -186,7 +197,16 @@ By default, the action will look for the `crowdin.yml` file in the root of the r
| `crowdin_branch_name` | Option to upload or download files to the specified version branch in your Crowdin project | `main` |
| `config` | Option to specify a path to the configuration file (without `/` at the beginning) | `path/to/your/crowdin.yml` |
| `dryrun_action` | Defines whether to run the action in the dry-run mode | `false` (default) |
| `user` | Run the action as specific user, accepts `uid:gid` or `auto` to detect from workspace ownership. Leave empty to run as root (default) | `auto` |

### Crowdin CLI installation

The action downloads the native [Crowdin CLI](https://crowdin.github.io/crowdin-cli/) binary for the runner platform from npm (`@crowdin/cli-<platform>` packages) and caches it in the runner tool cache. Linux (x64, arm64, glibc and musl), macOS (x64, arm64), and Windows (x64) runners are supported, including self-hosted runners and Kubernetes (ARC) runners without Docker.

| Option | Description | Example value |
|---------------------|------------------------------------------------------------------------------------------------------|----------------------------------------------|
| `cli_version` | Crowdin CLI version or npm dist-tag to install | `5.5.0` (default), `latest` |
| `use_installed_cli` | Use the `crowdin` executable already available on `PATH` instead of downloading it | `false` (default) |
| `npm_registry_url` | npm registry to download the CLI from, e.g. an internal mirror for restricted networks | `https://registry.npmjs.org` (default) |

### GitHub (Enterprise) configuration

Expand Down
191 changes: 191 additions & 0 deletions __tests__/args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest';
import {
customCommandArgs,
downloadBundleArgs,
downloadSourcesArgs,
downloadTranslationsArgs,
globalArgs,
splitArgs,
splitList,
uploadSourcesArgs,
uploadTranslationsArgs,
} from '../src/args.js';
import type { Inputs } from '../src/inputs.js';

const base = {
uploadSourcesArgs: '',
uploadLanguage: '',
autoApproveImported: false,
importEqSuggestions: false,
uploadTranslationsArgs: '',
downloadSourcesArgs: '',
downloadLanguage: '',
language: '',
downloadBundle: '',
skipUntranslatedStrings: false,
skipUntranslatedFiles: false,
exportOnlyApproved: false,
downloadTranslationsArgs: '',
crowdinBranchName: '',
config: '',
dryrunAction: false,
projectId: '',
token: '',
baseUrl: '',
basePath: '',
source: '',
translation: '',
command: '',
commandArgs: '',
debugMode: false,
} as Inputs;

const inputs = (overrides: Partial<Inputs> = {}): Inputs => ({ ...base, ...overrides });

describe('splitArgs', () => {
it('splits on whitespace', () => {
expect(splitArgs(' -l uk\t--method tm \n--branch main ')).toEqual(['-l', 'uk', '--method', 'tm', '--branch', 'main']);
});

it('honours quotes and escapes', () => {
expect(splitArgs(`--label "a b" --x='c "d"' e\\ f ""`)).toEqual(['--label', 'a b', '--x=c "d"', 'e f', '']);
expect(splitArgs('"say \\"hi\\""')).toEqual(['say "hi"']);
});

it('keeps Windows paths intact', () => {
expect(splitArgs('--config=C:\\a\\crowdin.yml')).toEqual(['--config=C:\\a\\crowdin.yml']);
});

it('returns no args for empty input', () => {
expect(splitArgs('')).toEqual([]);
});

it('rejects unterminated quotes', () => {
expect(() => splitArgs('"abc')).toThrow(/Unterminated/);
});
});

describe('splitList', () => {
it('splits by comma and trims', () => {
expect(splitList('a, b,c ,, d')).toEqual(['a', 'b', 'c', 'd']);
expect(splitList('')).toEqual([]);
});
});

describe('globalArgs', () => {
it('builds options in the entrypoint.sh order', () => {
expect(
globalArgs(
inputs({
debugMode: true,
config: 'crowdin.yml',
dryrunAction: true,
projectId: '1',
token: 't',
baseUrl: 'https://api.crowdin.com',
basePath: '.',
source: 's/*.json',
translation: 't/%locale%.json',
commandArgs: '--verbose',
crowdinBranchName: 'main',
}),
),
).toEqual([
'--no-progress',
'--no-colors',
'--verbose',
'--debug',
'--config=crowdin.yml',
'--dryrun',
'--project-id=1',
'--token=t',
'--base-url=https://api.crowdin.com',
'--base-path=.',
'--source=s/*.json',
'--translation=t/%locale%.json',
'--verbose',
'--branch=main',
]);
});

it('omits --branch for bundles', () => {
expect(downloadBundleArgs(inputs({ downloadBundle: '7', crowdinBranchName: 'main' }))).toEqual([
'bundle',
'download',
'7',
'--no-progress',
'--no-colors',
]);
});
});

describe('command args', () => {
it('runs custom commands without global options', () => {
expect(customCommandArgs(inputs({ command: 'string list', commandArgs: '--verbose', token: 't' }))).toEqual([
'string',
'list',
'--verbose',
]);
});

it('builds upload sources args', () => {
expect(uploadSourcesArgs(inputs({ uploadSourcesArgs: '--no-auto-update' }))).toEqual([
'upload',
'sources',
'--no-progress',
'--no-colors',
'--no-auto-update',
]);
});

it('builds upload translations args', () => {
expect(
uploadTranslationsArgs(
inputs({ uploadLanguage: 'uk', autoApproveImported: true, importEqSuggestions: true, uploadTranslationsArgs: '--x' }),
),
).toEqual([
'upload',
'translations',
'--no-progress',
'--no-colors',
'--language=uk',
'--auto-approve-imported',
'--import-eq-suggestions',
'--x',
]);
});

it('builds download sources args', () => {
expect(downloadSourcesArgs(inputs({ downloadSourcesArgs: '--reviewed' }))).toEqual([
'download',
'sources',
'--no-progress',
'--no-colors',
'--reviewed',
]);
});

it('builds download translations args with the legacy language fallback', () => {
expect(
downloadTranslationsArgs(
inputs({
language: 'de',
skipUntranslatedStrings: true,
skipUntranslatedFiles: true,
exportOnlyApproved: true,
downloadTranslationsArgs: '--all',
}),
),
).toEqual([
'download',
'--no-progress',
'--no-colors',
'--language=de',
'--skip-untranslated-strings',
'--skip-untranslated-files',
'--export-only-approved',
'--all',
]);
expect(downloadTranslationsArgs(inputs({ language: 'de', downloadLanguage: 'uk' }))).toContain('--language=uk');
});
});
53 changes: 53 additions & 0 deletions __tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { binaryName, platformSuffix, resolveVersion } from '../src/cli.js';

describe('platformSuffix', () => {
it.each([
['linux', 'x64', false, 'linux-x64'],
['linux', 'arm64', false, 'linux-arm64'],
['linux', 'x64', true, 'linux-x64-musl'],
['linux', 'arm64', true, 'linux-arm64-musl'],
['darwin', 'x64', false, 'darwin-x64'],
['darwin', 'arm64', false, 'darwin-arm64'],
['win32', 'x64', false, 'win32-x64'],
] as const)('%s %s musl=%s -> %s', (platform, arch, musl, expected) => {
expect(platformSuffix(platform, arch, musl)).toBe(expected);
});

it('rejects unsupported platforms', () => {
expect(() => platformSuffix('win32', 'arm64', false)).toThrow(/does not provide a native binary/);
expect(() => platformSuffix('freebsd', 'x64', false)).toThrow(/use_installed_cli/);
});
});

describe('binaryName', () => {
it('adds .exe on Windows', () => {
expect(binaryName('win32')).toBe('crowdin.exe');
expect(binaryName('linux')).toBe('crowdin');
});
});

describe('resolveVersion', () => {
const packument = {
'dist-tags': { latest: '5.5.0', next: '5.0.0-next.6' },
versions: {
'5.4.0': { version: '5.4.0', dist: { tarball: 'a' } },
'5.5.0': { version: '5.5.0', dist: { tarball: 'b' } },
'5.0.0-next.6': { version: '5.0.0-next.6', dist: { tarball: 'c' } },
},
};

it('resolves exact versions (with optional v prefix)', () => {
expect(resolveVersion(packument, '5.4.0').version).toBe('5.4.0');
expect(resolveVersion(packument, 'v5.4.0').version).toBe('5.4.0');
});

it('resolves dist-tags', () => {
expect(resolveVersion(packument, 'latest').version).toBe('5.5.0');
expect(resolveVersion(packument, 'next').version).toBe('5.0.0-next.6');
});

it('fails for unknown versions', () => {
expect(() => resolveVersion(packument, '9.9.9')).toThrow(/not found/);
});
});
Loading
Loading