补齐 CI 缓存维护器请求与任务契约校验
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
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
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
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
要求 API 请求显式指定 HTTP 方法并更新全部调用点 增加 workflow 全部任务名称与缓存导出映射的契约测试 从 Rust 任务映射推导集合和数量,消除重复硬编码 同步开发流程中的维护约定
This commit was merged in pull request #467.
This commit is contained in:
@@ -33,7 +33,6 @@ JOBS = {
|
||||
"Backend tests", "Native shell tests", "Frontend tests",
|
||||
"Repository checks", "AI game creator shell web tests",
|
||||
}
|
||||
RUST_JOBS = {name for name in JOBS if "Rust" in name or name in {"Backend tests", "Native shell tests"}}
|
||||
RUST_JOB_IDS = {
|
||||
"AI game creator shell Rust lane 1/2": "ai-game-creator-shell-rust-lane-1",
|
||||
"AI game creator shell Rust lane 2/2": "ai-game-creator-shell-rust-lane-2",
|
||||
@@ -42,6 +41,7 @@ RUST_JOB_IDS = {
|
||||
"Backend tests": "backend-tests",
|
||||
"Native shell tests": "native-shell-tests",
|
||||
}
|
||||
RUST_JOBS = set(RUST_JOB_IDS)
|
||||
ARTIFACT_PREFIX = "rust-cache-v1-"
|
||||
MAX_DOWNLOAD = 4 * 1024 ** 3 + 129 * 1024 ** 2
|
||||
EXPORT_STEP = "Publish master Rust cache artifact"
|
||||
@@ -143,14 +143,14 @@ class Api:
|
||||
raise ValueError("api_url must use HTTPS")
|
||||
self.token_file = Path(token_file)
|
||||
|
||||
def request(self, path, body=None, raw=False, method=None):
|
||||
def request(self, path, *, method, body=None, raw=False):
|
||||
token = self.token_file.read_text().strip()
|
||||
if not token or "\n" in token:
|
||||
raise ValueError("invalid token file")
|
||||
request = urllib.request.Request(
|
||||
self.url + "/" + path.lstrip("/"),
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
method=method or ("GET" if body is None else "PATCH"),
|
||||
method=method,
|
||||
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
@@ -199,7 +199,7 @@ class Api:
|
||||
separator = "&" if "?" in path else "?"
|
||||
page = 1
|
||||
while True:
|
||||
response = self.request(f"{path}{separator}limit=50&page={page}")
|
||||
response = self.request(f"{path}{separator}limit=50&page={page}", method="GET")
|
||||
items = response[key]
|
||||
yield from items
|
||||
if len(items) < 50:
|
||||
@@ -328,7 +328,7 @@ class Maintenance:
|
||||
continue
|
||||
jobs = list(self.api.pages(self.repo_api + f'/actions/runs/{run["id"]}/jobs', "jobs"))
|
||||
rust_jobs = [job for job in jobs if job["name"] in RUST_JOB_IDS]
|
||||
if len(rust_jobs) != 6 or {job["name"] for job in rust_jobs} != RUST_JOBS:
|
||||
if len(rust_jobs) != len(RUST_JOB_IDS) or {job["name"] for job in rust_jobs} != RUST_JOBS:
|
||||
continue
|
||||
if any(job.get("status") != "completed" or job.get("conclusion") not in {"success", "failure"}
|
||||
or job.get("head_sha") != sha
|
||||
@@ -344,7 +344,7 @@ class Maintenance:
|
||||
and item.get("workflow_run", {}).get("id") == run["id"]]
|
||||
if len(matches) != 1:
|
||||
break
|
||||
content = self.api.request(self.repo_api + f'/actions/jobs/{job["id"]}/logs', raw=True)
|
||||
content = self.api.request(self.repo_api + f'/actions/jobs/{job["id"]}/logs', method="GET", raw=True)
|
||||
if not re.search(re.escape(f"[rust-cache] artifact={name}")
|
||||
+ r" objects=\d+ bytes=\d+ complete=true", content):
|
||||
break
|
||||
@@ -354,7 +354,7 @@ class Maintenance:
|
||||
images.update(used)
|
||||
selected.append({"id": matches[0]["id"], "name": name,
|
||||
"job": RUST_JOB_IDS[job["name"]], "attempt": job["run_attempt"]})
|
||||
if len(selected) != 6 or len(images) != 1:
|
||||
if len(selected) != len(RUST_JOB_IDS) or len(images) != 1:
|
||||
continue
|
||||
source_image = images.pop()
|
||||
if source_image not in {row["image"] for row in self.state["versions"]}:
|
||||
@@ -493,7 +493,7 @@ class Maintenance:
|
||||
if not fully_passed(run, jobs):
|
||||
continue
|
||||
if all(verifies_image(job, self.api.request(
|
||||
self.repo_api + f'/actions/jobs/{job["id"]}/logs', raw=True), current["image"],
|
||||
self.repo_api + f'/actions/jobs/{job["id"]}/logs', method="GET", raw=True), current["image"],
|
||||
require_cache=current.get("source") is not None,
|
||||
) for job in jobs):
|
||||
current["verified_run"] = run["id"]
|
||||
@@ -588,7 +588,7 @@ class Maintenance:
|
||||
continue
|
||||
# Artifact.workflow_run 在 Gitea 1.26.4 中只有 id/repository_id/head_sha。
|
||||
if run_id not in runs:
|
||||
runs[run_id] = self.api.request(self.repo_api + f"/actions/runs/{run_id}")
|
||||
runs[run_id] = self.api.request(self.repo_api + f"/actions/runs/{run_id}", method="GET")
|
||||
run = runs[run_id]
|
||||
if not self.master_run(run) or run.get("status") != "completed":
|
||||
continue
|
||||
@@ -831,7 +831,7 @@ def main():
|
||||
config = json.loads(Path(args.config).read_text())
|
||||
maintenance = Maintenance(config)
|
||||
if not args.apply and not args.resume:
|
||||
maintenance.api.request(maintenance.repo_api + "/actions/artifacts?limit=1")
|
||||
maintenance.api.request(maintenance.repo_api + "/actions/artifacts?limit=1", method="GET")
|
||||
image = configured_image(maintenance.read_config())
|
||||
head = command("git", "ls-remote", config["clone_url"], "refs/heads/master").split()[0]
|
||||
maintenance.check_gate_route()
|
||||
|
||||
@@ -7,6 +7,7 @@ import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -32,7 +33,7 @@ class FakeApi:
|
||||
def __init__(self):
|
||||
self.requests: list[str] = []
|
||||
|
||||
def request(self, path, body=None, raw=False):
|
||||
def request(self, path, *, method, body=None, raw=False):
|
||||
self.requests.append(path)
|
||||
raise AssertionError(f"unexpected API request: {path}")
|
||||
|
||||
@@ -58,6 +59,41 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_api_requires_explicit_method_and_preserves_it_with_body(self):
|
||||
api = maintenance_module.Api(self.config["api_url"], self.token)
|
||||
with self.assertRaises(TypeError):
|
||||
api.request("example", body={"value": 1})
|
||||
with self.assertRaises(TypeError):
|
||||
api.request("example")
|
||||
with patch.object(maintenance_module.urllib.request, "build_opener") as build_opener:
|
||||
for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||
with self.subTest(method=method):
|
||||
build_opener.return_value.open.return_value = io.BytesIO(b'{}')
|
||||
body = {"value": 1} if method in {"POST", "PUT", "PATCH"} else None
|
||||
self.assertEqual(api.request("example", method=method, body=body), {})
|
||||
request = build_opener.return_value.open.call_args.args[0]
|
||||
self.assertEqual(request.get_method(), method)
|
||||
self.assertEqual(request.data, None if body is None else json.dumps(body).encode())
|
||||
|
||||
def test_workflow_jobs_and_cache_producers_match_maintenance_contract(self):
|
||||
workflow = (SCRIPT.parent.parent / ".gitea/workflows/project-ci.yml").read_text(encoding="utf-8")
|
||||
# 沿用 workflow 的显式 job/step 格式,枚举实际 job,避免另一份名单漏掉新增项。
|
||||
sections = re.split(r"(?m)^ ([\w-]+):\s*$", workflow.split("\njobs:\n", 1)[1])
|
||||
jobs, producers = {}, {}
|
||||
for job_id, section in zip(sections[1::2], sections[2::2]):
|
||||
names = re.findall(r"(?m)^ name: (.+)$", section)
|
||||
self.assertEqual(len(names), 1, f"{job_id}: expected one explicit job name")
|
||||
name = names[0].strip().strip("\"'")
|
||||
self.assertNotIn(name, jobs, f"duplicate workflow job name: {name}")
|
||||
jobs[name] = job_id
|
||||
if "run: python3 scripts/export-gitea-rust-cache.py" in section:
|
||||
self.assertIn(f" - name: {maintenance_module.EXPORT_STEP}\n", section)
|
||||
producers[name] = job_id
|
||||
self.assertEqual(set(jobs), maintenance_module.JOBS,
|
||||
"workflow job names drifted from maintainer JOBS")
|
||||
self.assertEqual(producers, maintenance_module.RUST_JOB_IDS,
|
||||
"workflow cache producer names/IDs drifted from maintainer RUST_JOB_IDS")
|
||||
|
||||
def record(self, image, *, source=SHA, owned=True, verified_run=None, base=BASE_IMAGE):
|
||||
return {
|
||||
"image": image,
|
||||
@@ -255,7 +291,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
class Api:
|
||||
def pages(self, path, key):
|
||||
return iter({"workflow_runs": [run], "jobs": jobs, "artifacts": artifacts}[key])
|
||||
def request(self, path, raw=False):
|
||||
def request(self, path, *, method, raw=False):
|
||||
job_id = int(path.split("/jobs/")[1].split("/")[0])
|
||||
name = next(item["name"] for item in artifacts if item["id"] == job_id)
|
||||
return f"worker image: {OLD_IMAGE}\n[rust-cache] artifact={name} objects=1 bytes=10 complete=true\n"
|
||||
@@ -295,7 +331,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
def test_exports_using_different_images_are_not_combined(self):
|
||||
instance, _, _, _ = self.source_fixture()
|
||||
original = instance.api.request
|
||||
instance.api.request = lambda path, raw=False: original(path, raw).replace(OLD_IMAGE, IMAGE) if '/jobs/1/' in path else original(path, raw)
|
||||
instance.api.request = lambda path, **kwargs: original(path, **kwargs).replace(OLD_IMAGE, IMAGE) if '/jobs/1/' in path else original(path, **kwargs)
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
|
||||
def test_completed_artifact_without_upload_completion_log_is_not_used(self):
|
||||
@@ -357,7 +393,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
{**artifact, "id": 5, "workflow_run": {"id": 11}}]
|
||||
deleted = []
|
||||
instance.api.pages = lambda *_: iter(rows)
|
||||
def request(path, method=None):
|
||||
def request(path, *, method):
|
||||
if method == "DELETE":
|
||||
return deleted.append((path, method))
|
||||
return {9: old_run, 10: {**old_run, "event": "pull_request"},
|
||||
@@ -554,7 +590,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
return iter(jobs)
|
||||
raise AssertionError(path)
|
||||
|
||||
def request(self, path, body=None, raw=False):
|
||||
def request(self, path, *, method, body=None, raw=False):
|
||||
if not raw or "/actions/jobs/" not in path:
|
||||
raise AssertionError(path)
|
||||
return f"runner image: {OLD_IMAGE}\n"
|
||||
|
||||
Reference in New Issue
Block a user