feat(ack): add project delivery workflow
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
||||
VALIDATOR = SCRIPTS_DIR / "validate_delivery.py"
|
||||
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "delivery.example.yaml"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import validate_delivery # noqa: E402
|
||||
|
||||
|
||||
def valid_contract() -> dict:
|
||||
entrypoints = {
|
||||
name: {
|
||||
"kind": "make",
|
||||
"target": name,
|
||||
"args": [],
|
||||
"requiredSecrets": [],
|
||||
"workingDirectory": ".",
|
||||
"timeoutSeconds": 300,
|
||||
}
|
||||
for name in ("verify", "build", "upload", "deploy", "health", "rollback")
|
||||
}
|
||||
return {
|
||||
"version": 1,
|
||||
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||
"project": {"name": "demo"},
|
||||
"enabled": True,
|
||||
"defaultProfile": "review",
|
||||
"entrypoints": entrypoints,
|
||||
"artifacts": {
|
||||
"service-deb": {
|
||||
"type": "deb",
|
||||
"build": "build",
|
||||
"outputs": ["dist/*.deb"],
|
||||
}
|
||||
},
|
||||
"destinations": {
|
||||
"preview-apt": {
|
||||
"type": "apt-repository",
|
||||
"channel": "preview",
|
||||
"endpoint": "https://packages.example.com",
|
||||
"repository": "testing",
|
||||
"upload": "upload",
|
||||
}
|
||||
},
|
||||
"environments": {
|
||||
"test-server": {
|
||||
"type": "ssh-host",
|
||||
"classification": "development",
|
||||
"target": "test-server",
|
||||
"deploy": "deploy",
|
||||
"healthCheck": "health",
|
||||
"rollback": "rollback",
|
||||
"mutex": "test-server-deploy",
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"review": {
|
||||
"stopAt": "review_ready",
|
||||
"steps": [
|
||||
{"id": "verify", "action": "verify", "entrypoint": "verify"},
|
||||
{
|
||||
"id": "open-pr",
|
||||
"action": "pull-request",
|
||||
"draft": True,
|
||||
"remote": "origin",
|
||||
"baseBranch": "main",
|
||||
},
|
||||
{"id": "build", "action": "build", "artifact": "service-deb"},
|
||||
{
|
||||
"id": "publish",
|
||||
"action": "publish",
|
||||
"artifact": "service-deb",
|
||||
"destination": "preview-apt",
|
||||
},
|
||||
{
|
||||
"id": "deploy",
|
||||
"action": "deploy",
|
||||
"artifact": "service-deb",
|
||||
"environment": "test-server",
|
||||
},
|
||||
{
|
||||
"id": "health",
|
||||
"action": "health-check",
|
||||
"environment": "test-server",
|
||||
},
|
||||
{"id": "ready", "action": "mark-ready"},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AckDeliveryValidationTests(unittest.TestCase):
|
||||
def test_example_is_valid_with_and_without_site_packages(self) -> None:
|
||||
for no_site_packages in (False, True):
|
||||
command = [sys.executable]
|
||||
if no_site_packages:
|
||||
command.append("-S")
|
||||
result = subprocess.run(
|
||||
[*command, str(VALIDATOR), str(EXAMPLE)],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
with self.subTest(no_site_packages=no_site_packages):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("交付契约校验通过", result.stdout)
|
||||
|
||||
def test_default_profile_rejects_stable_and_production_targets(self) -> None:
|
||||
contract = valid_contract()
|
||||
contract["destinations"]["preview-apt"]["channel"] = "stable"
|
||||
contract["environments"]["test-server"]["classification"] = "production"
|
||||
|
||||
errors = validate_delivery.validate_builtin(contract)
|
||||
|
||||
self.assertTrue(any("stable 发布前必须有 release approval" in item for item in errors))
|
||||
self.assertTrue(any("production 部署前必须有 production approval" in item for item in errors))
|
||||
self.assertTrue(any("defaultProfile 不能发布 stable" in item for item in errors))
|
||||
self.assertTrue(any("defaultProfile 不能部署 production" in item for item in errors))
|
||||
|
||||
def test_nondefault_release_profile_supports_stable_and_production_with_gates(self) -> None:
|
||||
contract = valid_contract()
|
||||
contract["destinations"]["stable-apt"] = {
|
||||
"type": "apt-repository",
|
||||
"channel": "stable",
|
||||
"endpoint": "https://packages.example.com",
|
||||
"repository": "stable",
|
||||
"upload": "upload",
|
||||
}
|
||||
contract["environments"]["prod-server"] = {
|
||||
"type": "ssh-host",
|
||||
"classification": "production",
|
||||
"target": "prod-server",
|
||||
"deploy": "deploy",
|
||||
"healthCheck": "health",
|
||||
"rollback": "rollback",
|
||||
"mutex": "prod-server-deploy",
|
||||
}
|
||||
contract["profiles"]["release"] = {
|
||||
"stopAt": "released",
|
||||
"steps": [
|
||||
{"id": "verify-release", "action": "verify", "entrypoint": "verify"},
|
||||
{
|
||||
"id": "open-release-pr",
|
||||
"action": "pull-request",
|
||||
"draft": True,
|
||||
"remote": "origin",
|
||||
"baseBranch": "main",
|
||||
},
|
||||
{"id": "build-release", "action": "build", "artifact": "service-deb"},
|
||||
{"id": "approve-release", "action": "approval", "gate": "release"},
|
||||
{
|
||||
"id": "publish-release",
|
||||
"action": "publish",
|
||||
"artifact": "service-deb",
|
||||
"destination": "stable-apt",
|
||||
},
|
||||
{"id": "approve-production", "action": "approval", "gate": "production"},
|
||||
{
|
||||
"id": "deploy-production",
|
||||
"action": "deploy",
|
||||
"artifact": "service-deb",
|
||||
"environment": "prod-server",
|
||||
},
|
||||
{
|
||||
"id": "health-production",
|
||||
"action": "health-check",
|
||||
"environment": "prod-server",
|
||||
},
|
||||
{"id": "ready-release", "action": "mark-ready"},
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||
|
||||
def test_publish_and_health_check_require_prior_steps(self) -> None:
|
||||
contract = valid_contract()
|
||||
steps = contract["profiles"]["review"]["steps"]
|
||||
steps[2], steps[3] = steps[3], steps[2]
|
||||
steps[4], steps[5] = steps[5], steps[4]
|
||||
|
||||
errors = validate_delivery.validate_builtin(contract)
|
||||
|
||||
self.assertTrue(any("publish 前必须先 build" in item for item in errors))
|
||||
self.assertTrue(any("health-check 前必须先 deploy" in item for item in errors))
|
||||
|
||||
def test_pull_request_requires_explicit_safe_remote_and_base(self) -> None:
|
||||
contract = valid_contract()
|
||||
step = contract["profiles"]["review"]["steps"][1]
|
||||
del step["remote"]
|
||||
step["baseBranch"] = "../main"
|
||||
|
||||
errors = validate_delivery.validate_builtin(contract)
|
||||
|
||||
self.assertTrue(any(".remote: action='pull-request' 时必填" in item for item in errors))
|
||||
self.assertTrue(any(".remote: 必须是安全的 Git remote 名称" in item for item in errors))
|
||||
self.assertTrue(any(".baseBranch: 必须是安全的 Git 分支名" in item for item in errors))
|
||||
|
||||
def test_unknown_shell_and_inline_secret_are_rejected(self) -> None:
|
||||
contract = valid_contract()
|
||||
contract["entrypoints"]["verify"]["shell"] = "make verify"
|
||||
contract["entrypoints"]["verify"]["requiredSecrets"] = ["token-value"]
|
||||
contract["destinations"]["preview-apt"]["repository"] = (
|
||||
"token=abcdefghijklmnop"
|
||||
)
|
||||
contract["destinations"]["preview-apt"]["registry"] = "unexpected.example"
|
||||
|
||||
errors = validate_delivery.validate_builtin(contract)
|
||||
|
||||
self.assertTrue(any("未知字段 'shell'" in item for item in errors))
|
||||
self.assertTrue(any("requiredSecrets" in item for item in errors))
|
||||
self.assertTrue(any("type='apt-repository' 不允许此字段" in item for item in errors))
|
||||
self.assertTrue(any("疑似包含敏感信息" in item for item in errors))
|
||||
|
||||
def test_project_script_must_be_executable_and_not_a_symlink(self) -> None:
|
||||
contract = valid_contract()
|
||||
contract["entrypoints"]["verify"] = {
|
||||
"kind": "script",
|
||||
"path": "scripts/verify.sh",
|
||||
"args": [],
|
||||
"requiredSecrets": [],
|
||||
"workingDirectory": ".",
|
||||
"timeoutSeconds": 300,
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
scripts = root / "scripts"
|
||||
scripts.mkdir()
|
||||
target = scripts / "target.sh"
|
||||
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
target.chmod(0o755)
|
||||
os.symlink("target.sh", scripts / "verify.sh")
|
||||
|
||||
errors = validate_delivery.validate_builtin(contract, root)
|
||||
self.assertTrue(any("路径不能包含 symlink" in item for item in errors))
|
||||
|
||||
(scripts / "verify.sh").unlink()
|
||||
plain = scripts / "verify.sh"
|
||||
plain.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
plain.chmod(0o644)
|
||||
errors = validate_delivery.validate_builtin(contract, root)
|
||||
self.assertTrue(any("脚本不可执行" in item for item in errors))
|
||||
|
||||
def test_tasks_link_requires_fixed_path_runs_and_same_project(self) -> None:
|
||||
contract = valid_contract()
|
||||
tasks = {
|
||||
"project": {"name": "other", "deliveryFile": "delivery.yaml"},
|
||||
"tasks": [],
|
||||
}
|
||||
|
||||
errors = validate_delivery.validate_tasks_link(contract, tasks)
|
||||
|
||||
self.assertIn(
|
||||
"tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml",
|
||||
errors,
|
||||
)
|
||||
self.assertIn("delivery.project.name 必须与 tasks.project.name 一致", errors)
|
||||
self.assertIn("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表", errors)
|
||||
|
||||
def test_disabled_empty_contract_remains_valid(self) -> None:
|
||||
contract = copy.deepcopy(valid_contract())
|
||||
contract.update(
|
||||
{
|
||||
"enabled": False,
|
||||
"defaultProfile": None,
|
||||
"entrypoints": {},
|
||||
"artifacts": {},
|
||||
"destinations": {},
|
||||
"environments": {},
|
||||
"profiles": {},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,9 +16,11 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"docs/ack/project.md",
|
||||
"docs/ack/tasks.yaml",
|
||||
"docs/ack/knowledge.yaml",
|
||||
"docs/ack/delivery.yaml",
|
||||
"tasks: []",
|
||||
"validate_tasks.py",
|
||||
"validate_knowledge.py",
|
||||
"validate_delivery.py",
|
||||
"select_knowledge.py",
|
||||
"references/kickoff.md",
|
||||
"不要修改项目的 `AGENTS.md`",
|
||||
@@ -56,9 +58,13 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"scripts/run_verification.py",
|
||||
"scripts/worker_profiles.py",
|
||||
"scripts/launch_worker.py",
|
||||
"templates/delivery.template.yaml",
|
||||
"templates/delivery.schema.json",
|
||||
"examples/delivery.example.yaml",
|
||||
"references/delivery.md",
|
||||
):
|
||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.10.0")
|
||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.11.0")
|
||||
self.assertIn(
|
||||
'ackVersion: "<接入时的 ack skill 版本>"',
|
||||
(ack_dir / "templates" / "tasks.template.yaml").read_text(
|
||||
|
||||
@@ -180,7 +180,7 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
with self.subTest(invalid_semver=invalid):
|
||||
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
||||
|
||||
current_gate, orchestration_gate, receipts_gate = schema["allOf"]
|
||||
current_gate, orchestration_gate, receipts_gate, delivery_gate = schema["allOf"]
|
||||
current_pattern = re.compile(
|
||||
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
||||
)
|
||||
@@ -218,6 +218,15 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
gate["then"]["properties"]["tasks"]["$ref"],
|
||||
"#/definitions/launchableTasks",
|
||||
)
|
||||
self.assertIn("deliveryRuns", delivery_gate["then"]["required"])
|
||||
|
||||
delivery_run = schema["definitions"]["deliveryRun"]
|
||||
revision_gate = delivery_run["allOf"][0]
|
||||
self.assertIn("planned", revision_gate["if"]["properties"]["status"]["enum"])
|
||||
self.assertEqual(
|
||||
revision_gate["then"]["properties"]["sourceRevision"]["type"],
|
||||
"string",
|
||||
)
|
||||
|
||||
role_dispatch = schema["definitions"]["roleDispatch"]
|
||||
self.assertIn("attemptId", role_dispatch["required"])
|
||||
@@ -231,6 +240,126 @@ class AckTaskValidationTests(unittest.TestCase):
|
||||
"string",
|
||||
)
|
||||
|
||||
def test_delivery_run_is_separate_and_requires_verified_tasks(self) -> None:
|
||||
board = valid_manual_routing_board()
|
||||
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||
board["tasks"][0]["status"] = "verified"
|
||||
board["deliveryRuns"] = [
|
||||
{
|
||||
"id": "DR-demo-1",
|
||||
"profile": "review",
|
||||
"taskIds": ["T-1"],
|
||||
"status": "review_ready",
|
||||
"sourceRevision": "a" * 40,
|
||||
"configRevision": "b" * 40,
|
||||
"pullRequest": "https://forge.example/demo/pulls/1",
|
||||
"artifacts": [
|
||||
{
|
||||
"id": "service-deb",
|
||||
"type": "deb",
|
||||
"reference": "demo_1.0.0_amd64.deb",
|
||||
"digest": "sha256:" + "c" * 64,
|
||||
}
|
||||
],
|
||||
"deployments": [
|
||||
{
|
||||
"environment": "test-server",
|
||||
"result": "succeeded",
|
||||
"evidence": "health endpoint returned 200",
|
||||
}
|
||||
],
|
||||
"evidence": ["CI run 42 passed"],
|
||||
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||
}
|
||||
]
|
||||
|
||||
self.assert_board_accepted_in_all_modes(board)
|
||||
|
||||
board["tasks"][0]["status"] = "open"
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"delivery run 只能引用 verified 任务",
|
||||
)
|
||||
|
||||
def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None:
|
||||
board = valid_manual_routing_board()
|
||||
board["deliveryRuns"] = []
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"deliveryRuns 存在时 project.deliveryFile 必须存在",
|
||||
)
|
||||
|
||||
board = valid_manual_routing_board()
|
||||
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"引用 deliveryFile 的任务板必须包含 deliveryRuns 列表",
|
||||
)
|
||||
|
||||
def test_delivery_run_binds_revisions_and_final_artifact_digest(self) -> None:
|
||||
board = valid_manual_routing_board()
|
||||
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||
board["deliveryRuns"] = [
|
||||
{
|
||||
"id": "DR-demo-2",
|
||||
"profile": "review",
|
||||
"taskIds": ["T-1"],
|
||||
"status": "planned",
|
||||
"sourceRevision": None,
|
||||
"configRevision": None,
|
||||
"pullRequest": None,
|
||||
"artifacts": [],
|
||||
"deployments": [],
|
||||
"evidence": [],
|
||||
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||
}
|
||||
]
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"delivery run 只能引用 verified 任务",
|
||||
"sourceRevision: status='planned' 时必须填写",
|
||||
"configRevision: status='planned' 时必须填写",
|
||||
)
|
||||
|
||||
board["tasks"][0]["status"] = "verified"
|
||||
run = board["deliveryRuns"][0]
|
||||
run.update(
|
||||
{
|
||||
"status": "review_ready",
|
||||
"sourceRevision": "a" * 40,
|
||||
"configRevision": "b" * 40,
|
||||
"pullRequest": "https://forge.example/demo/pulls/2",
|
||||
"artifacts": [
|
||||
{
|
||||
"id": "service-deb",
|
||||
"type": "deb",
|
||||
"reference": "demo_1.0.0_amd64.deb",
|
||||
"digest": None,
|
||||
}
|
||||
],
|
||||
"evidence": ["CI run 43 passed"],
|
||||
}
|
||||
)
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"digest: status='review_ready' 时必须填写",
|
||||
)
|
||||
|
||||
run.update(
|
||||
{
|
||||
"status": "skipped",
|
||||
"sourceRevision": None,
|
||||
"configRevision": None,
|
||||
"pullRequest": None,
|
||||
"artifacts": [],
|
||||
"evidence": [],
|
||||
}
|
||||
)
|
||||
self.assert_board_rejected_in_all_modes(
|
||||
board,
|
||||
"evidence: status='skipped' 时不能为空",
|
||||
)
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("jsonschema") is not None,
|
||||
"jsonschema is required for the schema-only contract test",
|
||||
|
||||
@@ -12,6 +12,7 @@ SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import validate_knowledge # noqa: E402
|
||||
import validate_delivery # noqa: E402
|
||||
import validate_tasks # noqa: E402
|
||||
from yaml_subset import YamlSubsetError, load_yaml_subset # noqa: E402
|
||||
|
||||
@@ -28,12 +29,14 @@ paths = (
|
||||
Path('skills/ack/examples/tasks.example.yaml'),
|
||||
Path('skills/ack/templates/knowledge.template.yaml'),
|
||||
Path('skills/ack/examples/knowledge.example.yaml'),
|
||||
Path('skills/ack/templates/delivery.template.yaml'),
|
||||
Path('skills/ack/examples/delivery.example.yaml'),
|
||||
)
|
||||
for path in paths:
|
||||
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
||||
if not isinstance(value, dict):
|
||||
raise SystemExit(f'{path}: top-level value is not a mapping')
|
||||
print('parsed=4')
|
||||
print('parsed=6')
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-S", "-c", script],
|
||||
@@ -44,7 +47,7 @@ print('parsed=4')
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "parsed=4")
|
||||
self.assertEqual(result.stdout.strip(), "parsed=6")
|
||||
|
||||
def test_tasks_validator_runs_without_site_packages(self) -> None:
|
||||
result = subprocess.run(
|
||||
@@ -63,6 +66,23 @@ print('parsed=4')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("任务板校验通过", result.stdout)
|
||||
|
||||
def test_delivery_validator_runs_without_site_packages(self) -> None:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-S",
|
||||
str(SCRIPTS_DIR / "validate_delivery.py"),
|
||||
str(REPO_ROOT / "skills" / "ack" / "templates" / "delivery.template.yaml"),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("交付契约校验通过", result.stdout)
|
||||
|
||||
def test_supported_subset_types_and_block_scalars(self) -> None:
|
||||
document = load_yaml_subset(
|
||||
"""
|
||||
|
||||
+119
-9
@@ -37,6 +37,13 @@ class SkillInitTests(unittest.TestCase):
|
||||
' devWorktree: "<dev_worktree>"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
tasks_template = skill / "templates" / "tasks.template.yaml"
|
||||
tasks_template.write_text(
|
||||
tasks_template.read_text(encoding="utf-8")
|
||||
+ ' deliveryFile: "docs/ack/delivery.yaml"\n'
|
||||
+ 'deliveryRuns: []\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(skill / "templates" / "knowledge.template.yaml").write_text(
|
||||
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||
'project:\n'
|
||||
@@ -44,7 +51,25 @@ class SkillInitTests(unittest.TestCase):
|
||||
' repoPath: "<repo_path>"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
|
||||
(skill / "templates" / "delivery.template.yaml").write_text(
|
||||
'version: 1\n'
|
||||
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||
'project:\n'
|
||||
' name: "<project_name>"\n'
|
||||
'enabled: false\n'
|
||||
'defaultProfile: null\n'
|
||||
'entrypoints: {}\n'
|
||||
'artifacts: {}\n'
|
||||
'destinations: {}\n'
|
||||
'environments: {}\n'
|
||||
'profiles: {}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
for validator_name in (
|
||||
"validate_tasks.py",
|
||||
"validate_knowledge.py",
|
||||
"validate_delivery.py",
|
||||
):
|
||||
(skill / "scripts" / validator_name).write_text(
|
||||
"raise SystemExit(0)\n",
|
||||
encoding="utf-8",
|
||||
@@ -79,12 +104,16 @@ class SkillInitTests(unittest.TestCase):
|
||||
project_content = (target / "project.md").read_text(encoding="utf-8")
|
||||
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
||||
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
||||
delivery_content = (target / "delivery.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn("# sample-app", project_content)
|
||||
self.assertIn("version=1.2.3", project_content)
|
||||
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
||||
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
||||
self.assertIn('name: "sample-app"', delivery_content)
|
||||
self.assertIn("enabled: false", delivery_content)
|
||||
self.assertNotIn("<project_name>", tasks_content)
|
||||
self.assertNotIn("<project_name>", knowledge_content)
|
||||
self.assertNotIn("<project_name>", delivery_content)
|
||||
|
||||
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
||||
project = self.home / "existing-app"
|
||||
@@ -100,6 +129,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||
self.assertFalse((target / "tasks.yaml").exists())
|
||||
self.assertFalse((target / "knowledge.yaml").exists())
|
||||
self.assertFalse((target / "delivery.yaml").exists())
|
||||
|
||||
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
||||
project = self.home / "existing-knowledge-app"
|
||||
@@ -116,6 +146,22 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertFalse((target / "project.md").exists())
|
||||
self.assertFalse((target / "tasks.yaml").exists())
|
||||
|
||||
def test_init_refuses_to_overwrite_existing_delivery_file(self) -> None:
|
||||
project = self.home / "existing-delivery-app"
|
||||
target = project / "docs" / "ack"
|
||||
target.mkdir(parents=True)
|
||||
existing = target / "delivery.yaml"
|
||||
existing.write_text("keep me", encoding="utf-8")
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("拒绝覆盖已有路径", result.stderr)
|
||||
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||
self.assertFalse((target / "project.md").exists())
|
||||
self.assertFalse((target / "tasks.yaml").exists())
|
||||
self.assertFalse((target / "knowledge.yaml").exists())
|
||||
|
||||
def test_init_rejects_symlinked_destination_directories(self) -> None:
|
||||
for symlink_level in ("docs", "ack"):
|
||||
with self.subTest(symlink_level=symlink_level):
|
||||
@@ -147,6 +193,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertFalse((outside / "project.md").exists())
|
||||
self.assertFalse((outside / "tasks.yaml").exists())
|
||||
self.assertFalse((outside / "knowledge.yaml").exists())
|
||||
self.assertFalse((outside / "delivery.yaml").exists())
|
||||
|
||||
def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None:
|
||||
project = self.home / "path-traversal-app"
|
||||
@@ -289,7 +336,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in moved_target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
||||
@@ -348,7 +395,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertFalse((target / "marker").exists())
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
|
||||
@@ -360,7 +407,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 7:
|
||||
if calls == 8:
|
||||
raise OSError("simulated directory fsync failure")
|
||||
real_fsync(file_descriptor)
|
||||
|
||||
@@ -386,7 +433,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
target = project / "docs" / "ack"
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
||||
@@ -473,7 +520,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
target = moved_project / "docs" / "ack"
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_docs_replacement_aborts_before_publish(self) -> None:
|
||||
@@ -568,7 +615,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
target = moved_docs / "ack"
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in target.iterdir()),
|
||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||
)
|
||||
|
||||
def test_ack_init_requires_knowledge_template(self) -> None:
|
||||
@@ -588,8 +635,29 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertIn("knowledge.template.yaml", result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_ack_init_requires_both_validators(self) -> None:
|
||||
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
|
||||
def test_ack_init_requires_delivery_template(self) -> None:
|
||||
project = self.home / "missing-delivery-template-app"
|
||||
project.mkdir()
|
||||
(
|
||||
self.skills_home
|
||||
/ "skills"
|
||||
/ "ack"
|
||||
/ "templates"
|
||||
/ "delivery.template.yaml"
|
||||
).unlink()
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("delivery.template.yaml", result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_ack_init_requires_all_validators(self) -> None:
|
||||
for validator_name in (
|
||||
"validate_tasks.py",
|
||||
"validate_knowledge.py",
|
||||
"validate_delivery.py",
|
||||
):
|
||||
with self.subTest(validator_name=validator_name):
|
||||
project = self.home / f"missing-{validator_name}-app"
|
||||
project.mkdir()
|
||||
@@ -632,6 +700,21 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertIn("初始化知识库校验失败", result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_delivery_validator_failure_leaves_no_partial_initialization(self) -> None:
|
||||
project = self.home / "invalid-delivery-app"
|
||||
project.mkdir()
|
||||
validator = (
|
||||
self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py"
|
||||
)
|
||||
validator.write_text("raise SystemExit(1)\n", encoding="utf-8")
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
self.assertIn("初始化交付契约校验失败", result.stderr)
|
||||
self.assertFalse((project / "docs" / "ack").exists())
|
||||
|
||||
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
|
||||
project = self.home / "mutated-staging-app"
|
||||
project.mkdir()
|
||||
@@ -677,6 +760,32 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file())
|
||||
|
||||
def test_ack_delivery_init_validates_mirrored_staging_root(self) -> None:
|
||||
project = self.home / "staged-delivery-app"
|
||||
project.mkdir()
|
||||
validator = (
|
||||
self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py"
|
||||
)
|
||||
validator.write_text(
|
||||
"import sys\n"
|
||||
"from pathlib import Path\n"
|
||||
"required = ['--tasks', '--project-root']\n"
|
||||
"if any(item not in sys.argv for item in required):\n"
|
||||
" raise SystemExit(3)\n"
|
||||
"root = Path(sys.argv[sys.argv.index('--project-root') + 1])\n"
|
||||
"delivery = Path(sys.argv[1])\n"
|
||||
"tasks = Path(sys.argv[sys.argv.index('--tasks') + 1])\n"
|
||||
"expected = root / 'docs' / 'ack'\n"
|
||||
"raise SystemExit(0 if delivery.parent == expected and "
|
||||
"tasks.parent == expected else 4)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue((project / "docs" / "ack" / "delivery.yaml").is_file())
|
||||
|
||||
def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None:
|
||||
skill = self.skills_home / "skills" / "plain"
|
||||
(skill / "templates").mkdir(parents=True)
|
||||
@@ -697,6 +806,7 @@ class SkillInitTests(unittest.TestCase):
|
||||
self.assertTrue((target / "project.md").is_file())
|
||||
self.assertTrue((target / "tasks.yaml").is_file())
|
||||
self.assertFalse((target / "knowledge.yaml").exists())
|
||||
self.assertFalse((target / "delivery.yaml").exists())
|
||||
|
||||
def test_init_rejects_missing_project_directory(self) -> None:
|
||||
project = self.home / "missing-app"
|
||||
|
||||
Reference in New Issue
Block a user