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
+291
View File
@@ -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()
+7 -1
View File
@@ -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(
+130 -1
View File
@@ -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",
+22 -2
View File
@@ -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
View File
@@ -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"