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:
parent
922c437689
commit
ec656a0e08
4 changed files with 170 additions and 7 deletions
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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-раннера, то есть релизной джобе нужна матрица. Работа
|
||||||
|
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
|
||||||
|
оставлена за владельцем.
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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, "отсутствующий каталог сборки должен быть отказом"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue