HUB-1: зелёный main — граница workspace и UTF-8 в verification #2
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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-раннера, то есть релизной джобе нужна матрица. Работа
|
||||
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
|
||||
оставлена за владельцем.
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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, "отсутствующий каталог сборки должен быть отказом"
|
||||
|
|
|
|||
Loading…
Reference in a new issue