From 5f68a8248e9ad92bf84669d18b807ba6e91805c3 Mon Sep 17 00:00:00 2001 From: Stofl <152594969+stofll@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:52:29 +0300 Subject: [PATCH 1/4] ci: run clippy in build-test and drop duplicated setup The lint job stood up a second Linux environment - apt dependencies, Node, pnpm, a frontend build for generate_context!() and its own cargo cache - purely to run the Linux clippy pass. build-test already builds all of that for ubuntu, so clippy now runs there for every OS and lint is formatting only. The Playwright harness typecheck was fanned out by the ui-tests matrix into four identical runs. frontend-test already runs it once per pull request. Comments, step names and CI log strings in both workflows are now English. --- .github/workflows/rust-ci.yml | 284 ++++++++++++++------------------- .github/workflows/ui-tests.yml | 10 +- docs/benchmarks.md | 2 +- 3 files changed, 130 insertions(+), 166 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 0d19965..005b8f5 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,25 +1,26 @@ name: Rust CI -# Висит на PR в main. Был отключён ради runner-минут (#22), но приложение -# готовится к раздаче, а собирается оно на трёх ОС из одного дерева: без CI -# весь `#[cfg(target_os = ...)]` проверяется только на той машине, где -# случилось разрабатывать, то есть на Windows. +# Runs on pull requests to main. It was disabled once to save runner minutes +# (#22), but the app is heading for distribution and it builds for three +# operating systems from one tree: without CI the whole +# `#[cfg(target_os = ...)]` surface is only ever compiled on whichever machine +# the work happened on, which is Windows. # -# На `push: main` сознательно не висит. Работа идёт через PR, так что -# мерж-коммит проверял бы ровно то содержимое, которое только что проверил -# `pull_request`, — двойная цена за один и тот же ответ. Если понадобится -# проверить main отдельно (прямой пуш, откат), есть workflow_dispatch. +# Deliberately not on `push: main`. Work goes through pull requests, so the +# merge commit would check exactly the content `pull_request` just checked — +# paying twice for one answer. Use workflow_dispatch when main really does +# need a run of its own (a direct push, a revert). # -# Дорогая релизная сборка сюда не входит — она живёт в release.yml и висит -# на теге. Здесь только CPU-вариант, см. комментарий ниже. +# The expensive release build is not here; it lives in release.yml and hangs +# off the tag. This pipeline is CPU-only, see the comment below. on: pull_request: branches: [main] workflow_dispatch: -# Пуш в ветку отменяет её предыдущий, ещё не досчитанный прогон. Иначе -# каждая правка по ходу ревью оплачивается дважды: вытесненный прогон -# считает матрицу из трёх ОС до конца, и его результат уже никому не нужен. +# A push to a branch cancels that branch's previous, still-running run. +# Otherwise every edit during review is paid for twice: the superseded run +# would finish the whole three-OS matrix for an answer nobody needs. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -28,25 +29,26 @@ permissions: contents: read pull-requests: read -# Все джобы собирают CPU-вариант: GPU-бэкенды whisper.cpp вынесены в -# опциональные фичи gpu-vulkan / gpu-metal (см. Cargo.toml), и без них -# Vulkan SDK на раннере не нужен. Не добавляйте сюда установку SDK — -# ускоритель проверяется релизной сборкой, а не этим пайплайном. +# Every job builds the CPU variant: the whisper.cpp GPU backends sit behind +# the optional gpu-vulkan / gpu-metal features (see Cargo.toml), and without +# them the runner needs no Vulkan SDK. Do not add the SDK install here — the +# accelerator is covered by the release build, not by this pipeline. jobs: - # Лендинг живёт в site/ отдельным проектом со своим локфайлом и своей - # сборкой. Правка текста на сайте не может сломать Rust, а матрица из трёх - # ОС стоит дороже всего остального вместе взятого, поэтому джобы ниже её - # пропускают. + # The landing page lives in site/ as a separate project with its own + # lockfile and its own build. Editing site copy cannot break Rust, and the + # three-OS matrix costs more than everything else combined, so the jobs + # below skip it. # - # Фильтр висит на джобах, а не на воркфлоу: `paths-ignore` был ловушкой. - # Все обязательные статус-чеки main живут здесь, а незапущенный воркфлоу не - # репортит свои контексты вовсе, так что PR, правящий только site/, вис бы - # навсегда с семью pending. Пропущенный по `if:` джоб, наоборот, засчитывается - # как успешный. + # The filter sits on the jobs rather than the workflow: `paths-ignore` was a + # trap. Every required status check for main lives here, and a workflow that + # never starts reports none of its contexts at all, so a site-only pull + # request would hang forever on seven pending checks. A job skipped by `if:` + # is counted as successful instead. # - # Это же и делает условие ниже `!= 'false'` внутри `!cancelled()`: раз пропуск - # закрывает обязательный чек, упавший гейт иначе открыл бы мерж вообще без - # проверок. Отказ проверять требует явного `false`, молчание — нет. + # That is also why the condition below is `!= 'false'` inside `!cancelled()`: + # since a skip satisfies a required check, a failed gate would otherwise open + # the merge with no checks at all. Refusing to check takes an explicit + # `false`; silence does not. changes: name: Changed paths runs-on: ubuntu-latest @@ -54,29 +56,30 @@ jobs: outputs: app: ${{ steps.filter.outputs.app }} steps: - # Список файлов берётся из API, а не из checkout: гейту не нужно дерево, - # а полная история ради `git diff` стоит дороже самого ответа. + # The file list comes from the API rather than a checkout: the gate does + # not need the tree, and full history just to run `git diff` costs more + # than the answer itself. - id: filter env: GH_TOKEN: ${{ github.token }} run: | set -uo pipefail if [ "${{ github.event_name }}" != "pull_request" ]; then - echo 'Не pull request — проверяется всё.' + echo 'Not a pull request — checking everything.' echo 'app=true' >> "$GITHUB_OUTPUT" exit 0 fi files=$(gh api --paginate \ "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ --jq '.[].filename') || files='' - # Пустой ответ означает, что список получить не удалось. Гейт для - # экономии минут, а не для проверок: молчание трактуется как «есть - # правки в приложении», и обязательные чеки отрабатывают честно. + # An empty response means the list could not be fetched. This gate + # exists to save minutes, not to gate correctness: silence is read as + # "the app was touched", and the required checks run for real. if printf '%s\n' "$files" | grep -qvE '^site/'; then - echo 'Изменения за пределами site/ — проверяется всё.' + echo 'Changes outside site/ — checking everything.' echo 'app=true' >> "$GITHUB_OUTPUT" else - echo 'Правки только в site/ — проверки приложения пропускаются.' + echo 'site/-only changes — skipping the application checks.' echo 'app=false' >> "$GITHUB_OUTPUT" fi @@ -107,115 +110,61 @@ jobs: working-directory: desktop run: pnpm audit --prod=false --audit-level high - # Ставим готовый бинарник, а не rustsec/audit-check. Тот экшен внутри - # делает `cargo install cargo-audit` без `--locked`, то есть на каждом - # прогоне заново резолвит свежие версии всех зависимостей самого - # cargo-audit. 2026-09-03 так и вышло: вышедший tinyvec 1.13.0 не - # собрался нашим тулчейном, и гейт лёг на всех PR разом, хотя ни один - # из них к этому отношения не имел. Плюс та сборка из исходников - # занимала почти всю джобу (~5 минут раннера на каждый PR). + # Install a prebuilt binary instead of rustsec/audit-check. That action + # runs `cargo install cargo-audit` without `--locked`, so every run + # re-resolves fresh versions of cargo-audit's own dependencies. On + # 2026-09-03 that is exactly what happened: the newly published tinyvec + # 1.13.0 did not compile with our toolchain, and the gate went red on + # every open pull request at once, none of which had anything to do with + # it. That source build also took up most of the job (~5 runner minutes + # per pull request). # - # Версия cargo-audit закреплена намеренно и не устаревает по данным: - # база advisory тянется с гита в момент запуска, в бинарник она не - # вшита. Бампать её нужно, только если cargo-audit перестанет читать - # новый формат Cargo.lock. + # The pinned cargo-audit version is deliberate and does not go stale on + # data: the advisory database is fetched from git at run time, not baked + # into the binary. Bump it only if cargo-audit stops reading the current + # Cargo.lock format. - name: Install cargo-audit uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4 with: tool: cargo-audit@0.22.2 - # Зовём бинарник напрямую, а не через `cargo audit`: шим rustup увидел - # бы rust-toolchain.toml и потащил качать весь закреплённый тулчейн, - # который аудиту лок-файла не нужен. + # Call the binary directly rather than through `cargo audit`: the rustup + # shim would read rust-toolchain.toml and download the whole pinned + # toolchain, which auditing a lockfile does not need. - name: Audit Rust lockfile working-directory: desktop/src-tauri run: cargo-audit audit # --------------------------------------------------------------- - # Run fmt once. Clippy also runs in the Windows/macOS build jobs - # because Linux cannot lint their platform-specific code. + # Formatting only. Clippy moved into build-test, where it already ran for + # Windows and macOS. Keeping the Linux pass here meant standing up a whole + # second environment — apt dependencies, Node, pnpm, a `pnpm build` for + # `generate_context!()` and a cargo cache of its own — all of which + # build-test builds for ubuntu anyway. Do not move clippy back here. + # + # rustfmt does not expand macros, so it needs no `desktop/dist`, and the + # setup is down to the toolchain: this job should stay seconds long. # --------------------------------------------------------------- lint: - name: Cargo fmt + clippy (Linux) + name: Cargo fmt (Linux) needs: changes if: ${{ !cancelled() && needs.changes.outputs.app != 'false' }} runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 10 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - # Версию задаёт rust-toolchain.toml в корне — rustup читает его для - # любого cargo внутри репозитория и всё равно переопределит то, что - # поставит экшен. Дублировать её здесь значит завести второе место, - # которое при бампе забудут. + # The version and components come from rust-toolchain.toml at the repo + # root — rustup reads it for any cargo invocation inside the repository + # and overrides whatever the action installed. Repeating them here would + # create a second place to forget on a bump. - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # ветка stable на 2026-09-03 - with: - components: rustfmt, clippy - - - name: Install Linux system dependencies - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq \ - libglib2.0-dev \ - libgtk-3-dev \ - libwebkit2gtk-4.1-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - libsoup-3.0-dev \ - libjavascriptcoregtk-4.1-dev \ - pkg-config \ - build-essential \ - curl \ - wget \ - libasound2-dev \ - libudev-dev \ - libxdo-dev \ - libxkbcommon-dev - - - name: Cache cargo registry & target - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: desktop/src-tauri -> target - key: ggml-baseline-${{ hashFiles('.cargo/config.toml', 'scripts/ggml-baseline.cmake') }} - cache-on-failure: false - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: .node-version - - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - with: - version: 11.9.0 - - # Tauri's `generate_context!()` reads `desktop/dist/`, which only - # exists after `pnpm build`. Build the frontend before any cargo - # step that triggers the macro (clippy included). - - name: Install frontend deps - working-directory: desktop - run: pnpm install --frozen-lockfile - - - name: Build frontend (produces desktop/dist for Tauri macro) - working-directory: desktop - run: pnpm build + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable branch as of 2026-09-03 - name: Format check working-directory: desktop/src-tauri run: cargo fmt --all -- --check - # --all-targets здесь несущий, а не косметика: он включает бенчмарки, - # которые не собираются ни `cargo build`, ни `cargo test`, и потому - # гниют молча — однажды уже сгнили (f28dd7b, «бенч снова собирается - # после добавления model_id»). Раньше их сторожил отдельный шаг - # `cargo bench --no-run`, но он делал полную сборку с opt-level 3 и - # стоил 16 минут из 23 в этой джобе, ловя сверх клиппи только отказы - # на кодогенерации и линковке. Убирать `--all-targets` нельзя. - - name: Clippy (deny warnings) - working-directory: desktop/src-tauri - run: cargo clippy --locked --all-targets -- -D warnings - # --------------------------------------------------------------- # Build + test on every target platform. This is a Windows-first # app with a macOS port: previously CI only ran on Linux, so the @@ -228,7 +177,7 @@ jobs: needs: changes if: ${{ !cancelled() && needs.changes.outputs.app != 'false' }} runs-on: ${{ matrix.os }} - # Allow time for the additional platform-specific Clippy pass. + # Allow time for the Clippy pass that runs before the build. timeout-minutes: 60 strategy: fail-fast: false @@ -237,9 +186,9 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - # Версию задаёт rust-toolchain.toml — см. комментарий в джобе lint. + # The version comes from rust-toolchain.toml — see the lint job. - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # ветка stable на 2026-09-03 + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable branch as of 2026-09-03 - name: Install Linux system dependencies if: runner.os == 'Linux' @@ -287,55 +236,69 @@ jobs: working-directory: desktop run: pnpm build - # `sherpa-onnx-sys` качает нативный архив сам и хеш не проверяет. - # Скачиваем и сверяем его здесь, а сборочному скрипту отдаём готовый - # каталог через SHERPA_ONNX_LIB_DIR — тогда он в сеть не ходит. - - name: Скачать и проверить нативный рантайм sherpa (Windows) + # `sherpa-onnx-sys` downloads the native archive itself and does not + # verify its hash. Download and check it here, then hand the build script + # a ready directory through SHERPA_ONNX_LIB_DIR so it never touches the + # network. + - name: Download and verify the sherpa native runtime (Windows) if: runner.os == 'Windows' shell: pwsh run: ./scripts/fetch-sherpa-runtime.ps1 -Target win-x64-shared - - name: Скачать и проверить нативный рантайм sherpa (macOS) + - name: Download and verify the sherpa native runtime (macOS) if: runner.os == 'macOS' run: sh scripts/fetch-sherpa-runtime.sh osx-arm64-static - # `tauri.windows.conf.json` объявляет ресурсом - # `.tauri-native/windows/x64/*.dll`, и build script падает, если glob - # ничего не нашёл. Раскладывает эти DLL `prepare-native-libs.ps1`, но - # он висит на `beforeBundleCommand`, то есть отрабатывает только при - # `tauri build` — при голом `cargo build` каталога нет вовсе. На машине - # разработчика он остаётся от прошлой сборки, на чистой — нет. + # `tauri.windows.conf.json` declares `.tauri-native/windows/x64/*.dll` + # as a resource, and the build script fails when that glob matches + # nothing. Those DLLs are staged by `prepare-native-libs.ps1`, but it + # hangs off `beforeBundleCommand`, so it only runs under `tauri build` — + # under a plain `cargo build` the directory does not exist at all. On a + # developer machine it survives from the previous build; on a clean + # runner it does not. # - # Скрипт берёт DLL из `target/[/]`, куда их кладёт - # сборочный скрипт `sherpa-onnx-sys`, поэтому сначала собираем именно её. - # Отдельно, а не весь граф: полный `cargo build` — это как раз тот - # шаг, который без разложенных DLL и падает. - - name: Разложить нативный рантайм sherpa (Windows) + # The script takes the DLLs from `target/[/]`, where the + # `sherpa-onnx-sys` build script puts them, so build that crate first. + # On its own, not the whole graph: a full `cargo build` is precisely the + # step that fails when the DLLs are not staged. + - name: Stage the sherpa native runtime (Windows) if: runner.os == 'Windows' shell: pwsh working-directory: desktop/src-tauri env: - # Профиль для скрипта: обе следующие команды идут в debug. + # Profile for the script: both commands below run in debug. TAURI_ENV_DEBUG: "true" run: | cargo build --locked -p sherpa-onnx-sys - # На прогоне с тёплым кэшем этого мало. rust-cache перед сохранением - # подчищает корень `target/` от всего, что не является - # артефактом зависимости, и разложенные скриптом DLL туда попадают, а - # `.fingerprint` пакета остаётся. Cargo видит build script - # отработавшим и не запускает его снова — файлов нет, а собирать, - # с точки зрения cargo, нечего. Сбросить нужно именно этот пакет: - # тогда build script отрабатывает заново, и это около десяти секунд, - # потому что скачивать ему уже нечего. + # On a warm-cache run that is not enough. Before saving, rust-cache + # prunes everything from the root of `target/` that is not a + # dependency artifact, and the DLLs staged by the script are caught by + # that, while the crate's `.fingerprint` survives. Cargo therefore + # considers the build script already run and does not rerun it — the + # files are gone, yet as far as cargo is concerned there is nothing to + # build. Clean exactly that one crate: the build script then runs + # again, which takes about ten seconds because there is nothing left + # to download. if (-not (Test-Path 'target/debug/sherpa-onnx-c-api.dll')) { cargo clean -p sherpa-onnx-sys cargo build --locked -p sherpa-onnx-sys } powershell -NoProfile -ExecutionPolicy Bypass -File prepare-native-libs.ps1 - # Linux is already covered by the lint job. + # Clippy on all three operating systems, Linux included. The Linux pass + # used to live in a separate lint job that stood up its own environment + # for it — apt dependencies, Node, pnpm, a frontend build and its own + # cargo cache — even though this job builds exactly that for ubuntu + # anyway. Here the target directory is already warm from the steps above. + # + # `--all-targets` is load-bearing, not cosmetic: it pulls in the + # benchmarks, which neither `cargo build` nor `cargo test` compiles, so + # they rot silently — and once did (f28dd7b, "the bench compiles again + # after model_id was added"). They used to be guarded by a separate + # `cargo bench --no-run` step, but that did a full opt-level 3 build and + # cost 16 minutes out of 23, catching nothing beyond clippy except + # codegen and link failures. Do not drop `--all-targets`. - name: Clippy (deny warnings) - if: runner.os != 'Linux' working-directory: desktop/src-tauri run: cargo clippy --locked --all-targets -- -D warnings @@ -402,9 +365,10 @@ jobs: working-directory: desktop run: npx tsc --noEmit - # У харнесса Playwright свой tsconfig: он живёт вне desktop/src и в - # прогон выше не попадает. Без этого шага правка типов моста ломала бы - # моки молча — до первого падения UI-тестов, где причина уже не видна. + # The Playwright harness has its own tsconfig: it lives outside + # desktop/src and the run above does not cover it. Without this step a + # change to the bridge types would break the mocks silently, surfacing + # only when a UI test fails and the cause is no longer visible. - name: Typecheck UI test harness working-directory: desktop run: npx tsc --noEmit -p ../tests/ui/tsconfig.json @@ -421,9 +385,9 @@ jobs: working-directory: desktop run: pnpm build - # Бюджет стартовой загрузки окна. Предупреждение Vite про 500 KB - # сборку не роняет, поэтому вес растёт незаметно; здесь он растёт - # только вместе с осознанно поднятым порогом. + # Initial window payload budget. Vite's 500 KB warning does not fail the + # build, so the weight creeps up unnoticed; here it can only grow + # together with a deliberately raised threshold. - name: Bundle size budget working-directory: desktop run: pnpm bundle:check @@ -457,9 +421,9 @@ jobs: # Pin actions to immutable commits so tag moves cannot change CI code. - name: Every GitHub Action is pinned to a commit SHA run: | - # Закреплённым считается либо полный SHA, либо workflow этого же - # репозитория (`./…`) — у него нет чужого владельца, чтобы что-то - # подменить. + # Pinned means either a full SHA or a workflow from this same + # repository (`./…`), which has no third-party owner who could swap + # anything out. ok='uses: *(\./|[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+@[0-9a-f]{40}( |$))' floating=$(grep -rnE '^ *-? *uses:' .github/workflows/ | grep -vE "$ok" || true) if [ -n "$floating" ]; then diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 6a705c3..3be547f 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -3,8 +3,8 @@ name: UI tests on: pull_request: branches: [main] - # Здесь проверяется UI приложения из desktop/. Лендинг в site/ к нему - # отношения не имеет и проверяется своей сборкой. + # This checks the application UI from desktop/. The landing page in site/ + # is unrelated and is covered by its own build. paths-ignore: - 'site/**' workflow_dispatch: @@ -61,9 +61,9 @@ jobs: run: | uv run --locked --project tests/ui ruff check tests/ui uv run --locked --project tests/ui ruff format --check tests/ui - - name: Check harness types - working-directory: desktop - run: pnpm exec tsc -p ../tests/ui/tsconfig.json + # The harness types are checked by the frontend-test job in rust-ci.yml, + # once per pull request. Here the same `tsc -p tests/ui/tsconfig.json` + # was fanned out by the matrix into four runs giving one answer. - name: Test ${{ matrix.mode }} UI in ${{ matrix.browser }} run: uv run --locked --project tests/ui pytest tests/ui --browser ${{ matrix.browser }} --ui-mode ${{ matrix.mode }} --ui-build-platform ${{ matrix.platform }} --junitxml=test-results/junit.xml - name: Upload failure traces and test report diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 3dda19d..8f956f6 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -158,7 +158,7 @@ For CI comparison, archive the `target/criterion` directory as a build artifact Run benchmarks on a local machine or dedicated runner with a consistent CPU. CI should **not** run the benchmarks, but it can archive a baseline from those runs. -CI checks benchmark compilation through `cargo clippy --all-targets`. A separate benchmark build was removed because it took 16 of the lint job's 23 minutes. +CI checks benchmark compilation through `cargo clippy --all-targets`, which runs in the `build-test` job on every target OS. A separate benchmark build was removed because it took 16 of that lint pass's 23 minutes. ## Adding new benchmarks From cb2656eb13e15f5cf4639cf068134a298778aca9 Mon Sep 17 00:00:00 2001 From: Stofl <152594969+stofll@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:57:30 +0300 Subject: [PATCH 2/4] docs: translate remaining Russian prose to English Covers the four workflows not touched by the previous commit, the dictionary development plan, one comment in MicrophoneSection, and the developer-facing output of check-i18n, find-eager-t, check-build-paths and build-installer. Russian is kept where it is data rather than prose: the ru_RU dictionaries, i18n strings, formatter test fixtures, README.ru.md, and the English comments that quote Russian words or UI strings to explain what the code does with them. Translating those would remove the thing being described. --- .github/workflows/release.yml | 219 +++++++++--------- .github/workflows/sbom.yml | 59 ++--- .github/workflows/site.yml | 55 +++-- desktop/check-i18n.mjs | 20 +- desktop/find-eager-t.mjs | 2 +- .../src/pages/settings/MicrophoneSection.tsx | 5 +- docs/dictionary-sets-plan.md | 150 ++++++------ scripts/build-installer.sh | 14 +- scripts/check-build-paths.py | 48 ++-- 9 files changed, 294 insertions(+), 278 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ad9e3e..572a37f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: "Тег релиза, например v0.2.0" + description: "Release tag, for example v0.2.0" required: true workflow_call: @@ -57,10 +57,10 @@ jobs: ref: ${{ needs.resolve.outputs.sha }} release: - name: Собрать и подписать + name: Build and sign needs: [resolve, browser-ui] permissions: - # tauri-action создаёт черновик релиза и заливает в него артефакты. + # tauri-action creates the draft release and uploads artifacts into it. contents: write strategy: fail-fast: false @@ -68,10 +68,11 @@ jobs: include: # --features gpu-*: GPU backends are opt-in (see Cargo.toml), so a # release that forgot them would quietly ship CPU-only inference. - # target_dir: каталог сборки уводится из рабочей копии — исходники - # whisper.cpp распаковываются под него, и MSVC/clang вшивают эти - # пути в бинарь через __FILE__. Внутри workspace это был бы путь с - # именем репозитория; снаружи — нейтральный. См. #41. + # target_dir: the build directory is moved out of the working copy. + # whisper.cpp sources are unpacked underneath it, and MSVC/clang bake + # those paths into the binary through __FILE__. Inside the workspace + # that would be a path carrying the repository name; outside it is + # neutral. See #41. - platform: windows-latest target_triple: x86_64-pc-windows-msvc args: "--target x86_64-pc-windows-msvc --features gpu-vulkan -- --locked" @@ -103,36 +104,38 @@ jobs: cache: pnpm cache-dependency-path: desktop/pnpm-lock.yaml - # Версию задаёт rust-toolchain.toml в корне; здесь остаётся только - # целевой триплет, которого в нём нет. + # The version comes from rust-toolchain.toml at the repo root; all that + # is left here is the target triple, which that file does not carry. - name: Rust - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # ветка stable на 2026-09-03 + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable branch as of 2026-09-03 with: targets: ${{ matrix.target_triple }} - - name: Проверить согласованность версии релиза + - name: Verify release version consistency shell: bash env: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: sh scripts/check-version.sh "$RELEASE_TAG" - # rustc вшивает file!() каждой зависимости в сообщения паники, а это - # $CARGO_HOME/registry — то есть домашний каталог пользователя раннера. - # Пути раннера не выдают мейнтейнера, но в раздаваемом артефакте им всё - # равно делать нечего. Профильный trim-paths сделал бы это декларативно, - # но в Cargo 1.95 он требует nightly. + # rustc bakes every dependency's file!() into panic messages, and that + # means $CARGO_HOME/registry — the runner user's home directory. Runner + # paths do not identify the maintainer, but they still have no business + # being in a distributed artifact. The trim-paths profile option would do + # this declaratively, but in Cargo 1.95 it requires nightly. # - # Разделитель — пробел, а не \x1f как в build-installer.sh: у путей - # раннера пробелов не бывает, и обычный RUSTFLAGS читается глазами. - # Шаг стоит до rust-cache: ключ кэша считается по флагам сборки. - - name: Ремап путей сборки + # The separator is a space rather than \x1f as in build-installer.sh: + # runner paths never contain spaces, and a plain RUSTFLAGS stays readable. + # This step comes before rust-cache: the cache key is derived from the + # build flags. + - name: Remap build paths shell: bash run: | cargo_home="${CARGO_HOME:-$HOME/.cargo}" workspace="$GITHUB_WORKSPACE" - # rustc видит пути в родной форме ОС, а bash на Windows — в форме - # msys (/c/Users/…). Ремап по несовпадающему префиксу молча ничего - # не делает, поэтому приводим к windows-форме там, где она есть. + # rustc sees paths in the OS-native form, while bash on Windows sees + # the msys form (/c/Users/…). A remap whose prefix does not match + # silently does nothing, so convert to the Windows form where there + # is one. if command -v cygpath >/dev/null 2>&1; then cargo_home=$(cygpath -w "$cargo_home") workspace=$(cygpath -w "$workspace") @@ -144,75 +147,76 @@ jobs: workspaces: desktop/src-tauri key: ggml-baseline-${{ hashFiles('.cargo/config.toml', 'scripts/ggml-baseline.cmake') }} - # whisper.cpp собирается с Vulkan-бэкендом, и для этого нужны glslc и - # заголовки. В рантайме vulkan-1.dll приезжает с драйвером GPU, так что - # SDK нужен только здесь. См. project_vulkan_build: без VULKAN_SDK - # cargo не соберётся. + # whisper.cpp is built with the Vulkan backend, which needs glslc and + # the headers. At runtime vulkan-1.dll ships with the GPU driver, so the + # SDK is only needed here. See project_vulkan_build: without VULKAN_SDK + # cargo will not build. # - # У пина версии есть срок годности: LunarG убирает с sdk.lunarg.com - # сборки старше примерно двух лет, и версия просто перестаёт - # скачиваться. Так умер 1.3.280.0 (февраль 2024): шаг падал за минуту - # на `curl failed with error code: 22` и `HTTP/1.1 404 Not Found`. - # Новую версию брать здесь: https://vulkan.lunarg.com/sdk/home + # The pinned version has a shelf life: LunarG removes builds older than + # roughly two years from sdk.lunarg.com, and the version simply stops + # downloading. That is how 1.3.280.0 (February 2024) died: the step + # failed within a minute on `curl failed with error code: 22` and + # `HTTP/1.1 404 Not Found`. Pick a new version here: + # https://vulkan.lunarg.com/sdk/home # - # Экшен — jakoch, а не humbletim/install-vulkan-sdk. Тот распаковывал - # инсталлятор одним `7z x` и на layout'е 1.4.x разложил дерево так, что - # CMake не нашёл ни заголовков, ни библиотек: + # The action is jakoch, not humbletim/install-vulkan-sdk. That one + # unpacked the installer with a single `7z x`, and on the 1.4.x layout it + # laid out the tree so that CMake found neither headers nor libraries: # Could NOT find Vulkan (missing: Vulkan_LIBRARY Vulkan_INCLUDE_DIR) - # Причём сам шаг при этом отчитывался успехом — glslangValidator на - # месте, 817 МБ распаковано, — так что поломка всплывала только через - # одиннадцать минут, в build script'е whisper-rs-sys. + # The step itself still reported success — glslangValidator was in place, + # 817 MB unpacked — so the breakage only surfaced eleven minutes later, + # in the whisper-rs-sys build script. - name: Vulkan SDK (Windows) if: matrix.platform == 'windows-latest' uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1.6.0 with: vulkan_version: 1.4.357.0 - # vulkan-1.dll в рантайме приезжает с драйвером GPU, раннеру он не - # нужен: здесь SDK берётся только ради заголовков, .lib и glslc. + # vulkan-1.dll ships with the GPU driver at runtime; the runner does + # not need it. The SDK is here only for headers, .lib files and glslc. install_runtime: false cache: true - - name: Установить зависимости фронтенда + - name: Install frontend dependencies working-directory: desktop run: pnpm install --frozen-lockfile - # `sherpa-onnx-sys` качает нативный архив сам и хеш не проверяет. - # Скачиваем и сверяем его здесь, а сборочному скрипту отдаём готовый - # каталог через SHERPA_ONNX_LIB_DIR — тогда он в сеть не ходит и в - # релизный бинарь не попадает непроверенный код. - - name: Скачать и проверить нативный рантайм sherpa (Windows) + # `sherpa-onnx-sys` downloads the native archive itself and does not + # verify its hash. Download and check it here, then hand the build script + # a ready directory through SHERPA_ONNX_LIB_DIR: it never touches the + # network, and no unverified code reaches the release binary. + - name: Download and verify the sherpa native runtime (Windows) if: matrix.platform == 'windows-latest' shell: pwsh run: ./scripts/fetch-sherpa-runtime.ps1 -Target win-x64-shared - - name: Скачать и проверить нативный рантайм sherpa (macOS) + - name: Download and verify the sherpa native runtime (macOS) if: runner.os == 'macOS' run: sh scripts/fetch-sherpa-runtime.sh osx-arm64-static - # `tauri.windows.conf.json` объявляет ресурсом - # `.tauri-native/windows/x64/*.dll`, и build script падает, если glob - # ничего не нашёл: + # `tauri.windows.conf.json` declares `.tauri-native/windows/x64/*.dll` + # as a resource, and the build script fails when that glob matches + # nothing: # glob pattern .tauri-native/windows/x64/*.dll path not found - # Раскладывает эти DLL `prepare-native-libs.ps1`, но он висит на - # `beforeBundleCommand` — то есть отрабатывает уже ПОСЛЕ компиляции, а - # падает как раз компиляция. На машине разработчика каталог остаётся от - # прошлой сборки, на чистом раннере его нет вовсе, поэтому здесь он - # нужен явным шагом. Тот же шаг с тем же обоснованием есть в - # rust-ci.yml; отличия только в профиле (release, не debug) и в том, - # что CARGO_BUILD_TARGET здесь задан, так что Cargo кладёт всё под - # триплет. - - name: Разложить нативный рантайм sherpa (Windows) + # Those DLLs are staged by `prepare-native-libs.ps1`, but it hangs off + # `beforeBundleCommand`, so it runs AFTER compilation — and compilation is + # exactly what fails. On a developer machine the directory survives from + # the previous build; on a clean runner it does not exist at all, so it + # needs an explicit step here. The same step with the same rationale is in + # rust-ci.yml; the only differences are the profile (release, not debug) + # and that CARGO_BUILD_TARGET is set here, so Cargo puts everything under + # the triple. + - name: Stage the sherpa native runtime (Windows) if: matrix.platform == 'windows-latest' working-directory: desktop/src-tauri shell: pwsh run: | cargo build --release --locked -p sherpa-onnx-sys - # На прогоне с тёплым кэшем этого мало: rust-cache перед сохранением - # подчищает `target//` от всего, что не является - # артефактом зависимости, и разложенные DLL туда попадают, а - # `.fingerprint` пакета остаётся. Cargo видит build script - # отработавшим и не запускает его снова. Сбросить нужно именно этот - # пакет — скачивать ему уже нечего, так что это секунды. + # On a warm-cache run that is not enough: before saving, rust-cache + # prunes everything from `target//` that is not a + # dependency artifact, and the staged DLLs are caught by that, while + # the crate's `.fingerprint` survives. Cargo therefore considers the + # build script already run and does not rerun it. Clean exactly that + # one crate — it has nothing left to download, so this takes seconds. $dll = Join-Path $env:CARGO_TARGET_DIR (Join-Path $env:CARGO_BUILD_TARGET 'release/sherpa-onnx-c-api.dll') if (-not (Test-Path -LiteralPath $dll)) { cargo clean -p sherpa-onnx-sys @@ -220,7 +224,7 @@ jobs: } powershell -NoProfile -ExecutionPolicy Bypass -File prepare-native-libs.ps1 - - name: Проверить статический рантайм macOS перед упаковкой + - name: Verify the macOS static runtime before packaging if: runner.os == 'macOS' working-directory: desktop/src-tauri shell: bash @@ -238,7 +242,7 @@ jobs: # The bundler runs plain `codesign -s`, so the identity has to be in # the search list; APPLE_CERTIFICATE is deliberately not exported, # because the bundler only recognises Apple-issued names through it. - - name: Подготовить подпись macOS + - name: Prepare macOS signing if: runner.os == 'macOS' shell: bash env: @@ -271,31 +275,31 @@ jobs: security list-keychains -d user -s "$keychain" $existing echo "APPLE_SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV" - - name: Собрать, подписать и выложить + - name: Build, sign and upload uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Приватный minisign-ключ. Публичная половина лежит в - # tauri.conf.json → plugins.updater.pubkey; без совпадения пары - # установленное приложение откажется ставить обновление. + # The private minisign key. Its public half lives in + # tauri.conf.json → plugins.updater.pubkey; if the pair does not + # match, an installed app refuses to apply the update. TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - # Публичный ingest-токен проекта PostHog, а не личный ключ: он - # компилируется в бинарник через option_env!, и отсутствующий - # секрет даёт молчащую сборку, неотличимую от рабочей. Guard'а, - # как в build-installer.sh, здесь нет — см. docs/RELEASE.md → - # Telemetry Ingest Token. + # The project's public PostHog ingest token, not a personal key. It + # is compiled into the binary through option_env!, so a missing secret + # produces a silent build indistinguishable from a working one. There + # is no guard here like the one in build-installer.sh — see + # docs/RELEASE.md → Telemetry Ingest Token. SOTTO_POSTHOG_API_KEY: ${{ secrets.SOTTO_POSTHOG_API_KEY }} with: projectPath: desktop tagName: ${{ inputs.tag || github.ref_name }} releaseName: "Sotto ${{ inputs.tag || github.ref_name }}" - # Ключа releaseBody здесь намеренно нет: апдейтер показывает тело - # релиза как «что нового», поэтому заметки пишутся руками в самом - # релизе на GitHub. См. docs/RELEASE.md → Publish. - # Черновик, а не сразу публикация: latest.json подхватится - # пользователями в тот момент, когда релиз перестанет быть - # черновиком, — то есть после того, как сборку проверили руками. + # There is deliberately no releaseBody key: the updater shows the + # release body as "what's new", so the notes are written by hand on + # the GitHub release itself. See docs/RELEASE.md → Publish. + # A draft rather than an immediate publish: users pick up latest.json + # the moment the release stops being a draft — that is, after the + # build has been checked by hand. releaseDraft: true prerelease: false includeUpdaterJson: true @@ -319,32 +323,33 @@ jobs: gh release upload $env:RELEASE_TAG $archive --clobber if ($LASTEXITCODE -ne 0) { throw "Portable ZIP upload failed" } - # Артефакты к этому моменту уже лежат в черновике релиза: шаг не - # отменяет заливку, он даёт повод черновик не публиковать. Публикация - # и так ручная, так что этого достаточно. См. #41. - - name: Проверить, что в бинаре нет путей сборочной машины + # By this point the artifacts are already in the draft release: this step + # does not undo the upload, it gives a reason not to publish the draft. + # Publishing is manual anyway, so that is enough. See #41. + - name: Check the binary for build machine paths shell: bash run: | target_dir="$CARGO_TARGET_DIR" - # То же преобразование, что и в «Ремап путей сборки»: find нужна - # msys-форма пути, а Cargo и PowerShell — родная. + # The same conversion as in "Remap build paths": find needs the msys + # form of the path, while Cargo and PowerShell need the native one. if command -v cygpath >/dev/null 2>&1; then target_dir=$(cygpath -u "$target_dir") fi bin=$(find "$target_dir" -maxdepth 3 -type f \ \( -name Sotto -o -name Sotto.exe \) | head -1) if [ -z "$bin" ]; then - echo "бинарь не найден в $target_dir" + echo "binary not found in $target_dir" exit 1 fi py=$(command -v python3 || command -v python) "$py" scripts/check-build-paths.py "$bin" - # SBOM считается после того, как tauri-action создал черновик: иначе - # заливать ассет некуда. Отдельным вызовом, а не шагом внутри матрицы, — - # инвентарь зависимостей один на релиз, а не по одному на платформу. + # The SBOM is generated after tauri-action has created the draft: otherwise + # there is nowhere to upload the asset. A separate call rather than a step + # inside the matrix, because the dependency inventory is one per release, not + # one per platform. sbom: - name: SBOM релиза + name: Release SBOM needs: release permissions: contents: write @@ -352,20 +357,20 @@ jobs: with: release_tag: ${{ inputs.tag || github.ref_name }} - # Контрольные суммы — то немногое, что пользователь может проверить сам, - # пока сборки не подписаны сертификатом издателя. Отдельной задачей после - # обеих сборок и SBOM: файл один на релиз и должен покрывать всё, что в - # черновике лежит, а посчитанный внутри матрицы знал бы только про свою - # платформу. + # Checksums are one of the few things a user can verify themselves while the + # builds are not signed with a publisher certificate. A separate job after + # both builds and the SBOM: the file is one per release and must cover + # everything in the draft, whereas one computed inside the matrix would only + # know about its own platform. checksums: - name: Контрольные суммы релиза + name: Release checksums needs: [release, sbom] runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write steps: - - name: Посчитать SHA-256 и приложить к черновику + - name: Compute SHA-256 and attach to the draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} @@ -373,14 +378,14 @@ jobs: run: | set -euo pipefail mkdir -p assets && cd assets - # Черновик виден только по токену с contents: write — публичной - # ссылки на него ещё нет. + # The draft is only visible to a token with contents: write — there + # is no public link to it yet. gh release download "$RELEASE_TAG" --clobber - # Формат — ровно тот, что понимает `sha256sum -c`, поэтому никаких - # заголовков и комментариев в файле: объяснение живёт в - # docs/verifying-downloads.md, а файл остаётся машинно проверяемым. - # Собственный файл сумм в список не попадает: он мог остаться от - # перевыпуска и посчитал бы сам себя. + # The format is exactly what `sha256sum -c` understands, so the file + # carries no headers or comments: the explanation lives in + # docs/verifying-downloads.md and the file stays machine-verifiable. + # The checksum file itself is excluded from the list: it could be left + # over from a re-release and would end up hashing itself. rm -f SHA256SUMS.txt sha256sum -- * > SHA256SUMS.txt cat SHA256SUMS.txt diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 9807130..167d3fc 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1,12 +1,13 @@ name: SBOM -# Инвентарь того, из чего собрано приложение: CycloneDX для Rust и npm плюс -# читаемый отчёт по лицензиям. Считается не на каждый PR — граф зависимостей -# меняется только вместе с lock-файлами, поэтому на них и висит. +# An inventory of what the app is built from: CycloneDX for Rust and npm plus +# a readable license report. Not computed on every pull request — the +# dependency graph only changes together with the lockfiles, so it hangs off +# those. # -# На теге релиза вызывается из release.yml, уже после того как tauri-action -# создал черновик: тогда SBOM кладётся в него как ассет, а не остаётся -# артефактом прогона, который истечёт. +# On a release tag it is called from release.yml, after tauri-action has +# created the draft: the SBOM then goes into it as an asset rather than staying +# a run artifact that expires. on: pull_request: branches: [main] @@ -20,7 +21,7 @@ on: workflow_call: inputs: release_tag: - description: "Тег релиза: SBOM прикладывается к черновику с этим тегом" + description: "Release tag: the SBOM is attached to the draft with this tag" required: false type: string @@ -33,11 +34,11 @@ permissions: jobs: sbom: - name: Собрать SBOM и отчёт по лицензиям + name: Build the SBOM and license report runs-on: ubuntu-latest timeout-minutes: 30 permissions: - # Заливка ассета в черновик релиза. Без release_tag не используется. + # Uploads an asset to the draft release. Unused without release_tag. contents: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -54,9 +55,9 @@ jobs: with: version: 11.9.0 - # Версию задаёт rust-toolchain.toml в корне. + # The version comes from rust-toolchain.toml at the repo root. - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # ветка stable на 2026-09-03 + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable branch as of 2026-09-03 - name: Cache cargo registry & target uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -69,10 +70,11 @@ jobs: - name: Install cargo-cyclonedx run: cargo install cargo-cyclonedx --locked - # --target all: половина графа приезжает через `[target.'cfg(...)']` - # (windows-sys, objc2, ashpd). Без этого флага инвентарь описывал бы - # только Linux-раннер, а раздаём мы Windows и macOS. - # --all-features: gpu-vulkan / gpu-metal опциональны, но в релиз идут. + # --target all: half the graph arrives through `[target.'cfg(...)']` + # (windows-sys, objc2, ashpd). Without this flag the inventory would + # describe only the Linux runner, while what we ship is Windows and macOS. + # --all-features: gpu-vulkan / gpu-metal are optional but do go into the + # release. - name: SBOM — Rust working-directory: desktop/src-tauri run: | @@ -97,10 +99,10 @@ jobs: -o "$GITHUB_WORKSPACE/sbom/sbom-npm.cdx.json" \ . - # Читаемый срез поверх SBOM: что и под какой лицензией пришло. `paths` - # из вывода pnpm выкидываем — это абсолютные пути раннера, в артефакте - # им делать нечего. - - name: Отчёт по лицензиям npm + # A readable slice on top of the SBOM: what arrived and under which + # license. `paths` is dropped from the pnpm output — those are absolute + # runner paths and have no business being in an artifact. + - name: npm license report working-directory: desktop run: | for scope in prod dev; do @@ -116,11 +118,12 @@ jobs: ' "/tmp/licenses-$scope.json" "$GITHUB_WORKSPACE/sbom/licenses-npm-$scope.json" done - # Не гейт, а сводка в логе: сколько компонентов попало в инвентарь и у - # скольких не нашлось лицензии. Пустая лицензия — не всегда проблема - # (платформенные бинарники esbuild под чужую ОС не устанавливаются, и - # читать их package.json неоткуда), но это то, на что смотрят глазами. - - name: Сводка + # Not a gate but a summary in the log: how many components made it into + # the inventory and how many had no license. An empty license is not + # always a problem (esbuild's platform binaries for other operating + # systems are never installed, so there is no package.json to read), but + # it is what a human looks at. + - name: Summary run: | node -e ' const fs = require("fs"); @@ -128,20 +131,20 @@ jobs: const bom = JSON.parse(fs.readFileSync(f, "utf8")); const comps = bom.components || []; const noLicense = comps.filter((c) => !c.licenses || c.licenses.length === 0); - console.log(`${f}: ${comps.length} компонент(ов), без лицензии ${noLicense.length}`); + console.log(`${f}: ${comps.length} component(s), without a license ${noLicense.length}`); for (const c of noLicense.slice(0, 20)) console.log(` ${c.name}@${c.version}`); - if (noLicense.length > 20) console.log(` … ещё ${noLicense.length - 20}`); + if (noLicense.length > 20) console.log(` … ${noLicense.length - 20} more`); } ' - - name: Выложить как артефакт прогона + - name: Upload as a run artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sbom path: sbom/ if-no-files-found: error - - name: Приложить к черновику релиза + - name: Attach to the draft release if: inputs.release_tag != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 2db470e..c4170aa 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -1,13 +1,15 @@ name: Site -# Зеркало к paths-ignore в rust-ci.yml и ui-tests.yml: те игнорируют site/, -# этот воркфлоу смотрит только на него. Так правка лендинга не запускает -# матрицу из трёх ОС, а правка Rust не собирает Astro. +# The mirror image of paths-ignore in rust-ci.yml and ui-tests.yml: those +# ignore site/, this workflow looks at nothing else. That way a landing page +# edit does not start the three-OS matrix, and a Rust change does not build +# Astro. # -# Деплой висит на двух триггерах. Push в main — потому что лендинг живёт своим -# циклом: опечатку в тексте не должен ждать следующий релиз. `release: -# published` — потому что бейдж версии в герое резолвится на сборке, и без -# пересборки он врёт до следующей правки текста. +# Deployment hangs off two triggers. Push to main, because the landing page +# lives on its own cycle: a typo in the copy should not wait for the next +# release. `release: published`, because the version badge in the hero is +# resolved at build time, and without a rebuild it lies until the next copy +# change. on: push: branches: [main] @@ -26,13 +28,15 @@ on: permissions: contents: read -# Проверку PR отменять по новому пушу правильно, начатый деплой — нет. +# Cancelling a pull request check on a new push is right; cancelling a +# deployment already under way is not. # -# У прода группа одна на все триггеры, а не `github.ref`: на `release: -# published` ref — это тег, так что релизный деплой попадал бы в собственную -# группу и мог идти параллельно с деплоем от пуша в main. Освободись такой -# пуш последним, он перезаписал бы свежий сайт своим, более старым `github.sha` -# — и так до следующего триггера. Общая группа выстраивает их в очередь. +# Production uses one group for every trigger rather than `github.ref`: on +# `release: published` the ref is a tag, so the release deploy would land in a +# group of its own and could run alongside a deploy from a push to main. If +# that push finished last, it would overwrite the fresh site with its own, +# older `github.sha` — and so on until the next trigger. A shared group puts +# them in a queue. concurrency: group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.ref) || 'site-production' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -60,11 +64,11 @@ jobs: deploy: name: Deploy to Cloudflare - # Job проверки намеренно без креденшелов, чтобы собираться и с форков; - # секреты живут только здесь, и только на ветке main. Условие проверяет - # ветку явно: `workflow_dispatch` запускается с любой, а чекаут ниже берёт - # `github.sha`, так что без этого ручной прогон выкатил бы в прод - # содержимое произвольной ветки. + # The check job deliberately has no credentials so it also builds from + # forks; the secrets live only here, and only on the main branch. The + # condition checks the branch explicitly: `workflow_dispatch` can be started + # from any branch, and the checkout below takes `github.sha`, so without + # this a manual run would ship an arbitrary branch to production. if: >- github.event_name == 'release' || (github.event_name != 'pull_request' && github.ref == 'refs/heads/main') @@ -77,9 +81,10 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - # На `release: published` checkout по умолчанию берёт тег релиза, и - # деплой откатил бы лендинг к тексту на момент тега. Нужен ровно - # обратный эффект: свежий сайт, пересобранный с новой версией. + # On `release: published` checkout takes the release tag by default, + # and the deploy would roll the landing page back to the copy as of + # that tag. The desired effect is the exact opposite: the current + # site, rebuilt with the new version. ref: ${{ github.event_name == 'release' && 'main' || github.sha }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: @@ -88,15 +93,15 @@ jobs: with: package_json_file: site/package.json - run: pnpm install --frozen-lockfile - # Сборка занимает секунду, поэтому деплой собирает сам, а не тащит - # артефакт из соседнего job: меньше движущихся частей. + # The build takes a second, so the deploy builds for itself rather than + # pulling an artifact from the neighbouring job: fewer moving parts. - run: pnpm build - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - # Для action рабочая директория задаётся здесь: `defaults.run` на неё - # не распространяется. + # The working directory for an action is set here: `defaults.run` + # does not apply to it. workingDirectory: site wranglerVersion: '4.134.0' packageManager: pnpm diff --git a/desktop/check-i18n.mjs b/desktop/check-i18n.mjs index d8c8d5e..1f2b0d2 100644 --- a/desktop/check-i18n.mjs +++ b/desktop/check-i18n.mjs @@ -88,12 +88,12 @@ for (const file of walkFiles(root)) { if (process.argv.includes("--keys")) { const sorted = [...used].sort((a, b) => a.localeCompare(b, "ru")); fs.writeFileSync("src/i18n/keys.json", JSON.stringify(sorted, null, 2) + "\n", "utf8"); - console.log(`keys.json переписан: ${sorted.length}`); + console.log(`keys.json rewritten: ${sorted.length}`); } const indexed = JSON.parse(fs.readFileSync("src/i18n/keys.json", "utf8")); const staleIndex = indexed.length !== used.size || new Set(indexed).size !== used.size || indexed.some((key) => !used.has(key)); -if (staleIndex) console.error("keys.json устарел; выполните pnpm i18n:keys"); +if (staleIndex) console.error("keys.json is stale; run pnpm i18n:keys"); // The dictionary is read as text: a .ts file cannot be imported from node // without a build, and all we need are the top-level keys. @@ -106,22 +106,22 @@ for (const m of enSource.matchAll(/^\s{2}"((?:[^"\\]|\\.)*)":/gm)) { const missing = [...used].filter((k) => !translated.has(k)).sort((a, b) => a.localeCompare(b, "ru")); const stale = [...translated].filter((k) => !used.has(k)).sort((a, b) => a.localeCompare(b, "ru")); -console.log(`ключей в коде: ${used.size}`); -console.log(`переведено: ${translated.size}`); -console.log(`не переведено: ${missing.length}`); -console.log(`лишних в en: ${stale.length}`); -console.log(`кириллица вне t(): ${bare.length}`); +console.log(`keys in code: ${used.size}`); +console.log(`translated: ${translated.size}`); +console.log(`untranslated: ${missing.length}`); +console.log(`stale in en: ${stale.length}`); +console.log(`Cyrillic outside t(): ${bare.length}`); if (missing.length) { - console.log("\nбез перевода:"); + console.log("\nwithout a translation:"); for (const k of missing) console.log(` ${JSON.stringify(k)}`); } if (stale.length) { - console.log("\nперевод есть, ключа в коде нет (копию правили после перевода?):"); + console.log("\ntranslated, but no key in code (was the copy edited after translation?):"); for (const k of stale) console.log(` ${JSON.stringify(k)}`); } if (bare.length) { - console.log("\nкириллица вне t():"); + console.log("\nCyrillic outside t():"); for (const b of bare) console.log(` ${b.file}:${b.line} ${JSON.stringify(b.text)}`); } diff --git a/desktop/find-eager-t.mjs b/desktop/find-eager-t.mjs index 7218757..ddff6e6 100644 --- a/desktop/find-eager-t.mjs +++ b/desktop/find-eager-t.mjs @@ -46,5 +46,5 @@ for (const file of walkFiles("src")) { } for (const h of hits) console.log(`${h.file}:${h.line} ${h.name}`); -console.log(`\nвсего: ${hits.length}`); +console.log(`\ntotal: ${hits.length}`); process.exit(hits.length ? 1 : 0); diff --git a/desktop/src/pages/settings/MicrophoneSection.tsx b/desktop/src/pages/settings/MicrophoneSection.tsx index d32c20a..bbd55c8 100644 --- a/desktop/src/pages/settings/MicrophoneSection.tsx +++ b/desktop/src/pages/settings/MicrophoneSection.tsx @@ -260,8 +260,9 @@ export function MicPicker({ microphone, microphones, onConfigChanged }: { microp try { if (checking) { setChecking(false); - // Захват остаётся жить ради эха, но индикатор — часть выключенного - // режима: без сброса он продолжал бы прыгать под погашенной кнопкой. + // The capture stays alive for the echo, but the meter belongs to the + // mode being switched off: without a reset it would keep jumping under + // a button that is no longer lit. if (echo) resetMeter(); else await stopCapture(); } else { diff --git a/docs/dictionary-sets-plan.md b/docs/dictionary-sets-plan.md index 21d6438..5d58015 100644 --- a/docs/dictionary-sets-plan.md +++ b/docs/dictionary-sets-plan.md @@ -1,113 +1,113 @@ -# План развития словарей +# Dictionary development plan -Статус: первая версия реализована и прошла независимое ревью Astra medium. Руководство по доступным возможностям: [dictionaries.md](dictionaries.md); этот документ сохраняет согласованный объём и границы дальнейшего развития. +Status: the first version is implemented and has passed an independent Astra medium review. For a guide to what is available today see [dictionaries.md](dictionaries.md); this document records the agreed scope and the boundaries of further work. -## Цель +## Goal -Превратить словари в библиотеку понятных, управляемых наборов терминов. Пользователь должен иметь возможность посмотреть содержимое до включения, создать набор для своей работы и адаптировать готовый набор. +Turn dictionaries into a library of understandable, manageable term sets. A user should be able to inspect the contents before enabling a set, create a set for their own work, and adapt a built-in one. -Словари остаются локальной функцией без обязательного облака или LLM. Изменения не должны добавлять заметную задержку между записью, распознаванием и вставкой. +Dictionaries remain a local feature with no mandatory cloud or LLM. The changes must not add noticeable latency between recording, recognition and paste. -## Исходное состояние перед реализацией +## State before implementation -На странице «Обработка → Текст» находятся общий личный словарь, свои слова-паразиты и переключатели готовых наборов. В исходниках сейчас определён один готовый набор — «Разработка»; интерфейс получает его полный список слов, но показывает только название и количество в подсказке. +The «Processing → Text» page holds the shared personal dictionary, custom filler words and the toggles for built-in sets. The sources currently define one built-in set, «Development»; the interface receives its full word list but shows only the name and a count in the tooltip. -Готовый набор включается по идентификатору, его содержимое не копируется в личный словарь. Обработка объединяет личные слова и включённые наборы, убирая повторы без учёта регистра; при таком совпадении личное написание имеет приоритет. +A built-in set is enabled by identifier and its contents are not copied into the personal dictionary. Processing merges personal words with the enabled sets, dropping case-insensitive duplicates; on such a match the personal spelling wins. -Локальный корректор исправляет похожие написания, а для Whisper словарь также используется как подсказка распознаванию. Некоторые короткие термины не проходят ограничения корректора, поэтому присутствие термина в словаре не гарантирует исправление. +The local corrector fixes similar spellings, and for Whisper the dictionary also serves as a recognition prompt. Some short terms do not pass the corrector's constraints, so a term being in the dictionary does not guarantee a correction. -Исходные точки для реализации: [страница обработки текста](../desktop/src/pages/OtherPages.tsx), [контракты frontend](../desktop/src/bridge/types.ts), [словарные наборы и корректор](../desktop/src-tauri/src/formatter.rs), [команды и подсказка Whisper](../desktop/src-tauri/src/lib.rs). Перед изменениями перепроверить актуальные вызовы и связанные тесты. +Starting points for the implementation: [the text processing page](../desktop/src/pages/OtherPages.tsx), [the frontend contracts](../desktop/src/bridge/types.ts), [dictionary sets and the corrector](../desktop/src-tauri/src/formatter.rs), [the commands and the Whisper prompt](../desktop/src-tauri/src/lib.rs). Re-check the current call sites and related tests before making changes. -## Первая версия +## First version -### Список и просмотр +### List and inspection -- Заменить маленькие кнопки наборами строк с названием, количеством терминов и отдельным переключателем. -- Открывать содержимое нажатием на название, не меняя состояние включения. -- Показывать полный список терминов, поиск внутри набора, короткое описание при наличии и явный статус «Включён» / «Выключен». -- Разрешить просмотр любого набора до его включения. -- Различать встроенные и пользовательские наборы. Встроенные доступны для чтения и копирования; пользовательские — для редактирования. -- Показывать число активных уникальных терминов, не смешивая его с количеством слов-паразитов. Не обозначать это число как гарантию исправления всех терминов. +- Replace the small buttons with rows carrying a name, a term count and a toggle of their own. +- Open the contents by clicking the name, without changing the enabled state. +- Show the full term list, search within the set, a short description where one exists and an explicit «Enabled» / «Disabled» status. +- Allow any set to be inspected before it is enabled. +- Distinguish built-in sets from user ones. Built-in sets are readable and copyable; user sets are editable. +- Show the number of active unique terms without mixing it with the filler word count. Do not present that number as a guarantee that every term will be corrected. -### Создание и редактирование +### Creating and editing -- Добавить действие «Создать набор»: название, необязательное описание и список терминов по одному в строке. -- Поддержать вставку многострочного списка, удаление пустых строк и повторов. Проверить совместимость с текущим вводом через запятые, сохранив возможность терминов из нескольких слов. -- Разрешить переименование, изменение содержимого, дублирование, включение, выключение и удаление своих наборов. -- Дать действие «Создать копию» для встроенного набора. Копия независима от последующих обновлений встроенного оригинала. -- Создание и редактирование выполнять через черновик с явными «Сохранить» и «Отмена». Ошибка сохранения не должна уничтожать введённые данные; предоставить повторную попытку. -- Создавать копию выключенной, чтобы просмотр и подготовка изменений не меняли действующую обработку. В форме создания явно показывать, будет ли новый набор включён после сохранения. -- При удалении показывать название удаляемого набора и предупреждать о потере его содержимого; выключение остаётся отдельным действием без потери данных. +- Add a «Create set» action: a name, an optional description and a list of terms, one per line. +- Support pasting a multi-line list, dropping blank lines and duplicates. Check compatibility with the current comma-separated input while keeping multi-word terms possible. +- Allow renaming, editing, duplicating, enabling, disabling and deleting a user's own sets. +- Provide a «Make a copy» action for a built-in set. The copy is independent of later updates to the built-in original. +- Perform creation and editing through a draft with explicit «Save» and «Cancel». A save failure must not destroy the entered data; offer a retry. +- Create the copy disabled, so that inspecting and preparing changes does not alter live processing. In the creation form, state explicitly whether the new set will be enabled after saving. +- On deletion, show the name of the set being removed and warn that its contents will be lost; disabling remains a separate action with no data loss. -### Организация страницы +### Page organisation -Перенести «Свои слова-паразиты» в «Очистку», сохранив их содержимое и существующее поведение. В «Словарях» оставить термины, имена и правильные написания. +Move «Custom filler words» into «Cleanup», preserving their contents and existing behaviour. Leave terms, names and correct spellings in «Dictionaries». -Использовать существующие компоненты, токены, карточки и средства i18n. Способ открытия подробностей выбрать по существующим паттернам приложения и доступному месту; отдельный новый раздел навигации для первой версии не требуется. +Use the existing components, tokens, cards and i18n facilities. Choose how to open the details from the app's existing patterns and the space available; a separate new navigation section is not needed for the first version. -### Совместимость и правила применения +### Compatibility and application rules -- Преобразовать текущий личный список в включённый набор «Мои слова», сохранив содержимое и результат обработки. Не создавать лишний пустой набор для нового пользователя. -- Сохранить выбранные встроенные наборы и настройки очистки. Повторная загрузка или миграция не должна создавать дубликаты. -- Разрешить одновременное включение нескольких наборов; выключенный набор не участвует ни в коррекции, ни в формировании подсказки распознаванию. -- Учитывать одинаковые термины один раз. Сохранить приоритет пользовательского написания над встроенным при совпадении без учёта регистра. -- При конфликтующих написаниях в пользовательских наборах показывать варианты и давать выбрать действующее написание. Не вводить скрытый приоритет по порядку включения или открытия набора. -- До реализации уточнить границу между точным дублем, разницей регистра и фонетически похожими терминами. Не считать всякое похожее слово конфликтом и не менять алгоритм нечёткого сопоставления в рамках организации наборов. -- Показывать ограничения корректора для неподдерживаемых терминов понятным пояснением. Не удалять такие термины молча: у подсказки распознаванию могут быть другие ограничения. -- Не скрывать библиотеку при ошибке загрузки: показать ошибку и действие повтора. Обработать пустую библиотеку, пустой набор и отсутствие результатов поиска. -- Сохранить существующие зависимости от настроек обработки; явно объяснять пользователю, когда отключение обработки мешает применению словаря. Не вводить новый общий переключатель без необходимости. +- Convert the current personal list into an enabled set named «My words», preserving its contents and the processing result. Do not create a spare empty set for a new user. +- Preserve the selected built-in sets and the cleanup settings. Reloading or migrating again must not create duplicates. +- Allow several sets to be enabled at once; a disabled set takes part in neither correction nor the recognition prompt. +- Count identical terms once. Keep the user spelling's precedence over the built-in one on a case-insensitive match. +- When user sets carry conflicting spellings, show the options and let the user pick the effective one. Do not introduce a hidden precedence based on the order in which sets were enabled or opened. +- Before implementing, settle the boundary between an exact duplicate, a case difference and phonetically similar terms. Do not treat every similar word as a conflict, and do not change the fuzzy matching algorithm as part of organising sets. +- Explain the corrector's limits for unsupported terms in plain language. Do not drop such terms silently: the recognition prompt may have different limits. +- Do not hide the library on a load error: show the error and a retry action. Handle an empty library, an empty set and a search with no results. +- Preserve the existing dependencies on the processing settings; explain to the user explicitly when disabling processing prevents the dictionary from applying. Do not introduce a new global toggle without need. -## Этапы реализации +## Implementation stages -1. Проверить актуальные контракты, хранение конфигурации и все места применения словаря: диктовка, файловая транскрипция, повторная обработка истории и предпросмотр. Зафиксировать точные правила дублей, конфликтов и включения при создании, не расширяя объём первой версии. -2. Добавить хранение пользовательских наборов и безопасный переход со старого личного списка. Собирать действующий словарь в Rust через общий механизм; обновить затронутые IPC-контракты с обеих сторон и bridge-тесты. -3. Реализовать список, просмотр и поиск по содержимому встроенных и пользовательских наборов. Отделить открытие набора от переключения его действия. -4. Добавить создание, редактирование, копирование и удаление, обработку конфликтов и ошибок сохранения. Перенести свои слова-паразиты в очистку. -5. Проверить совместимость обработки и интерфейс, обновить пользовательское описание словарей и отчитаться о проверках и ограничениях. +1. Check the current contracts, configuration storage and every place the dictionary is applied: dictation, file transcription, reprocessing from history and preview. Pin down the exact rules for duplicates, conflicts and enablement on creation, without widening the scope of the first version. +2. Add storage for user sets and a safe transition from the old personal list. Assemble the effective dictionary in Rust through one shared mechanism; update the affected IPC contracts on both sides along with the bridge tests. +3. Implement the list, inspection and content search across built-in and user sets. Separate opening a set from toggling its effect. +4. Add creation, editing, copying and deletion, along with conflict handling and save errors. Move custom filler words into cleanup. +5. Verify processing compatibility and the interface, update the user-facing description of dictionaries, and report what was checked and what the limits are. -Не перестраивать алгоритм коррекции и не добавлять новые источники данных ради редактора наборов. Повторно использовать существующую обработку и предпросмотр; не добавлять чтение файлов или сетевые запросы в горячий путь диктовки. +Do not rebuild the correction algorithm and do not add new data sources for the sake of the set editor. Reuse the existing processing and preview; do not add file reads or network requests to the hot dictation path. -## Проверка и критерии готовности +## Verification and acceptance criteria -- Набор можно открыть и найти термин до включения; просмотр ничего не активирует. -- Пользователь может создать набор вставкой списка, сохранить, изменить, выключить и снова включить его; данные сохраняются после перезапуска. -- Копирование встроенного набора не изменяет оригинал и действующий словарь до явного включения копии. -- Старые личные слова и включённые встроенные наборы сохраняют поведение после перехода; повторная миграция безопасна. -- Дубли, конфликты, пустые строки и термины из нескольких слов имеют предсказуемое поведение и регрессионные тесты. Удаление или отключение одного набора не убирает термин, который остаётся в другом активном наборе. -- Ошибки загрузки и сохранения видимы, повторная попытка работает, несохранённый ввод не теряется. -- Проверены обе локали, светлая и тёмная темы, клавиатурная навигация, фокус при открытии и закрытии редактора, длинные названия и большие списки. -- На синтетических примерах проверены исправления и отсутствие нежелательных изменений обычного текста. Проверены существующие пути использования словаря и подсказка Whisper; предпросмотр текста не выдаётся за проверку распознавания аудио. -- Выполнены применимые проверки из [testing.md](testing.md), включая i18n и бюджеты сборки. Нативные сценарии проверяются на затронутых ОС с изолированными данными; непроверенные платформы и сценарии явно перечисляются. +- A set can be opened and a term found in it before it is enabled; inspection activates nothing. +- A user can create a set by pasting a list, save it, edit it, disable it and enable it again; the data survives a restart. +- Copying a built-in set changes neither the original nor the effective dictionary until the copy is explicitly enabled. +- Old personal words and enabled built-in sets keep their behaviour after the transition; migrating again is safe. +- Duplicates, conflicts, blank lines and multi-word terms behave predictably and have regression tests. Deleting or disabling one set does not remove a term that remains in another active set. +- Load and save errors are visible, the retry works, and unsaved input is not lost. +- Both locales, the light and dark themes, keyboard navigation, focus when the editor opens and closes, long names and large lists are all checked. +- Corrections, and the absence of unwanted changes to ordinary text, are checked on synthetic examples. The existing dictionary usage paths and the Whisper prompt are checked; a text preview is not passed off as a check of audio recognition. +- The applicable checks from [testing.md](testing.md) are done, including i18n and the build budgets. Native scenarios are checked on the affected operating systems with isolated data; unchecked platforms and scenarios are listed explicitly. -Для подготовки нативных проверок использовать [development.md](development.md), для границ приложения — [architecture.md](architecture.md), для утверждений о поддержке ОС — [platforms.md](platforms.md). Автотесты не должны читать или мигрировать живую пользовательскую конфигурацию. +Use [development.md](development.md) to prepare native checks, [architecture.md](architecture.md) for the app's boundaries, and [platforms.md](platforms.md) for claims about OS support. Automated tests must not read or migrate a live user configuration. -## После первой версии +## After the first version -Следующие улучшения согласованы как направление развития, но не входят в первую реализацию: +The following improvements are agreed as a direction but are not part of the first implementation: -- Импорт и экспорт наборов с предпросмотром содержимого и разбором дублей до применения. -- Поиск по всем наборам с указанием источника и состояния включения. -- Объяснение исправлений в существующем предпросмотре: исходный фрагмент, результат и набор-источник. Отдельно обозначать коррекцию текста и влияние подсказки на распознавание. -- Добавление термина из истории в выбранный набор. -- Исключение отдельных слов из встроенного набора, если практика покажет, что копирования недостаточно. -- Привязка наборов к приложениям или рабочим профилям после оценки реальной потребности в переключении контекста. +- Importing and exporting sets, with a content preview and duplicate resolution before anything is applied. +- Search across all sets, showing the source and the enabled state. +- An explanation of corrections inside the existing preview: the original fragment, the result and the set it came from. Distinguish text correction from the prompt's effect on recognition. +- Adding a term from history into a chosen set. +- Excluding individual words from a built-in set, should practice show that copying is not enough. +- Binding sets to applications or work profiles, after assessing the real need for context switching. -Расширение каталога готовых наборов выполнять с проверкой качества терминов и риска ложных исправлений. Большое число слов само по себе не является целью. +Expand the catalogue of built-in sets only with checks on term quality and the risk of false corrections. A large word count is not a goal in itself. -## Уточнения после ревью перед реализацией +## Clarifications from the pre-implementation review -Конфликт — несколько разных написаний с одинаковым ключом после удаления краевых пробелов и приведения к нижнему регистру. Точные повторы учитываются один раз; фонетическое сходство не считается конфликтом. Пользователь выбирает написание до сохранения конфликтующей активной конфигурации; выбор хранится явно и действует только пока выбранный вариант есть в активных пользовательских наборах. +A conflict is several different spellings sharing one key after trimming edge whitespace and lowercasing. Exact duplicates are counted once; phonetic similarity is not a conflict. The user picks the spelling before a conflicting active configuration is saved; the choice is stored explicitly and applies only while the chosen variant is present in the active user sets. -Новый набор и копия по умолчанию выключены; состояние можно изменить в редакторе перед сохранением. Редактирование включённого набора начинает действовать только после успешного сохранения. Пустой набор допустим, пустое название — нет. +A new set and a copy are disabled by default; the state can be changed in the editor before saving. Editing an enabled set takes effect only after a successful save. An empty set is allowed, an empty name is not. -Старый список преобразуется в памяти при чтении конфигурации и записывается в новом виде при следующем успешном сохранении. Порядок терминов и прежний выбор первого написания сохраняются; повторное чтение не создаёт копий. +The old list is converted in memory when the configuration is read and written in the new shape on the next successful save. The term order and the previous choice of first spelling are preserved; reading again creates no copies. -Отключение локального форматирования по-прежнему отключает коррекцию текста, но не подсказку Whisper. Интерфейс объясняет это различие. Первая версия не меняет механизм сохранения регистра при коррекции: выбранное написание остаётся входом существующего алгоритма, а не обещанием буквальной замены в любом контексте. +Turning off local formatting still turns off text correction but not the Whisper prompt. The interface explains that difference. The first version does not change how case is preserved during correction: the chosen spelling remains an input to the existing algorithm, not a promise of a literal replacement in every context. -## Проверка результата +## Result verification -Выполнены frontend-тесты, проверка TypeScript, i18n, сборка и проверка бюджетов всех окон; выполнены Rust-тесты, Clippy и rustfmt. Автотесты покрывают миграцию, членство в активных наборах, конфликты, подсказку Whisper, коррекцию текста, сохранность файла при отказе записи и успешную повторную попытку. +The frontend tests, the TypeScript check, i18n, the build and the budget checks for every window were run; the Rust tests, Clippy and rustfmt were run. Automated tests cover the migration, membership in the active sets, conflicts, the Whisper prompt, text correction, file integrity on a write failure and a successful retry. -В отдельной нативной сборке Windows с временной конфигурацией проверены перенос личных слов, просмотр выключенного встроенного набора, сохранение независимой выключенной копии, создание включённого набора, удаление точных повторов при сохранении, пояснение коротких терминов и выбор написания при конфликте. Список проверен в обеих локалях и обеих темах. +In a separate native Windows build with a temporary configuration, the following were checked: migrating personal words, inspecting a disabled built-in set, saving an independent disabled copy, creating an enabled set, dropping exact duplicates on save, the explanation for short terms and picking a spelling on a conflict. The list was checked in both locales and both themes. -Независимое ревью выявило уход фокуса при переходе к подтверждениям; исправление прошло повторное ревью. Нативная проверка исправленных клавиатурных переходов, повторного сохранения после искусственной ошибки и состояния интерфейса после перезапуска была прервана пользователем через Escape. Эти сценарии UI, длинные названия и очень большие списки требуют дополнительной ручной проверки; macOS и распознавание реальной записи в рамках этой доработки не проверялись. +The independent review found focus being lost on the way to the confirmations; the fix passed a second review. The native check of the fixed keyboard transitions, of saving again after an artificial error, and of the interface state after a restart was interrupted by the user via Escape. Those UI scenarios, long names and very large lists need additional manual checking; macOS and recognition of a real recording were not checked as part of this work. diff --git a/scripts/build-installer.sh b/scripts/build-installer.sh index 43bdc15..cf22196 100755 --- a/scripts/build-installer.sh +++ b/scripts/build-installer.sh @@ -41,13 +41,13 @@ export SOTTO_POSTHOG_API_KEY if [[ -z "${SOTTO_POSTHOG_API_KEY:-}" ]]; then if [[ "${SOTTO_ALLOW_NO_TELEMETRY:-}" != "1" ]]; then - echo "[build-installer] нет ingest-токена PostHog." >&2 - echo "[build-installer] Положите публичный токен проекта (phc_...) в:" >&2 + echo "[build-installer] no PostHog ingest token." >&2 + echo "[build-installer] Put the project public token (phc_...) in:" >&2 echo "[build-installer] $posthog_key_file" >&2 - echo "[build-installer] Сборка без телеметрии — SOTTO_ALLOW_NO_TELEMETRY=1." >&2 + echo "[build-installer] To build without telemetry: SOTTO_ALLOW_NO_TELEMETRY=1." >&2 exit 1 fi - echo "[build-installer] SOTTO_ALLOW_NO_TELEMETRY=1 — собираем без телеметрии" + echo "[build-installer] SOTTO_ALLOW_NO_TELEMETRY=1 — building without telemetry" fi # Otherwise the build machine's paths travel into the distributed binary: rustc @@ -108,10 +108,10 @@ python check-build-paths.py "$exe" # later. The string is POSTHOG_CAPTURE_URL from telemetry.rs. if [[ "${SOTTO_ALLOW_NO_TELEMETRY:-}" != "1" ]]; then if grep -aqF "eu.i.posthog.com" "$exe"; then - echo "[build-installer] телеметрия: адрес ingest в бинаре есть" + echo "[build-installer] telemetry: the ingest address is present in the binary" else - echo "[build-installer] в бинаре нет адреса ingest PostHog:" >&2 - echo "[build-installer] телеметрия скомпилирована в no-op." >&2 + echo "[build-installer] the binary carries no PostHog ingest address:" >&2 + echo "[build-installer] telemetry compiled down to a no-op." >&2 exit 1 fi fi diff --git a/scripts/check-build-paths.py b/scripts/check-build-paths.py index 4690e40..d666e76 100755 --- a/scripts/check-build-paths.py +++ b/scripts/check-build-paths.py @@ -1,22 +1,23 @@ #!/usr/bin/env python3 -"""Проверить, что в раздаваемом артефакте нет путей сборочной машины. +"""Check that a distributed artifact carries no build machine paths. python scripts/check-build-paths.py desktop/src-tauri/target/release/Sotto.exe -Зачем. rustc вшивает `file!()` каждой зависимости в сообщения паники, а MSVC — -`__FILE__` в ассерты whisper.cpp. И то и другое — абсолютные пути той машины, -где собирали: домашний каталог с именем пользователя ОС и каталог сборки. В -раздаваемом бинаре им делать нечего. Подробности и замеры — в issue #41. +Why. rustc bakes every dependency's `file!()` into panic messages, and MSVC +bakes `__FILE__` into the whisper.cpp asserts. Both are absolute paths of the +machine that built it: the home directory carrying the OS account name, and the +build directory. Neither belongs in a distributed binary. Details and +measurements are in issue #41. -Скрипт ничего не чинит, он только ловит регрессию: флаги ремапа живут в -`scripts/build-installer.sh`, и первая же сборка мимо этого скрипта вернёт -пути обратно незамеченными. +This script fixes nothing, it only catches a regression: the remap flags live in +`scripts/build-installer.sh`, and the first build that bypasses this script +would bring the paths back unnoticed. -Ищем в сыром байтовом содержимом, а не в извлечённых строках: пути лежат и в -UTF-8, и в UTF-16, и внутри сжатых секций — построчный разбор формата PE тут -дал бы меньше, чем простой поиск подстроки. +The search runs over the raw bytes rather than extracted strings: the paths sit +in UTF-8, in UTF-16 and inside compressed sections alike, and parsing the PE +format properly would buy less here than a plain substring search. -Выход: 0 — чисто, 1 — найдены следы, 2 — файл не читается. +Exit codes: 0 clean, 1 traces found, 2 file unreadable. """ from __future__ import annotations @@ -46,10 +47,11 @@ def variants(path: Path | str) -> list[bytes]: - """Один и тот же каталог в тех видах, в каких он может лежать в бинаре. + """The same directory in every form it may take inside the binary. - Пути приезжают из разных инструментов: rustc пишет их через прямой слэш, - MSVC — через обратный, а в UTF-16-секциях каждый байт разделён нулём. + The paths arrive from different tools: rustc writes them with a forward + slash, MSVC with a backslash, and in UTF-16 sections every byte is + separated by a zero. """ text = str(path) forms = {text, text.replace("\\", "/"), text.replace("/", "\\")} @@ -61,16 +63,16 @@ def variants(path: Path | str) -> list[bytes]: def checks() -> list[tuple[str, list[bytes]]]: - """Что ищем. Порядок — от «однозначно утечка» к «след машины».""" + """What to look for, ordered from "definitely a leak" to "a trace of the machine".""" return [ # The user's home directory: it carries the OS account name. - ("домашний каталог пользователя", variants("C:\\Users\\")), + ("user home directory", variants("C:\\Users\\")), # The crate registry is the same home directory, but it has its own # reason to end up in the binary (file!() of dependencies) and its own # remap, so it gets its own check. - ("реестр cargo", variants(CARGO_HOME)), + ("cargo registry", variants(CARGO_HOME)), # The working copy: the maintainer's directory name and disk layout. - ("рабочая копия", variants(REPO_ROOT)), + ("working copy", variants(REPO_ROOT)), ] @@ -78,10 +80,10 @@ def scan(path: Path) -> int: try: data = path.read_bytes() except OSError as e: - print(f"не прочитать {path}: {e}", file=sys.stderr) + print(f"cannot read {path}: {e}", file=sys.stderr) return 2 - print(f"{path} — {len(data) / 1024 / 1024:.1f} МБ") + print(f"{path} — {len(data) / 1024 / 1024:.1f} MB") found = False for label, needles in checks(): hits = sum(len(re.findall(re.escape(n), data)) for n in needles) @@ -91,8 +93,8 @@ def scan(path: Path) -> int: if found: print( - "\nВ артефакте остались пути сборочной машины. Собирайте релиз " - "через scripts/build-installer.sh — он выставляет ремап; см. #41.", + "\nBuild machine paths are still in the artifact. Build the release " + "through scripts/build-installer.sh, which sets the remap; see #41.", file=sys.stderr, ) return 1 if found else 0 From 817008fd1af855c1b4ea46045aa8c4ab4f2ed2e5 Mon Sep 17 00:00:00 2001 From: Stofl <152594969+stofll@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:12:33 +0300 Subject: [PATCH 3/4] ci: warm the cargo cache from main instead of per pull request An Actions cache is restorable only by the ref that wrote it or by the default branch. With pull_request as the only trigger, every cache landed on refs/pull/N/merge, which no other pull request can reach, and nothing ever wrote one on main. Across 48 runs every first run on a branch logged "No cache found": 12m48-14m19 on Windows cold against 4m49-5m39 warm. Running on push to main leaves a cache later pull requests can restore, and save-if stops them writing their own. That keeps the repo under the 10 GB cache ceiling it was already over, and takes the ~2m20 cache upload off the critical path. Runs on main are not cancelled by a following merge: the cache they leave is the whole point, and a cancelled upload sends the next pull request back to a cold build. --- .github/workflows/rust-ci.yml | 37 ++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 005b8f5..b95cfe4 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -6,24 +6,46 @@ name: Rust CI # `#[cfg(target_os = ...)]` surface is only ever compiled on whichever machine # the work happened on, which is Windows. # -# Deliberately not on `push: main`. Work goes through pull requests, so the -# merge commit would check exactly the content `pull_request` just checked — -# paying twice for one answer. Use workflow_dispatch when main really does -# need a run of its own (a direct push, a revert). +# It also runs on `push: main`, and that run exists for its cargo cache rather +# than for its answer. A GitHub Actions cache can only be restored by the ref +# that wrote it or by the default branch, and a `pull_request` run writes to +# `refs/pull/N/merge` — a scope no other pull request can reach. With pull +# requests as the only trigger, nothing ever wrote a cache on main: across 48 +# runs, every first run on a branch logged `No cache found` and rebuilt the +# whole dependency graph, which cost about eight minutes on Windows (12m48 to +# 14m19 cold against 4m49 to 5m39 warm). Merging to main now leaves a cache +# every later pull request can restore from, including a partial restore +# through the prefix key when the lockfile differs. +# +# That is also why `save-if` below limits writing to main: pull requests only +# read. It keeps the repository under the 10 GB cache ceiling, which the +# per-pull-request caches were already exceeding, and it takes the cache upload +# off the critical path. # # The expensive release build is not here; it lives in release.yml and hangs # off the tag. This pipeline is CPU-only, see the comment below. on: pull_request: branches: [main] + push: + branches: [main] + # Unlike the jobs below, this filter is safe here: required status checks + # apply to pull requests, and a push to main reports to nothing. A landing + # page merge has no dependencies to warm. + paths-ignore: + - 'site/**' workflow_dispatch: # A push to a branch cancels that branch's previous, still-running run. # Otherwise every edit during review is paid for twice: the superseded run # would finish the whole three-OS matrix for an answer nobody needs. +# +# Runs on main are not cancelled. Their point is the cache they leave behind, +# and two merges in quick succession would otherwise kill the first upload and +# send the next pull request back to a cold build. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -217,6 +239,11 @@ jobs: workspaces: desktop/src-tauri -> target key: ggml-baseline-${{ hashFiles('.cargo/config.toml', 'scripts/ggml-baseline.cmake') }} cache-on-failure: false + # Only main writes; see the note on the triggers above. A pull + # request that pushes several times now restores from main each time + # instead of from its own previous run, which is a full match unless + # it changed the lockfile and a prefix restore when it did. + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 From 72f0c2783991c3cd892a70d4a7cff5f8c3007309 Mon Sep 17 00:00:00 2001 From: Stofl <152594969+stofll@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:12:56 +0300 Subject: [PATCH 4/4] docs: note that Rust CI now runs on a push to main --- docs/RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 7c372c4..7c6788f 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -52,7 +52,7 @@ An App token's tag push triggers Release automatically. Do not also call Release #### Failures and retries -- If CI is incomplete, finish it before preparing the release. If the source cannot reuse a merged PR's identical checked tree (for example, after a direct commit), run **Rust CI** and **UI tests** manually on `main` first. Preparation does not start those jobs automatically. +- If CI is incomplete, finish it before preparing the release. If the source cannot reuse a merged PR's identical checked tree (for example, after a direct commit), Rust CI runs on its own for a push to `main` that touches anything outside `site/`, but **UI tests** still has to be started manually. Preparation starts neither. - If `main` changes during preparation, start a new Prepare Release run. The push never force-updates refs: the release commit and tag are either both accepted or both rejected. - For an invalid version or missing App configuration, fix the reported problem and start again. If the push result was uncertain, inspect `main` and the tag before retrying; an already pushed tag reserves that version. - If the release build fails after tagging, rerun its failed jobs or run **Release** manually with the existing tag. The build and SBOM resolve that tag rather than the selected UI branch. Published releases cannot be rebuilt; issue a new version instead.