From 5dc96dd43cfc5496c274934c6c67666f937097db Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:48:21 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0:=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D1=80=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D0=B0=D1=8F=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=BE=D1=81?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=84=D0=B5=D1=81=D1=82=D1=83=20(#129,=20#39=20T2.2,=20M?= =?UTF-8?q?-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/core/release.py | 36 ++++++++++++++ tests/test_release.py | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/src/sdlc_hub/core/release.py b/src/sdlc_hub/core/release.py index 10d413a..ba4b841 100644 --- a/src/sdlc_hub/core/release.py +++ b/src/sdlc_hub/core/release.py @@ -99,6 +99,7 @@ def publish(release_dir, source) -> dict: raise PublishError("файл не в UTF-8") from e version = parse_version(text) rd = Path(release_dir) + _recover(rd) prev = current(rd) if prev and version <= prev["version"]: raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") @@ -123,6 +124,41 @@ def publish(release_dir, source) -> dict: return manifest +def _sha256(path: Path) -> str | None: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except FileNotFoundError: + return None + + +def _recover(rd: Path) -> None: + """Привести release/ к опубликованной версии после прерванной публикации (design hub-backend-fixes, решение 9). + Сверка с manifest.json по sha256: agent.py.prev совпадает, а agent.py нет или не совпадает — .prev возвращается на + место; agent.py совпадает — лишний .prev удаляется; иначе .prev не трогается, только предупреждение. Сбой + (OSError) — предупреждение в лог, публикация продолжается.""" + agent, prev_file = rd / "agent.py", rd / "agent.py.prev" + try: + if not prev_file.exists(): + return + try: + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + m = None + sha = m.get("sha256") if _manifest_problem(m) is None else None + if sha is None: + log.warning("release: найден %s, но манифеста нет или он негоден — не трогаем", prev_file) + elif _sha256(agent) == sha: + prev_file.unlink() + log.warning("release: лишний %s удалён: agent.py сходится с манифестом", prev_file) + elif _sha256(prev_file) == sha: + os.replace(prev_file, agent) + log.warning("release: прерванная публикация: %s возвращён на место agent.py", prev_file) + else: + log.warning("release: найден %s, не сходится с манифестом — не трогаем", prev_file) + except OSError as e: + log.warning("release: восстановление после прерванной публикации не удалось: %s", e) + + def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None: """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем. diff --git a/tests/test_release.py b/tests/test_release.py index d98abe4..4856311 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -298,3 +298,99 @@ def test_invalid_manifest_warning_not_repeated_across_alternating_dirs(tmp_path, assert release.current(bad) is None assert release.current(none) is None assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1 + + +# --- пакет C: восстановление прерванной публикации (design hub-backend-fixes, решение 9; #39 T2.2, M-6) ---------- + +def _no_space(monkeypatch): + def no_space(fd): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(release.os, "fsync", no_space) + + +def test_recover_missing_agent_restores_prev(tmp_path, monkeypatch, caplog): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + os.replace(rd / "agent.py", rd / "agent.py.prev") # процесс упал между переименованием и записью + _no_space(monkeypatch) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 6, name="new.py")) + monkeypatch.undo() + assert (rd / "agent.py").read_bytes() == v5 + assert not (rd / "agent.py.prev").exists() + assert release.load(rd)["version"] == 5 + assert "agent.py.prev" in caplog.text + + +def test_recover_partial_agent_keeps_good_prev(tmp_path, monkeypatch): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + (rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode()) + _no_space(monkeypatch) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 6, name="new.py")) + monkeypatch.undo() + assert (rd / "agent.py").read_bytes() == v5 + assert not (rd / "agent.py.prev").exists() or (rd / "agent.py.prev").read_bytes() == v5 + assert release.load(rd)["version"] == 5 + + +def test_recover_success_after_crash(tmp_path): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + (rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode()) + v6 = agent_file(tmp_path, 6, name="new.py") + release.publish(rd, v6) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert (rd / "agent.py").read_bytes() == v6.read_bytes() + assert release.load(rd)["version"] == 6 + + +def test_recover_leftover_prev_removed(tmp_path, monkeypatch): + """agent.py совпадает с манифестом, рядом лишний .prev: _recover удаляет его, даже если публикация потом падает + до переименования agent.py в .prev.""" + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + _fail_replace_into(monkeypatch, "agent.py.prev") + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 6, name="new.py")) + monkeypatch.undo() + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.load(rd)["version"] == 5 + + +def test_recover_unknown_state_untouched(tmp_path, caplog): + rd = tmp_path / "release" + rd.mkdir() + (rd / "agent.py.prev").write_bytes(b"AGENT_VERSION = 3\n") + release._recover(rd) + assert _listing(rd) == {"agent.py.prev": b"AGENT_VERSION = 3\n"} + assert "agent.py.prev" in caplog.text + assert release.publish(rd, agent_file(tmp_path, 1))["version"] == 1 # первая публикация проходит + assert release.load(rd)["version"] == 1 + + +def test_recover_oserror_does_not_stop_publish(tmp_path, monkeypatch, caplog): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + real = Path.unlink + + def fake(self, *a, **kw): + if self.name == "agent.py.prev" and not getattr(fake, "done", False): + fake.done = True + raise OSError(13, "Permission denied") + return real(self, *a, **kw) + + monkeypatch.setattr(Path, "unlink", fake) + assert release.publish(rd, agent_file(tmp_path, 6, name="new.py"))["version"] == 6 + assert "Permission denied" in caplog.text