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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 112 additions & 107 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

313 changes: 152 additions & 161 deletions .github/workflows/rust-ci.yml

Large diffs are not rendered by default.

54 changes: 31 additions & 23 deletions .github/workflows/sbom.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
name: SBOM

# Inventory Rust and npm dependencies, native sources and archives, and licenses.
# Run when dependency inputs or this workflow change rather than on every PR.
# An inventory of what the app is built from: CycloneDX for Rust and npm,
# native sources and archives, 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 calls this after tauri-action creates the draft release, allowing
# the SBOM to persist as a release asset beyond the workflow artifact retention.
# 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]
Expand All @@ -21,7 +24,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

Expand All @@ -34,11 +37,11 @@ permissions:

jobs:
sbom:
name: Собрать SBOM и отчёт по лицензиям
name: Build the SBOM and license report
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
# Required to upload draft release assets when release_tag is provided.
# Uploads an asset to the draft release. Unused without release_tag.
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -55,7 +58,7 @@ jobs:
with:
version: 11.9.0

# The repository's rust-toolchain.toml selects the Rust version.
# The version comes from rust-toolchain.toml at the repo root.
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable branch as of 2026-09-03

Expand All @@ -73,10 +76,12 @@ jobs:
- name: Install cargo-cyclonedx
run: cargo install cargo-cyclonedx --version 0.5.9 --locked

# --target all includes platform-specific dependencies such as windows-sys,
# objc2 and ashpd, so the inventory covers more than this Linux runner.
# --all-features includes the optional GPU backends shipped in releases;
# this inspects metadata and does not build incompatible backends together.
# --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. Both flags only read metadata, so the mutually exclusive GPU
# backends are never built together.
- name: SBOM — Rust
working-directory: desktop/src-tauri
run: |
Expand All @@ -101,9 +106,10 @@ jobs:
-o "$GITHUB_WORKSPACE/sbom/sbom-npm.cdx.json" \
.

# Produce a readable license report alongside the SBOM. Strip pnpm's
# absolute runner paths because they do not belong in published artifacts.
- 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
Expand All @@ -119,31 +125,33 @@ jobs:
' "/tmp/licenses-$scope.json" "$GITHUB_WORKSPACE/sbom/licenses-npm-$scope.json"
done

# Report component and missing-license counts for review without gating.
# A missing license can reflect an uninstalled platform-specific package
# (for example, an esbuild binary for another OS), not a licensing defect.
- 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");
for (const f of ["sbom/sbom-rust.cdx.json", "sbom/sbom-npm.cdx.json"]) {
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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 }}
Expand Down
55 changes: 32 additions & 23 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -28,13 +30,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' }}
Expand All @@ -53,7 +57,12 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Resolve main once on release events; deploy uses this exact commit.
# 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. Main is resolved to a sha once,
# here, so the deploy below ships this exact commit even if main
# moves in between.
ref: ${{ github.event_name == 'release' && 'main' || github.sha }}
- id: source
working-directory: .
Expand All @@ -72,11 +81,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')
Expand All @@ -97,15 +106,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
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/ui-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions desktop/check-i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)}`);
}

Expand Down
2 changes: 1 addition & 1 deletion desktop/find-eager-t.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
5 changes: 3 additions & 2 deletions desktop/src/pages/settings/MicrophoneSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading