feat(ack): add project delivery workflow
This commit is contained in:
@@ -49,6 +49,9 @@ KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"}
|
||||
KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
||||
SEMVER_RE = re.compile(
|
||||
r"^(0|[1-9][0-9]*)\."
|
||||
r"(0|[1-9][0-9]*)\."
|
||||
@@ -86,6 +89,30 @@ KNOWLEDGE_CHECK_FIELDS = {
|
||||
"checkedBy",
|
||||
"checkedAt",
|
||||
}
|
||||
DELIVERY_RUN_FIELDS = {
|
||||
"id",
|
||||
"profile",
|
||||
"taskIds",
|
||||
"status",
|
||||
"sourceRevision",
|
||||
"configRevision",
|
||||
"pullRequest",
|
||||
"artifacts",
|
||||
"deployments",
|
||||
"evidence",
|
||||
"updatedAt",
|
||||
}
|
||||
DELIVERY_STATUSES = {
|
||||
"planned",
|
||||
"running",
|
||||
"blocked",
|
||||
"failed",
|
||||
"review_ready",
|
||||
"released",
|
||||
"skipped",
|
||||
}
|
||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||
DISPATCH_FIELDS = {
|
||||
"taskId",
|
||||
"dispatchId",
|
||||
@@ -392,6 +419,162 @@ def validate_knowledge_fields(
|
||||
errors.append(f"{check_where}: verified 任务不能保留失败的知识检查")
|
||||
|
||||
|
||||
def validate_delivery_runs(
|
||||
value: object,
|
||||
task_statuses: dict[str, object],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
if not isinstance(value, list):
|
||||
errors.append("deliveryRuns 必须是列表")
|
||||
return
|
||||
|
||||
seen_run_ids: set[str] = set()
|
||||
for index, run in enumerate(value):
|
||||
where = f"deliveryRuns[{index}]"
|
||||
if not isinstance(run, dict):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(run, DELIVERY_RUN_FIELDS, where, errors)
|
||||
missing = sorted(DELIVERY_RUN_FIELDS - set(run))
|
||||
for field in missing:
|
||||
errors.append(f"{where}.{field}: 必填")
|
||||
|
||||
run_id = run.get("id")
|
||||
if not isinstance(run_id, str) or DELIVERY_RUN_ID_RE.fullmatch(run_id) is None:
|
||||
errors.append(f"{where}.id: 必须使用 DR-<id> 格式")
|
||||
elif run_id in seen_run_ids:
|
||||
errors.append(f"{where}.id: 不能重复 {run_id!r}")
|
||||
else:
|
||||
seen_run_ids.add(run_id)
|
||||
|
||||
profile = run.get("profile")
|
||||
if not isinstance(profile, str) or DELIVERY_PROFILE_RE.fullmatch(profile) is None:
|
||||
errors.append(f"{where}.profile: 必须使用小写连字符 ID")
|
||||
status = run.get("status")
|
||||
if status not in DELIVERY_STATUSES:
|
||||
errors.append(f"{where}.status: 必须是 {sorted(DELIVERY_STATUSES)}")
|
||||
|
||||
task_ids = run.get("taskIds")
|
||||
if (
|
||||
not isinstance(task_ids, list)
|
||||
or not task_ids
|
||||
or any(not _nonempty_string(task_id) for task_id in task_ids)
|
||||
):
|
||||
errors.append(f"{where}.taskIds: 必须是非空任务 ID 列表")
|
||||
task_ids = []
|
||||
elif len(task_ids) != len(set(task_ids)):
|
||||
errors.append(f"{where}.taskIds: 不能包含重复值")
|
||||
for task_id in task_ids:
|
||||
if task_id not in task_statuses:
|
||||
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
|
||||
elif task_statuses[task_id] != "verified":
|
||||
errors.append(
|
||||
f"{where}: delivery run 只能引用 verified 任务,"
|
||||
f"{task_id!r} 当前是 {task_statuses[task_id]!r}"
|
||||
)
|
||||
|
||||
for field in ("sourceRevision", "configRevision"):
|
||||
revision = run.get(field)
|
||||
if revision is not None and (
|
||||
not isinstance(revision, str) or GIT_REVISION_RE.fullmatch(revision) is None
|
||||
):
|
||||
errors.append(f"{where}.{field}: 必须是 null 或 7..64 位小写十六进制 revision")
|
||||
pull_request = run.get("pullRequest")
|
||||
if pull_request is not None and not isinstance(pull_request, str):
|
||||
errors.append(f"{where}.pullRequest: 必须是字符串或 null")
|
||||
if status != "skipped":
|
||||
for field in ("sourceRevision", "configRevision"):
|
||||
if not _nonempty_string(run.get(field)):
|
||||
errors.append(f"{where}.{field}: status={status!r} 时必须填写")
|
||||
if status in {"review_ready", "released"}:
|
||||
if not _nonempty_string(run.get("pullRequest")):
|
||||
errors.append(f"{where}.pullRequest: status={status!r} 时必须填写")
|
||||
|
||||
artifacts = run.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
errors.append(f"{where}.artifacts: 必须是列表")
|
||||
else:
|
||||
seen_artifacts: set[str] = set()
|
||||
for artifact_index, artifact in enumerate(artifacts):
|
||||
artifact_where = f"{where}.artifacts[{artifact_index}]"
|
||||
if not isinstance(artifact, dict):
|
||||
errors.append(f"{artifact_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
artifact,
|
||||
DELIVERY_ARTIFACT_FIELDS,
|
||||
artifact_where,
|
||||
errors,
|
||||
)
|
||||
artifact_id = artifact.get("id")
|
||||
if (
|
||||
not isinstance(artifact_id, str)
|
||||
or DELIVERY_PROFILE_RE.fullmatch(artifact_id) is None
|
||||
):
|
||||
errors.append(f"{artifact_where}.id: 必须使用小写连字符 ID")
|
||||
elif artifact_id in seen_artifacts:
|
||||
errors.append(f"{artifact_where}.id: 不能重复 {artifact_id!r}")
|
||||
else:
|
||||
seen_artifacts.add(artifact_id)
|
||||
if artifact.get("type") not in {"deb", "oci-image", "file"}:
|
||||
errors.append(f"{artifact_where}.type: 必须是 deb/oci-image/file")
|
||||
if not _nonempty_string(artifact.get("reference")):
|
||||
errors.append(f"{artifact_where}.reference: 必须是非空字符串")
|
||||
digest = artifact.get("digest")
|
||||
if digest is not None and (
|
||||
not isinstance(digest, str)
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None
|
||||
):
|
||||
errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null")
|
||||
if status in {"review_ready", "released"} and not _nonempty_string(digest):
|
||||
errors.append(
|
||||
f"{artifact_where}.digest: status={status!r} 时必须填写"
|
||||
)
|
||||
|
||||
deployments = run.get("deployments")
|
||||
if not isinstance(deployments, list):
|
||||
errors.append(f"{where}.deployments: 必须是列表")
|
||||
else:
|
||||
seen_environments: set[str] = set()
|
||||
for deployment_index, deployment in enumerate(deployments):
|
||||
deployment_where = f"{where}.deployments[{deployment_index}]"
|
||||
if not isinstance(deployment, dict):
|
||||
errors.append(f"{deployment_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
deployment,
|
||||
DELIVERY_DEPLOYMENT_FIELDS,
|
||||
deployment_where,
|
||||
errors,
|
||||
)
|
||||
environment = deployment.get("environment")
|
||||
if (
|
||||
not isinstance(environment, str)
|
||||
or DELIVERY_PROFILE_RE.fullmatch(environment) is None
|
||||
):
|
||||
errors.append(f"{deployment_where}.environment: 必须使用小写连字符 ID")
|
||||
elif environment in seen_environments:
|
||||
errors.append(f"{deployment_where}.environment: 不能重复 {environment!r}")
|
||||
else:
|
||||
seen_environments.add(environment)
|
||||
if deployment.get("result") not in {"succeeded", "failed", "rolled_back"}:
|
||||
errors.append(
|
||||
f"{deployment_where}.result: 必须是 succeeded/failed/rolled_back"
|
||||
)
|
||||
if not _nonempty_string(deployment.get("evidence")):
|
||||
errors.append(f"{deployment_where}.evidence: 必须是非空字符串")
|
||||
|
||||
evidence = run.get("evidence")
|
||||
if not isinstance(evidence, list) or any(
|
||||
not _nonempty_string(item) for item in evidence
|
||||
):
|
||||
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||
elif status in {"blocked", "failed", "review_ready", "released", "skipped"} and not evidence:
|
||||
errors.append(f"{where}.evidence: status={status!r} 时不能为空")
|
||||
if not _nonempty_string(run.get("updatedAt")):
|
||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||
|
||||
|
||||
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||
import jsonschema # type: ignore
|
||||
|
||||
@@ -468,7 +651,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
errors.append("project.name 必须是非空字符串")
|
||||
validate_string_fields(
|
||||
project,
|
||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile"},
|
||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
|
||||
"project",
|
||||
)
|
||||
if (
|
||||
@@ -478,6 +661,17 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
errors.append(
|
||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
|
||||
)
|
||||
if (
|
||||
"deliveryFile" in project
|
||||
and project.get("deliveryFile") != "docs/ack/delivery.yaml"
|
||||
):
|
||||
errors.append(
|
||||
"project.deliveryFile 必须固定为 docs/ack/delivery.yaml"
|
||||
)
|
||||
if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list):
|
||||
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||
if "deliveryRuns" in data and "deliveryFile" not in project:
|
||||
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
||||
|
||||
ack_version = data.get("ackVersion")
|
||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||
@@ -678,6 +872,14 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
):
|
||||
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
|
||||
|
||||
if "deliveryRuns" in data:
|
||||
task_statuses = {
|
||||
task.get("id"): task.get("status")
|
||||
for task in tasks
|
||||
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
||||
}
|
||||
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user