fix(release): конвейер не публикует релиз, которым нельзя обновиться

Найдено сверх задания и сверх аудита.

Каждый прогон 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 <noreply@anthropic.com>
This commit is contained in:
ochenstarik-ui 2026-09-03 14:44:14 +07:00
parent 922c437689
commit ec656a0e08
4 changed files with 170 additions and 7 deletions

View file

@ -57,6 +57,21 @@ jobs:
$manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8 $manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8
Write-Host "Generated update_manifest.json with SHA256: $hash" 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 - name: Publish GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@ -68,3 +83,12 @@ jobs:
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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

View file

@ -23,8 +23,8 @@
`ruff check .` — чисто. Release Gate — PASSED на обеих системах. `ruff check .` — чисто. Release Gate — PASSED на обеих системах.
Локально (Linux): **777 passed, 2 skipped, 4 deselected**. База до работы — Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы —
739 passed, 2 skipped. Число тестов выросло на 38, ни один не удалён. 739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён.
--- ---
@ -196,13 +196,53 @@ CORS уже закрыт правкой ревьюера, но CORS мешает
- Неизмеренное названо причиной: Publication Gate вне режима публикации - Неизмеренное названо причиной: Publication Gate вне режима публикации
печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`. печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `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 - Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test
(windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не (windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не
сломалось; если её будут включать — имена проверок брать новые. сломалось; если её будут включать — имена проверок брать новые.
- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе - Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе
каждый PR краснел бы за отсутствие релиза для ветки. Перед публикацией каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже
релиза его нужно запускать явно — `python scripts/release_gate.py встроен и блокирует (`--publication-only`, после публикации). Вручную:
--publication`. Имеет смысл добавить этот вызов в `release.yml` отдельным `python scripts/release_gate.py --publication`.
заданием. - **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает
zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и
`installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик
требует Linux-раннера, то есть релизной джобе нужна матрица. Работа
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
оставлена за владельцем.

View file

@ -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(): def run_release_gate():
print("=" * 70) print("=" * 70)
print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})") print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})")
@ -416,5 +455,24 @@ def run_release_gate():
sys.exit(1) 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__": if __name__ == "__main__":
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() run_release_gate()

View file

@ -621,3 +621,44 @@ def test_offline_run_does_not_claim_publication_verified(monkeypatch):
assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза" assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза"
assert "НЕ БЛОКИРУЕТ" in msg assert "НЕ БЛОКИРУЕТ" in msg
assert "PACKAGE_HASH_VERIFIED=True" not 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, "отсутствующий каталог сборки должен быть отказом"