fix(release): сбой переименования agent.py не удаляет прежний agent.py (T2-I1) (#32)

Откат удаляет agent.py только при первой публикации; при сбое до переименования прежний файл не трогается. Тест: os.replace падает на agent.py.prev — release/ байт-в-байт прежний.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 10:33:54 +04:00
parent f977d9bd34
commit 57934383e7
2 changed files with 26 additions and 5 deletions

View File

@ -74,7 +74,7 @@ def publish(release_dir, source) -> dict:
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
moved = False
existed, moved = agent.exists(), False
try:
secure_dir(rd)
if agent.exists():
@ -83,7 +83,7 @@ def publish(release_dir, source) -> dict:
_write_atomic(agent, data)
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
except OSError as e:
_undo(agent, prev_file, moved)
_undo(agent, prev_file, moved, existed)
raise PublishError(f"не удалось записать публикацию: {e}") from e
try:
prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design)
@ -92,13 +92,14 @@ def publish(release_dir, source) -> dict:
return manifest
def _undo(agent: Path, prev_file: Path, moved: bool) -> None:
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
убрать записанный agent.py. Сбой возврата — в лог: команда всё равно завершается сообщением."""
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
Сбой возврата — в лог: команда всё равно завершается сообщением."""
try:
if moved:
os.replace(prev_file, agent)
else:
elif not existed:
agent.unlink(missing_ok=True)
except OSError as e:
log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e)

View File

@ -247,3 +247,23 @@ def test_load_non_utf8_with_matching_sha_is_none(tmp_path, caplog):
assert release.current(rd)["version"] == 6
assert release.load(rd) is None
assert "agent.py не в UTF-8" in caplog.text
def test_move_failure_keeps_previous_agent(tmp_path, monkeypatch):
"""T2-I1: сбой переименования agent.py -> agent.py.prev при существующем agent.py — прежний agent.py не трогаем."""
rd = tmp_path / "release"
release.publish(rd, agent_file(tmp_path, 8))
before = _listing(rd)
real = release.os.replace
def fake(src, dst, *a, **kw):
if Path(dst).name == "agent.py.prev":
raise OSError(13, "Permission denied")
return real(src, dst, *a, **kw)
monkeypatch.setattr(release.os, "replace", fake)
with pytest.raises(release.PublishError, match=r"^не удалось записать публикацию: "):
release.publish(rd, agent_file(tmp_path, 9))
monkeypatch.undo()
assert _listing(rd) == before
assert release.load(rd)["version"] == 8