feat(ack): add project delivery workflow
This commit is contained in:
+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