diff --git a/docs/changelog.d/708-release-evidence-git-metadata-timeout.md b/docs/changelog.d/708-release-evidence-git-metadata-timeout.md new file mode 100644 index 000000000..de75454cf --- /dev/null +++ b/docs/changelog.d/708-release-evidence-git-metadata-timeout.md @@ -0,0 +1,5 @@ +# Release evidence Git metadata timeout + +## Fixed + +- Bound `git rev-parse` in the release evidence index builder with a fail-closed timeout. diff --git a/scripts/build_release_evidence_index.py b/scripts/build_release_evidence_index.py index 6547a2c4e..e4aef6804 100644 --- a/scripts/build_release_evidence_index.py +++ b/scripts/build_release_evidence_index.py @@ -18,6 +18,9 @@ from _bounded_json import read_json_object +GIT_METADATA_TIMEOUT_SECONDS = 5 + + REQUIRED_COVERAGE = { "acceptance_summary", "sales_readiness_manifest", @@ -45,6 +48,7 @@ def _sha256(path: Path) -> str: def _source_commit(repo_root: Path) -> str: + """Return HEAD SHA, failing closed when Git metadata lookup times out.""" try: completed = subprocess.run( ["git", "rev-parse", "HEAD"], @@ -52,7 +56,10 @@ def _source_commit(repo_root: Path) -> str: capture_output=True, text=True, check=True, + timeout=GIT_METADATA_TIMEOUT_SECONDS, ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("source commit lookup timed out") from exc except Exception: return "unknown" return completed.stdout.strip() or "unknown" diff --git a/tests/test_release_evidence_git_metadata_timeout.py b/tests/test_release_evidence_git_metadata_timeout.py new file mode 100644 index 000000000..639d8754f --- /dev/null +++ b/tests/test_release_evidence_git_metadata_timeout.py @@ -0,0 +1,40 @@ +"""Fail-first reliability contracts for release evidence Git metadata reads.""" + +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + + +def _load_release_index(): + """Load the release evidence index builder for boundary tests.""" + script = Path(__file__).resolve().parents[1] / "scripts" / "build_release_evidence_index.py" + spec = importlib.util.spec_from_file_location("build_release_evidence_index", script) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_source_commit_bounds_git_metadata_lookup(monkeypatch, tmp_path: Path) -> None: + """A hung ``git rev-parse`` must fail closed under a package-owned deadline.""" + module = _load_release_index() + observed_timeouts: list[object] = [] + + def timeout_run(*args, **kwargs): + observed_timeouts.append(kwargs.get("timeout")) + raise subprocess.TimeoutExpired( + cmd=args[0] if args else kwargs.get("args", ["git", "rev-parse", "HEAD"]), + timeout=kwargs.get("timeout"), + ) + + monkeypatch.setattr(module.subprocess, "run", timeout_run) + + with pytest.raises(RuntimeError, match="source commit lookup timed out"): + module._source_commit(tmp_path) + + assert observed_timeouts == [module.GIT_METADATA_TIMEOUT_SECONDS]