mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-04 16:19:50 +00:00
Allocates the version for the advisory clean-sentinel fix (#86) and repairs the three CI defects that kept the release tier red. The v6.87.0 tag run had already failed on ui-smoke, docker-ui-smoke and full-test (windows); none of those were caused by the code they were gating. - `test_telegram_miniapp_lifecycle::test_stuck_proxy_close_hard_exits_companion_process` spawned a real interpreter with a 1s budget. That budget is the harness's patience, not the contract under test — `_SIDECAR_STOP_GRACE_SEC = 0.01` keeps the stuck close fast and the exit-code assertion is what proves the behaviour — and it could not cover Python startup on a loaded Windows runner. Raised, and marked `serial` per the repository's own rule for real-process tests. - `test_osworld_cu_bridge::test_task_scoped_proxy_gives_each_task_its_own_session` asserted POSIX mode bits on every platform. Windows does not express privacy that way, so the check failed there while saying nothing about security. It is now guarded by the existing `posix_private_modes_supported()` helper, matching how `test_observability_outcomes_v2` already handles the same question; every other assertion in the test still runs everywhere. - `test_ui_smoke_playwright::..._cannot_hide_open_drawer_*` slept a fixed 220ms for a 180ms transform transition. Forty milliseconds of margin lost the race on the Linux WebKit runner, which then measured the drawer at its closed position (translateX(-105%) => left -336). It now waits for the drawer to arrive, using the exact predicate the assertion checks so the wait cannot pass on a value the assertion would reject. A drawer that never opens still fails, now naming the cause instead of reporting stale geometry. The release run then exposed a fifth version carrier the tooling did not know about: `web/modules/api_types.js` pins `GATEWAY_CONTRACT_VERSION` to VERSION and `test_gateway_parity` enforces it, but `release_sync` synced only four carriers. Every release therefore had to rediscover it through a red CI run. The carrier is now reported by `version_carrier_desyncs`, repaired by `sync_release_metadata`, passed by all three production call sites, and covered by tests — the same shape as the sentinel bug above: a contract pinned in one place and unknown to the code that exists to satisfy it. Worth recording: `ui-smoke` does not run in the pull-request lane, only on manual/tag/stable. The commit that introduced both the mobile drawer and its test therefore never ran that test, and the defect reached a release tag unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
579 lines
22 KiB
Python
579 lines
22 KiB
Python
"""Tests for ouroboros/tools/release_sync.py (standalone library, no wire-up)."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from ouroboros.tools.release_sync import (
|
|
check_history_limit,
|
|
detect_numeric_claims,
|
|
run_release_preflight,
|
|
sync_release_metadata,
|
|
version_carrier_desyncs,
|
|
)
|
|
from ouroboros.tools.release_sync import ( # noqa: E402 — private helpers under test
|
|
_normalize_pep440,
|
|
_shields_escape,
|
|
_VERSION_RE,
|
|
_VERSION_ROW_RE,
|
|
_README_BADGE_RE,
|
|
_ARCH_HEADER_RE,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_repo(tmp_path: Path, version: str = "4.99.1") -> Path:
|
|
"""Create a minimal fake repo with all version-carrier files."""
|
|
(tmp_path / "VERSION").write_text(version + "\n", encoding="utf-8")
|
|
|
|
(tmp_path / "pyproject.toml").write_text(
|
|
'[tool.poetry]\nname = "ouroboros"\nversion = "0.0.0"\n',
|
|
encoding="utf-8",
|
|
)
|
|
web = tmp_path / "web"
|
|
web.mkdir()
|
|
(web / "package.json").write_text(
|
|
'{\n "name": "ouroboros-web",\n "version": "0.0.0"\n}\n',
|
|
encoding="utf-8",
|
|
)
|
|
modules = web / "modules"
|
|
modules.mkdir()
|
|
(modules / "api_types.js").write_text(
|
|
"export const GATEWAY_CONTRACT_VERSION = '0.0.0';\n", encoding="utf-8"
|
|
)
|
|
|
|
badge_line = (
|
|
'[![Version 0.0.0]'
|
|
'(https://img.shields.io/badge/version-0.0.0-green.svg)]'
|
|
'(VERSION)\n'
|
|
)
|
|
readme_content = (
|
|
"# Ouroboros\n\n"
|
|
+ badge_line
|
|
+ "\n## Version History\n\n"
|
|
"| Version | Date | Description |\n"
|
|
"|---------|------|-------------|\n"
|
|
f"| {version} | 2026-01-01 | New release |\n"
|
|
)
|
|
(tmp_path / "README.md").write_text(readme_content, encoding="utf-8")
|
|
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "ARCHITECTURE.md").write_text(
|
|
"# Ouroboros v0.0.0 — Architecture\n\nContent here.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
return tmp_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sync_release_metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSyncReleaseMetadata:
|
|
def test_syncs_pyproject_toml(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "1.2.3")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "pyproject.toml" in changed
|
|
text = (repo / "pyproject.toml").read_text()
|
|
assert 'version = "1.2.3"' in text
|
|
|
|
def test_syncs_readme_badge(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "1.2.3")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "README.md" in changed
|
|
text = (repo / "README.md").read_text()
|
|
assert "Version 1.2.3" in text
|
|
assert "version-1.2.3-green" in text
|
|
|
|
def test_syncs_web_package_json(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "1.2.3-rc.4")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "web/package.json" in changed
|
|
text = (repo / "web" / "package.json").read_text()
|
|
assert '"version": "1.2.3-rc.4"' in text
|
|
|
|
def test_web_package_json_participates_in_desync_checks(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "1.2.3-rc.4")
|
|
sync_release_metadata(str(repo))
|
|
(repo / "web" / "package.json").write_text('{"version": "1.2.3-rc.3"}', encoding="utf-8")
|
|
|
|
desync = version_carrier_desyncs(
|
|
"1.2.3-rc.4",
|
|
web_package_text=(repo / "web" / "package.json").read_text(encoding="utf-8"),
|
|
detailed=True,
|
|
)
|
|
|
|
assert desync == ['web/package.json (expected "version": "1.2.3-rc.4")']
|
|
|
|
def test_gateway_contract_version_is_a_release_carrier(self, tmp_path):
|
|
"""tests/test_gateway_parity.py pins GATEWAY_CONTRACT_VERSION to VERSION,
|
|
so the release tooling must sync it. It did not, and every release had to
|
|
rediscover the carrier through a red CI run."""
|
|
repo = _make_repo(tmp_path, "1.2.3")
|
|
|
|
changed = sync_release_metadata(str(repo))
|
|
api_types = repo / "web" / "modules" / "api_types.js"
|
|
|
|
assert "web/modules/api_types.js" in changed
|
|
assert "GATEWAY_CONTRACT_VERSION = '1.2.3'" in api_types.read_text(encoding="utf-8")
|
|
|
|
def test_stale_gateway_contract_version_is_reported(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "1.2.3")
|
|
sync_release_metadata(str(repo))
|
|
api_types = repo / "web" / "modules" / "api_types.js"
|
|
api_types.write_text("export const GATEWAY_CONTRACT_VERSION = '1.2.2';\n", encoding="utf-8")
|
|
|
|
desync = version_carrier_desyncs(
|
|
"1.2.3",
|
|
api_types_text=api_types.read_text(encoding="utf-8"),
|
|
detailed=True,
|
|
)
|
|
|
|
assert desync == ["web/modules/api_types.js (expected GATEWAY_CONTRACT_VERSION = '1.2.3')"]
|
|
|
|
def test_syncs_architecture_header(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "1.2.3")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "docs/ARCHITECTURE.md" in changed
|
|
text = (repo / "docs" / "ARCHITECTURE.md").read_text()
|
|
assert "v1.2.3" in text
|
|
|
|
def test_idempotent_second_call_returns_no_changes(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "1.2.3")
|
|
sync_release_metadata(str(repo)) # first call mutates
|
|
changed2 = sync_release_metadata(str(repo)) # second call: already in sync
|
|
assert changed2 == []
|
|
|
|
def test_no_changes_when_already_in_sync(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "0.0.0") # _make_repo already uses 0.0.0 in carriers
|
|
# Overwrite VERSION to match the pre-set values
|
|
(repo / "VERSION").write_text("0.0.0\n", encoding="utf-8")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert changed == []
|
|
|
|
def test_returns_empty_when_version_file_missing(self, tmp_path):
|
|
changed = sync_release_metadata(str(tmp_path))
|
|
assert changed == []
|
|
|
|
def test_returns_empty_for_invalid_version_string(self, tmp_path):
|
|
(tmp_path / "VERSION").write_text("not-a-version\n", encoding="utf-8")
|
|
changed = sync_release_metadata(str(tmp_path))
|
|
assert changed == []
|
|
|
|
def test_missing_pyproject_skipped_gracefully(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "2.0.0")
|
|
(repo / "pyproject.toml").unlink()
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "pyproject.toml" not in changed
|
|
# README and ARCHITECTURE still synced
|
|
assert "README.md" in changed
|
|
|
|
def test_missing_architecture_skipped_gracefully(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "2.0.0")
|
|
(repo / "docs" / "ARCHITECTURE.md").unlink()
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "docs/ARCHITECTURE.md" not in changed
|
|
assert "pyproject.toml" in changed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_history_limit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_readme_history(*versions: str) -> str:
|
|
rows = "\n".join(f"| {v} | 2026-01-01 | desc |" for v in versions)
|
|
return f"## Version History\n\n| Version | Date | Description |\n|---|---|---|\n{rows}\n"
|
|
|
|
|
|
class TestCheckHistoryLimit:
|
|
def test_no_warnings_within_limits(self):
|
|
readme = _build_readme_history(
|
|
"5.0.0", "4.0.0", # 2 major
|
|
"4.5.0", "4.4.0", "4.3.0", "4.2.0", "4.1.0", # 5 minor
|
|
"4.5.5", "4.5.4", "4.5.3", "4.5.2", "4.5.1", # 5 patch
|
|
)
|
|
warnings = check_history_limit(readme)
|
|
assert warnings == []
|
|
|
|
def test_too_many_major_rows(self):
|
|
readme = _build_readme_history("5.0.0", "4.0.0", "3.0.0") # 3 major
|
|
warnings = check_history_limit(readme)
|
|
assert any("major" in w for w in warnings)
|
|
|
|
def test_too_many_minor_rows(self):
|
|
readme = _build_readme_history(
|
|
"4.6.0", "4.5.0", "4.4.0", "4.3.0", "4.2.0", "4.1.0" # 6 minor
|
|
)
|
|
warnings = check_history_limit(readme)
|
|
assert any("minor" in w for w in warnings)
|
|
|
|
def test_too_many_patch_rows(self):
|
|
readme = _build_readme_history(
|
|
"4.5.6", "4.5.5", "4.5.4", "4.5.3", "4.5.2", "4.5.1" # 6 patch
|
|
)
|
|
warnings = check_history_limit(readme)
|
|
assert any("patch" in w for w in warnings)
|
|
|
|
def test_multiple_violations_reported_separately(self):
|
|
# 3 major + 6 minor → two separate warnings
|
|
readme = _build_readme_history(
|
|
"5.0.0", "4.0.0", "3.0.0",
|
|
"4.6.0", "4.5.0", "4.4.0", "4.3.0", "4.2.0", "4.1.0",
|
|
)
|
|
warnings = check_history_limit(readme)
|
|
assert len(warnings) >= 2
|
|
|
|
def test_empty_readme_returns_no_warnings(self):
|
|
assert check_history_limit("") == []
|
|
|
|
def test_exact_limit_not_a_violation(self):
|
|
readme = _build_readme_history(
|
|
"4.0.0", "3.0.0", # exactly 2 major
|
|
"4.5.0", "4.4.0", "4.3.0", "4.2.0", "4.1.0", # exactly 5 minor
|
|
"4.5.5", "4.5.4", "4.5.3", "4.5.2", "4.5.1", # exactly 5 patch
|
|
)
|
|
warnings = check_history_limit(readme)
|
|
assert warnings == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# detect_numeric_claims
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDetectNumericClaims:
|
|
def test_detects_N_tests(self):
|
|
claims = detect_numeric_claims("Added 16 tests for the new module.")
|
|
assert any("16" in c for c in claims)
|
|
|
|
def test_detects_N_fixes(self):
|
|
claims = detect_numeric_claims("Contains 3 fixes for edge cases.")
|
|
assert any("3" in c for c in claims)
|
|
|
|
def test_detects_N_new_tests(self):
|
|
claims = detect_numeric_claims("Includes 42 new regression tests.")
|
|
assert any("42" in c for c in claims)
|
|
|
|
def test_no_false_positive_on_plain_numbers(self):
|
|
claims = detect_numeric_claims("Version 4.36.3 released in 2026.")
|
|
assert claims == []
|
|
|
|
def test_no_false_positive_on_non_claim_nouns(self):
|
|
claims = detect_numeric_claims("The 5 providers are all supported.")
|
|
assert claims == []
|
|
|
|
def test_returns_all_matches_in_text(self):
|
|
text = "Added 5 tests and fixed 2 regressions plus 10 new assertions."
|
|
claims = detect_numeric_claims(text)
|
|
assert len(claims) == 3
|
|
|
|
def test_empty_string_returns_empty(self):
|
|
assert detect_numeric_claims("") == []
|
|
|
|
def test_case_insensitive(self):
|
|
claims = detect_numeric_claims("Ships 7 TESTS for reliability.")
|
|
assert any("7" in c for c in claims)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_release_preflight (orchestrator)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRunReleasePreflight:
|
|
def test_returns_changed_and_warnings_tuple(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "4.99.1")
|
|
changed, warnings = run_release_preflight(str(repo))
|
|
assert isinstance(changed, list)
|
|
assert isinstance(warnings, list)
|
|
|
|
def test_syncs_carriers_and_returns_paths(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "4.99.1")
|
|
changed, _ = run_release_preflight(str(repo))
|
|
assert len(changed) >= 1 # at least one carrier was out of sync
|
|
|
|
def test_second_call_idempotent_no_changes(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "4.99.1")
|
|
run_release_preflight(str(repo))
|
|
changed2, _ = run_release_preflight(str(repo))
|
|
assert changed2 == []
|
|
|
|
def test_warns_on_history_limit_breach(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "4.99.1")
|
|
# Inject too many patch rows into README
|
|
readme = repo / "README.md"
|
|
extra_rows = "\n".join(
|
|
f"| 4.99.{i} | 2026-01-01 | desc |" for i in range(10)
|
|
)
|
|
text = readme.read_text()
|
|
readme.write_text(text + "\n" + extra_rows + "\n", encoding="utf-8")
|
|
_, warnings = run_release_preflight(str(repo))
|
|
assert any("patch" in w for w in warnings)
|
|
|
|
def test_warns_on_numeric_claims_in_changelog_row(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "4.99.1")
|
|
readme = repo / "README.md"
|
|
text = readme.read_text()
|
|
# Replace the changelog row description with a numeric claim
|
|
text = text.replace(
|
|
"| 4.99.1 | 2026-01-01 | New release |",
|
|
"| 4.99.1 | 2026-01-01 | Ships 12 new tests for reliability. |",
|
|
)
|
|
readme.write_text(text, encoding="utf-8")
|
|
_, warnings = run_release_preflight(str(repo))
|
|
assert any("numeric claims" in w for w in warnings)
|
|
|
|
def test_no_warnings_on_clean_repo(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "0.0.0")
|
|
(repo / "VERSION").write_text("0.0.0\n", encoding="utf-8")
|
|
_, warnings = run_release_preflight(str(repo))
|
|
assert warnings == []
|
|
|
|
def test_handles_missing_readme_gracefully(self, tmp_path):
|
|
repo = _make_repo(tmp_path, "4.99.1")
|
|
(repo / "README.md").unlink()
|
|
changed, warnings = run_release_preflight(str(repo))
|
|
# Should not raise; README-dependent output will be empty
|
|
assert isinstance(changed, list)
|
|
assert isinstance(warnings, list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PEP 440 pre-release (RC / alpha / beta) support
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormalizePep440:
|
|
"""``_normalize_pep440`` turns author-facing RC spellings into pip-compatible form."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"src,expected",
|
|
[
|
|
("4.50.0-rc.1", "4.50.0rc1"),
|
|
("4.50.0rc1", "4.50.0rc1"),
|
|
("4.50.0-rc1", "4.50.0rc1"),
|
|
("4.50.0rc.1", "4.50.0rc1"),
|
|
# PEP 440 §Pre-release spelling: ``alpha`` / ``beta`` collapse
|
|
# to their canonical short forms ``a`` / ``b`` in the output
|
|
# spelling (pip normalises on read anyway, but the helper's
|
|
# docstring promises to return the canonical form).
|
|
("4.50.0-alpha.2", "4.50.0a2"),
|
|
("4.50.0-beta.3", "4.50.0b3"),
|
|
("4.50.0-ALPHA.2", "4.50.0a2"), # case-insensitive alpha alias
|
|
("4.50.0-BETA.3", "4.50.0b3"), # case-insensitive beta alias
|
|
("4.50.0-a.1", "4.50.0a1"),
|
|
("4.50.0-b.1", "4.50.0b1"),
|
|
("4.50.0-RC.1", "4.50.0rc1"), # case-insensitive identifier
|
|
],
|
|
)
|
|
def test_rc_spellings_normalise(self, src, expected):
|
|
assert _normalize_pep440(src) == expected
|
|
|
|
def test_stable_version_passes_through_unchanged(self):
|
|
assert _normalize_pep440("4.50.0") == "4.50.0"
|
|
assert _normalize_pep440("1.2.3") == "1.2.3"
|
|
|
|
|
|
class TestShieldsEscape:
|
|
"""shields.io badge URL path-segment escape."""
|
|
|
|
def test_hyphen_doubled_for_rc(self):
|
|
assert _shields_escape("4.50.0-rc.1") == "4.50.0--rc.1"
|
|
|
|
def test_plain_version_unchanged(self):
|
|
assert _shields_escape("4.50.0") == "4.50.0"
|
|
|
|
def test_multiple_hyphens_all_doubled(self):
|
|
assert _shields_escape("1.2.3-rc-1") == "1.2.3--rc--1"
|
|
|
|
|
|
class TestVersionRegexAcceptsRc:
|
|
"""The widened ``_VERSION_RE`` accepts both plain and RC spellings."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"ver",
|
|
[
|
|
"4.50.0",
|
|
"4.50.0-rc.1",
|
|
"4.50.0rc1",
|
|
"4.50.0-alpha.2",
|
|
"4.50.0-beta.3",
|
|
"4.50.0-a.1",
|
|
"4.50.0-b.1",
|
|
],
|
|
)
|
|
def test_accepts_valid_versions(self, ver):
|
|
assert _VERSION_RE.match(ver) is not None, ver
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[
|
|
"",
|
|
"not-a-version",
|
|
"4.50", # too few segments
|
|
"4.50.0.1", # too many segments
|
|
"4.50.0-foo.1", # unknown identifier
|
|
"v4.50.0", # leading 'v' not allowed
|
|
],
|
|
)
|
|
def test_rejects_invalid_versions(self, bad):
|
|
assert _VERSION_RE.match(bad) is None, bad
|
|
|
|
|
|
def _make_rc_repo(tmp_path: Path, version: str = "4.50.0-rc.1") -> Path:
|
|
"""Like _make_repo but with a realistic pre-existing plain carrier set.
|
|
|
|
Mimics the real ``ouroboros`` tree state right before the VERSION
|
|
bump: VERSION carries the new RC spelling, but badge / pyproject /
|
|
ARCHITECTURE still reference the previous stable (``4.50.0``) —
|
|
``sync_release_metadata`` must migrate them to the RC spelling.
|
|
"""
|
|
(tmp_path / "VERSION").write_text(version + "\n", encoding="utf-8")
|
|
|
|
(tmp_path / "pyproject.toml").write_text(
|
|
'[project]\nname = "ouroboros"\nversion = "4.50.0"\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Existing plain-version badge that was in-sync for ``4.50.0``.
|
|
badge_line = (
|
|
'[![Version 4.50.0]'
|
|
'(https://img.shields.io/badge/version-4.50.0-green.svg)]'
|
|
'(VERSION)\n'
|
|
)
|
|
readme_content = (
|
|
"# Ouroboros\n\n"
|
|
+ badge_line
|
|
+ "\n## Version History\n\n"
|
|
"| Version | Date | Description |\n"
|
|
"|---------|------|-------------|\n"
|
|
"| 4.50.0 | 2026-04-21 | Phase 6 landed |\n"
|
|
)
|
|
(tmp_path / "README.md").write_text(readme_content, encoding="utf-8")
|
|
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "ARCHITECTURE.md").write_text(
|
|
"# Ouroboros v4.50.0 — Architecture\n\nContent here.\n",
|
|
encoding="utf-8",
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
class TestSyncReleaseMetadataRc:
|
|
"""``sync_release_metadata`` handles PEP 440 RC versions end-to-end."""
|
|
|
|
def test_syncs_pyproject_to_canonical_pep440(self, tmp_path):
|
|
repo = _make_rc_repo(tmp_path, "4.50.0-rc.1")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "pyproject.toml" in changed
|
|
text = (repo / "pyproject.toml").read_text()
|
|
# PEP 440 canonical: no separator between base and ``rc``, no dot.
|
|
assert 'version = "4.50.0rc1"' in text
|
|
# And NOT the author-facing spelling.
|
|
assert 'version = "4.50.0-rc.1"' not in text
|
|
|
|
def test_syncs_readme_badge_display_and_url_differ(self, tmp_path):
|
|
repo = _make_rc_repo(tmp_path, "4.50.0-rc.1")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "README.md" in changed
|
|
text = (repo / "README.md").read_text()
|
|
# Display text keeps the author spelling (single hyphen).
|
|
assert "[![Version 4.50.0-rc.1]" in text
|
|
# URL path doubles the literal hyphen per shields.io escape.
|
|
assert "badge/version-4.50.0--rc.1-green" in text
|
|
|
|
def test_syncs_architecture_header(self, tmp_path):
|
|
repo = _make_rc_repo(tmp_path, "4.50.0-rc.1")
|
|
changed = sync_release_metadata(str(repo))
|
|
assert "docs/ARCHITECTURE.md" in changed
|
|
text = (repo / "docs" / "ARCHITECTURE.md").read_text()
|
|
assert "v4.50.0-rc.1" in text
|
|
# Must not leave the old stable spelling around.
|
|
assert "v4.50.0 —" not in text
|
|
|
|
def test_idempotent_on_rc_version(self, tmp_path):
|
|
repo = _make_rc_repo(tmp_path, "4.50.0-rc.1")
|
|
sync_release_metadata(str(repo))
|
|
changed2 = sync_release_metadata(str(repo))
|
|
assert changed2 == []
|
|
|
|
def test_accepts_canonical_pep440_input(self, tmp_path):
|
|
"""VERSION=``4.50.0rc1`` (already canonical) works too."""
|
|
repo = _make_rc_repo(tmp_path, "4.50.0rc1")
|
|
changed = sync_release_metadata(str(repo))
|
|
# At least pyproject should update (since its starting value was 4.50.0).
|
|
assert "pyproject.toml" in changed
|
|
pyproject_text = (repo / "pyproject.toml").read_text()
|
|
assert 'version = "4.50.0rc1"' in pyproject_text
|
|
|
|
def test_rejects_unparseable_version(self, tmp_path):
|
|
repo = _make_rc_repo(tmp_path, "4.50.0-rc.1")
|
|
(repo / "VERSION").write_text("not-a-version\n", encoding="utf-8")
|
|
assert sync_release_metadata(str(repo)) == []
|
|
|
|
|
|
class TestVersionRowRegexBucketingWithRc:
|
|
"""A pre-release row buckets under its base semver slot."""
|
|
|
|
def test_rc_row_counted_as_minor(self):
|
|
readme = (
|
|
"## Version History\n\n"
|
|
"| Version | Date | Description |\n"
|
|
"|---|---|---|\n"
|
|
"| 4.50.0-rc.1 | 2026-04-21 | RC of phase 6 |\n"
|
|
"| 4.50.0 | 2026-04-21 | phase 6 |\n"
|
|
)
|
|
rows = list(_VERSION_ROW_RE.finditer(readme))
|
|
assert len(rows) == 2
|
|
# Both rows parse as X=4, Y=50, Z=0 (RC and its base share the slot).
|
|
x0, y0, z0 = (int(g) for g in rows[0].groups())
|
|
x1, y1, z1 = (int(g) for g in rows[1].groups())
|
|
assert (x0, y0, z0) == (4, 50, 0)
|
|
assert (x1, y1, z1) == (4, 50, 0)
|
|
|
|
def test_rc_rows_do_not_break_history_limit_check(self):
|
|
# 2 majors + 5 minors + 1 RC (also a minor slot) + 4 patches:
|
|
# within P9 caps overall.
|
|
readme = (
|
|
"## Version History\n\n"
|
|
"| Version | Date | Description |\n"
|
|
"|---|---|---|\n"
|
|
"| 5.0.0 | 2026-01-01 | major |\n"
|
|
"| 4.0.0 | 2026-01-01 | major |\n"
|
|
"| 4.5.0 | 2026-01-01 | minor |\n"
|
|
"| 4.4.0 | 2026-01-01 | minor |\n"
|
|
"| 4.3.0 | 2026-01-01 | minor |\n"
|
|
"| 4.2.0 | 2026-01-01 | minor |\n"
|
|
"| 4.1.0 | 2026-01-01 | minor |\n"
|
|
"| 4.1.0-rc.1 | 2026-01-01 | minor-rc |\n"
|
|
"| 4.5.5 | 2026-01-01 | patch |\n"
|
|
"| 4.5.4 | 2026-01-01 | patch |\n"
|
|
"| 4.5.3 | 2026-01-01 | patch |\n"
|
|
"| 4.5.2 | 2026-01-01 | patch |\n"
|
|
)
|
|
warnings = check_history_limit(readme)
|
|
# 6 minor rows (5 + 1 RC sharing minor slot) — should warn.
|
|
assert any("minor" in w for w in warnings)
|
|
|
|
|
|
class TestBadgeAndArchRegexAcceptRc:
|
|
"""Carrier-regex smoke: make sure the RC spellings match existing prose."""
|
|
|
|
def test_badge_regex_matches_rc_badge(self):
|
|
badge = (
|
|
'[![Version 4.50.0-rc.1]'
|
|
'(https://img.shields.io/badge/version-4.50.0--rc.1-green.svg)](VERSION)'
|
|
)
|
|
assert _README_BADGE_RE.search(badge) is not None
|
|
|
|
def test_arch_header_regex_matches_rc_header(self):
|
|
header = "# Ouroboros v4.50.0-rc.1 — Three-layer refactor\n"
|
|
assert _ARCH_HEADER_RE.search(header) is not None
|
|
|
|
def test_arch_header_regex_still_matches_stable(self):
|
|
header = "# Ouroboros v4.50.0 — Three-layer refactor\n"
|
|
assert _ARCH_HEADER_RE.search(header) is not None
|