From ec656a0e085d2b2f56e122903fc89e0e6f6e3d1e Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:44:14 +0700 Subject: [PATCH] =?UTF-8?q?fix(release):=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D0=B9=D0=B5=D1=80=20=D0=BD=D0=B5=20=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D1=83=D0=B5=D1=82=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=BC=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D1=8F=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено сверх задания и сверх аудита. Каждый прогон Release Pipeline завершался ошибкой — все пять последних, включая тег текущего релиза v0.1.3-b1. Причина та же, что у красного CI: шаг Release Gate падал на test_a37_isolation_guards и test_a41_clean_install. До публикации не доходил ни один прогон, релизы выкладывались мимо конвейера. Отсюда ловушка. release.yml собирает hermes-hub-<версия>.zip и update_manifest.json, а update_manager ищет строго HermesHubSetup.exe или hermes-hub-setup.sh. Настоящие релизы содержат установщики и checksums.txt, то есть собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего не публиковал; как только они позеленели, случайная защита исчезла: первый же тег опубликовал бы "latest" без установщиков, и любое обновление отвечало бы "В релизе не найден подходящий файл обновления для текущей платформы". Ловушка закрыта до публикации: release_gate.py --assets dist проверяет, что собранный набор содержит установщик и checksums.txt, и падает с названной причиной и подсказкой про installer/build_installer.*. После публикации добавлен шаг release_gate.py --publication-only — строгий режим, ради которого ворота и разделялись. Сборку установщиков в release.yml не переписывал: проверяется только настоящей публикацией по тегу, это решение владельца. Конвейер по-прежнему не доходит до публикации, но падает теперь с честной причиной вместо чужой. Отчёт перенесён в agents/done/ по конвенции репозитория. Тесты: 777 -> 778 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 24 ++++++++ .../2026-09-02-HUB1-audit-p0-green-main.md | 52 ++++++++++++++-- scripts/release_gate.py | 60 ++++++++++++++++++- tests/test_p0_release_gate.py | 41 +++++++++++++ 4 files changed, 170 insertions(+), 7 deletions(-) rename agents/{reports => done}/2026-09-02-HUB1-audit-p0-green-main.md (79%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4b00ec..99b22e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,21 @@ jobs: $manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8 Write-Host "Generated update_manifest.json with SHA256: $hash" + # Набор проверяется ДО публикации. + # + # update_manager ищет в релизе строго HermesHubSetup.exe или + # hermes-hub-setup.sh, а шаг выше собирает только zip и манифест. Такой + # релиз становится "latest", и любая попытка обновиться отвечает «в + # релизе не найден подходящий файл обновления для текущей платформы». + # + # Раньше это не проявлялось лишь потому, что весь конвейер падал на шаге + # Release Gate — на тех же двух дефектах, что и CI; ни один его прогон не + # доходил до публикации, а релизы выкладывались мимо него. Как только + # тесты позеленели, случайная защита исчезла. + - name: Built assets must be installable by the updater + run: | + python scripts/release_gate.py --assets dist + - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: @@ -68,3 +83,12 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Ворота публикации: проверяют опубликованный релиз, а не сборку. + # Релиз есть, ассеты есть, пакет скачан целиком, SHA-256 сошёлся с + # опубликованным checksums.txt. Здесь они блокируют: раньше эта проверка + # возвращала PASS при обрыве сети, при 404 на манифест и при 404 на + # пакет, то есть пропускала релиз при любом исходе. + - name: Publication Gate (published release must be verifiable) + run: | + python scripts/release_gate.py --publication-only diff --git a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md b/agents/done/2026-09-02-HUB1-audit-p0-green-main.md similarity index 79% rename from agents/reports/2026-09-02-HUB1-audit-p0-green-main.md rename to agents/done/2026-09-02-HUB1-audit-p0-green-main.md index 53cb3e3..f257aff 100644 --- a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md +++ b/agents/done/2026-09-02-HUB1-audit-p0-green-main.md @@ -23,8 +23,8 @@ `ruff check .` — чисто. Release Gate — PASSED на обеих системах. -Локально (Linux): **777 passed, 2 skipped, 4 deselected**. База до работы — -739 passed, 2 skipped. Число тестов выросло на 38, ни один не удалён. +Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы — +739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён. --- @@ -196,13 +196,53 @@ CORS уже закрыт правкой ревьюера, но CORS мешает - Неизмеренное названо причиной: Publication Gate вне режима публикации печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`. +--- + +## Найдено сверх задания: релизный конвейер был мёртв, и мой же фикс снимал с него защиту + +Это самое важное из того, что не значилось ни в задании, ни в аудите. + +**Каждый прогон `Release Pipeline` завершался ошибкой** — все пять последних, +включая тег текущего релиза `v0.1.3-b1`. Причина ровно та же, что у красного +CI: шаг `Run Release Gate Check` падал на `test_a37_isolation_guards` и +`test_a41_clean_install`. До публикации не доходил ни один прогон, а релизы +выкладывались мимо конвейера. + +**Отсюда ловушка.** `release.yml` собирает `hermes-hub-<версия>.zip` и +`update_manifest.json`, а `update_manager` ищет в релизе строго +`HermesHubSetup.exe` или `hermes-hub-setup.sh`. Настоящие релизы содержат +`HermesHubSetup.exe`, `hermes-hub-setup.sh` и `checksums.txt` — то есть +собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего +не публиковал; **как только я их починил, случайная защита исчезла**: первый +же тег привёл бы к публикации «latest» без установщиков, и любая попытка +обновиться отвечала бы «В релизе не найден подходящий файл обновления для +текущей платформы». + +Ловушка закрыта явно, до публикации: шаг `Built assets must be installable by +the updater` (`release_gate.py --assets dist`) проверяет, что собранный набор +содержит установщик и `checksums.txt`, и падает с названной причиной и +подсказкой про `installer/build_installer.ps1` и +`installer/build_installer_linux.sh`. После публикации добавлен шаг +`Publication Gate` (`release_gate.py --publication-only`) — тот самый строгий +режим, ради которого ворота и разделялись. + +Чего я **не** делал: не переписывал сборку установщиков в `release.yml`. +Проверить это можно только выкладыванием настоящего релиза по тегу, а это +решение владельца, не исполнителя. Конвейер по-прежнему не доходит до +публикации — но теперь падает с честной причиной вместо чужой. + ## Что стоит решить ревьюеру - Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test (windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не сломалось; если её будут включать — имена проверок брать новые. - Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе - каждый PR краснел бы за отсутствие релиза для ветки. Перед публикацией - релиза его нужно запускать явно — `python scripts/release_gate.py - --publication`. Имеет смысл добавить этот вызов в `release.yml` отдельным - заданием. + каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже + встроен и блокирует (`--publication-only`, после публикации). Вручную: + `python scripts/release_gate.py --publication`. +- **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает + zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и + `installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик + требует Linux-раннера, то есть релизной джобе нужна матрица. Работа + небольшая, но проверяется только настоящей публикацией по тегу — поэтому + оставлена за владельцем. diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 11e2814..2198931 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -377,6 +377,45 @@ def check_publication_gate() -> tuple[bool, str]: ) +def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]: + """Собранный набор ассетов действительно устанавливается обновлением. + + Проверяется до публикации. Причина: update_manager ищет в релизе строго + HermesHubSetup.exe или hermes-hub-setup.sh/install-linux.sh, а release.yml + собирает hermes-hub-<версия>.zip и update_manifest.json. Такой релиз + становится "latest", и на любой попытке обновиться владелец получает + "В релизе не найден подходящий файл обновления для текущей платформы". + + Раньше это не проявлялось лишь потому, что весь релизный конвейер падал + на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а + релизы публиковались мимо него. Как только тесты позеленели, случайная + защита исчезла — поэтому набор проверяется явно. + """ + if not dist_dir.is_dir(): + return False, f"Каталог сборки не найден: {dist_dir}" + + present = {item.name for item in dist_dir.iterdir() if item.is_file()} + installers = sorted(present & set(PACKAGE_ASSET_NAMES)) + problems = [] + if not installers: + problems.append( + f"нет ни одного установщика {list(PACKAGE_ASSET_NAMES)} — " + f"обновление такой релиз поставить не сможет" + ) + if CHECKSUMS_ASSET_NAME not in present: + problems.append(f"нет {CHECKSUMS_ASSET_NAME} — сверять хеш пакета будет не с чем") + + if problems: + return False, ( + f"Набор ассетов в {dist_dir} непригоден для публикации: " + + "; ".join(problems) + + f". Собрано: {sorted(present)}. Установщики собираются скриптами " + f"installer/build_installer.ps1 и installer/build_installer_linux.sh" + ) + + return True, f"Набор ассетов пригоден для публикации: {installers} + {CHECKSUMS_ASSET_NAME}" + + def run_release_gate(): print("=" * 70) print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})") @@ -416,5 +455,24 @@ def run_release_gate(): sys.exit(1) +def _run_single(title: str, check) -> None: + """Выполнить одну проверку и завершиться её итогом.""" + print("=" * 70) + print(f" Hermes Hub — {title}") + print("=" * 70) + ok, msg = check() + print(f" {'[OK]' if ok else '[FAIL]'} {msg}") + sys.exit(0 if ok else 1) + + if __name__ == "__main__": - run_release_gate() + if "--assets" in sys.argv: + index = sys.argv.index("--assets") + target = Path(sys.argv[index + 1]) if len(sys.argv) > index + 1 else ROOT / "dist" + _run_single("Publishable Assets Check", lambda: check_publishable_assets(target)) + elif "--publication-only" in sys.argv: + # Запускается ПОСЛЕ публикации: проверяет опубликованный релиз, а не сборку. + os.environ[PUBLICATION_MODE_ENV] = "1" + _run_single("Publication Gate", check_publication_gate) + else: + run_release_gate() diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index eeef7cf..430501c 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -621,3 +621,44 @@ def test_offline_run_does_not_claim_publication_verified(monkeypatch): assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза" assert "НЕ БЛОКИРУЕТ" in msg assert "PACKAGE_HASH_VERIFIED=True" not in msg, "непроверенное не должно объявляться проверенным" + + +@pytest.mark.unit +def test_publishable_assets_check_rejects_uninstallable_release(tmp_path): + """Набор без установщика не должен уходить в публикацию. + + update_manager ищет в релизе HermesHubSetup.exe или hermes-hub-setup.sh, а + release.yml собирает только zip и манифест. Такой релиз становится + «latest», и обновление отвечает «в релизе не найден подходящий файл + обновления для текущей платформы». Раньше это не проявлялось лишь потому, + что весь релизный конвейер падал на шаге Release Gate — на тех же двух + дефектах, что и CI, — и до публикации не доходил ни один его прогон. + """ + release_gate = _load_release_gate() + + as_built_today = tmp_path / "dist_zip_only" + as_built_today.mkdir() + (as_built_today / "hermes-hub-0.1.3.zip").write_bytes(b"zip") + (as_built_today / "update_manifest.json").write_text("{}", encoding="utf-8") + + ok, msg = release_gate.check_publishable_assets(as_built_today) + assert ok is False, "набор без установщика признан пригодным к публикации" + assert "HermesHubSetup.exe" in msg + assert "checksums.txt" in msg + + without_checksums = tmp_path / "dist_no_sums" + without_checksums.mkdir() + (without_checksums / "HermesHubSetup.exe").write_bytes(b"exe") + ok, msg = release_gate.check_publishable_assets(without_checksums) + assert ok is False, "набор без checksums.txt признан пригодным" + assert "checksums.txt" in msg + + as_published_really = tmp_path / "dist_full" + as_published_really.mkdir() + for name in ("HermesHubSetup.exe", "hermes-hub-setup.sh", "checksums.txt"): + (as_published_really / name).write_bytes(b"x") + ok, msg = release_gate.check_publishable_assets(as_published_really) + assert ok is True, msg + + ok, msg = release_gate.check_publishable_assets(tmp_path / "нет-такого") + assert ok is False, "отсутствующий каталог сборки должен быть отказом"