1e6b0e5684
使用专用 BuildKit builder 持久复用 Cargo 与 npm 下载缓存,并支持从可信镜像导入 按当前依赖物化镜像下载快照,配置独立缓存回收策略 补齐 AGC vendor 本地依赖清单并缩小构建上下文 增加缓存维护阶段、耗时和失败日志路径 补充下载缓存与构建上下文测试,同步部署说明和共享记忆
192 lines
8.2 KiB
Python
192 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Regression tests for the minimal trusted Gitea CI download-cache build context."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import shlex
|
|
import stat
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import textwrap
|
|
import tomllib
|
|
import unittest
|
|
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parent.parent
|
|
IMAGE_SCRIPT = REPOSITORY_ROOT / "scripts" / "gitea-ci-job-image.sh"
|
|
STATIC_CONTEXT_FILES = (
|
|
"deploy/container/gitea-ci-job.Dockerfile",
|
|
"deploy/container/gitea-ci-job.Dockerfile.dockerignore",
|
|
"deploy/container/gitea-ci-buildkitd.toml",
|
|
"deploy/container/gitea-ci-checkout.sh",
|
|
"scripts/export-ci-npm-download-cache.mjs",
|
|
"package.json",
|
|
"package-lock.json",
|
|
"apps/admin-web/package.json",
|
|
"apps/ai-game-creator-shell/package.json",
|
|
"apps/desktop-shell/package.json",
|
|
"apps/mobile-shell/package.json",
|
|
"apps/preview-deployer-web/package.json",
|
|
"packages/image-canvas-core/package.json",
|
|
"packages/image-canvas-react/package.json",
|
|
"packages/shared/package.json",
|
|
"tools/spine-json-export-validator/package.json",
|
|
"apps/ai-game-creator-shell/src-tauri/Cargo.toml",
|
|
"apps/ai-game-creator-shell/src-tauri/Cargo.lock",
|
|
"server-rs/Cargo.toml",
|
|
"server-rs/Cargo.lock",
|
|
"apps/desktop-shell/src-tauri/Cargo.toml",
|
|
"apps/desktop-shell/src-tauri/Cargo.lock",
|
|
)
|
|
|
|
|
|
class GiteaCiImageContextTest(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.temporary_directory = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
|
self.root = Path(self.temporary_directory.name)
|
|
self.context_archive = self.root / "context.tar"
|
|
self.bin = self.root / "bin"
|
|
self.bin.mkdir()
|
|
self.write_fake_docker()
|
|
self.execution_bin = self.wsl_path(self.bin)
|
|
self.wsl_fake_root: str | None = None
|
|
if os.name == "nt":
|
|
self.wsl_fake_root = f"/tmp/gitea-ci-image-context-{self.root.name}"
|
|
subprocess.run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
"rm -rf {root}; mkdir -p {root}/bin; cp {source}/* {root}/bin/; chmod +x {root}/bin/*".format(
|
|
root=shlex.quote(self.wsl_fake_root),
|
|
source=shlex.quote(self.wsl_path(self.bin)),
|
|
),
|
|
],
|
|
check=True,
|
|
)
|
|
self.execution_bin = f"{self.wsl_fake_root}/bin"
|
|
|
|
def tearDown(self) -> None:
|
|
if self.wsl_fake_root is not None:
|
|
subprocess.run(
|
|
["bash", "-c", f"rm -rf {shlex.quote(self.wsl_fake_root)}"], check=False
|
|
)
|
|
self.temporary_directory.cleanup()
|
|
|
|
@staticmethod
|
|
def wsl_path(path: Path) -> str:
|
|
value = path.resolve().as_posix()
|
|
if len(value) >= 3 and value[1] == ":":
|
|
return f"/mnt/{value[0].lower()}{value[2:]}"
|
|
return value
|
|
|
|
def write_fake_docker(self) -> None:
|
|
docker = self.bin / "docker"
|
|
docker.write_bytes(textwrap.dedent(
|
|
"""#!/usr/bin/env bash
|
|
set -eu
|
|
if [[ "$1" == buildx && "$2" == version ]]; then exit 0; fi
|
|
if [[ "$1" == buildx && "$2" == inspect ]]; then
|
|
if [[ " $* " == *" --format "* ]]; then printf 'docker-container\\n'; fi
|
|
exit 0
|
|
fi
|
|
if [[ "$1" == buildx && "$2" == build ]]; then
|
|
cat > "$TAR_CAPTURE"
|
|
exit 0
|
|
fi
|
|
if [[ "$1" == image && "$2" == inspect ]]; then
|
|
printf 'sha256:%064d\\n' 0
|
|
exit 0
|
|
fi
|
|
if [[ "$1" == run ]]; then exit 0; fi
|
|
echo "unexpected docker invocation" >&2
|
|
exit 9
|
|
"""
|
|
).encode("utf-8"))
|
|
docker.chmod(docker.stat().st_mode | stat.S_IXUSR)
|
|
|
|
def run_script(self, script: Path, *arguments: str, capture_context: bool = False) -> subprocess.CompletedProcess[str]:
|
|
exports = [f"export PATH={shlex.quote(self.execution_bin)}:\"$PATH\""]
|
|
if capture_context:
|
|
exports.append(f"export TAR_CAPTURE={shlex.quote(self.wsl_path(self.context_archive))}")
|
|
command = "; ".join(exports) + "; cd /; exec bash " + shlex.quote(self.wsl_path(script))
|
|
command += " " + " ".join(shlex.quote(argument) for argument in arguments)
|
|
return subprocess.run(
|
|
["bash", "-c", command], env=os.environ, text=True, capture_output=True, check=False
|
|
)
|
|
|
|
@staticmethod
|
|
def dependency_paths(value):
|
|
if not isinstance(value, dict):
|
|
return
|
|
for key, child in value.items():
|
|
if key in {"dependencies", "build-dependencies", "dev-dependencies"} and isinstance(child, dict):
|
|
for dependency in child.values():
|
|
if isinstance(dependency, dict) and isinstance(dependency.get("path"), str):
|
|
yield dependency["path"]
|
|
yield from GiteaCiImageContextTest.dependency_paths(child)
|
|
|
|
def test_build_context_contains_all_local_dependency_manifests_and_no_source(self) -> None:
|
|
result = self.run_script(IMAGE_SCRIPT, "build", capture_context=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
with tarfile.open(self.context_archive) as archive:
|
|
names = {member.name.removeprefix("./") for member in archive.getmembers() if member.isfile()}
|
|
|
|
manifests = {Path(name) for name in names if name.endswith("Cargo.toml")}
|
|
self.assertTrue(manifests)
|
|
expected = set()
|
|
for manifest in manifests:
|
|
data = tomllib.loads((REPOSITORY_ROOT / manifest).read_text(encoding="utf-8"))
|
|
for path in self.dependency_paths(data):
|
|
dependency = (REPOSITORY_ROOT / manifest.parent / path).resolve()
|
|
try:
|
|
cargo_toml = (dependency / "Cargo.toml").relative_to(REPOSITORY_ROOT)
|
|
except ValueError:
|
|
continue
|
|
expected.add(cargo_toml)
|
|
self.assertTrue(expected)
|
|
self.assertTrue(expected.issubset(manifests), sorted(expected - manifests))
|
|
|
|
self.assertFalse(any(Path(name).suffix in {".rs", ".c", ".cc", ".cpp", ".h"} for name in names))
|
|
self.assertFalse(any("target" in Path(name).parts for name in names))
|
|
self.assertFalse(any(
|
|
Path(name).name.startswith(".env") or Path(name).suffix in {".pem", ".key"}
|
|
for name in names
|
|
))
|
|
|
|
def test_revision_tracks_vendor_manifests_but_ignores_regular_source(self) -> None:
|
|
fixture = self.root / "revision-fixture"
|
|
script = fixture / "scripts" / "gitea-ci-job-image.sh"
|
|
script.parent.mkdir(parents=True)
|
|
shutil.copy2(IMAGE_SCRIPT, script)
|
|
script.chmod(script.stat().st_mode | stat.S_IXUSR)
|
|
for name in STATIC_CONTEXT_FILES:
|
|
target = fixture / name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text("fixture\n", encoding="utf-8")
|
|
vendor_manifest = fixture / "apps/ai-game-creator-shell/src-tauri/vendor/example/Cargo.toml"
|
|
vendor_manifest.parent.mkdir(parents=True)
|
|
vendor_manifest.write_text('[package]\nname = "example"\nversion = "0.1.0"\n', encoding="utf-8")
|
|
bridge_manifest = fixture / "plugins/agc-example-editor/native/example-editor-bridge/Cargo.toml"
|
|
bridge_manifest.parent.mkdir(parents=True)
|
|
bridge_manifest.write_text('[package]\nname = "bridge"\nversion = "0.1.0"\n', encoding="utf-8")
|
|
(fixture / "server-rs/crates").mkdir(parents=True)
|
|
|
|
first = self.run_script(script, "revision")
|
|
self.assertEqual(first.returncode, 0, first.stderr)
|
|
source = fixture / "apps/ai-game-creator-shell/src-tauri/src/lib.rs"
|
|
source.parent.mkdir(parents=True)
|
|
source.write_text("pub fn ignored() {}\n", encoding="utf-8")
|
|
self.assertEqual(self.run_script(script, "revision").stdout, first.stdout)
|
|
vendor_manifest.write_text('[package]\nname = "example"\nversion = "0.2.0"\n', encoding="utf-8")
|
|
changed = self.run_script(script, "revision")
|
|
self.assertEqual(changed.returncode, 0, changed.stderr)
|
|
self.assertNotEqual(changed.stdout, first.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|