From 4f02d970bfa25fd0103056189f0092767040ab1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:57:03 +0000 Subject: [PATCH 1/3] fix(update): start_detached passes Windows-only flags only on Windows On Linux and macOS subprocess.Popen refuses creationflags with a ValueError, which the OSError fallback did not catch: the background install never started there and four tests failed on the Linux runner since 3af3ad5. Elsewhere the child now gets its own session instead. The builds for those systems update through the release page and never call it; only the tests do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xm2TjWChGFxTMcXbxuvAaQ --- core/updater.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/updater.py b/core/updater.py index 9e48a64..64564af 100644 --- a/core/updater.py +++ b/core/updater.py @@ -497,6 +497,9 @@ def start_detached(args: list[str]) -> subprocess.Popen: | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)) kw = dict(stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, env=child_env()) + if sys.platform != "win32": + # creationflags is Windows-only (Popen raises ValueError elsewhere). + return subprocess.Popen(args, start_new_session=True, **kw) try: return subprocess.Popen(args, creationflags=flags | 0x01000000, **kw) except OSError: From bbd63fc7f221bb933062cda80877e48e3e075c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:48:40 +0000 Subject: [PATCH 2/3] tools(build): Windows release builds on GitHub Actions, signed through SignPath SignPath Foundation signs only artifacts built by a GitHub Actions run of this repository, so the Windows files of a release now come from the build workflow instead of a local build: - build.yml, job "windows": the tests, the programs (build_installer.py --portable --only programs), a signing request for them, the installer from the signed files (--only installer), a silent per-user install and a 20-second start check, a signing request for the installer, a signature check. Signing is on while the repository has the variable SIGNPATH_ORGANIZATION_ID; without it the same files are built unsigned. - installer/signpath/: the two artifact configurations and the set-up and release steps. - release.py uploads the CI-built Windows files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xm2TjWChGFxTMcXbxuvAaQ --- .github/workflows/build.yml | 193 ++++++++++++++++++++++++++++++- .github/workflows/tests.yml | 4 +- ARCHITECTURE.md | 23 +++- docs/engineering-notes.md | 29 +++++ installer/signpath/README.md | 176 ++++++++++++++++++++++++++++ installer/signpath/installer.xml | 12 ++ installer/signpath/programs.xml | 24 ++++ tests/test_updater.py | 4 +- tools/build_installer.py | 33 ++++-- tools/release.py | 16 +-- 10 files changed, 485 insertions(+), 29 deletions(-) create mode 100644 installer/signpath/README.md create mode 100644 installer/signpath/installer.xml create mode 100644 installer/signpath/programs.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ae30ec..e6d0cd1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,12 @@ -# Linux and macOS builds. PyInstaller does not cross-compile, so each is -# made on its own runner by tools/build_unix.py, then STARTED: the build is -# kept only if the program is still running 20 seconds later, and a screen -# shot of it is kept with the build (artifacts). Windows builds are made by -# hand with tools/build_installer.py. +# The release builds. PyInstaller does not cross-compile, so each system is +# built on its own runner, then STARTED: the build is kept only if the +# program is still running 20 seconds later, and a screen shot of it is kept +# with the build (artifacts). +# +# windows tools/build_installer.py — the installer and the two portable +# exes, signed by SignPath Foundation (see the job) +# linux tools/build_unix.py — the AppImage +# macos tools/build_unix.py — ModbusReader.app in a zip # # Runs for a version tag and by hand (Actions → build → Run workflow). name: build @@ -16,6 +20,185 @@ permissions: contents: read jobs: + windows: + # Code signing (https://modbusreader.ru/code-signing/): SignPath signs + # only what this job built from the tagged source, and only after a + # person approved the request on app.signpath.io — twice per build: + # 1. the programs: the one-folder app, the launcher, the portable exes + # (the installer must carry them signed, so it is built after); + # 2. the installer. + # On while the repository has the variable SIGNPATH_ORGANIZATION_ID + # (and the secret SIGNPATH_API_TOKEN); without it the same files are + # built unsigned. installer/signpath/ holds the two artifact + # configurations and the set-up steps. + runs-on: windows-latest + timeout-minutes: 150 # mostly waiting for the two approvals + permissions: + contents: read + actions: read # SignPath fetches the unsigned artifacts + env: + PYTHONUTF8: "1" + SIGNPATH_POLICY: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG || 'release-signing' }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + + - name: Dependencies (the pinned versions) + run: python -m pip install -r requirements-dev.txt + + # The style of installer/ModbusReader.iss needs a current Inno Setup, + # whatever the runner image carries. + - name: Inno Setup + run: choco upgrade innosetup --no-progress -y + + - name: Tests + run: python -m pytest -q -p no:cacheprovider + + - name: Build the programs + run: python tools/build_installer.py --portable --only programs + + # Four files, three of them named ModbusReader.exe: one folder each. + # installer/signpath/programs.xml lists the same paths. + - name: Collect the programs for signing + if: vars.SIGNPATH_ORGANIZATION_ID != '' + shell: pwsh + run: | + $s = "signing/programs" + New-Item -ItemType Directory -Force "$s/program", "$s/launcher", "$s/portable" | Out-Null + Copy-Item dist/ModbusReader/ModbusReader.exe "$s/program/" + Copy-Item build/launcher/ModbusReader.exe "$s/launcher/" + Copy-Item dist/ModbusReader.exe, dist/ModbusReader_debug.exe "$s/portable/" + + - id: unsigned-programs + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: actions/upload-artifact@v7 + with: + name: unsigned-programs + path: signing/programs + retention-days: 1 + overwrite: true + + - name: Sign the programs (approve on app.signpath.io) + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG || 'ModbusReader' }} + signing-policy-slug: ${{ env.SIGNPATH_POLICY }} + artifact-configuration-slug: programs + github-artifact-id: ${{ steps.unsigned-programs.outputs.artifact-id }} + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: signing/programs-signed + + - name: Put the signed programs in place + if: vars.SIGNPATH_ORGANIZATION_ID != '' + shell: pwsh + run: | + $s = "signing/programs-signed" + Copy-Item -Force "$s/program/ModbusReader.exe" dist/ModbusReader/ + Copy-Item -Force "$s/launcher/ModbusReader.exe" build/launcher/ + Copy-Item -Force "$s/portable/ModbusReader.exe", "$s/portable/ModbusReader_debug.exe" dist/ + + - name: Build the installer + run: python tools/build_installer.py --only installer + + # The whole way a user goes: install for the current user (no rights + # needed), start the launcher, which starts the version folder. + - name: Install it and start it + shell: pwsh + run: | + $setup = Start-Process dist/ModbusReader-Setup.exe -PassThru -Wait -ArgumentList ` + '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER', '/NOLAUNCH' + if ($setup.ExitCode) { Write-Output "::error::the installer exited with $($setup.ExitCode)"; exit 1 } + $root = "$env:LOCALAPPDATA\Programs\ModbusReader" + Start-Process "$root\ModbusReader.exe" + Start-Sleep 20 + try { + Add-Type -AssemblyName System.Windows.Forms, System.Drawing + $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height + [System.Drawing.Graphics]::FromImage($bmp).CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size) + $bmp.Save("$PWD\dist\screen-windows.png") + } catch { Write-Output "no screen shot: $_" } + $app = Get-Process ModbusReader -ErrorAction SilentlyContinue | + Where-Object { $_.Path -like "$root\app-*" } + if (-not $app) { Write-Output "::error::the program exited"; exit 1 } + $app | Stop-Process -Force + $log = "$env:APPDATA\ModbusReader\modbusreader_errors.log" + if ((Test-Path $log) -and (Get-Item $log).Length) { Get-Content $log; exit 1 } + + - id: unsigned-installer + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: actions/upload-artifact@v7 + with: + name: unsigned-installer + path: dist/ModbusReader-Setup.exe + retention-days: 1 + overwrite: true + + - name: Sign the installer (approve on app.signpath.io) + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG || 'ModbusReader' }} + signing-policy-slug: ${{ env.SIGNPATH_POLICY }} + artifact-configuration-slug: installer + github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }} + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: signing/installer-signed + + - name: Put the signed installer in place + if: vars.SIGNPATH_ORGANIZATION_ID != '' + run: Copy-Item -Force signing/installer-signed/ModbusReader-Setup.exe dist/ + + # Each file with its signature and SHA-256 (what GitHub will show for + # the release asset). When signing is on, a file without a valid + # signature fails the build; SignPath's test-signing policy signs with + # a certificate Windows does not trust, so then it only has to be there. + - name: Signatures + shell: pwsh + env: + SIGNING: ${{ vars.SIGNPATH_ORGANIZATION_ID != '' }} + run: | + $bad = 0 + foreach ($f in 'dist/ModbusReader-Setup.exe', 'dist/ModbusReader.exe', 'dist/ModbusReader_debug.exe', + 'dist/ModbusReader/ModbusReader.exe', 'build/launcher/ModbusReader.exe') { + $sig = Get-AuthenticodeSignature $f + $hash = (Get-FileHash $f -Algorithm SHA256).Hash.ToLower() + '{0,-36} {1,-13} {2} {3}' -f $f, $sig.Status, $hash, $sig.SignerCertificate.Subject + $ok = if ($env:SIGNPATH_POLICY -eq 'test-signing') { $sig.SignerCertificate -and $sig.Status -ne 'HashMismatch' } + else { $sig.Status -eq 'Valid' } + if ($env:SIGNING -eq 'true' -and -not $ok) { $bad++ } + } + if ($bad) { Write-Output "::error::$bad file(s) without a valid signature"; exit 1 } + + - uses: actions/upload-artifact@v7 + with: + name: windows + # All in dist/, so the artifact holds them without a folder. + path: | + dist/ModbusReader-Setup.exe + dist/ModbusReader.exe + dist/ModbusReader_debug.exe + dist/screen-windows.png + if-no-files-found: error + retention-days: 30 + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: windows-failed + path: dist/screen-windows.png + if-no-files-found: ignore + retention-days: 30 + linux: # The oldest supported Ubuntu: an AppImage runs on systems with the same # or a NEWER glibc than the one it was built on, never an older one. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4a9c5c1..d9c7fd7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,5 @@ -# The test suite on Linux and macOS (Windows is where the releases are built -# and tested by hand). Each run also photographs every screen headless +# The test suite on Linux and macOS (on Windows it runs in the release build, +# .github/workflows/build.yml). Each run also photographs every screen headless # (tools/ui_snapshots.py) and keeps the pictures as an artifact, to see how # the window looks with that system's fonts. # diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 435a99f..4091deb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -429,9 +429,20 @@ pictures, from the demo project). | `installer/ModbusReader.iss` | the installer (Inno Setup 6) | | `ModbusReader_unix.spec` | Linux and macOS: the program as a folder / `ModbusReader.app` | -Windows builds are made locally by `tools/build_installer.py`. Linux and -macOS builds cannot be (PyInstaller does not cross-compile): -`.github/workflows/build.yml` runs `tools/build_unix.py` on an Ubuntu 22.04 +Release builds come from `.github/workflows/build.yml`, one runner per +system (PyInstaller does not cross-compile). **Windows**: the tests, then +`tools/build_installer.py` in two halves — `--portable --only programs` +(the one-folder program, the launcher, the portable exes) and +`--only installer` (ISCC) — with a SignPath signing request after each, so +the installer carries signed files and is signed itself; then a silent +per-user install, a start of the launcher and the same 20-second check as +below. SignPath Foundation signs only artifacts of this workflow, after a +person approves each request; the job signs while the repository has the +variable `SIGNPATH_ORGANIZATION_ID` and builds the same files unsigned +without it (`installer/signpath/` — the artifact configurations and the +set-up; the public policy is `docs/code-signing.md`). A local +`tools/build_installer.py` build is for testing: it is never signed. +**Linux and macOS**: `tools/build_unix.py` on an Ubuntu 22.04 runner — `ModbusReader-x86_64.AppImage`, built on the oldest supported glibc so it runs on newer ones — and a macOS arm64 runner — `ModbusReader-macOS-arm64.zip`, ad-hoc signed, not notarized. Each build is @@ -471,7 +482,9 @@ commit passes the test suite. ## 11. Known limitations - Polling reads one register per request; block reads are not implemented yet. -- Builds are not code-signed, so Windows SmartScreen warns on first - installation. +- Windows builds are signed only once SignPath Foundation has accepted the + project; until then, and for a new version with few downloads, Windows + SmartScreen warns on first installation. Linux and macOS builds are not + signed (macOS: ad-hoc only). - `ModernScadaApp` is still the largest module and the natural next candidate for splitting (project handling, overlay management). diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 673381b..7c33901 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -1063,3 +1063,32 @@ page, menu, dialog and settings page headless for the next review. stale TCP answers, dead links) plus the core/statistics/scanner/raw-console paths; `test_library_contracts` guards that pymodbus never returns. Still to do: try it on real adapters (FTDI latency, cheap CH340 echo). +- **Code signing through SignPath Foundation** (2026-10-26, the user's + call: free signing for open source, the certificate is issued to SignPath + Foundation, which is then the publisher in the signature; the copyright + stays with the author). SignPath signs only an artifact uploaded by a + GitHub Actions run of this repository, so the Windows release files are + now built by `.github/workflows/build.yml` (job `windows`), not locally. + Two signing requests per build, each approved by hand on app.signpath.io: + the programs first (the one-folder `ModbusReader.exe`, the launcher, the + two portable exes — three files named ModbusReader.exe, hence one folder + each in the zip, `installer/signpath/programs.xml`), because Inno Setup + must pack them signed (Smart App Control and antivirus look at every + exe, not only the download); then the installer + (`installer/signpath/installer.xml`). `tools/build_installer.py --only + programs|installer` splits the build for that. The uninstaller + (`unins000.exe`, written by Setup at install time) stays unsigned: that + would take Inno's `SignTool` calling SignPath in the middle of ISCC, and + nothing downloads it. Third-party DLLs (Python, Qt) are not signed with + the certificate. Everything is gated on the repository variable + `SIGNPATH_ORGANIZATION_ID`: without it the job builds the same files + unsigned, so releases come from CI already before SignPath accepts the + project. A file without a valid signature fails the job (test-signing + policy: present is enough). The job also runs the test suite on Windows + (the launcher tests run only there) and installs the built Setup per + user (`/VERYSILENT /CURRENTUSER /NOLAUNCH`), starts the launcher and + checks after 20 s that the version folder's program runs and wrote no + error log. The policy SignPath requires on the home page is + `docs/code-signing.md`: roles, what is signed, how to verify, and the + privacy statement — the update check is the only traffic the app starts + by itself. diff --git a/installer/signpath/README.md b/installer/signpath/README.md new file mode 100644 index 0000000..dbceda3 --- /dev/null +++ b/installer/signpath/README.md @@ -0,0 +1,176 @@ +# Подпись кода через SignPath Foundation + +SignPath Foundation бесплатно подписывает сборки открытых проектов. Сертификат +выдан на SignPath Foundation: в свойствах файла издателем будет указан +SignPath Foundation. Права на программу остаются у автора. Публичная часть — +[политика подписи кода](https://modbusreader.ru/code-signing/) +(`docs/code-signing.md`), её SignPath требует на сайте проекта. + +Как устроена сборка: `.github/workflows/build.yml`, задание `windows`. Оно +отправляет в SignPath два запроса на подпись: + +| Запрос | Конфигурация | Что подписывается | +|---|---|---| +| 1 | [`programs.xml`](programs.xml), slug `programs` | программа (`dist/ModbusReader/ModbusReader.exe`), запускающий файл установленной копии, портативные `ModbusReader.exe` и `ModbusReader_debug.exe` | +| 2 | [`installer.xml`](installer.xml), slug `installer` | `ModbusReader-Setup.exe`, собранный уже из подписанных файлов | + +Пока в репозитории нет переменной `SIGNPATH_ORGANIZATION_ID`, задание собирает +те же файлы без подписи. + +## 1. До заявки + +1. Слейте в `main` изменения с этой папкой. Сайт опубликуется сам, и + страница https://modbusreader.ru/code-signing/ должна открываться. +2. Проверьте сборку на GitHub Actions: **Actions → build → Run workflow** + (ветка `main`). Через 15–20 минут в запуске появится артефакт `windows` с + `ModbusReader-Setup.exe`, портативными exe и снимком экрана. Установите + этот `ModbusReader-Setup.exe` у себя и убедитесь, что всё работает так же, + как с локальной сборкой. Отличие от локальной сборки одно: на GitHub нет + UPX, поэтому exe не сжаты им (файлы чуть больше, зато антивирусы меньше + придираются). +3. Выпускайте версии из этой сборки (см. раздел 5) — ещё до подписи. + SignPath подписывает только проекты, которые уже выпускаются в том виде, + в каком будут подписываться. +4. Включите двухфакторную аутентификацию на GitHub: **Settings → Password + and authentication → Two-factor authentication**. SignPath требует её у + всех, кто вносит код и утверждает подпись. + +## 2. Заявка + +На https://signpath.org откройте **Apply** (условия — https://signpath.org/terms) +и заполните форму. Данные для неё: + +| Поле | Значение | +|---|---| +| Project name | ModbusReader | +| Repository | https://github.com/Mercurrial/ModbusReader | +| Homepage | https://modbusreader.ru/ | +| Download page | https://github.com/Mercurrial/ModbusReader/releases/latest | +| Code signing policy | https://modbusreader.ru/code-signing/ | +| License | GPL-3.0-or-later | +| Build system | GitHub Actions (GitHub-hosted runners), `.github/workflows/build.yml` | +| What to sign | Windows PE files: an Inno Setup installer and PyInstaller executables, a small C# launcher | + +Описание проекта (на английском): + +> ModbusReader is a free, open-source desktop tool for commissioning and +> monitoring Modbus RTU and Modbus TCP devices: a register map with names +> and units, live trends, an HMI-style dashboard, a bus scanner, an RS-485 +> sniffer, Python macros and a Modbus slave simulator. Windows, Linux and +> macOS; Russian and English UI. All components are open source (see +> THIRD_PARTY_LICENSES.md). Windows releases are built by GitHub Actions +> from the tagged source; we would like to sign the installer and the +> executables so that users stop seeing SmartScreen's "unknown publisher" +> warning. + +Если спросят про состав сборки: все библиотеки открытые +(`THIRD_PARTY_LICENSES.md`); загрузчик PyInstaller — GPL-2.0 с исключением +для загрузчика; Inno Setup — открытый исходный код +(https://github.com/jrsoftware/issrc) под собственной разрешительной лицензией. + +Ответ придёт на почту — обычно через несколько дней, иногда через несколько недель. + +## 3. Настройка в SignPath + +После одобрения придёт приглашение на https://app.signpath.io. Войдите и +включите двухфакторную аутентификацию в своём профиле. + +Названия пунктов меню могут немного отличаться — ориентируйтесь на смысл. +Обычно SignPath Foundation сама создаёт проект, политики подписи +`test-signing` и `release-signing` и подключает GitHub как доверенную систему +сборки. Проверьте и дополните: + +1. **Projects → ModbusReader.** Запомните **slug** проекта. Если он не + `ModbusReader`, его нужно будет указать в GitHub (раздел 4). В настройках + проекта должен стоять репозиторий + `https://github.com/Mercurrial/ModbusReader`, в **Trusted Build Systems** + — GitHub.com. +2. **Artifact Configurations → Add**, две штуки: + - slug `programs`, содержимое — файл [`programs.xml`](programs.xml) + целиком; + - slug `installer`, содержимое — файл [`installer.xml`](installer.xml). +3. **Signing Policies.** В `release-signing` утверждающим (Approver) должны + быть вы. Тот, кто отправляет запросы (Submitter), — пользователь из + следующего шага. +4. **Users → Add CI user** (например, `github-actions`), затем **Generate API + token** — скопируйте токен сразу, второй раз его не покажут. Добавьте + этого пользователя в **Submitters** обеих политик (`test-signing` и + `release-signing`). +5. **ID организации** — в **Settings** организации (Organization ID, вида + `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) или в адресе страницы + `app.signpath.io/Web//…`. + +## 4. Настройка в GitHub + +Репозиторий → **Settings → Secrets and variables → Actions**: + +- вкладка **Secrets → New repository secret**: + `SIGNPATH_API_TOKEN` — токен из шага 3.4; +- вкладка **Variables → New repository variable**: + `SIGNPATH_ORGANIZATION_ID` — ID организации из шага 3.5. С этой переменной + подпись включается; +- только если slug проекта не `ModbusReader`: переменная + `SIGNPATH_PROJECT_SLUG`. + +### Пробный запуск + +1. Добавьте переменную `SIGNPATH_SIGNING_POLICY_SLUG` = `test-signing`. +2. **Actions → build → Run workflow** (ветка `main`). +3. Задание дойдёт до шага «Sign the programs» и будет ждать (до часа). На + app.signpath.io в **Signing Requests** появится запрос: откройте его и + нажмите **Approve**, если политика требует утверждения. Затем то же для + «Sign the installer». +4. Шаг «Signatures» покажет каждый файл с подписью. Тестовый сертификат + Windows не доверяет — для пробы этого достаточно. +5. Удалите переменную `SIGNPATH_SIGNING_POLICY_SLUG`: без неё используется + `release-signing`. + +## 5. Выпуск версии + +```bash +python tools/release.py # номер версии, CHANGELOG +python -m pytest +git commit -am "release: ГГ.ММ.N" +git tag -a vГГ.ММ.N -m "ModbusReader ГГ.ММ.N" +git push origin main vГГ.ММ.N +``` + +Тег запускает **Actions → build**. Задание `windows` дважды остановится и +будет ждать утверждения на app.signpath.io: сначала программы, потом +установщик. SignPath пришлёт письмо о каждом запросе. На каждое утверждение +есть час; если не успели, перезапустите задание (**Re-run failed jobs**). + +Когда все три задания (`windows`, `linux`, `macos`) зелёные, скачайте файлы и +загрузите их в выпуск. Команды печатает `tools/release.py`: + +```bash +gh run download <номер запуска> -D dist/ci -n windows -n linux -n macos +gh release upload <текущий тег> --clobber dist/ci/windows/ModbusReader-Setup.exe ... +gh release edit <текущий тег> --tag vГГ.ММ.N --title "ModbusReader ГГ.ММ.N" --notes-file <заметки> +``` + +Перед загрузкой проверьте подпись: **Свойства → Цифровые подписи → SignPath +Foundation** у `ModbusReader-Setup.exe`. + +## 6. После первого подписанного выпуска + +- Поправьте тексты про SmartScreen: теперь программа подписана, а + предупреждение может появиться только у совсем новой версии. Тексты лежат + в `README.md` («Windows может предупредить»), `docs/install.md` и + `docs/install.en.md` (раздел `#smartscreen`), `docs/troubleshooting.md` и + `docs/troubleshooting.en.md`, а также в строке «Known limitations» в + `ARCHITECTURE.md`. +- По желанию отправляйте `ModbusReader-Setup.exe` каждой новой версии в + Microsoft: https://www.microsoft.com/wdsi/filesubmission → Software + developer → Microsoft Defender SmartScreen. Так предупреждение для новой + версии пропадёт быстрее. + +## Чего не делать + +- Не подписывайте этим сертификатом чужие файлы и не собирайте файлы выпуска + на своём компьютере: SignPath подписывает только то, что собрал GitHub + Actions из этого репозитория. +- Не добавляйте в программу закрытые компоненты, рекламу или незаявленную + передачу данных. Это нарушит условия SignPath Foundation, и она может + отозвать подпись. Если программа начнёт передавать что-то по сети сама, + сначала допишите это в раздел «Конфиденциальность» политики. diff --git a/installer/signpath/installer.xml b/installer/signpath/installer.xml new file mode 100644 index 0000000..f97cee5 --- /dev/null +++ b/installer/signpath/installer.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/installer/signpath/programs.xml b/installer/signpath/programs.xml new file mode 100644 index 0000000..2f0876d --- /dev/null +++ b/installer/signpath/programs.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_updater.py b/tests/test_updater.py index d0852b4..5aacf92 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -743,7 +743,9 @@ def test_release_tool_moves_the_one_release_to_the_new_tag(): text = out.getvalue() assert applied and applied[0][0] == "99.12.0" assert 'git tag -a v99.12.0' in text and "git push origin main v99.12.0" in text - up = text.index("gh release upload --clobber dist/ModbusReader-Setup.exe") + # The Windows files are the ones CI built (and signed), not a local build. + assert "build_installer.py" not in text + up = text.index("gh release upload --clobber dist/ci/windows/ModbusReader-Setup.exe") ed = text.index("gh release edit --tag v99.12.0") assert up < ed assert "release create" not in text and "--delete" not in text and "tag -d" not in text diff --git a/tools/build_installer.py b/tools/build_installer.py index 868e7a7..c92301f 100644 --- a/tools/build_installer.py +++ b/tools/build_installer.py @@ -3,6 +3,8 @@ python tools/build_installer.py installer only python tools/build_installer.py --portable + the one-file exes + python tools/build_installer.py --portable --only programs + python tools/build_installer.py --only installer 1. PyInstaller, ModbusReader_setup.spec → dist/ModbusReader/ (one folder) 2. The launcher, installer/launcher/Launcher.cs → build/launcher/ModbusReader.exe @@ -13,6 +15,11 @@ 4. (--portable) ModbusReader.spec + ModbusReader_debug.spec → dist/*.exe 5. Prints each file with its size and SHA-256. +"--only programs" stops before 3, "--only installer" is 3 alone, packing +the program and launcher already in dist/ and build/. Between the two the +release build (.github/workflows/build.yml) has the exes signed, so the +installer carries signed files and is signed itself afterwards. + The release itself (tag vX.Y.Z, not a pre-release) must carry exactly these asset names — updater.py picks the build by name, and the installer's name never changes, so one link always downloads the latest: @@ -99,6 +106,9 @@ def environment_drift(wanted: dict) -> list[str]: def main(): portable = "--portable" in sys.argv + only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv[:-1] else "" + if only not in ("", "programs", "installer"): + sys.exit(f"--only {only}: expected programs or installer") # Only the tested versions may be shipped: a build from a drifted # environment would carry libraries no test has seen. drift = environment_drift(pins(os.path.join(ROOT, "requirements-dev.txt"))) @@ -106,24 +116,29 @@ def main(): sys.exit("The environment differs from the pinned versions:\n " + "\n ".join(drift) + "\nRun: python -m pip install -r requirements-dev.txt") - iscc = find_iscc() - if not iscc: + iscc = find_iscc() if only != "programs" else "" + if only != "programs" and not iscc: sys.exit("Inno Setup (ISCC.exe) not found — install it first: " "winget install JRSoftware.InnoSetup") py = sys.executable - run([py, "-m", "PyInstaller", "--noconfirm", "ModbusReader_setup.spec"]) - import build_launcher - build_launcher.build() - run([iscc, f"/DAppVersion={APP_VERSION}", os.path.join("installer", "ModbusReader.iss")]) - outputs = [os.path.join(ROOT, "dist", "ModbusReader-Setup.exe")] - if portable: + outputs = [] + if only != "installer": + run([py, "-m", "PyInstaller", "--noconfirm", "ModbusReader_setup.spec"]) + import build_launcher + outputs += [os.path.join(ROOT, "dist", "ModbusReader", "ModbusReader.exe"), + build_launcher.build()] + if only != "programs": + run([iscc, f"/DAppVersion={APP_VERSION}", os.path.join("installer", "ModbusReader.iss")]) + outputs += [os.path.join(ROOT, "dist", "ModbusReader-Setup.exe")] + if portable and only != "installer": for spec in ("ModbusReader.spec", "ModbusReader_debug.spec"): run([py, "-m", "PyInstaller", "--noconfirm", spec]) outputs += [os.path.join(ROOT, "dist", n) for n in ("ModbusReader.exe", "ModbusReader_debug.exe")] print() for p in outputs: - print(f"{os.path.basename(p):34} {os.path.getsize(p) / 1_048_576:7.1f} MB {sha256(p)}") + name = os.path.relpath(p, ROOT).replace(os.sep, "/") + print(f"{name:36} {os.path.getsize(p) / 1_048_576:7.1f} MB {sha256(p)}") if __name__ == "__main__": diff --git a/tools/release.py b/tools/release.py index 3fb592b..c576325 100644 --- a/tools/release.py +++ b/tools/release.py @@ -34,10 +34,12 @@ REPO_URL = "https://github.com/Mercurrial/ModbusReader" UNRELEASED = "Не выпущено" -ASSETS = ("dist/ModbusReader-Setup.exe", "dist/ModbusReader.exe", - "dist/ModbusReader_debug.exe", - # Built by CI for the tag (.github/workflows/build.yml), downloaded - # with "gh run download -D dist/ci": +# All built by CI for the tag (.github/workflows/build.yml) — the Windows +# files signed there, which a local build cannot be — and downloaded with +# "gh run download -D dist/ci -n windows -n linux -n macos": +ASSETS = ("dist/ci/windows/ModbusReader-Setup.exe", + "dist/ci/windows/ModbusReader.exe", + "dist/ci/windows/ModbusReader_debug.exe", "dist/ci/linux/dist/ModbusReader-x86_64.AppImage", "dist/ci/macos/dist/ModbusReader-macOS-arm64.zip") _VERSION_RE = re.compile(r"^\d{2}\.(?:[1-9]|1[0-2])\.\d+$") @@ -127,10 +129,10 @@ def main(): print(" python -m pytest") print(f' git commit -am "release: {version}"') print(f' git tag -a v{version} -m "ModbusReader {version}"') - print(" python tools/build_installer.py --portable") print(f" git push origin main v{version}") - print(" (the tag starts the Linux and macOS builds: Actions → build)") - print(" gh run download -D dist/ci") + print(" (the tag starts the builds: Actions → build; the Windows one waits") + print(" until both of its signing requests are approved on app.signpath.io)") + print(" gh run download -D dist/ci -n windows -n linux -n macos") # ONE release: replace its files, THEN move it to the new tag. In that # order a copy never sees the new number next to the old build (it would # install the old one and be offered the update again). The old tag stays. From d3517ceddb910b55b09378d0c37cda6ea52a062f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:48:40 +0000 Subject: [PATCH 3/3] docs: code signing policy for SignPath Foundation The page SignPath requires on the project site: attribution, what is signed, how a signed file is made, team roles, how to check a signature and the privacy statement (the update check is the only traffic the app starts by itself). Linked from the README, SECURITY.md and the changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xm2TjWChGFxTMcXbxuvAaQ --- CHANGELOG.md | 5 +++ README.md | 16 ++++++- SECURITY.md | 9 +++- docs/code-signing.en.md | 95 +++++++++++++++++++++++++++++++++++++++++ docs/code-signing.md | 93 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 6 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 docs/code-signing.en.md create mode 100644 docs/code-signing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbfc31..a47ee0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,11 @@ неверной CRC, оборванный или искажённый кадр учитывается как ошибка CRC, а не как таймаут; эхо адаптера RS-485 и помехи перед ответом обрабатываются; ответ читается сразу, как только он полностью пришёл. +- Файлы для Windows собирает GitHub Actions из исходного кода выпуска, а не + компьютер разработчика: так их сможет подписывать SignPath Foundation + (бесплатная подпись для открытых проектов). Что подписывается и какие + данные программа передаёт по сети — в + [политике подписи кода](https://modbusreader.ru/code-signing/). ### Исправлено - Демо-проект в английском интерфейсе показывал русские единицы, подписи diff --git a/README.md b/README.md index 1c76023..177563c 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,11 @@ Linux / macOS: те же шаги (`source .venv/bin/activate`, `python main.py` `ModbusReader.exe` / `ModbusReader_debug.exe` (с консолью). Сведения о версии exe берутся из `app/version.py`; иконка и заставка — `tools/make_icon.py`, `tools/make_splash.py`. + +Локальная сборка — для проверки. Файлы выпуска собирает GitHub Actions +(`.github/workflows/build.yml`): тесты, сборка в два этапа (`--only programs`, +затем `--only installer`), подпись через SignPath после каждого этапа, пробная +установка и запуск. Настройка подписи — [installer/signpath/](installer/signpath/README.md).
@@ -310,7 +315,8 @@ CHANGELOG в раздел выпуска со ссылкой на него. В репозитории один выпуск — всегда последний: новая версия заменяет в нём файлы `ModbusReader-Setup.exe`, `ModbusReader.exe` и `ModbusReader_debug.exe` -и переносит его на свой тег `vX.Y.Z` (не pre-release). Теги всех версий +(подписанные, из сборки GitHub Actions для тега) и переносит его на свой тег +`vX.Y.Z` (не pre-release). Теги всех версий остаются в истории, а выпуск — один. Скачанное программа сверяет с SHA-256, который GitHub считает при загрузке файла. @@ -377,6 +383,14 @@ ModbusReader бесплатен и таким останется. Если пр вопросы по работе с программой — в [Discussions](https://github.com/Mercurrial/ModbusReader/discussions). О найденных уязвимостях сообщайте по правилам [SECURITY.md](SECURITY.md). +## Подпись кода + +Бесплатная подпись кода предоставлена [SignPath.io](https://about.signpath.io/), +сертификат — [SignPath Foundation](https://signpath.org/). Файлы для Windows +собирает GitHub Actions из исходного кода выпуска; что подписывается, кто +утверждает выпуски и какие данные программа передаёт по сети — в +[политике подписи кода](https://modbusreader.ru/code-signing/). + ## Лицензия ModbusReader — свободная программа: её можно использовать (в том числе в diff --git a/SECURITY.md b/SECURITY.md index f6a093c..3744d14 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,10 @@ GitHub и устанавливает файл, лишь если его SHA-256 совпадает с контрольной суммой, которую GitHub публикует для файла выпуска. Любой обход этой проверки — уязвимость. +- **Подпись выпусков.** Файлы для Windows подписываются сертификатом + SignPath Foundation только после сборки в GitHub Actions и ручного + утверждения ([политика подписи кода](https://modbusreader.ru/code-signing/)). + Способ получить такую подпись в обход этой сборки — уязвимость. - **Открытие файлов.** Карты регистров (`.json`), проекты (`.mbproj`), дашборды (`.mbdash`), снимки (`.mbsnap`) и импортируемые таблицы могут прийти от третьих лиц. Выполнение кода или запись за пределами выбранного @@ -50,5 +54,6 @@ - Протокол Modbus RTU не предусматривает аутентификации и шифрования; любой, кто подключён к линии RS-485, может читать и записывать регистры. Это свойство протокола, а не программы. -- Предупреждение Windows SmartScreen при первом запуске: сборки пока не - подписаны сертификатом разработчика. +- Предупреждение Windows SmartScreen при первом запуске: неподписанные + выпуски (до подключения подписи) и только что вышедшая версия, у которой + ещё мало скачиваний. diff --git a/docs/code-signing.en.md b/docs/code-signing.en.md new file mode 100644 index 0000000..4e0f18f --- /dev/null +++ b/docs/code-signing.en.md @@ -0,0 +1,95 @@ +--- +seo_title: ModbusReader code signing policy +description: How the Windows builds of ModbusReader are signed — free code signing by SignPath.io, certificate by SignPath Foundation. What is signed, who approves releases, how to check a signature and what the app sends over the network. +--- + +# Code signing policy + +Free code signing provided by [SignPath.io](https://about.signpath.io/), +certificate by [SignPath Foundation](https://signpath.org/). + +## What is signed + +The Windows files of a release on the +[Releases](https://github.com/Mercurrial/ModbusReader/releases/latest) page: + +- `ModbusReader-Setup.exe` — the installer; +- `ModbusReader.exe` and `ModbusReader_debug.exe` — the portable builds; +- inside the installer — the program itself and the `ModbusReader.exe` that + starts it. + +Only files built from the source code of the +[ModbusReader repository](https://github.com/Mercurrial/ModbusReader) are +signed. The libraries of other projects that the build contains (Python, Qt +and others — see +[THIRD_PARTY_LICENSES.md](https://github.com/Mercurrial/ModbusReader/blob/main/THIRD_PARTY_LICENSES.md)) +are not signed with this certificate. The Linux and macOS builds are not +signed. + +Releases built after signing was set up are signed; older ones have no +"Digital Signatures" tab in the file's properties. + +## How a signed file is made + +1. A release is a tag `vYY.MM.N` in the repository. +2. GitHub Actions builds the files from the source code of that tag + ([`.github/workflows/build.yml`](https://github.com/Mercurrial/ModbusReader/blob/main/.github/workflows/build.yml)), + not a developer's computer. +3. SignPath verifies that the files come from that very build of this + repository and signs them only after a release approver has approved the + request by hand. + +The certificate's private key is kept by SignPath in a hardware security +module (HSM): the developers of the app do not have it and cannot sign +anything with it outside the build. + +## Team roles + +| Role | Members | +|---|---| +| Committers and reviewers | [Mercurrial](https://github.com/Mercurrial) | +| Approvers | [Mercurrial](https://github.com/Mercurrial) | + +Changes from other contributors (pull requests) get into the code only after +a reviewer has checked them. Everyone listed above uses two-factor +authentication on GitHub and on SignPath. + +## How to check a signature + +Right-click the file → **Properties → Digital Signatures**. The list should +show a signature by **SignPath Foundation**, and **Details** should say "This +digital signature is OK". + +The same in PowerShell: + +```powershell +Get-AuthenticodeSignature .\ModbusReader-Setup.exe | Format-List Status, SignerCertificate +``` + +`Status` should be `Valid`. If there is no signature or Windows says it is +not valid, do not run the file and +[report it](https://github.com/Mercurrial/ModbusReader/security/advisories/new). + +For a brand-new version Windows SmartScreen may still show a warning for a +while, until the version has been downloaded enough; it names SignPath +Foundation as the publisher. What to do — see [Install](install.md#smartscreen). + +## Privacy policy + +This program will not transfer any information to other networked systems +unless specifically requested by the user or the person installing or +operating it. The one thing it does by itself is check for updates: + +- at start-up and every few hours the program asks GitHub + (`api.github.com`) for the latest release and, if a new version is out, + downloads its file from GitHub. The request carries only the program's + version; GitHub, like any website, sees the computer's IP address. How + GitHub handles that data is described in its + [privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement); +- the check can be turned off: **Settings → General → Updates**, the + "Update automatically" switch. + +Everything else — connecting to a device over Modbus TCP, looking for +devices on the network with the scanner, opening pages in the browser — the +program does only when the user tells it to. It sends no telemetry, usage +statistics or error reports. diff --git a/docs/code-signing.md b/docs/code-signing.md new file mode 100644 index 0000000..858199c --- /dev/null +++ b/docs/code-signing.md @@ -0,0 +1,93 @@ +--- +seo_title: Политика подписи кода ModbusReader +description: Как подписываются сборки ModbusReader для Windows — бесплатная подпись SignPath.io, сертификат SignPath Foundation. Что подписывается, кто утверждает выпуски, как проверить подпись и какие данные программа передаёт по сети. +--- + +# Политика подписи кода + +Бесплатная подпись кода предоставлена [SignPath.io](https://about.signpath.io/), +сертификат — [SignPath Foundation](https://signpath.org/). + +## Что подписывается + +Файлы выпуска для Windows со страницы +[Releases](https://github.com/Mercurrial/ModbusReader/releases/latest): + +- `ModbusReader-Setup.exe` — установщик; +- `ModbusReader.exe` и `ModbusReader_debug.exe` — портативные версии; +- внутри установщика — сама программа и запускающий её `ModbusReader.exe`. + +Подписываются только файлы, собранные из исходного кода +[репозитория ModbusReader](https://github.com/Mercurrial/ModbusReader). +Библиотеки других проектов, которые входят в сборку (Python, Qt и другие — +см. [THIRD_PARTY_LICENSES.md](https://github.com/Mercurrial/ModbusReader/blob/main/THIRD_PARTY_LICENSES.md)), +этой подписью не подписываются. Сборки для Linux и macOS не подписываются. + +Подписаны выпуски, собранные после подключения подписи; у более ранних +вкладки «Цифровые подписи» в свойствах файла нет. + +## Как получается подписанный файл + +1. Выпуск — это тег `vГГ.ММ.N` в репозитории. +2. Файлы собирает GitHub Actions из исходного кода этого тега + ([`.github/workflows/build.yml`](https://github.com/Mercurrial/ModbusReader/blob/main/.github/workflows/build.yml)), + а не компьютер разработчика. +3. SignPath проверяет, что файлы получены именно этой сборкой из этого + репозитория, и подписывает их только после того, как запрос вручную + утвердил ответственный за выпуски. + +Закрытый ключ сертификата хранится у SignPath в аппаратном модуле (HSM): +у разработчиков программы его нет, и подписать им что-то в обход сборки +нельзя. + +## Кто отвечает + +| Роль | Участники | +|---|---| +| Авторы и рецензенты кода | [Mercurrial](https://github.com/Mercurrial) | +| Утверждают подпись выпусков | [Mercurrial](https://github.com/Mercurrial) | + +Изменения от других участников (pull request) попадают в код только после +проверки рецензентом. У всех, кто указан в таблице, включена двухфакторная +аутентификация на GitHub и в SignPath. + +## Как проверить подпись + +Щёлкните файл правой кнопкой мыши → **«Свойства» → «Цифровые подписи»**. +В списке должна быть подпись **SignPath Foundation**, а кнопка +**«Подробно»** покажет «Эта цифровая подпись действительна». + +То же в PowerShell: + +```powershell +Get-AuthenticodeSignature .\ModbusReader-Setup.exe | Format-List Status, SignerCertificate +``` + +`Status` должен быть `Valid`. Если подписи нет или Windows пишет, что она +недействительна, не запускайте файл и +[сообщите об этом](https://github.com/Mercurrial/ModbusReader/security/advisories/new). + +Для совсем новой версии Windows SmartScreen может ещё какое-то время +показывать предупреждение, пока у неё мало скачиваний; издателем в нём +будет указан SignPath Foundation. Что делать — в разделе +[Установка](install.md#smartscreen). + +## Конфиденциальность + +Программа не передаёт никаких сведений другим компьютерам в сети, если об +этом не попросил пользователь или тот, кто её устанавливает и настраивает. +Единственное, что она делает сама, — проверяет обновления: + +- при запуске и раз в несколько часов программа запрашивает у GitHub + (`api.github.com`) сведения о последнем выпуске, а если вышла новая + версия — скачивает её файл с GitHub. В запросе передаётся только номер + версии программы; GitHub, как любой сайт, видит IP-адрес компьютера. Как + GitHub обращается с этими данными, описано в его + [заявлении о конфиденциальности](https://docs.github.com/ru/site-policy/privacy-policies/github-general-privacy-statement); +- проверку можно выключить: **«Настройки» → «Общие» → «Обновления»**, + переключатель «Обновлять автоматически». + +Всё остальное — подключение к прибору по Modbus TCP, поиск приборов в сети +сканером, открытие страниц в браузере — программа делает только по команде +пользователя. Телеметрию, статистику использования и отчёты об ошибках она не +отправляет. diff --git a/mkdocs.yml b/mkdocs.yml index 490214e..c68d473 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -210,3 +210,4 @@ nav: - Помощь: - troubleshooting.md - contributing.md + - code-signing.md