Files
Auto_Bangumi/e2e/scripts/build_test_image.py
2026-07-10 13:49:52 +02:00

183 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Build the native E2E image from an isolated temporary context."""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Iterable, Sequence
DEFAULT_VERSION = "3.3.999-e2e.1"
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$")
def repository_root() -> Path:
return Path(__file__).resolve().parents[2]
def validate_version(version: str) -> str:
if not _VERSION_RE.fullmatch(version):
raise ValueError(f"E2E image version must be SemVer-compatible: {version!r}")
return version
def tracked_backend_files(repo_root: Path) -> tuple[Path, ...]:
"""Return tracked build inputs while excluding unrelated untracked files."""
result = subprocess.run(
[
"git",
"ls-files",
"-z",
"--",
".dockerignore",
"Dockerfile",
"entrypoint.sh",
"backend",
],
cwd=repo_root,
check=True,
capture_output=True,
)
tracked = (
Path(value.decode("utf-8")) for value in result.stdout.split(b"\0") if value
)
# `git ls-files` still reports tracked files deleted in the current
# worktree. Local pre-commit image verification must reflect those
# deletions instead of trying to copy paths that intentionally no longer
# exist. Explicit `tracked_files` passed to `prepare_context` remain
# strict and still surface missing required inputs.
return tuple(path for path in tracked if (repo_root / path).is_file())
def _safe_relative(path: Path) -> Path:
if path.is_absolute() or ".." in path.parts:
raise ValueError(f"Tracked path must stay inside the repository: {path}")
return path
def prepare_context(
repo_root: Path,
dist_dir: Path,
destination: Path,
*,
version: str = DEFAULT_VERSION,
tracked_files: Iterable[Path] | None = None,
) -> Path:
"""Copy tracked build inputs, WebUI dist, and version into *destination*."""
version = validate_version(version)
repo_root = repo_root.resolve()
dist_dir = dist_dir.resolve()
destination = destination.resolve()
if not (dist_dir / "index.html").is_file():
raise FileNotFoundError(f"Built WebUI is missing index.html: {dist_dir}")
destination.mkdir(parents=True, exist_ok=True)
selected = tracked_files or tracked_backend_files(repo_root)
for relative in selected:
relative = _safe_relative(Path(relative))
source = repo_root / relative
if not source.is_file():
raise FileNotFoundError(f"Tracked build input is missing: {source}")
target = destination / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
staged_dist = destination / "backend/src/dist"
if staged_dist.exists():
shutil.rmtree(staged_dist)
shutil.copytree(dist_dir, staged_dist)
version_module = destination / "backend/src/module/__version__.py"
version_module.parent.mkdir(parents=True, exist_ok=True)
version_module.write_text(f'VERSION = "{version}"\n', encoding="utf-8")
return destination
def build_command(context: Path, *, image: str, version: str) -> list[str]:
validate_version(version)
if not image or any(character.isspace() for character in image):
raise ValueError(f"Invalid Docker image name: {image!r}")
return [
"docker",
"build",
"--build-arg",
f"VERSION={version}",
"--tag",
image,
"--pull=false",
str(context.resolve()),
]
def verification_command(*, image: str, version: str) -> list[str]:
validate_version(version)
script = (
"import sys; "
"from pathlib import Path; "
"from module.__version__ import VERSION; "
"expected=sys.argv[1]; "
"image_version=Path('/app/IMAGE_VERSION').read_text().strip(); "
"assert VERSION == expected == image_version, "
"(VERSION, expected, image_version)"
)
return [
"docker",
"run",
"--rm",
"--entrypoint",
"python",
image,
"-c",
script,
version,
]
def build_image(
*,
repo_root: Path,
dist_dir: Path,
image: str,
version: str,
runner=subprocess.run,
) -> None:
with tempfile.TemporaryDirectory(prefix="ab-e2e-build-") as temporary:
context = prepare_context(
repo_root,
dist_dir,
Path(temporary) / "context",
version=version,
)
runner(build_command(context, image=image, version=version), check=True)
runner(verification_command(image=image, version=version), check=True)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", default=DEFAULT_VERSION)
parser.add_argument("--image", default="auto-bangumi:e2e")
parser.add_argument("--dist", type=Path)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
root = repository_root()
dist = args.dist or root / "webui/dist"
build_image(
repo_root=root,
dist_dir=dist,
image=args.image,
version=args.version,
)
print(f"Built and verified {args.image} at version {args.version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())