Files
Genarrative/scripts/export-gitea-rust-cache.py
T
lhk229 b1ef9eef81
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
复用 master CI 产物自动更新编译缓存
仅由 master CI 导出新增缓存对象,宿主合并去重并组装限额快照,避免重复预热编译。
跟踪任务领取与最终上报,仅在安全空闲时切换镜像,移除管理员 API 依赖。
排除取消及不完整产物,定向清理过期镜像、归档和中断上传残块。
同步部署文档、共享记忆及缓存发布、合并和清理的定向测试。
2026-09-22 08:49:58 +00:00

275 lines
12 KiB
Python

#!/usr/bin/env python3
"""只把 master push 的任务内 sccache 对象导出为 Gitea Actions 产物。"""
import base64
from datetime import datetime, timedelta, timezone
import hashlib
import io
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
MAX_OBJECT_BYTES = 4 * 1024**3
CHUNK_BYTES = 8 * 1024**2
JOBS = {
"ai-game-creator-shell-rust-lane-1",
"ai-game-creator-shell-rust-lane-2",
"ai-game-creator-shell-rust-smoke",
"ai-game-creator-shell-rust-crates",
"backend-tests",
"native-shell-tests",
}
def object_path(name):
parts = name.split("/")
return (
len(parts) == 3
and re.fullmatch(r"[a-f0-9]{64}", parts[2]) is not None
and parts[0] == parts[2][0]
and parts[1] == parts[2][1]
)
class HashingReader:
def __init__(self, stream):
self.stream = stream
self.digest = hashlib.sha256()
def read(self, size=-1):
data = self.stream.read(size)
self.digest.update(data)
return data
def scan_objects(root):
objects = root / "objects"
if objects.is_symlink() or not objects.is_dir():
raise ValueError("cache object directory is unavailable")
candidates = []
for directory, directories, files in os.walk(objects, followlinks=False):
directories[:] = [
name for name in directories if not (Path(directory) / name).is_symlink()
]
for filename in files:
path = Path(directory) / filename
relative = path.relative_to(objects).as_posix()
info = path.lstat()
if object_path(relative) and stat.S_ISREG(info.st_mode):
candidates.append((path, relative, info))
return candidates
def save_baseline(root, destination):
# sccache 命中会更新 mtime,不能把时间变化当成内容变化,否则又变成全量上传。
baseline = {
relative: {"size": info.st_size, "mtime_ns": info.st_mtime_ns}
for _, relative, info in scan_objects(root)
}
destination.write_text(json.dumps(baseline), encoding="utf-8")
def pack_snapshot(root, destination, metadata, baseline, limit=MAX_OBJECT_BYTES):
"""仅归档新 key;命中的已有对象只传递新近使用时间,不传输对象内容。"""
candidates = scan_objects(root)
candidates.sort(key=lambda item: (-item[2].st_mtime_ns, item[1]))
entries = []
touched = []
total = 0
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_STORED) as bundle, \
bundle.open("snapshot.tar", "w", force_zip64=True) as tar_stream, \
tarfile.open(fileobj=tar_stream, mode="w|", format=tarfile.USTAR_FORMAT) as archive:
for path, relative, info in candidates:
previous = baseline.get(relative)
if previous is not None and previous["size"] == info.st_size:
if previous["mtime_ns"] != info.st_mtime_ns:
touched.append({"path": "objects/" + relative, "mtime_ns": info.st_mtime_ns})
continue
if info.st_size <= 0 or total + info.st_size > limit:
continue
# report 已成功停止 daemon;打开后再核实对象,避免导出变化中的文件。
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
with os.fdopen(descriptor, "rb") as stream:
actual = os.fstat(stream.fileno())
if not stat.S_ISREG(actual.st_mode) or (
actual.st_size, actual.st_mtime_ns, actual.st_ino
) != (info.st_size, info.st_mtime_ns, info.st_ino):
raise ValueError("cache object changed during export")
member = tarfile.TarInfo("objects/" + relative)
member.size = info.st_size
member.mode = 0o644
member.mtime = int(info.st_mtime)
reader = HashingReader(stream)
archive.addfile(member, reader)
entries.append({
"path": member.name,
"size": info.st_size,
"sha256": reader.digest.hexdigest(),
"mtime_ns": info.st_mtime_ns,
})
total += info.st_size
manifest = dict(metadata, schema=1, mode="delta", objects=entries, touched=touched)
data = json.dumps(manifest, ensure_ascii=False, sort_keys=True).encode("utf-8")
member = tarfile.TarInfo("manifest.json")
member.size = len(data)
member.mode = 0o644
archive.addfile(member, io.BytesIO(data))
return len(entries), total
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
# runtime token 只允许发往明确配置的 Gitea 地址。
return None
def request(method, url, token, data=None, headers=None, attempts=3):
opener = urllib.request.build_opener(NoRedirect())
for attempt in range(attempts):
req = urllib.request.Request(url, data=data, method=method, headers={
**({"Authorization": "Bearer " + token} if token else {}),
"Content-Type": "application/json",
**(headers or {}),
})
try:
with opener.open(req, timeout=120) as response:
body = response.read(1024 * 1024)
return json.loads(body) if body else {}
except urllib.error.HTTPError as error:
if error.code not in (408, 429, 500, 502, 503, 504) or attempt == attempts - 1:
raise RuntimeError(f"artifact {method} failed: HTTP {error.code}") from None
except (urllib.error.URLError, TimeoutError, ConnectionError):
if attempt == attempts - 1:
raise RuntimeError(f"artifact {method} connection failed") from None
time.sleep(2 ** attempt)
def artifact_base_url(env):
# .runner.address 可指向仅支持 RPC 的领取网关,不能用 GITHUB_SERVER_URL。
repository = env["GITHUB_REPOSITORY"]
clone_url = env["GENARRATIVE_GITEA_REPOSITORY_URL"]
suffix = "/" + repository + ".git"
parsed = urllib.parse.urlsplit(clone_url)
if (
parsed.scheme not in ("http", "https")
or not parsed.netloc
or parsed.username or parsed.password or parsed.query or parsed.fragment
or not parsed.path.endswith(suffix)
):
raise ValueError("a credential-free HTTP Gitea repository URL is required")
return urllib.parse.urlunsplit((
parsed.scheme, parsed.netloc, parsed.path[:-len(suffix)], "", ""
))
def upload_snapshot(path, name, run_id, base_url, token):
"""Gitea 1.26.4 原生 v4:只有完成全部块并校验 SHA256 后才发布。"""
producer_match = re.fullmatch(r"rust-cache-v1-([a-z0-9-]+)-attempt-(0|[1-9][0-9]*)", name)
if not producer_match or producer_match[1] not in JOBS:
raise ValueError("unexpected cache artifact name")
producer = f"{producer_match[1]}:{producer_match[2]}"
endpoint = f"{base_url}/twirp/github.actions.results.api.v1.ArtifactService"
# Gitea 从任务凭据确定 job,只要求请求中的 run ID 与任务所属 run 一致。
identity = {"workflowRunBackendId": str(run_id), "name": name}
created = request("POST", endpoint + "/CreateArtifact", token, json.dumps({
**identity, "version": 4,
# Gitea 将剩余小时向下取整成天;多给一天,服务端才实际保留至少七天。
"expiresAt": (datetime.now(timezone.utc) + timedelta(days=8)).isoformat(),
}).encode())
received = urllib.parse.urlsplit(created.get("signedUploadUrl", ""))
expected_path = urllib.parse.urlsplit(endpoint).path + "/UploadArtifact"
if not created.get("ok") or received.path != expected_path or not received.query:
raise ValueError("unexpected artifact upload path")
# 服务可能返回外部 AppURL;保留签名,但只连接配置的内部 Gitea 地址。
upload_url = endpoint + "/UploadArtifact?" + received.query
size = path.stat().st_size
digest = hashlib.sha256()
blocks = []
with path.open("rb") as source:
while chunk := source.read(CHUNK_BYTES):
# 双层编码中的内层保留所属任务;宿主只回收带此前缀的过期未完成块。
block = base64.b64encode(
f"genarrative-rust-cache-v1:{producer}:{len(blocks):08d}".encode()
).decode()
blocks.append(block)
digest.update(chunk)
request("PUT", upload_url + "&" + urllib.parse.urlencode({
"comp": "block", "blockid": block,
}), None, chunk, {
"Content-Type": "application/octet-stream",
})
blocklist = "<BlockList>" + "".join(f"<Latest>{block}</Latest>" for block in blocks) + "</BlockList>"
request("PUT", upload_url + "&comp=blocklist", None, blocklist.encode(), {
"Content-Type": "application/xml",
})
# Finalize 会消耗服务端块列表;响应不确定时不盲目重试、也不宣告成功。
finalized = request("POST", endpoint + "/FinalizeArtifact", token, json.dumps({
**identity, "size": str(size), "hash": "sha256:" + digest.hexdigest(),
}).encode(), attempts=1)
if not finalized.get("ok"):
raise ValueError("artifact was not finalized")
def export(env):
# 双重限定:手工调用、PR 和其它分支不会扫描、打包或上传对象。
if env.get("GITHUB_EVENT_NAME") != "push" or env.get("GITHUB_REF") != "refs/heads/master":
return
if env.get("GENARRATIVE_CI_RUST_CACHE_EXPORT_READY") != "1":
print("[rust-cache] export skipped: cache daemon did not stop cleanly")
return
job = env.get("GITHUB_JOB")
if job not in JOBS:
raise ValueError("unexpected Rust cache job")
sha = env["GITHUB_SHA"]
if not re.fullmatch(r"[a-f0-9]{40}", sha):
raise ValueError("a complete source SHA is required")
run_id, attempt = int(env["GITHUB_RUN_ID"]), int(env["GITHUB_RUN_ATTEMPT"])
if run_id <= 0 or attempt < 0:
raise ValueError("invalid run identity")
token = env.get("ACTIONS_RUNTIME_TOKEN") or env["GENARRATIVE_GITEA_TOKEN"]
base_url = artifact_base_url(env)
root = Path(env.get("GENARRATIVE_CI_RUST_CACHE_ROOT", "/opt/genarrative-ci/rust-cache"))
baseline_path = root / "export-baseline.json"
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
rustc = subprocess.check_output(["rustc", "-vV"], text=True)
workspace = str(Path.cwd().resolve())
if rustc != (root / "rustc.txt").read_text() or workspace != (root / "workspace.txt").read_text().strip():
raise ValueError("cache provenance changed after prepare")
metadata = {
"repository": env["GITHUB_REPOSITORY"],
"run_id": run_id, "run_attempt": attempt, "job": job, "source_sha": sha,
"rustc": rustc, "workspace": workspace,
"inherited_source_sha": (root / "source-commit.txt").read_text().strip(),
"sccache_version": subprocess.check_output([str(root / "sccache"), "--version"], text=True).strip(),
}
if (root / "base-image.txt").is_file():
metadata["base_image"] = (root / "base-image.txt").read_text().strip()
name = f"rust-cache-v1-{job}-attempt-{attempt}"
try:
with tempfile.TemporaryDirectory(prefix="ci-rust-cache-export-") as directory:
path = Path(directory) / "snapshot.zip"
count, size = pack_snapshot(root, path, metadata, baseline)
upload_snapshot(path, name, run_id, base_url, token)
finally:
baseline_path.unlink(missing_ok=True)
print(f"[rust-cache] artifact={name} objects={count} bytes={size} complete=true")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "baseline":
save_baseline(Path(os.environ.get("GENARRATIVE_CI_RUST_CACHE_ROOT", "/opt/genarrative-ci/rust-cache")), Path(sys.argv[2]))
else:
export(os.environ)