feat(ack): add project delivery workflow

This commit is contained in:
2026-08-01 12:35:42 +08:00
parent f08edb6452
commit 2c3d91c75c
28 changed files with 2559 additions and 67 deletions
+809
View File
@@ -0,0 +1,809 @@
#!/usr/bin/env python3
"""校验 ACK 项目交付契约。
权威结构位于 templates/delivery.schema.json。jsonschema 是可选依赖;内置规则始终
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
用法:
python3 validate_delivery.py docs/ack/delivery.yaml
python3 validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <project-root>
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
"""
from __future__ import annotations
import argparse
import json
import re
import stat
import sys
from pathlib import Path, PurePosixPath
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$")
REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
TOP_LEVEL_FIELDS = {
"version",
"updatedAt",
"project",
"enabled",
"defaultProfile",
"entrypoints",
"artifacts",
"destinations",
"environments",
"profiles",
}
ENTRYPOINT_FIELDS = {
"kind",
"target",
"function",
"path",
"args",
"requiredSecrets",
"workingDirectory",
"timeoutSeconds",
}
ARTIFACT_FIELDS = {"type", "build", "outputs", "image", "platforms"}
DESTINATION_FIELDS = {
"type",
"channel",
"registry",
"repository",
"endpoint",
"artifactName",
"upload",
}
ENVIRONMENT_FIELDS = {
"type",
"classification",
"target",
"deploy",
"healthCheck",
"rollback",
"mutex",
}
PROFILE_FIELDS = {"stopAt", "steps"}
STEP_FIELDS = {
"id",
"action",
"entrypoint",
"artifact",
"destination",
"environment",
"gate",
"draft",
"remote",
"baseBranch",
}
ENTRYPOINT_KINDS = {"make", "just", "task", "dagger", "script"}
ARTIFACT_TYPES = {"deb", "oci-image", "file"}
DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
CHANNELS = {"preview", "staging", "stable"}
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
CLASSIFICATIONS = {"development", "staging", "production"}
STOP_POINTS = {"verified", "review_ready", "released"}
ACTIONS = {
"verify",
"pull-request",
"build",
"publish",
"deploy",
"health-check",
"approval",
"mark-ready",
}
ACTION_FIELDS = {
"verify": {"entrypoint"},
"pull-request": {"draft", "remote", "baseBranch"},
"build": {"artifact"},
"publish": {"artifact", "destination"},
"deploy": {"artifact", "environment"},
"health-check": {"environment"},
"approval": {"gate"},
"mark-ready": set(),
}
SECRET_PATTERNS = (
("private key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
("OpenAI-style token", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
("URL credentials", re.compile(r"https?://[^/\s:@]+:[^/\s@]+@")),
(
"inline secret assignment",
re.compile(
r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)"
r"\s*[:=]\s*[\"']?[^\s,\"']{8,}"
),
),
)
def _nonempty(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _mapping(value: Any) -> bool:
return isinstance(value, dict)
def _reject_unknown(
value: dict[str, Any],
allowed: set[str],
where: str,
errors: list[str],
) -> None:
for field in sorted(set(value) - allowed):
errors.append(f"{where}: 未知字段 {field!r}")
def _load_document(path: Path, label: str) -> dict[str, Any]:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
if path.suffix.lower() == ".json":
try:
data = load_json_unique(content)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write(f"{label}顶层必须是对象(mapping\n")
raise SystemExit(1)
return data
def _safe_relative_path(value: Any, *, allow_glob: bool = False) -> bool:
if not _nonempty(value) or value.startswith("/") or "\\" in value:
return False
if not RELATIVE_PATH_RE.fullmatch(value):
return False
if not allow_glob and any(marker in value for marker in "*?"):
return False
parts = PurePosixPath(value).parts
return ".." not in parts and all(part not in {"", "/"} for part in parts)
def _safe_branch_name(value: Any) -> bool:
if not _nonempty(value) or len(value) > 255:
return False
if value == "@" or value.startswith(("/", ".", "-")):
return False
if value.endswith(("/", ".", ".lock")):
return False
if "@{" in value or ".." in value or "//" in value:
return False
return re.search(r"[\x00-\x20\x7f~^:?*\[\\]", value) is None
def _validate_path_binding(
project_root: Path,
relative_path: str,
where: str,
*,
expected: str,
) -> list[str]:
errors: list[str] = []
current = project_root
parts = PurePosixPath(relative_path).parts
if relative_path == ".":
parts = ()
for index, part in enumerate(parts):
current = current / part
try:
metadata = current.lstat()
except FileNotFoundError:
return [f"{where}: 路径不存在: {relative_path!r}"]
except OSError as exc:
return [f"{where}: 路径不可访问: {relative_path!r}: {exc}"]
if stat.S_ISLNK(metadata.st_mode):
return [f"{where}: 路径不能包含 symlink: {relative_path!r}"]
if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode):
return [f"{where}: 中间路径不是目录: {relative_path!r}"]
try:
metadata
except UnboundLocalError:
metadata = project_root.lstat()
if expected == "directory" and not stat.S_ISDIR(metadata.st_mode):
errors.append(f"{where}: 必须指向目录: {relative_path!r}")
if expected == "executable":
if not stat.S_ISREG(metadata.st_mode):
errors.append(f"{where}: 必须指向普通文件: {relative_path!r}")
elif metadata.st_mode & 0o111 == 0:
errors.append(f"{where}: 脚本不可执行: {relative_path!r}")
return errors
def _scan_secrets(value: Any, where: str, errors: list[str]) -> None:
if isinstance(value, dict):
for key, item in value.items():
_scan_secrets(item, f"{where}.{key}", errors)
return
if isinstance(value, list):
for index, item in enumerate(value):
_scan_secrets(item, f"{where}[{index}]", errors)
return
if not isinstance(value, str):
return
for label, pattern in SECRET_PATTERNS:
if pattern.search(value):
errors.append(f"{where}: 疑似包含敏感信息({label}")
def _validate_ids(values: Any, where: str, errors: list[str]) -> dict[str, Any]:
if not isinstance(values, dict):
errors.append(f"{where}: 必须是对象")
return {}
for key in values:
if not isinstance(key, str) or ID_RE.fullmatch(key) is None:
errors.append(f"{where}: ID {key!r} 必须使用小写连字符格式")
return values
def _validate_entrypoints(
values: dict[str, Any],
errors: list[str],
project_root: Path | None,
) -> None:
for entrypoint_id, value in values.items():
where = f"entrypoints.{entrypoint_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ENTRYPOINT_FIELDS, where, errors)
kind = value.get("kind")
if kind not in ENTRYPOINT_KINDS:
errors.append(f"{where}.kind: 必须是 {sorted(ENTRYPOINT_KINDS)}")
required_selector = {
"make": "target",
"just": "target",
"task": "target",
"dagger": "function",
"script": "path",
}.get(kind)
for selector in ("target", "function", "path"):
if selector == required_selector:
if not _nonempty(value.get(selector)):
errors.append(f"{where}.{selector}: {kind} 入口必须填写非空值")
elif selector in value:
errors.append(f"{where}.{selector}: kind={kind!r} 不允许此字段")
args = value.get("args")
if not isinstance(args, list) or any(not isinstance(item, str) for item in args):
errors.append(f"{where}.args: 必须是字符串列表")
required_secrets = value.get("requiredSecrets")
if (
not isinstance(required_secrets, list)
or any(
not isinstance(item, str) or SECRET_NAME_RE.fullmatch(item) is None
for item in required_secrets
)
or (
isinstance(required_secrets, list)
and len(required_secrets) != len(set(required_secrets))
)
):
errors.append(
f"{where}.requiredSecrets: 必须是唯一的大写 secret 名称列表"
)
working_directory = value.get("workingDirectory")
if not _safe_relative_path(working_directory):
errors.append(f"{where}.workingDirectory: 必须是安全的仓库内相对路径")
timeout = value.get("timeoutSeconds")
if (
not isinstance(timeout, int)
or isinstance(timeout, bool)
or not 1 <= timeout <= 86400
):
errors.append(f"{where}.timeoutSeconds: 必须是 1..86400 的整数")
if kind == "script" and not _safe_relative_path(value.get("path")):
errors.append(f"{where}.path: 必须是安全的仓库内相对路径")
if project_root is not None:
if _safe_relative_path(working_directory):
errors.extend(
_validate_path_binding(
project_root,
working_directory,
f"{where}.workingDirectory",
expected="directory",
)
)
if kind == "script" and _safe_relative_path(value.get("path")):
errors.extend(
_validate_path_binding(
project_root,
value["path"],
f"{where}.path",
expected="executable",
)
)
def _validate_artifacts(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for artifact_id, value in values.items():
where = f"artifacts.{artifact_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ARTIFACT_FIELDS, where, errors)
artifact_type = value.get("type")
if artifact_type not in ARTIFACT_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(ARTIFACT_TYPES)}")
build = value.get("build")
if build not in entrypoints:
errors.append(f"{where}.build: 未定义 entrypoint {build!r}")
outputs = value.get("outputs")
if artifact_type in {"deb", "file"}:
if (
not isinstance(outputs, list)
or not outputs
or any(not _safe_relative_path(item, allow_glob=True) for item in outputs)
):
errors.append(f"{where}.outputs: deb/file 必须填写安全的产物路径列表")
if "image" in value or "platforms" in value:
errors.append(f"{where}: deb/file 不允许 image 或 platforms")
if artifact_type == "oci-image":
if not _nonempty(value.get("image")):
errors.append(f"{where}.image: oci-image 必须填写镜像名")
platforms = value.get("platforms")
if (
not isinstance(platforms, list)
or not platforms
or any(not isinstance(item, str) or PLATFORM_RE.fullmatch(item) is None for item in platforms)
or len(platforms) != len(set(platforms))
):
errors.append(f"{where}.platforms: 必须是唯一的 os/arch 列表")
if "outputs" in value:
errors.append(f"{where}: oci-image 不允许 outputs")
def _validate_destinations(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for destination_id, value in values.items():
where = f"destinations.{destination_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, DESTINATION_FIELDS, where, errors)
destination_type = value.get("type")
if destination_type not in DESTINATION_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(DESTINATION_TYPES)}")
type_fields = {
"apt-repository": {"endpoint", "repository"},
"oci-registry": {"registry", "repository"},
"ci-artifact": {"artifactName"},
}.get(destination_type, set())
allowed_fields = {"type", "channel", "upload"} | type_fields
for field in sorted(set(value) - allowed_fields):
errors.append(f"{where}.{field}: type={destination_type!r} 不允许此字段")
if value.get("channel") not in CHANNELS:
errors.append(f"{where}.channel: 必须是 {sorted(CHANNELS)}")
upload = value.get("upload")
if upload is not None and upload not in entrypoints:
errors.append(f"{where}.upload: 未定义 entrypoint {upload!r}")
if destination_type == "apt-repository":
if not _nonempty(value.get("endpoint")):
errors.append(f"{where}.endpoint: APT 目标必须填写服务地址")
if not _nonempty(value.get("repository")):
errors.append(f"{where}.repository: APT 目标必须填写仓库名")
if destination_type == "oci-registry":
for field in ("registry", "repository"):
if not _nonempty(value.get(field)):
errors.append(f"{where}.{field}: OCI 目标必须填写非空值")
if destination_type == "ci-artifact" and not _nonempty(value.get("artifactName")):
errors.append(f"{where}.artifactName: CI artifact 必须填写名称")
def _validate_environments(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for environment_id, value in values.items():
where = f"environments.{environment_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ENVIRONMENT_FIELDS, where, errors)
if value.get("type") not in ENVIRONMENT_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(ENVIRONMENT_TYPES)}")
classification = value.get("classification")
if classification not in CLASSIFICATIONS:
errors.append(f"{where}.classification: 必须是 {sorted(CLASSIFICATIONS)}")
if not _nonempty(value.get("target")):
errors.append(f"{where}.target: 必须是非空目标别名")
for field in ("deploy", "healthCheck"):
reference = value.get(field)
if reference not in entrypoints:
errors.append(f"{where}.{field}: 未定义 entrypoint {reference!r}")
rollback = value.get("rollback")
if rollback is not None and rollback not in entrypoints:
errors.append(f"{where}.rollback: 未定义 entrypoint {rollback!r}")
if classification == "production" and rollback is None:
errors.append(f"{where}.rollback: production 环境必须提供回滚入口")
if not _nonempty(value.get("mutex")):
errors.append(f"{where}.mutex: 必须填写部署互斥锁 ID")
def _artifact_destination_compatible(artifact_type: str, destination_type: str) -> bool:
return destination_type in {
"deb": {"apt-repository", "ci-artifact"},
"oci-image": {"oci-registry", "ci-artifact"},
"file": {"ci-artifact"},
}.get(artifact_type, set())
def _validate_profiles(
values: dict[str, Any],
*,
default_profile: Any,
entrypoints: dict[str, Any],
artifacts: dict[str, Any],
destinations: dict[str, Any],
environments: dict[str, Any],
errors: list[str],
) -> None:
for profile_id, value in values.items():
where = f"profiles.{profile_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, PROFILE_FIELDS, where, errors)
stop_at = value.get("stopAt")
if stop_at not in STOP_POINTS:
errors.append(f"{where}.stopAt: 必须是 {sorted(STOP_POINTS)}")
steps = value.get("steps")
if not isinstance(steps, list):
errors.append(f"{where}.steps: 必须是列表")
continue
seen_step_ids: set[str] = set()
built_artifacts: set[str] = set()
published_artifacts: set[str] = set()
deployed_environments: set[str] = set()
approvals: set[str] = set()
has_pull_request = False
has_mark_ready = False
for index, step in enumerate(steps):
step_where = f"{where}.steps[{index}]"
if not _mapping(step):
errors.append(f"{step_where}: 必须是对象")
continue
_reject_unknown(step, STEP_FIELDS, step_where, errors)
step_id = step.get("id")
if not isinstance(step_id, str) or ID_RE.fullmatch(step_id) is None:
errors.append(f"{step_where}.id: 必须使用小写连字符格式")
elif step_id in seen_step_ids:
errors.append(f"{step_where}.id: 不能重复 {step_id!r}")
else:
seen_step_ids.add(step_id)
action = step.get("action")
if action not in ACTIONS:
errors.append(f"{step_where}.action: 必须是 {sorted(ACTIONS)}")
continue
required_fields = ACTION_FIELDS[action]
for field in sorted(required_fields):
if field not in step:
errors.append(f"{step_where}.{field}: action={action!r} 时必填")
allowed_fields = {"id", "action"} | required_fields
for field in sorted(set(step) - allowed_fields):
errors.append(f"{step_where}.{field}: action={action!r} 不允许此字段")
if action == "verify" and step.get("entrypoint") not in entrypoints:
errors.append(
f"{step_where}.entrypoint: 未定义 entrypoint {step.get('entrypoint')!r}"
)
if action == "pull-request":
if not isinstance(step.get("draft"), bool):
errors.append(f"{step_where}.draft: 必须是布尔值")
if (
not isinstance(step.get("remote"), str)
or REMOTE_RE.fullmatch(step["remote"]) is None
):
errors.append(f"{step_where}.remote: 必须是安全的 Git remote 名称")
if not _safe_branch_name(step.get("baseBranch")):
errors.append(f"{step_where}.baseBranch: 必须是安全的 Git 分支名")
has_pull_request = True
if action == "build":
artifact_id = step.get("artifact")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
else:
built_artifacts.add(artifact_id)
if action == "publish":
artifact_id = step.get("artifact")
destination_id = step.get("destination")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
elif artifact_id not in built_artifacts:
errors.append(f"{step_where}: publish 前必须先 build {artifact_id!r}")
if destination_id not in destinations:
errors.append(
f"{step_where}.destination: 未定义 destination {destination_id!r}"
)
elif artifact_id in artifacts:
artifact_type = artifacts[artifact_id].get("type")
destination_type = destinations[destination_id].get("type")
if not _artifact_destination_compatible(artifact_type, destination_type):
errors.append(
f"{step_where}: artifact {artifact_type!r} 不能发布到 "
f"{destination_type!r}"
)
if destinations[destination_id].get("channel") == "stable" and "release" not in approvals:
errors.append(f"{step_where}: stable 发布前必须有 release approval")
published_artifacts.add(artifact_id)
if action == "deploy":
artifact_id = step.get("artifact")
environment_id = step.get("environment")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
elif artifact_id not in built_artifacts:
errors.append(f"{step_where}: deploy 前必须先 build {artifact_id!r}")
if environment_id not in environments:
errors.append(
f"{step_where}.environment: 未定义 environment {environment_id!r}"
)
else:
classification = environments[environment_id].get("classification")
if classification == "production" and "production" not in approvals:
errors.append(f"{step_where}: production 部署前必须有 production approval")
if (
classification == "production"
and artifact_id not in published_artifacts
):
errors.append(f"{step_where}: production 部署前必须先 publish 同一产物")
deployed_environments.add(environment_id)
if action == "health-check":
environment_id = step.get("environment")
if environment_id not in environments:
errors.append(
f"{step_where}.environment: 未定义 environment {environment_id!r}"
)
elif environment_id not in deployed_environments:
errors.append(
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
)
if action == "approval":
gate = step.get("gate")
if gate not in {"release", "production"}:
errors.append(f"{step_where}.gate: 必须是 release/production")
else:
if gate == "release" and not built_artifacts:
errors.append(f"{step_where}: release approval 前必须先 build 产物")
if gate == "production" and not published_artifacts:
errors.append(f"{step_where}: production approval 前必须先 publish 产物")
approvals.add(gate)
if action == "mark-ready":
if not has_pull_request:
errors.append(f"{step_where}: mark-ready 前必须先创建 pull-request")
has_mark_ready = True
if index != len(steps) - 1:
errors.append(f"{step_where}: mark-ready 必须是 profile 最后一步")
if stop_at in {"review_ready", "released"} and (
not has_pull_request or not has_mark_ready
):
errors.append(
f"{where}: {stop_at} 必须包含 pull-request 和末尾 mark-ready"
)
if stop_at == "released" and not ({"release", "production"} & approvals):
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
if profile_id == default_profile:
if stop_at != "review_ready":
errors.append(f"{where}: defaultProfile 必须停在 review_ready")
used_destinations = {
step.get("destination")
for step in steps
if isinstance(step, dict) and step.get("action") == "publish"
}
used_environments = {
step.get("environment")
for step in steps
if isinstance(step, dict) and step.get("action") == "deploy"
}
if any(
destinations.get(item, {}).get("channel") == "stable"
for item in used_destinations
):
errors.append(f"{where}: defaultProfile 不能发布 stable 目标")
if any(
environments.get(item, {}).get("classification") == "production"
for item in used_environments
):
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
errors: list[str] = []
_reject_unknown(data, TOP_LEVEL_FIELDS, "<root>", errors)
if data.get("version") != 1 or isinstance(data.get("version"), bool):
errors.append("version 必须是整数 1")
if "updatedAt" in data and not _nonempty(data.get("updatedAt")):
errors.append("updatedAt 必须是非空字符串")
project = data.get("project")
if not _mapping(project):
errors.append("project 必须是对象")
project = {}
else:
_reject_unknown(project, {"name"}, "project", errors)
if not _nonempty(project.get("name")):
errors.append("project.name 必须是非空字符串")
enabled = data.get("enabled")
if not isinstance(enabled, bool):
errors.append("enabled 必须是布尔值")
default_profile = data.get("defaultProfile")
if default_profile is not None and (
not isinstance(default_profile, str) or ID_RE.fullmatch(default_profile) is None
):
errors.append("defaultProfile 必须是 null 或小写连字符 ID")
entrypoints = _validate_ids(data.get("entrypoints"), "entrypoints", errors)
artifacts = _validate_ids(data.get("artifacts"), "artifacts", errors)
destinations = _validate_ids(data.get("destinations"), "destinations", errors)
environments = _validate_ids(data.get("environments"), "environments", errors)
profiles = _validate_ids(data.get("profiles"), "profiles", errors)
_validate_entrypoints(entrypoints, errors, project_root)
_validate_artifacts(artifacts, entrypoints, errors)
_validate_destinations(destinations, entrypoints, errors)
_validate_environments(environments, entrypoints, errors)
_validate_profiles(
profiles,
default_profile=default_profile,
entrypoints=entrypoints,
artifacts=artifacts,
destinations=destinations,
environments=environments,
errors=errors,
)
if enabled:
if default_profile not in profiles:
errors.append("enabled=true 时 defaultProfile 必须引用已定义 profile")
elif not profiles[default_profile].get("steps"):
errors.append("enabled=true 时 defaultProfile.steps 不能为空")
elif default_profile is not None and default_profile not in profiles:
errors.append("defaultProfile 必须引用已定义 profile")
_scan_secrets(data, "<root>", errors)
return errors
def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list[str]:
errors: list[str] = []
project = tasks.get("project")
if not isinstance(project, dict):
return ["tasks.project 必须是对象"]
if project.get("deliveryFile") != "docs/ack/delivery.yaml":
errors.append("tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml")
delivery_project = delivery.get("project")
if (
isinstance(delivery_project, dict)
and _nonempty(delivery_project.get("name"))
and _nonempty(project.get("name"))
and delivery_project["name"] != project["name"]
):
errors.append("delivery.project.name 必须与 tasks.project.name 一致")
if not isinstance(tasks.get("deliveryRuns"), list):
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
return errors
def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
import jsonschema # type: ignore
schema = json.loads(schema_path.read_text(encoding="utf-8"))
validator = jsonschema.Draft7Validator(schema)
errors = []
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
location = "/".join(str(part) for part in error.path) or "<root>"
errors.append(f"[schema] {location}: {error.message}")
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
parser.add_argument("delivery", nargs="?", default="docs/ack/delivery.yaml")
parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml")
parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径")
parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)")
args = parser.parse_args(argv)
delivery_path = Path(args.delivery)
if not delivery_path.is_file():
sys.stderr.write(f"找不到交付契约: {delivery_path}\n")
return 2
project_root = Path(args.project_root).resolve() if args.project_root else None
if project_root is not None and not project_root.is_dir():
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
return 2
delivery = _load_document(delivery_path, "交付契约")
errors = validate_builtin(delivery, project_root)
if args.tasks:
tasks_path = Path(args.tasks)
if not tasks_path.is_file():
sys.stderr.write(f"找不到任务板: {tasks_path}\n")
return 2
tasks = _load_document(tasks_path, "任务板")
errors.extend(validate_tasks_link(delivery, tasks))
schema_path = (
Path(args.schema)
if args.schema
else Path(__file__).resolve().parent.parent / "templates" / "delivery.schema.json"
)
if args.schema and not schema_path.is_file():
sys.stderr.write(f"找不到 schema: {schema_path}\n")
return 2
if schema_path.is_file():
try:
errors.extend(validate_with_schema(delivery, schema_path))
except ImportError:
sys.stderr.write("提示: 未安装 jsonschema,仅执行内置语义规则\n")
except (OSError, json.JSONDecodeError) as exc:
sys.stderr.write(f"schema 读取失败: {exc}\n")
return 2
if errors:
for error in errors:
sys.stderr.write(f"- {error}\n")
return 1
sys.stdout.write("交付契约校验通过\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())