Quality-gate finding 7a: bump_branch_in_flight matched ANY chore/auto-bump-*
branch, so one stale historical branch (branches are never cleaned up) stood
the safety net down permanently. The guard now matches exactly
chore/auto-bump-{NEW} — the in-flight version — with new_version=None as the
documented legacy escape hatch; wait_out_closed_path passes NEW_VERSION
through.
Quality-gate finding 7b (docs): release-flow.md claimed the closed path was
'genuinely absent, not merely slow' and that the paths 'can never
double-bump'. Rewritten: attribution is best-effort; a >90s delayed closed
path can race into a double-bump, bounded by tag idempotency (duplicate
release creation is a no-op); stale-branch cleanup is a known limitation.
Red evidence: anchored-match tests failed against the prefix-wide guard.
Green: 12/12.
137 lines
5.6 KiB
Python
137 lines
5.6 KiB
Python
"""Tests for scripts/auto_bump_pr.py — the protected-branch-safe bump-PR machinery (#192).
|
|
|
|
Pure tests cover the attribution gate (bump-branch detection). The dry-run mode
|
|
is exercised end-to-end with monkeypatched env (no API calls, no token needed).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _load_module(name: str) -> Any:
|
|
spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py")
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
abp = _load_module("auto_bump_pr")
|
|
|
|
|
|
class TestBumpBranchInFlight:
|
|
@pytest.mark.parametrize(
|
|
("names", "expected"),
|
|
[
|
|
(["master", "chore/auto-bump-0.55.0"], True),
|
|
(["chore/auto-bump-0.55.0"], True),
|
|
(["master", "feat/x", "chore/other"], False),
|
|
([], False),
|
|
],
|
|
)
|
|
def test_detection(self, names: list[str], expected: bool) -> None:
|
|
assert abp.bump_branch_in_flight(names) is expected
|
|
|
|
def test_anchored_to_in_flight_version(self) -> None:
|
|
# Spec-gate finding 7a (#192): matching ANY chore/auto-bump-* branch let
|
|
# one stale historical branch stand the net down forever. The guard must
|
|
# match exactly chore/auto-bump-{NEW} — the in-flight version.
|
|
assert abp.bump_branch_in_flight(["master", "chore/auto-bump-0.54.0"], "0.56.0") is False
|
|
assert abp.bump_branch_in_flight(["chore/auto-bump-0.55.0"], "0.56.0") is False
|
|
assert abp.bump_branch_in_flight(["master", "chore/auto-bump-0.56.0"], "0.56.0") is True
|
|
|
|
def test_legacy_prefix_widens_to_any_suffix(self) -> None:
|
|
# Backward-compat escape hatch: when no in-flight version is given,
|
|
# the old prefix-wide behavior is preserved (documented as best-effort).
|
|
assert abp.bump_branch_in_flight(["master", "chore/auto-bump-anything"], None) is True
|
|
|
|
|
|
class TestDryRun:
|
|
def test_prints_decision_without_api_calls(
|
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
monkeypatch.setenv("RELEASE_VERSION", "0.55.0")
|
|
monkeypatch.setenv("NEW_VERSION", "0.56.0")
|
|
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
|
rc = abp.main(["--dry-run"])
|
|
assert rc == 0
|
|
decision = json.loads(capsys.readouterr().out)
|
|
assert decision["dry_run"] is True
|
|
assert decision["release_version"] == "0.55.0"
|
|
assert decision["new_version"] == "0.56.0"
|
|
assert decision["branch"] == "chore/auto-bump-0.56.0"
|
|
assert "0.56.0" in decision["pr_title"]
|
|
|
|
def test_missing_env_defaults_empty(
|
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
for var in ("RELEASE_VERSION", "NEW_VERSION", "GITEA_TOKEN"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
rc = abp.main(["--dry-run"])
|
|
assert rc == 0
|
|
decision = json.loads(capsys.readouterr().out)
|
|
assert decision["release_version"] == ""
|
|
assert decision["branch"] == "chore/auto-bump-"
|
|
|
|
|
|
VERSION_SURFACE = "src/rune/__init__.py"
|
|
OTHER_SURFACE = "src/rune/rune_tui.py"
|
|
|
|
|
|
class TestSurfaceFailurePolicy:
|
|
"""Spec-gate finding 4 (#192): the version surface is NOT optional.
|
|
|
|
A GET failure on src/rune/__init__.py used to be skipped like an optional
|
|
surface, producing a bump PR that silently bumps nothing. The failure must
|
|
be fatal; genuinely optional surfaces (pattern absent) still skip.
|
|
"""
|
|
|
|
def test_init_py_get_failure_is_fatal(self) -> None:
|
|
assert abp.is_surface_fatal(VERSION_SURFACE) is True
|
|
|
|
def test_other_surfaces_stay_optional(self) -> None:
|
|
for surface in ("src/rune/rune_tui.py", "src/rune/canary_gitea_automation.py", "README.md"):
|
|
assert abp.is_surface_fatal(surface) is False, surface
|
|
|
|
def test_bump_aborts_when_init_py_unreadable(self) -> None:
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
def fake_api(path: str, method: str = "GET", data: dict[str, Any] | None = None, ok: tuple[int, ...] = (200,)):
|
|
calls.append((method, path.split("?")[0]))
|
|
if path.startswith("/contents/src/rune/__init__.py"):
|
|
return 404, {"_body": "not found"}
|
|
if path.startswith("/contents/"):
|
|
return 200, {"content": "", "sha": "deadbeef"}
|
|
return 200, {}
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
abp.apply_surface_edits("chore/auto-bump-0.56.0", "0.55.0", "0.56.0", fake_api)
|
|
assert excinfo.value.code == 1
|
|
# abort must happen before any PUT was attempted
|
|
assert all(m != "PUT" for m, _ in calls)
|
|
|
|
def test_optional_surface_failure_still_skips(self) -> None:
|
|
import base64
|
|
|
|
committed: list[str] = []
|
|
init_content = base64.b64encode(b'__version__ = "0.55.0"\n').decode()
|
|
|
|
def fake_api(path: str, method: str = "GET", data: dict[str, Any] | None = None, ok: tuple[int, ...] = (200,)):
|
|
if path.startswith("/contents/src/rune/__init__.py"):
|
|
return 200, {"content": init_content, "sha": "deadbeef"}
|
|
if path.startswith("/contents/"):
|
|
return 404, {"_body": "not found"}
|
|
return 200, {}
|
|
|
|
abp.apply_surface_edits("chore/auto-bump-0.56.0", "0.55.0", "0.56.0", fake_api, on_commit=committed.append)
|
|
# only the version surface was committed; optional ones skipped
|
|
assert committed == [VERSION_SURFACE]
|