From 507a229dd4501cf30d67c9a5993cb0e1876ae9b6 Mon Sep 17 00:00:00 2001 From: lifestreamy Date: Thu, 24 Sep 2026 01:06:49 +0300 Subject: [PATCH] docs: app-palette badges, brew blocks and doc cleanups Badges render from the release tag: scripts/cd/render_badges.py + tests. --- README.en.md | 26 ++- README.md | 26 ++- assets/badges/apt.svg | 13 ++ assets/badges/brew.svg | 13 ++ assets/badges/lang-en-muted.svg | 11 + assets/badges/lang-en.svg | 11 + assets/badges/lang-ru-muted.svg | 11 + assets/badges/lang-ru.svg | 11 + assets/badges/license.svg | 13 ++ assets/badges/pip.svg | 13 ++ assets/badges/python.svg | 13 ++ assets/badges/release.svg | 13 ++ assets/badges/uv.svg | 13 ++ assets/badges/winget-en.svg | 13 ++ assets/badges/winget-ru.svg | 13 ++ docs/dev/RELEASE.en.md | 6 + docs/dev/RELEASE.md | 6 + docs/dev/publishing/brew.md | 15 ++ docs/dev/publishing/winget.md | 15 ++ docs/user/PLANNED.en.md | 3 +- docs/user/PLANNED.md | 3 +- docs/user/SETUP.en.md | 2 +- packaging/badges/badge-lang.svg | 11 + packaging/badges/badge.svg | 13 ++ .../tests/test_render_badges_script.py | 130 ++++++++++++ scripts/cd/render_badges.py | 192 ++++++++++++++++++ 26 files changed, 588 insertions(+), 21 deletions(-) create mode 100644 assets/badges/apt.svg create mode 100644 assets/badges/brew.svg create mode 100644 assets/badges/lang-en-muted.svg create mode 100644 assets/badges/lang-en.svg create mode 100644 assets/badges/lang-ru-muted.svg create mode 100644 assets/badges/lang-ru.svg create mode 100644 assets/badges/license.svg create mode 100644 assets/badges/pip.svg create mode 100644 assets/badges/python.svg create mode 100644 assets/badges/release.svg create mode 100644 assets/badges/uv.svg create mode 100644 assets/badges/winget-en.svg create mode 100644 assets/badges/winget-ru.svg create mode 100644 packaging/badges/badge-lang.svg create mode 100644 packaging/badges/badge.svg create mode 100644 python-client/tests/test_render_badges_script.py create mode 100644 scripts/cd/render_badges.py diff --git a/README.en.md b/README.en.md index 944c1ac..2e50b83 100644 --- a/README.en.md +++ b/README.en.md @@ -1,5 +1,5 @@ -[![Русский](https://img.shields.io/badge/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9-00a693?style=flat)](README.md) -[![English](https://img.shields.io/badge/English-808080?style=flat)](README.en.md) +[![Русский](assets/badges/lang-ru-muted.svg)](README.md) +[![English](assets/badges/lang-en.svg)](README.en.md)

xrayvpn @@ -7,10 +7,14 @@ # Xray Reality VPN Server — deployment -[![Release](https://img.shields.io/github/v/release/lifestreamy/xrayvpn?style=flat&label=release&color=00a693)](https://github.com/lifestreamy/xrayvpn/releases/latest) -[![PyPI](https://img.shields.io/pypi/v/xrayvpn?style=flat&label=pypi&color=00a693)](https://pypi.org/project/xrayvpn/) -[![Python](https://img.shields.io/badge/python-3.12%2B-00a693?style=flat&logo=python&logoColor=white)](python-client/README.en.md) -[![License](https://img.shields.io/badge/license-AGPL--3.0%20%2B%20commercial-00a693?style=flat)](LICENSE) +[![release v0.4.1](assets/badges/release.svg?v=0.4.1)](https://github.com/lifestreamy/xrayvpn/releases/latest) +[![brew v0.4.1](assets/badges/brew.svg?v=0.4.1)](https://github.com/lifestreamy/homebrew-tap) +[![winget soon](assets/badges/winget-en.svg)](https://github.com/microsoft/winget-pkgs/pull/440001) +[![apt v0.4.1](assets/badges/apt.svg?v=0.4.1)](https://lifestreamy.github.io/xrayvpn/apt/) +[![pip v0.4.1](assets/badges/pip.svg?v=0.4.1)](https://pypi.org/project/xrayvpn/) +[![uv tool v0.4.1](assets/badges/uv.svg?v=0.4.1)](https://pypi.org/project/xrayvpn/) +[![python 3.12+](assets/badges/python.svg)](python-client/README.en.md) +[![license AGPL-3.0](assets/badges/license.svg)](LICENSE) > VLESS Xray Reality with an optional Cloudflare WARP outbound. A personal VPN on your own VPS > running Ubuntu/Debian — no domain to buy, just an IP and the root password. @@ -87,7 +91,7 @@ VPN stopped — start with [`docs/user/RUNBOOK.en.md`](docs/user/RUNBOOK.en.md). | Path | What you need | How | Comment | |---|---|---|---| | Portable app | one downloaded file, nothing to install | download from [Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) and run it (double-click on Windows), type `deploy` | console app | -| Package managers | a package manager | `pip install xrayvpn` (Python 3.12+), apt (Ubuntu 24.04 / Debian 12) | one-command install | +| Package managers | a package manager | `pip install xrayvpn` (Python 3.12+), brew (macOS Apple Silicon), apt (Ubuntu 24.04 / Debian 12) | one-command install | | From the repository | `uv` (installs Python 3.12+) | `uv run --project python-client xrayvpn deploy` | running from source | | Shell wrappers | Linux/WSL (bash) or Windows+WSL (PowerShell) | `provision-vpn.sh` / `Provision-VPN.ps1` | maintained, not developed | | Ansible directly | ansible-core 2.14+ and `community.general` | `ansible-playbook -i inventory.yml deploy.yml` | for techies | @@ -101,7 +105,13 @@ access to it — a password or a key. Install guide — [`docs/user/SETUP.en.md` **Windows:** download `xrayvpn--windows-x64-portable.exe` from [Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) and double-click it. -**macOS (Apple Silicon):** download `xrayvpn--macos-arm64-portable`: +**macOS (Apple Silicon)** — Homebrew: + +```bash +brew install lifestreamy/tap/xrayvpn +``` + +Or download `xrayvpn--macos-arm64-portable`: ```bash chmod +x xrayvpn-*-macos-arm64-portable diff --git a/README.md b/README.md index b166797..33cb023 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![English](https://img.shields.io/badge/English-808080?style=flat)](README.en.md) -[![Русский](https://img.shields.io/badge/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9-00a693?style=flat)](README.md) +[![English](assets/badges/lang-en-muted.svg)](README.en.md) +[![Русский](assets/badges/lang-ru.svg)](README.md)

xrayvpn @@ -7,10 +7,14 @@ # Xray Reality VPN Server — развёртывание -[![Release](https://img.shields.io/github/v/release/lifestreamy/xrayvpn?style=flat&label=release&color=00a693)](https://github.com/lifestreamy/xrayvpn/releases/latest) -[![PyPI](https://img.shields.io/pypi/v/xrayvpn?style=flat&label=pypi&color=00a693)](https://pypi.org/project/xrayvpn/) -[![Python](https://img.shields.io/badge/python-3.12%2B-00a693?style=flat&logo=python&logoColor=white)](python-client/README.md) -[![License](https://img.shields.io/badge/license-AGPL--3.0%20%2B%20commercial-00a693?style=flat)](LICENSE) +[![release v0.4.1](assets/badges/release.svg?v=0.4.1)](https://github.com/lifestreamy/xrayvpn/releases/latest) +[![brew v0.4.1](assets/badges/brew.svg?v=0.4.1)](https://github.com/lifestreamy/homebrew-tap) +[![winget скоро](assets/badges/winget-ru.svg)](https://github.com/microsoft/winget-pkgs/pull/440001) +[![apt v0.4.1](assets/badges/apt.svg?v=0.4.1)](https://lifestreamy.github.io/xrayvpn/apt/) +[![pip v0.4.1](assets/badges/pip.svg?v=0.4.1)](https://pypi.org/project/xrayvpn/) +[![uv tool v0.4.1](assets/badges/uv.svg?v=0.4.1)](https://pypi.org/project/xrayvpn/) +[![python 3.12+](assets/badges/python.svg)](python-client/README.md) +[![license AGPL-3.0](assets/badges/license.svg)](LICENSE) > VLESS Xray Reality + опциональный исходящий Cloudflare WARP. Личный VPN-сервер на своём VPS > Ubuntu/Debian без покупки домена: достаточно IP и пароля root. @@ -85,7 +89,7 @@ | Путь | Что нужно | Как | Комментарий | |---|---|---|---| | Портативное приложение | один скачанный файл, ставить ничего не надо | скачать из [Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) и запустить (Windows — двойной клик), набрать `deploy` | консольное приложение | -| Пакетные менеджеры | менеджер пакетов | `pip install xrayvpn` (Python 3.12+), apt (Ubuntu 24.04 / Debian 12) | установка одной командой | +| Пакетные менеджеры | менеджер пакетов | `pip install xrayvpn` (Python 3.12+), brew (macOS Apple Silicon), apt (Ubuntu 24.04 / Debian 12) | установка одной командой | | Из репозитория | `uv` (поставит Python 3.12+) | `uv run --project python-client xrayvpn deploy` | запуск из исходников | | Shell-обёртки | Linux/WSL (bash) или Windows+WSL (PowerShell) | `provision-vpn.sh` / `Provision-VPN.ps1` | поддержка без развития | | Ansible напрямую | ansible-core 2.14+ и `community.general` | `ansible-playbook -i inventory.yml deploy.yml` | для технарей | @@ -99,7 +103,13 @@ SSH-доступ к нему — пароль или ключ. Инструкц **Windows:** скачайте `xrayvpn-<версия>-windows-x64-portable.exe` из [Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) и запустите двойным кликом. -**macOS (Apple Silicon):** скачайте `xrayvpn-<версия>-macos-arm64-portable`: +**macOS (Apple Silicon)** — Homebrew: + +```bash +brew install lifestreamy/tap/xrayvpn +``` + +Или скачайте `xrayvpn-<версия>-macos-arm64-portable`: ```bash chmod +x xrayvpn-*-macos-arm64-portable diff --git a/assets/badges/apt.svg b/assets/badges/apt.svg new file mode 100644 index 0000000..12df230 --- /dev/null +++ b/assets/badges/apt.svg @@ -0,0 +1,13 @@ + + apt v0.4.1 + + + + + + + + + apt + v0.4.1 + \ No newline at end of file diff --git a/assets/badges/brew.svg b/assets/badges/brew.svg new file mode 100644 index 0000000..5fd34d0 --- /dev/null +++ b/assets/badges/brew.svg @@ -0,0 +1,13 @@ + + brew v0.4.1 + + + + + + + + + brew + v0.4.1 + \ No newline at end of file diff --git a/assets/badges/lang-en-muted.svg b/assets/badges/lang-en-muted.svg new file mode 100644 index 0000000..033340f --- /dev/null +++ b/assets/badges/lang-en-muted.svg @@ -0,0 +1,11 @@ + + English + + + + + + + + English + \ No newline at end of file diff --git a/assets/badges/lang-en.svg b/assets/badges/lang-en.svg new file mode 100644 index 0000000..dac55d2 --- /dev/null +++ b/assets/badges/lang-en.svg @@ -0,0 +1,11 @@ + + English + + + + + + + + English + \ No newline at end of file diff --git a/assets/badges/lang-ru-muted.svg b/assets/badges/lang-ru-muted.svg new file mode 100644 index 0000000..efb1a9c --- /dev/null +++ b/assets/badges/lang-ru-muted.svg @@ -0,0 +1,11 @@ + + Русский + + + + + + + + Русский + \ No newline at end of file diff --git a/assets/badges/lang-ru.svg b/assets/badges/lang-ru.svg new file mode 100644 index 0000000..b1763f4 --- /dev/null +++ b/assets/badges/lang-ru.svg @@ -0,0 +1,11 @@ + + Русский + + + + + + + + Русский + \ No newline at end of file diff --git a/assets/badges/license.svg b/assets/badges/license.svg new file mode 100644 index 0000000..f99632d --- /dev/null +++ b/assets/badges/license.svg @@ -0,0 +1,13 @@ + + license AGPL-3.0 + + + + + + + + + license + AGPL-3.0 + \ No newline at end of file diff --git a/assets/badges/pip.svg b/assets/badges/pip.svg new file mode 100644 index 0000000..749b74c --- /dev/null +++ b/assets/badges/pip.svg @@ -0,0 +1,13 @@ + + pip v0.4.1 + + + + + + + + + pip + v0.4.1 + \ No newline at end of file diff --git a/assets/badges/python.svg b/assets/badges/python.svg new file mode 100644 index 0000000..f9a94a2 --- /dev/null +++ b/assets/badges/python.svg @@ -0,0 +1,13 @@ + + python 3.12+ + + + + + + + + + python + 3.12+ + \ No newline at end of file diff --git a/assets/badges/release.svg b/assets/badges/release.svg new file mode 100644 index 0000000..be17a0d --- /dev/null +++ b/assets/badges/release.svg @@ -0,0 +1,13 @@ + + release v0.4.1 + + + + + + + + + release + v0.4.1 + \ No newline at end of file diff --git a/assets/badges/uv.svg b/assets/badges/uv.svg new file mode 100644 index 0000000..57bbd21 --- /dev/null +++ b/assets/badges/uv.svg @@ -0,0 +1,13 @@ + + uv tool v0.4.1 + + + + + + + + + uv tool + v0.4.1 + \ No newline at end of file diff --git a/assets/badges/winget-en.svg b/assets/badges/winget-en.svg new file mode 100644 index 0000000..1010d7d --- /dev/null +++ b/assets/badges/winget-en.svg @@ -0,0 +1,13 @@ + + winget soon + + + + + + + + + winget + soon + \ No newline at end of file diff --git a/assets/badges/winget-ru.svg b/assets/badges/winget-ru.svg new file mode 100644 index 0000000..035f733 --- /dev/null +++ b/assets/badges/winget-ru.svg @@ -0,0 +1,13 @@ + + winget скоро + + + + + + + + + winget + скоро + \ No newline at end of file diff --git a/docs/dev/RELEASE.en.md b/docs/dev/RELEASE.en.md index 6f610fd..c4a6a61 100644 --- a/docs/dev/RELEASE.en.md +++ b/docs/dev/RELEASE.en.md @@ -38,6 +38,12 @@ echo "deb [signed-by=/usr/share/keyrings/xrayvpn-archive-keyring.gpg] https://li sudo apt update && sudo apt install xrayvpn ``` +Homebrew (macOS, Apple Silicon): + +```bash +brew install lifestreamy/tap/xrayvpn +``` + ## CHANGELOG - Kept per shipped version; entries are added by the release commit; format — Added / Changed / Fixed / Removed. \ No newline at end of file diff --git a/docs/dev/RELEASE.md b/docs/dev/RELEASE.md index 1d261bb..9483d65 100644 --- a/docs/dev/RELEASE.md +++ b/docs/dev/RELEASE.md @@ -39,6 +39,12 @@ echo "deb [signed-by=/usr/share/keyrings/xrayvpn-archive-keyring.gpg] https://li sudo apt update && sudo apt install xrayvpn ``` +Homebrew (macOS, Apple Silicon): + +```bash +brew install lifestreamy/tap/xrayvpn +``` + ## CHANGELOG - Ведётся по выпущенным версиям; записи вносит релизный коммит; формат — Added / Changed / Fixed / Removed. \ No newline at end of file diff --git a/docs/dev/publishing/brew.md b/docs/dev/publishing/brew.md index e5344f3..3421d92 100644 --- a/docs/dev/publishing/brew.md +++ b/docs/dev/publishing/brew.md @@ -25,6 +25,21 @@ The `xrayvpn` formula in the tap `lifestreamy/homebrew-tap`, so users install wi Secret: `BREW_DEPLOY_KEY` (write deploy key of the tap repository). +## Tap trust + +Since Homebrew 6.0 a third-party tap is not loaded until it is trusted. The fully qualified +command trusts exactly one formula, so it needs no extra step: + +``` +brew install lifestreamy/tap/xrayvpn +``` + +The short form works only after an explicit trust: `brew tap lifestreamy/tap`, then +`brew trust --formula lifestreamy/tap/xrayvpn` (or `brew trust lifestreamy/tap` for the whole tap), +and only then `brew install xrayvpn`. + +The formula covers macOS arm64 only (`depends_on arch: :arm64`); Homebrew on Linux is not covered. + ## Repair Re-run the pipeline with the released tag and approve it. The formula can also be pushed by hand: diff --git a/docs/dev/publishing/winget.md b/docs/dev/publishing/winget.md index 7188809..3a69d68 100644 --- a/docs/dev/publishing/winget.md +++ b/docs/dev/publishing/winget.md @@ -61,6 +61,21 @@ If `wingetcreate submit` fails in the pipeline, the same command can be run loca wingetcreate.exe submit ``` +## URL validation + +The validation pipeline checks the URLs in the manifest set — metadata URLs (`PublisherUrl`, +`PublisherSupportUrl`, `PackageUrl`, `LicenseUrl`, `ReleaseNotesUrl`) and the installer URL — and +fails the URL step with a `URL-Validation-Error` when one of them answers with an HTTP error; the +comment names the URL and the status code, and the domain step that follows is skipped. A publisher +site that is down or answers through a broken origin fails this way. The check runs from Azure IP +ranges, so a server that blocks them fails it too. + +Fix the URL and re-run the validation; no manifest change is needed when the fix is on the site +side. `@wingetbot run` is a moderator command — it re-runs the validation and strips the transient +labels — so the re-run is requested from a moderator. The validation service also retries failed +steps on its own schedule: a failed run is picked up again within a few hours, the transient labels +are dropped, and the retry re-checks every URL. + ## Verify and remove ``` diff --git a/docs/user/PLANNED.en.md b/docs/user/PLANNED.en.md index bad5e87..b4f87e7 100644 --- a/docs/user/PLANNED.en.md +++ b/docs/user/PLANNED.en.md @@ -2,7 +2,6 @@ What's coming next. The order can change. -- **Visual client.** The deploy wizard and everyday commands in one menu: deploy, status, logs, +- **Visual client.** The deploy wizard and regular commands in one menu: deploy, status, logs, rotation. Russian and English. -- **Status and recovery.** Service state, recent logs and a restart from the client. - **WARP.** Multiple outbounds and endpoint rotation. \ No newline at end of file diff --git a/docs/user/PLANNED.md b/docs/user/PLANNED.md index 05ca024..d91055f 100644 --- a/docs/user/PLANNED.md +++ b/docs/user/PLANNED.md @@ -2,7 +2,6 @@ Что появится дальше. Порядок может меняться. -- **Визуальный клиент.** Мастер развёртывания и повседневные команды в одном меню: деплой, +- **Визуальный клиент.** Мастер развёртывания и обычные команды в одном меню: деплой, статус, логи, ротация. Русский и английский интерфейс. -- **Статус и восстановление.** Состояние сервиса, последние логи и перезапуск из клиента. - **WARP.** Несколько исходящих и смена точки подключения. \ No newline at end of file diff --git a/docs/user/SETUP.en.md b/docs/user/SETUP.en.md index 40e805d..89c1ac0 100644 --- a/docs/user/SETUP.en.md +++ b/docs/user/SETUP.en.md @@ -1,6 +1,6 @@ # SETUP — install and configure -A console application: everything for the first run and everyday use. Technical details live in the neighbouring files. +A console application: everything for the first run and regular use. Technical details live in the neighbouring files. Requirements: a VPS — fresh Ubuntu 20.04+ or Debian 11+, root or sudo, a public IP; on Windows for local mode — WSL2 with Ubuntu/Debian. ## Contents diff --git a/packaging/badges/badge-lang.svg b/packaging/badges/badge-lang.svg new file mode 100644 index 0000000..4abff93 --- /dev/null +++ b/packaging/badges/badge-lang.svg @@ -0,0 +1,11 @@ + + {{ALT}} + + + + + + + + {{TEXT}} + \ No newline at end of file diff --git a/packaging/badges/badge.svg b/packaging/badges/badge.svg new file mode 100644 index 0000000..cf6960b --- /dev/null +++ b/packaging/badges/badge.svg @@ -0,0 +1,13 @@ + + {{ALT}} + + + + + + + + + {{LABEL}} + {{VALUE}} + \ No newline at end of file diff --git a/python-client/tests/test_render_badges_script.py b/python-client/tests/test_render_badges_script.py new file mode 100644 index 0000000..a580c8c --- /dev/null +++ b/python-client/tests/test_render_badges_script.py @@ -0,0 +1,130 @@ +"""Tests for scripts/cd/render_badges.py (CD glue, README badges).""" + +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "cd" / "render_badges.py" +sys.path.insert(0, str(_SCRIPT.parent)) +_spec = importlib.util.spec_from_file_location("render_badges", _SCRIPT) +assert _spec and _spec.loader +badges_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(badges_mod) + +_ROOT = _SCRIPT.parents[2] +_NAMES = { + "release.svg", + "brew.svg", + "winget-ru.svg", + "winget-en.svg", + "apt.svg", + "pip.svg", + "uv.svg", + "python.svg", + "license.svg", + "lang-ru.svg", + "lang-ru-muted.svg", + "lang-en.svg", + "lang-en-muted.svg", +} + + +def test_channel_badges_winget_soon() -> None: + badges = {row[0]: row for row in badges_mod.channel_badges("0.4.1", False)} + assert badges["release.svg"][2] == "v0.4.1" + assert badges["winget-ru.svg"][2] == "скоро" + assert badges["winget-en.svg"][2] == "soon" + assert badges["winget-ru.svg"][3] == "warn" + + +def test_channel_badges_winget_live() -> None: + badges = {row[0]: row for row in badges_mod.channel_badges("0.4.1", True)} + assert badges["winget-ru.svg"][2] == "v0.4.1" + assert badges["winget-en.svg"][2] == "v0.4.1" + assert badges["winget-ru.svg"][3] == "accent" + + +def test_segment_width_fits_text() -> None: + for text in ("release", "v0.4.1", "Русский", "AGPL-3.0"): + assert badges_mod.segment_width(text, 400) > badges_mod.text_width(text, 400) + 16 + assert badges_mod.segment_width(text, 700) > badges_mod.text_width(text, 700) + 16 + + +def test_shade_darkens_palette_colors() -> None: + assert badges_mod.shade("#00D587") == "#00B371" + assert badges_mod.shade("#FFFFFF", 0.5) == "#808080" + + +def test_badge_files_complete() -> None: + files = badges_mod.badge_files(_ROOT, "0.4.1", False) + assert {path.name for path in files} == _NAMES + + +def test_badge_files_use_palette() -> None: + palette = badges_mod.load_palette(_ROOT) + files = badges_mod.badge_files(_ROOT, "0.4.1", False) + release = files[Path("release.svg")].decode() + winget = files[Path("winget-ru.svg")].decode() + python = files[Path("python.svg")].decode() + assert palette["accent"] in release + assert palette["warn"] in winget + assert palette["muted"] in python + assert "url(#g)" in release + + +def test_language_badges_pick_tone() -> None: + palette = badges_mod.load_palette(_ROOT) + files = badges_mod.badge_files(_ROOT, "0.4.1", False) + active = files[Path("lang-ru.svg")].decode() + inactive = files[Path("lang-ru-muted.svg")].decode() + assert palette["accent"] in active + assert palette["muted"] in inactive + assert "Русский" in active + + +def test_two_segment_geometry() -> None: + template = badges_mod.read_template(_ROOT / "packaging" / "badges" / "badge.svg") + gradient = ("#00D587", "#00B371") + svg = badges_mod.render_two_segment( + template, "uv tool", "v0.4.1", gradient, badges_mod.INK, "uv tool v0.4.1" + ) + total = int(re.search(r'width="(\d+)"', svg).group(1)) + label_width = int(re.search(r']*font-weight="700"', svg).group(1)) + assert value_x == label_width + 8 + assert label_x + badges_mod.text_width("uv tool", 400) <= label_width - 8 + 0.5 + assert value_x + badges_mod.text_width("v0.4.1", 700) <= total - 8 + 0.5 + + +def test_rendered_files_are_lf_and_resolved() -> None: + files = badges_mod.badge_files(_ROOT, "0.4.1", False) + for path, data in files.items(): + assert b"\r\n" not in data, path + assert b"{{" not in data, path + assert b"" in data, path + + +def test_main_writes_files(tmp_path: Path) -> None: + code = badges_mod.main(["--tag", "v0.4.1", "--out", str(tmp_path)]) + assert code == 0 + assert {path.name for path in tmp_path.iterdir()} == _NAMES + expected = badges_mod.badge_files(_ROOT, "0.4.1", False)[Path("release.svg")] + assert (tmp_path / "release.svg").read_bytes() == expected + + +def test_main_winget_live(tmp_path: Path) -> None: + badges_mod.main(["--tag", "v0.4.1", "--winget-live", "--out", str(tmp_path)]) + winget = (tmp_path / "winget-ru.svg").read_text(encoding="utf-8") + assert "v0.4.1" in winget + assert "скоро" not in winget + + +def test_main_rejects_plain_tag(tmp_path: Path) -> None: + with pytest.raises(SystemExit): + badges_mod.main(["--tag", "0.4.1", "--out", str(tmp_path)]) \ No newline at end of file diff --git a/scripts/cd/render_badges.py b/scripts/cd/render_badges.py new file mode 100644 index 0000000..c621683 --- /dev/null +++ b/scripts/cd/render_badges.py @@ -0,0 +1,192 @@ +"""Render the README badge set into assets/badges from the release tag and channel states. + +Consumed by the docs finalize wave and by channel-state changes: reads the templates +under packaging/badges and the palette from assets/color-tokens.json, then writes +self-contained SVG badges with their own background and border, readable on both +GitHub themes. +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path + +from release_common import normalized_version +from render_channels import read_template, substitute + +PAD = 8.0 +RADIUS = 4 +INK = "#0B1F16" +INK_NEUTRAL = "#161B22" + +# Advance widths of Arial-compatible 12px text, measured in Chromium on 2026-09-26. +WIDTHS_400 = { + "0": 6.67, "1": 6.67, "2": 6.67, "3": 6.67, "4": 6.67, "5": 6.67, + "6": 6.67, "7": 6.67, "8": 6.67, "9": 6.67, + "a": 6.67, "b": 6.67, "c": 6.0, "d": 6.67, "e": 6.67, "f": 3.33, "g": 6.67, + "h": 6.67, "i": 2.67, "j": 2.67, "k": 6.0, "l": 2.67, "m": 10.0, "n": 6.67, + "o": 6.67, "p": 6.67, "q": 6.67, "r": 4.0, "s": 6.0, "t": 3.33, "u": 6.67, + "v": 6.0, "w": 8.67, "x": 6.0, "y": 6.0, "z": 6.0, + "A": 8.0, "B": 8.0, "C": 8.67, "D": 8.67, "E": 8.0, "F": 7.33, "G": 9.33, + "H": 8.67, "I": 3.33, "J": 6.0, "K": 8.0, "L": 6.67, "M": 10.0, "N": 8.67, + "O": 9.33, "P": 8.0, "Q": 9.33, "R": 8.67, "S": 8.0, "T": 7.33, "U": 8.67, + "V": 8.0, "W": 11.33, "X": 8.0, "Y": 8.0, "Z": 7.33, + " ": 3.33, ".": 3.33, "-": 4.0, "+": 7.01, + "Р": 8.0, "у": 6.0, "с": 6.0, "к": 5.25, "и": 6.7, "й": 6.7, "о": 6.67, "р": 6.67, +} +WIDTHS_700 = { + "0": 6.67, "1": 6.67, "2": 6.67, "3": 6.67, "4": 6.67, "5": 6.67, + "6": 6.67, "7": 6.67, "8": 6.67, "9": 6.67, + "a": 6.67, "b": 7.33, "c": 6.67, "d": 7.33, "e": 6.67, "f": 4.0, "g": 7.33, + "h": 7.33, "i": 3.33, "j": 3.33, "k": 6.67, "l": 3.33, "m": 10.67, "n": 7.33, + "o": 7.33, "p": 7.33, "q": 7.33, "r": 4.67, "s": 6.67, "t": 4.0, "u": 7.33, + "v": 6.67, "w": 9.33, "x": 6.67, "y": 6.67, "z": 6.0, + "A": 8.67, "B": 8.67, "C": 8.67, "D": 8.67, "E": 8.0, "F": 7.33, "G": 9.33, + "H": 8.67, "I": 3.33, "J": 6.67, "K": 8.67, "L": 7.33, "M": 10.0, "N": 8.67, + "O": 9.33, "P": 8.0, "Q": 9.33, "R": 8.67, "S": 8.0, "T": 7.33, "U": 8.67, + "V": 8.0, "W": 11.33, "X": 8.0, "Y": 8.0, "Z": 7.33, + " ": 3.33, ".": 3.33, "-": 4.0, "+": 7.01, + "Р": 8.0, "у": 6.67, "с": 6.67, "к": 6.01, "и": 7.38, "й": 7.38, "о": 7.33, "р": 7.33, +} +DEFAULTS = {400: 7.0, 700: 7.5} + + +def text_width(text: str, weight: int) -> float: + table = WIDTHS_400 if weight == 400 else WIDTHS_700 + return sum(table.get(char, DEFAULTS[weight]) for char in text) + + +def segment_width(text: str, weight: int) -> int: + return math.ceil(text_width(text, weight) * 1.02 + 2 * PAD) + + +def shade(hex_color: str, factor: float = 0.84) -> str: + channels = (int(hex_color[index : index + 2], 16) for index in (1, 3, 5)) + return "#" + "".join(f"{round(channel * factor):02X}" for channel in channels) + + +def load_palette(root: Path) -> dict[str, str]: + raw = (root / "assets" / "color-tokens.json").read_text(encoding="utf-8") + return json.loads(raw) + + +def render_two_segment( + template: str, label: str, value: str, gradient: tuple[str, str], ink: str, alt: str +) -> str: + label_width = segment_width(label, 400) + value_width = segment_width(value, 700) + total = label_width + value_width + return substitute( + template, + { + "W": str(total), + "IW": str(total - 1), + "X2": str(label_width), + "ARCS": str(total - 1 - RADIUS), + "XRIGHT": str(total - 1), + "X2T": str(label_width + int(PAD)), + "LABEL": label, + "VALUE": value, + "G1": gradient[0], + "G2": gradient[1], + "INK": ink, + "ALT": alt, + }, + ) + + +def render_single_segment( + template: str, text: str, gradient: tuple[str, str], ink: str, alt: str +) -> str: + total = segment_width(text, 700) + return substitute( + template, + { + "W": str(total), + "IW": str(total - 1), + "TEXT": text, + "G1": gradient[0], + "G2": gradient[1], + "INK": ink, + "ALT": alt, + }, + ) + + +def channel_badges(version: str, winget_live: bool) -> list[tuple[str, str, str, str, str]]: + tag = f"v{version}" + winget_value_ru = tag if winget_live else "скоро" + winget_value_en = tag if winget_live else "soon" + winget_tone = "accent" if winget_live else "warn" + return [ + ("release.svg", "release", tag, "accent", f"release {tag}"), + ("brew.svg", "brew", tag, "accent", f"brew {tag}"), + ("winget-ru.svg", "winget", winget_value_ru, winget_tone, f"winget {winget_value_ru}"), + ("winget-en.svg", "winget", winget_value_en, winget_tone, f"winget {winget_value_en}"), + ("apt.svg", "apt", tag, "accent", f"apt {tag}"), + ("pip.svg", "pip", tag, "accent", f"pip {tag}"), + ("uv.svg", "uv tool", tag, "accent", f"uv tool {tag}"), + ("python.svg", "python", "3.12+", "muted", "python 3.12+"), + ("license.svg", "license", "AGPL-3.0", "muted", "license AGPL-3.0"), + ] + + +def language_badges() -> list[tuple[str, str, str]]: + return [ + ("lang-ru.svg", "Русский", "accent"), + ("lang-ru-muted.svg", "Русский", "muted"), + ("lang-en.svg", "English", "accent"), + ("lang-en-muted.svg", "English", "muted"), + ] + + +def badge_files(root: Path, version: str, winget_live: bool) -> dict[Path, bytes]: + templates = root / "packaging" / "badges" + palette = load_palette(root) + gradients = { + tone: (palette[tone], shade(palette[tone])) for tone in ("accent", "warn", "muted") + } + inks = {"accent": INK, "warn": INK, "muted": INK_NEUTRAL} + two_segment = read_template(templates / "badge.svg") + single_segment = read_template(templates / "badge-lang.svg") + files: dict[Path, bytes] = {} + for name, label, value, tone, alt in channel_badges(version, winget_live): + rendered = render_two_segment( + two_segment, label, value, gradients[tone], inks[tone], alt + ) + files[Path(name)] = rendered.encode("utf-8") + for name, text, tone in language_badges(): + rendered = render_single_segment( + single_segment, text, gradients[tone], inks[tone], text + ) + files[Path(name)] = rendered.encode("utf-8") + return files + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True, help="released tag, e.g. v0.4.1") + parser.add_argument( + "--winget-live", + action="store_true", + help="render the winget version instead of the placeholder", + ) + parser.add_argument("--out", type=Path, help="output directory (default assets/badges)") + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) + args = parser.parse_args(argv) + version = normalized_version(args.tag) + out = args.out or args.root / "assets" / "badges" + files = badge_files(args.root, version, args.winget_live) + for relative, data in sorted(files.items()): + target = out / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + print(f"wrote {target}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file