chore: guard generated OpenAPI spec sync in pre-commit and pre-push

This commit is contained in:
rookiestar28
2026-03-12 18:43:49 +08:00
parent 069635a1f7
commit 91423bc320
5 changed files with 192 additions and 1 deletions
+6
View File
@@ -29,6 +29,12 @@ repos:
language: python
pass_filenames: false
always_run: true
- id: guard-openapi-sync
name: guard generated OpenAPI sync (staged)
entry: python -B scripts/check_openapi_sync.py --staged
language: python
pass_filenames: false
always_run: true
# Secret detection
- repo: https://github.com/Yelp/detect-secrets
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
from typing import Callable, Iterable
OPENAPI_SYNC_TRIGGER_PATHS = {
"docs/openapi.yaml",
"docs/release/api_contract.md",
"scripts/generate_openapi_spec.py",
"services/openapi_generation.py",
}
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _normalize_path(path: str) -> str:
return path.replace("\\", "/").strip()
def _get_staged_paths() -> list[str]:
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
return []
return [
_normalize_path(line)
for line in result.stdout.splitlines()
if _normalize_path(line)
]
def should_validate_openapi(paths: Iterable[str]) -> bool:
normalized = {_normalize_path(path) for path in paths if _normalize_path(path)}
if not normalized:
return False
return any(path in OPENAPI_SYNC_TRIGGER_PATHS for path in normalized)
def _load_generate_openapi_yaml() -> Callable[[str | Path | None], str]:
root = str(_repo_root())
if root not in sys.path:
sys.path.insert(0, root)
from services.openapi_generation import generate_openapi_yaml
return generate_openapi_yaml
def validate_openapi_sync(
*,
openapi_path: str | Path | None = None,
contract_path: str | Path | None = None,
generate_openapi_yaml: Callable[[str | Path | None], str] | None = None,
) -> tuple[bool, str]:
root = _repo_root()
openapi_file = Path(openapi_path) if openapi_path else root / "docs" / "openapi.yaml"
contract_file = (
Path(contract_path)
if contract_path
else root / "docs" / "release" / "api_contract.md"
)
if not openapi_file.exists():
return False, f"[OpenClaw] OpenAPI sync check failed: missing {openapi_file}"
generator = generate_openapi_yaml or _load_generate_openapi_yaml()
expected = generator(contract_file)
actual = openapi_file.read_text(encoding="utf-8")
if actual == expected:
return True, ""
message = "\n".join(
[
"[OpenClaw] Commit/push blocked: docs/openapi.yaml is out of sync.",
"",
"Generated specs must not be edited by hand without regenerating them.",
"",
"Fix:",
" 1. Update generator/contract inputs as needed.",
" 2. Regenerate with: python scripts/generate_openapi_spec.py",
" 3. Review and stage docs/openapi.yaml together with the source change.",
]
)
return False, message
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Guard that docs/openapi.yaml stays in sync with the generator."
)
parser.add_argument(
"--staged",
action="store_true",
help="Only validate when staged changes touch OpenAPI generator/spec sources.",
)
args = parser.parse_args(argv)
if args.staged and not should_validate_openapi(_get_staged_paths()):
return 0
ok, message = validate_openapi_sync()
if ok:
return 0
print(message, file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -339,6 +339,10 @@ else
fi
fi
# IMPORTANT: generated spec drift must fail before backend tests so docs-only
# edits cannot hide until deep in the pre-push unit suite.
"$VENV_PY" scripts/check_openapi_sync.py
echo "[pre-push] 3/7 backend unit tests"
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_unit" \
"$VENV_PY" scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json
+1 -1
View File
@@ -322,7 +322,7 @@ def build_openapi_document(
"in": "header",
"name": "X-OpenClaw-Bridge-Token",
},
}
},
},
}
+64
View File
@@ -0,0 +1,64 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
def _load_module():
root = Path(__file__).resolve().parents[1]
module_path = root / "scripts" / "check_openapi_sync.py"
spec = importlib.util.spec_from_file_location("openapi_sync_guard_mod", module_path)
if spec is None or spec.loader is None:
raise RuntimeError("Failed to load check_openapi_sync.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestOpenApiSyncGuard(unittest.TestCase):
def setUp(self):
self.mod = _load_module()
def test_should_validate_openapi_true_for_generated_spec(self):
self.assertTrue(self.mod.should_validate_openapi(["docs/openapi.yaml"]))
def test_should_validate_openapi_true_for_generator_source(self):
self.assertTrue(
self.mod.should_validate_openapi(["services/openapi_generation.py"])
)
def test_should_validate_openapi_false_for_unrelated_paths(self):
self.assertFalse(self.mod.should_validate_openapi(["README.md"]))
def test_validate_openapi_sync_passes_when_content_matches(self):
with tempfile.TemporaryDirectory() as td:
openapi_path = Path(td) / "openapi.yaml"
contract_path = Path(td) / "api_contract.md"
contract_path.write_text("dummy", encoding="utf-8")
openapi_path.write_text("expected-yaml\n", encoding="utf-8")
ok, message = self.mod.validate_openapi_sync(
openapi_path=openapi_path,
contract_path=contract_path,
generate_openapi_yaml=lambda _: "expected-yaml\n",
)
self.assertTrue(ok)
self.assertEqual(message, "")
def test_validate_openapi_sync_fails_when_content_drifts(self):
with tempfile.TemporaryDirectory() as td:
openapi_path = Path(td) / "openapi.yaml"
contract_path = Path(td) / "api_contract.md"
contract_path.write_text("dummy", encoding="utf-8")
openapi_path.write_text("hand-edited\n", encoding="utf-8")
ok, message = self.mod.validate_openapi_sync(
openapi_path=openapi_path,
contract_path=contract_path,
generate_openapi_yaml=lambda _: "generated\n",
)
self.assertFalse(ok)
self.assertIn("docs/openapi.yaml is out of sync", message)
self.assertIn("python scripts/generate_openapi_spec.py", message)
if __name__ == "__main__":
unittest.main()