feat(kimi): track Kimi Code subscription quota alongside balance #81
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| ACTIONLINT_VERSION: "1.7.12" | |
| ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" | |
| SHELLCHECK_VERSION: "v0.11.0" | |
| SHELLCHECK_SHA256: "b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6" | |
| CROWDIN_CLI_VERSION: "4.14.2" | |
| jobs: | |
| # ─── workflow lint (runs first — catches YAML errors in other jobs) ────────── | |
| actionlint: | |
| name: Workflow lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Install actionlint (verified checksum) | |
| run: | | |
| TARBALL="/tmp/actionlint.tar.gz" | |
| curl -fsSL \ | |
| "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ | |
| -o "$TARBALL" | |
| echo "${ACTIONLINT_SHA256} ${TARBALL}" | sha256sum --check --strict | |
| tar -xzf "$TARBALL" -C /tmp actionlint | |
| - name: Run actionlint | |
| run: /tmp/actionlint -color | |
| # ─── metadata ──────────────────────────────────────────────────────────────── | |
| metadata: | |
| name: Metadata | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Validate plugin.json (syntax + semver) | |
| run: | | |
| jq --exit-status . plugin.json >/dev/null | |
| VERSION="$(jq -r '.version' plugin.json)" | |
| echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { | |
| echo "::error::plugin.json version '${VERSION}' is not semver (expected X.Y.Z)" >&2 | |
| exit 1 | |
| } | |
| echo "version=${VERSION}" >> "$GITHUB_STEP_SUMMARY" | |
| - name: Validate i18n JSON syntax | |
| run: | | |
| for f in i18n/*.json; do | |
| jq --exit-status . "$f" >/dev/null || { | |
| echo "::error::${f} is not valid JSON" >&2 | |
| exit 1 | |
| } | |
| done | |
| - name: Validate i18n key parity (all locales must match en) | |
| run: | | |
| python3 - <<'PY' | |
| import json, sys | |
| from pathlib import Path | |
| base = set(json.loads(Path('i18n/en.json').read_text()).keys()) | |
| errors = [] | |
| for locale in ('pt_BR', 'zh_CN', 'es_ES', 'de_DE'): | |
| keys = set(json.loads(Path(f'i18n/{locale}.json').read_text()).keys()) | |
| missing = sorted(base - keys) | |
| extra = sorted(keys - base) | |
| if missing: | |
| errors.append(f'::error::{locale}: missing {len(missing)} key(s): {missing}') | |
| if extra: | |
| errors.append(f'::error::{locale}: {len(extra)} extra key(s) not in en.json: {extra}') | |
| if errors: | |
| sys.exit('\n'.join(errors)) | |
| print(f'i18n OK — {len(base)} keys in en.json, all locales match.') | |
| PY | |
| - name: Validate CHANGELOG has entry for plugin.json version | |
| run: | | |
| VERSION="$(jq -r '.version' plugin.json)" | |
| grep -qF "## ${VERSION}" CHANGELOG.md || grep -qF "## [${VERSION}]" CHANGELOG.md || { | |
| echo "::error::CHANGELOG.md has no entry for version ${VERSION}" >&2 | |
| exit 1 | |
| } | |
| # ─── shell ─────────────────────────────────────────────────────────────────── | |
| shell: | |
| name: Shell scripts | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Syntax check — all get-* scripts | |
| run: find providers -name 'get-*' -type f -print0 | xargs -0 bash -n | |
| - name: Confirm all provider scripts are executable | |
| run: | | |
| FAIL=0 | |
| while IFS= read -r -d '' f; do | |
| if [ ! -x "$f" ]; then | |
| echo "::error::Not executable: ${f}" | |
| FAIL=1 | |
| fi | |
| done < <(find providers -name 'get-*' -type f -print0) | |
| exit "$FAIL" | |
| - name: Install shellcheck (verified checksum) | |
| run: | | |
| TARBALL="/tmp/shellcheck.tar.gz" | |
| curl -fsSL \ | |
| "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.gz" \ | |
| -o "$TARBALL" | |
| echo "${SHELLCHECK_SHA256} ${TARBALL}" | sha256sum --check --strict | |
| tar -xzf "$TARBALL" --strip-components=1 -C /tmp "shellcheck-${SHELLCHECK_VERSION}/shellcheck" | |
| /tmp/shellcheck --version | |
| - name: shellcheck — main backends (error level) | |
| run: | | |
| /tmp/shellcheck -S error \ | |
| providers/get-provider-usage \ | |
| providers/get-copilot-usage \ | |
| providers/get-claude-usage \ | |
| providers/get-provider-wrapper | |
| - name: shellcheck — main backends (warning level) | |
| run: | | |
| /tmp/shellcheck -S warning \ | |
| providers/get-provider-usage \ | |
| providers/get-copilot-usage \ | |
| providers/get-claude-usage \ | |
| providers/get-provider-wrapper | |
| - name: shellcheck — all provider stubs (warning level) | |
| run: | | |
| find providers -name 'get-*' \ | |
| ! -name 'get-provider-usage' \ | |
| ! -name 'get-copilot-usage' \ | |
| ! -name 'get-claude-usage' \ | |
| ! -name 'get-provider-wrapper' \ | |
| -type f -print0 \ | |
| | xargs -0 /tmp/shellcheck -S warning | |
| - name: Provider dispatch coverage | |
| run: | | |
| python3 - <<'PY' | |
| import re, sys | |
| from pathlib import Path | |
| # collect stub IDs: get-<id>-usage (strip known non-provider scripts) | |
| skip = {'provider', 'provider-wrapper', 'copilot', 'claude', 'codex', 'provider-health'} | |
| stubs = set() | |
| for f in Path('providers').glob('get-*-usage'): | |
| name = f.name.removeprefix('get-').removesuffix('-usage') | |
| if name not in skip: | |
| stubs.add(name) | |
| # collect dispatch cases. Each case is `canonical|alias1|alias2)`: | |
| # the first token is the canonical provider (expected to own a stub), | |
| # the rest are documented aliases (no stub of their own, by design). | |
| # The char class includes '.' so dotted aliases like `z.ai` parse. | |
| src = Path('providers/get-provider-usage').read_text() | |
| dispatched = set() # every routable token (canonical + aliases) | |
| canonical = set() # first token of each case only | |
| in_func = False | |
| for line in src.splitlines(): | |
| if re.match(r'^fetch_provider\(\)', line): | |
| in_func = True | |
| if not in_func: | |
| continue | |
| m = re.match(r'^\s+([\w.|/-]+)\)', line) | |
| if m: | |
| tokens = [a.strip() for a in m.group(1).split('|')] | |
| if tokens == ['*']: | |
| continue | |
| canonical.add(tokens[0]) | |
| dispatched.update(tokens) | |
| # a provider is covered if ANY of its tokens routes; aliases are fine. | |
| missing_dispatch = sorted(stubs - dispatched) | |
| # only canonical names are expected to back a script; ignore the | |
| # dedicated non-stub providers (claude/copilot/codex live in `skip`). | |
| existing = {f.name.removeprefix('get-').removesuffix('-usage') | |
| for f in Path('providers').glob('get-*-usage')} | |
| missing_stub = sorted(canonical - existing - skip) | |
| errors = [] | |
| if missing_dispatch: | |
| errors.append(f'Stubs with no dispatch case: {missing_dispatch}') | |
| if missing_stub: | |
| errors.append(f'Canonical dispatch cases with no script: {missing_stub}') | |
| print(f'Coverage: {len(stubs)} stubs, {len(dispatched)} dispatch entries ' | |
| f'({len(canonical)} canonical).') | |
| if errors: | |
| sys.exit('\n'.join(f'::error::{error}' for error in errors)) | |
| PY | |
| # ─── integration tests ─────────────────────────────────────────────────────── | |
| integration: | |
| name: Integration tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Install dependencies (jq, curl, sqlite3) | |
| run: | | |
| sudo apt-get update -qq | |
| sudo apt-get install -y --no-install-recommends jq curl sqlite3 | |
| - name: Smoke test — get-claude-usage (no credentials) | |
| run: | | |
| # get-claude-usage emits KEY=VALUE pairs (sourced by QML widget, not JSON) | |
| OUT="$(HOME=/tmp providers/get-claude-usage 2>/dev/null || true)" | |
| # Must contain at least SUBSCRIPTION_TYPE and WEEK_TOKENS keys | |
| echo "$OUT" | grep -qE '^SUBSCRIPTION_TYPE=' || { | |
| echo "::error::get-claude-usage did not emit SUBSCRIPTION_TYPE key" | |
| echo "Output was: ${OUT}" | |
| exit 1 | |
| } | |
| echo "$OUT" | grep -qE '^WEEK_TOKENS=' || { | |
| echo "::error::get-claude-usage did not emit WEEK_TOKENS key" | |
| exit 1 | |
| } | |
| # All lines must be KEY=VALUE (no bare text, no JSON, no stack traces) | |
| INVALID="$(echo "$OUT" | grep -vE '^[A-Z_]+=.*$' || true)" | |
| if [ -n "$INVALID" ]; then | |
| echo "::error::get-claude-usage emitted unexpected non-KEY=VALUE lines:" | |
| echo "$INVALID" | |
| exit 1 | |
| fi | |
| echo "get-claude-usage output (no creds): OK — $(echo "$OUT" | wc -l) KEY=VALUE lines" | |
| - name: Smoke test — get-provider-usage single provider (error path) | |
| run: | | |
| OUT="$(providers/get-provider-usage "openrouter" "" 2>/dev/null || true)" | |
| echo "$OUT" | jq --exit-status '. | type == "array" and length > 0' >/dev/null || { | |
| echo "::error::get-provider-usage did not return a non-empty JSON array" | |
| echo "Output was: ${OUT}" | |
| exit 1 | |
| } | |
| PROVIDER="$(echo "$OUT" | jq -r '.[0].provider')" | |
| [ "$PROVIDER" = "openrouter" ] || { | |
| echo "::error::Expected provider=openrouter, got: ${PROVIDER}" | |
| exit 1 | |
| } | |
| echo "get-provider-usage smoke: OK — provider=${PROVIDER}" | |
| - name: Integration test — Codex app-server fixture | |
| run: | | |
| FAKE_BIN="$(mktemp -d)" | |
| cat > "$FAKE_BIN/codex" <<'SH' | |
| #!/usr/bin/env bash | |
| cat >/dev/null | |
| cat "$GITHUB_WORKSPACE/tests/fixtures/${CODEX_FIXTURE:-codex-app-server.jsonl}" | |
| SH | |
| chmod +x "$FAKE_BIN/codex" | |
| OUT="$(PATH="$FAKE_BIN:$PATH" providers/get-provider-usage "codex" "")" | |
| echo "$OUT" | jq --exit-status ' | |
| length == 1 and .[0].provider == "codex" and | |
| .[0].usage.primary.usedPercent == 25 and | |
| .[0].usage.primary.resetDescription == "Session" and | |
| .[0].usage.secondary.usedPercent == 50 and | |
| .[0].usage.secondary.resetDescription == "Weekly" and | |
| .[0].credits.remaining == "12" | |
| ' >/dev/null | |
| WEEKLY_OUT="$(CODEX_FIXTURE=codex-app-server-weekly-only.jsonl \ | |
| PATH="$FAKE_BIN:$PATH" providers/get-provider-usage "codex" "")" | |
| echo "$WEEKLY_OUT" | jq --exit-status ' | |
| length == 1 and .[0].provider == "codex" and | |
| .[0].usage.primary.usedPercent == 79 and | |
| .[0].usage.primary.windowMinutes == 10080 and | |
| .[0].usage.primary.resetDescription == "Weekly" and | |
| .[0].usage.secondary == null and | |
| .[0].credits.remaining == "1" | |
| ' >/dev/null | |
| - name: Integration test — 9Router cached tokens | |
| run: | | |
| HOME_DIR="$(mktemp -d)" | |
| mkdir -p "$HOME_DIR/.9router" | |
| TODAY="$(date +%F)" | |
| printf '{"dailySummary":{"%s":{"requests":2,"promptTokens":100,"cachedTokens":40,"completionTokens":20,"cost":0.5}}}\n' "$TODAY" > "$HOME_DIR/.9router/usage.json" | |
| OUT="$(HOME="$HOME_DIR" providers/get-provider-usage "9router" "")" | |
| echo "$OUT" | jq --exit-status '.[0].usage.tertiary.displayValue | contains("40 cached")' >/dev/null | |
| - name: Integration test — Antigravity quota fixture | |
| run: | | |
| OUT="$(ANTIGRAVITY_RESPONSE_FILE=tests/fixtures/antigravity-valid.json \ | |
| ANTIGRAVITY_ACCOUNT_EMAIL=test@example.invalid \ | |
| providers/get-provider-usage "antigravity" "")" | |
| echo "$OUT" | jq -e ' | |
| length == 1 | |
| and .[0].provider == "antigravity" | |
| and .[0].usage.primary.resetDescription == "Gemini 3.1 Pro" | |
| and .[0].usage.primary.usedPercent == 80 | |
| and .[0].usage.secondary.resetDescription == "Gemini 3.5 Flash" | |
| and .[0].usage.secondary.usedPercent == 50 | |
| and .[0].usage.tertiary.resetDescription == "GPT-OSS 120B" | |
| and .[0].usage.tertiary.usedPercent == 10 | |
| ' >/dev/null | |
| AUTH_ERROR="$(ANTIGRAVITY_RESPONSE_FILE=tests/fixtures/antigravity-valid.json \ | |
| ANTIGRAVITY_HTTP_STATUS=401 providers/get-provider-usage "antigravity" "")" | |
| echo "$AUTH_ERROR" | jq -e '.[0].error.code == 401' >/dev/null | |
| RATE_ERROR="$(ANTIGRAVITY_RESPONSE_FILE=tests/fixtures/antigravity-valid.json \ | |
| ANTIGRAVITY_HTTP_STATUS=429 providers/get-provider-usage "antigravity" "")" | |
| echo "$RATE_ERROR" | jq -e '.[0].error.code == 429' >/dev/null | |
| MISSING_SESSION="$(ANTIGRAVITY_STATE_DB=/does/not/exist \ | |
| providers/get-provider-usage "antigravity" "")" | |
| echo "$MISSING_SESSION" | jq -e '.[0].error.code == 2' >/dev/null | |
| SCHEMA_ERROR="$(ANTIGRAVITY_RESPONSE_FILE=tests/fixtures/antigravity-schema-changed.json \ | |
| providers/get-provider-usage "antigravity" "")" | |
| echo "$SCHEMA_ERROR" | jq -e '.[0].error.message | contains("schema changed")' >/dev/null | |
| - name: Integration test — Antigravity available-models fixture | |
| run: | | |
| # fetchAvailableModels is the live endpoint. Its models member is an | |
| # object keyed by model ID, unlike the older groups/buckets response. | |
| OUT="$(ANTIGRAVITY_RESPONSE_FILE=tests/fixtures/antigravity-models-valid.json \ | |
| providers/get-provider-usage "antigravity" "")" | |
| echo "$OUT" | jq -e ' | |
| .[0].usage.primary.resetDescription == "Gemini Models" | |
| and .[0].usage.primary.usedPercent == 100 | |
| and ([.[0].usage.primary, .[0].usage.secondary, .[0].usage.tertiary] | |
| | map(select(.resetDescription == "Claude & OpenAI Models" and .usedPercent == 0)) | |
| | length) == 1 | |
| and ([.[0].usage.primary, .[0].usage.secondary, .[0].usage.tertiary] | |
| | map(select(.resetDescription == "Other Models" and .usedPercent == 25)) | |
| | length) == 1 | |
| and .[0].accounts[0].modelWindows[0].name == "Gemini Flash" | |
| and .[0].accounts[0].modelWindows[0].usedPercent == 65 | |
| and .[0].accounts[0].modelWindows[1].name == "Gemini Pro" | |
| and .[0].accounts[0].modelWindows[1].usedPercent == 100 | |
| and ([.[0].accounts[0].modelWindows[].name] | index("Placeholder")) == null | |
| ' >/dev/null | |
| - name: Integration test — Antigravity live request safeguards | |
| run: tests/test-antigravity-live.sh | |
| - name: Smoke test — all stubs delegate correctly (structure check) | |
| run: | | |
| FAIL=0 | |
| for stub in providers/get-*-usage; do | |
| name="$(basename "$stub")" | |
| case "$name" in | |
| get-provider-usage|get-provider-health|get-copilot-usage|get-claude-usage|get-codex-usage) continue ;; | |
| esac | |
| # Each stub must exec get-provider-wrapper — verify it contains the pattern | |
| grep -q 'get-provider-wrapper' "$stub" || { | |
| echo "::error::${name} does not delegate to get-provider-wrapper" | |
| FAIL=1 | |
| } | |
| done | |
| exit "$FAIL" | |
| - name: Smoke test — prerequisite health schema | |
| run: | | |
| OUT="$(providers/get-provider-health "codex,perplexity")" | |
| echo "$OUT" | jq --exit-status ' | |
| type == "array" and length == 2 and | |
| all(.[]; has("provider") and has("status") and has("detail")) | |
| ' >/dev/null | |
| - name: Integration test — usage history round-trip | |
| run: | | |
| XDG_CACHE_HOME="$(mktemp -d)" | |
| export XDG_CACHE_HOME | |
| HIST_DIR="$XDG_CACHE_HOME/AiOverviewControl" | |
| mkdir -p "$HIST_DIR" | |
| # Empty cache → exactly "{}" | |
| [ "$(providers/get-usage-history)" = "{}" ] || { | |
| echo "::error::get-usage-history must emit {} when no history exists" | |
| exit 1 | |
| } | |
| # Seed unordered snapshots; aggregation must sort by ts and group by provider | |
| printf '%s\n' \ | |
| '{"ts":200,"provider":"codex","pct":20}' \ | |
| '{"ts":100,"provider":"codex","pct":10}' \ | |
| '{"ts":150,"provider":"claude","pct":5.5}' \ | |
| > "$HIST_DIR/usage-history.jsonl" | |
| OUT="$(providers/get-usage-history)" | |
| echo "$OUT" | jq --exit-status ' | |
| (.codex | length == 2) and | |
| .codex[0] == {t:100, p:10} and | |
| .codex[1] == {t:200, p:20} and | |
| .claude[0] == {t:150, p:5.5} | |
| ' >/dev/null || { | |
| echo "::error::get-usage-history aggregation mismatch. Output: ${OUT}" | |
| exit 1 | |
| } | |
| # Dispatcher must not append snapshots for informational/local cards | |
| # that intentionally report usedPercent:0. History is reserved for | |
| # real non-zero quota/spend pressure so sparklines stay meaningful. | |
| BEFORE="$(wc -l < "$HIST_DIR/usage-history.jsonl")" | |
| providers/get-provider-usage "perplexity,ollama" "" >/dev/null | |
| AFTER="$(wc -l < "$HIST_DIR/usage-history.jsonl")" | |
| if [ "$AFTER" != "$BEFORE" ]; then | |
| echo "::error::get-provider-usage recorded a 0% informational/local snapshot" | |
| echo "Before lines: ${BEFORE}; after lines: ${AFTER}" | |
| cat "$HIST_DIR/usage-history.jsonl" | |
| exit 1 | |
| fi | |
| echo "usage history round-trip: OK" | |
| # ─── qml ───────────────────────────────────────────────────────────────────── | |
| qml: | |
| name: QML lint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| # Qt5's qmllint is a syntax verifier: it validates QML/JS structure | |
| # (braces, bindings, property declarations) and exits non-zero on a | |
| # malformed file, without trying to resolve the DankMaterialShell `qs.*` | |
| # modules — so there is no import-resolution noise to filter. Qt6's | |
| # qmllint resolves imports and would drown the run in unavoidable | |
| # `qs.*`/unqualified warnings, so we deliberately pin the Qt5 tool to keep | |
| # this a clean HARD gate rather than an advisory one. | |
| - name: Install Qt5 qmllint | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends qtdeclarative5-dev-tools | |
| - name: Locate qmllint | |
| id: qmllint | |
| run: | | |
| bin="$(command -v qmllint || true)" | |
| if [ -z "$bin" ]; then | |
| for cand in \ | |
| /usr/lib/qt5/bin/qmllint \ | |
| /usr/lib/x86_64-linux-gnu/qt5/bin/qmllint; do | |
| if [ -x "$cand" ]; then bin="$cand"; break; fi | |
| done | |
| fi | |
| if [ -z "$bin" ]; then | |
| echo "::error::qmllint not found after installing qtdeclarative5-dev-tools" | |
| exit 1 | |
| fi | |
| echo "bin=$bin" >> "$GITHUB_OUTPUT" | |
| "$bin" --version | |
| - name: QML lint (hard gate) | |
| run: | | |
| "${{ steps.qmllint.outputs.bin }}" \ | |
| AiOverviewControlWidget.qml \ | |
| AiOverviewControlSettings.qml \ | |
| AiOverviewControlI18n.qml \ | |
| ProviderLogo.qml | |
| # ─── crowdin ───────────────────────────────────────────────────────────────── | |
| crowdin: | |
| name: Crowdin config | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Validate Crowdin config structure | |
| run: | | |
| python3 - <<'PY' | |
| from pathlib import Path | |
| config = Path('.github/crowdin.yml').read_text() | |
| required = [ | |
| 'project_id: "899362"', | |
| 'base_path: ".."', | |
| 'source: "/i18n/en.json"', | |
| 'translation: "/i18n/%locale_with_underscore%.json"', | |
| ] | |
| missing = [item for item in required if item not in config] | |
| if missing: | |
| raise SystemExit(f'::error::Crowdin config missing entries: {missing}') | |
| if not Path('i18n/en.json').is_file(): | |
| raise SystemExit('::error::Crowdin source file i18n/en.json is missing') | |
| PY | |
| - name: Validate with Crowdin CLI (requires secret) | |
| env: | |
| CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }} | |
| run: | | |
| if [ -z "${CROWDIN_API_TOKEN:-}" ]; then | |
| echo "Crowdin token not available — skipping remote CLI validation." | |
| exit 0 | |
| fi | |
| curl -fsSL \ | |
| "https://github.com/crowdin/crowdin-cli/releases/download/${CROWDIN_CLI_VERSION}/crowdin-cli.zip" \ | |
| -o /tmp/crowdin-cli.zip | |
| unzip -q /tmp/crowdin-cli.zip -d /tmp/crowdin-cli | |
| python3 - <<'PY' | |
| import os | |
| from pathlib import Path | |
| Path('/tmp/crowdin-identity.yml').write_text( | |
| 'api_token: ' + os.environ['CROWDIN_API_TOKEN'] + '\n' | |
| ) | |
| PY | |
| JAR="/tmp/crowdin-cli/${CROWDIN_CLI_VERSION}/crowdin-cli.jar" | |
| java -jar "$JAR" config lint --config .github/crowdin.yml --identity /tmp/crowdin-identity.yml | |
| # These commands call Crowdin's remote API and can fail or hang for | |
| # reasons outside this repository (service issues, token scope, rate | |
| # limiting). Keep the structural lint above as the hard gate, and | |
| # surface remote validation problems as warnings instead of breaking CI. | |
| for check in sources translations; do | |
| if ! timeout 60 java -jar "$JAR" config "$check" --config .github/crowdin.yml --identity /tmp/crowdin-identity.yml; then | |
| echo "::warning::Crowdin remote '${check}' validation failed or timed out; skipping as non-blocking." | |
| fi | |
| done |