152 lines
4.6 KiB
Python
152 lines
4.6 KiB
Python
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()
|