CI-GATE.0-2: establish repository CI canary
This commit is contained in:
151
app/tests/static/test_ci_gate_contract.py
Normal file
151
app/tests/static/test_ci_gate_contract.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
WORKFLOW_PATH = PROJECT_ROOT / ".gitea" / "workflows" / "ci-required.yml"
|
||||
DEV_LOCK_PATH = PROJECT_ROOT / "app" / "requirements-dev.lock"
|
||||
PRODUCTION_LOCK_PATH = PROJECT_ROOT / "app" / "requirements.lock"
|
||||
CHECKOUT_REFERENCE = (
|
||||
"https://github.com/actions/checkout"
|
||||
"@34e114876b0b11c390a56381ad16ebd13914f8d5"
|
||||
)
|
||||
EXPECTED_JOB_IDS = (
|
||||
"quality",
|
||||
"offline-regression",
|
||||
"loopback-integration",
|
||||
)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _logical_requirements(lock_text: str) -> tuple[str, ...]:
|
||||
records: list[str] = []
|
||||
current: list[str] = []
|
||||
|
||||
for raw_line in lock_text.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
current.append(stripped.removesuffix("\\").rstrip())
|
||||
if stripped.endswith("\\"):
|
||||
continue
|
||||
|
||||
records.append(" ".join(current))
|
||||
current.clear()
|
||||
|
||||
assert not current, "Последняя запись lock-файла не завершена"
|
||||
return tuple(records)
|
||||
|
||||
|
||||
def _locked_versions(lock_text: str) -> dict[str, str]:
|
||||
versions: dict[str, str] = {}
|
||||
for record in _logical_requirements(lock_text):
|
||||
match = re.match(r"^([A-Za-z0-9_.-]+)==([^ ;]+)", record)
|
||||
assert match is not None, f"Не распознана lock-запись: {record}"
|
||||
normalized_name = match.group(1).lower().replace("_", "-")
|
||||
version = match.group(2)
|
||||
previous = versions.setdefault(normalized_name, version)
|
||||
assert previous == version, (
|
||||
f"Для {normalized_name} обнаружены версии {previous} и {version}"
|
||||
)
|
||||
return versions
|
||||
|
||||
|
||||
def test_workflow_is_manual_canary_only() -> None:
|
||||
workflow = _read(WORKFLOW_PATH)
|
||||
trigger = workflow.split("\non:\n", maxsplit=1)[1].split(
|
||||
"\nenv:\n",
|
||||
maxsplit=1,
|
||||
)[0]
|
||||
|
||||
assert trigger.strip() == "workflow_dispatch: {}"
|
||||
assert "push:" not in trigger
|
||||
assert "pull_request:" not in trigger
|
||||
assert "schedule:" not in trigger
|
||||
assert "workflow_run:" not in trigger
|
||||
|
||||
|
||||
def test_workflow_has_stable_jobs_and_runner_label() -> None:
|
||||
workflow = _read(WORKFLOW_PATH)
|
||||
jobs = workflow.split("\njobs:\n", maxsplit=1)[1]
|
||||
job_ids = tuple(
|
||||
re.findall(
|
||||
r"^ ([a-z][a-z0-9-]*):\n name: \1$",
|
||||
jobs,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
)
|
||||
|
||||
assert job_ids == EXPECTED_JOB_IDS
|
||||
assert jobs.count(" runs-on: dzentra-python312\n") == len(
|
||||
EXPECTED_JOB_IDS
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_uses_only_immutable_checkout_reference() -> None:
|
||||
workflow = _read(WORKFLOW_PATH)
|
||||
action_references = tuple(
|
||||
re.findall(r"^\s+uses:\s+(\S+)$", workflow, flags=re.MULTILINE)
|
||||
)
|
||||
|
||||
assert action_references == (CHECKOUT_REFERENCE,) * len(EXPECTED_JOB_IDS)
|
||||
assert workflow.count(" persist-credentials: false\n") == len(
|
||||
EXPECTED_JOB_IDS
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_avoids_unsupported_gitea_125_controls() -> None:
|
||||
workflow = _read(WORKFLOW_PATH)
|
||||
|
||||
for key in (
|
||||
"concurrency",
|
||||
"continue-on-error",
|
||||
"environment",
|
||||
"permissions",
|
||||
"timeout-minutes",
|
||||
):
|
||||
assert re.search(rf"^\s*{re.escape(key)}:", workflow, re.MULTILINE) is None
|
||||
|
||||
|
||||
def test_workflow_is_offline_and_secret_free_after_dependency_install() -> None:
|
||||
workflow = _read(WORKFLOW_PATH)
|
||||
|
||||
assert workflow.count(" --require-hashes \\\n") == len(
|
||||
EXPECTED_JOB_IDS
|
||||
)
|
||||
assert workflow.count(" -r app/requirements-dev.lock\n") == len(
|
||||
EXPECTED_JOB_IDS
|
||||
)
|
||||
assert "secrets." not in workflow
|
||||
assert "DZENTRA_RUN_LIVE_TESTS" not in workflow
|
||||
assert "DZENTRA_RUN_POSTGRES_TESTS" not in workflow
|
||||
assert "docker " not in workflow.lower()
|
||||
|
||||
|
||||
def test_dev_lock_has_hash_for_every_requirement() -> None:
|
||||
lock_text = _read(DEV_LOCK_PATH)
|
||||
records = _logical_requirements(lock_text)
|
||||
|
||||
assert lock_text.startswith(
|
||||
"# Сгенерированный hash-lock developer/CI-зависимостей; "
|
||||
"вручную не редактировать.\n"
|
||||
)
|
||||
assert records
|
||||
for record in records:
|
||||
assert "==" in record
|
||||
assert "--hash=sha256:" in record
|
||||
assert "https://" not in record
|
||||
assert "git+" not in record
|
||||
|
||||
|
||||
def test_dev_lock_preserves_all_production_versions() -> None:
|
||||
production_versions = _locked_versions(_read(PRODUCTION_LOCK_PATH))
|
||||
dev_versions = _locked_versions(_read(DEV_LOCK_PATH))
|
||||
|
||||
assert production_versions.items() <= dev_versions.items()
|
||||
Reference in New Issue
Block a user