feat(ship): clarify delivery stop semantics
This commit is contained in:
@@ -7,27 +7,31 @@ change_ready -> verified -> pr_opened -> merged -> regression_passed -> artifact
|
|||||||
```
|
```
|
||||||
|
|
||||||
入口可以从已有状态开始,但不能跳过本次变更所需的验证。内部里程碑可以更细,但项目
|
入口可以从已有状态开始,但不能跳过本次变更所需的验证。内部里程碑可以更细,但项目
|
||||||
ACK 配置必须使用以下 canonical `stopAt`:
|
Ship 配置使用以下 canonical `stopAt`:
|
||||||
|
|
||||||
- `verified`:只完成代码和验证。
|
- `develop_verified`:开发阶段验证完成,包含单元测试、新增功能集成测试和静态检查;不等于完整回归。
|
||||||
- `validation_ready`:不可变产物已部署到开发/测试环境并通过健康检查,可交给用户验证。
|
- `review_ready`:PR/MR 已创建,等待审核。
|
||||||
- `review_ready`:PR/MR、预览产物和非生产验证证据已齐备,等待审核。
|
- `release_ready`:PR 已合并且完整回归通过,映射内部 `regression_passed`;不表示已发布或部署。
|
||||||
- `released`:经过本次 approval 后完成稳定发布或生产部署。
|
- `released`:产物已构建并发布,映射内部 `published`。
|
||||||
|
- `deployed`:目标环境实际部署完成,映射内部 `deployed`。
|
||||||
|
|
||||||
|
旧记录可继续使用 `verified`(`develop_verified` 的别名)和 `validation_ready`
|
||||||
|
(`release_ready` 的别名);新记录应使用 canonical 名称。
|
||||||
|
|
||||||
入口 skill 的自然语言停止点映射如下:
|
入口 skill 的自然语言停止点映射如下:
|
||||||
|
|
||||||
| 用户目标 | ACK stopAt | 必要内部里程碑 |
|
| 用户目标 | Ship stopAt | 必要内部里程碑 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 只验证 | `verified` | `verified` |
|
| 只做开发验证 | `develop_verified` | `verified` |
|
||||||
| 发 PR | `review_ready` | `pr_opened`,必要时预览构建/测试 |
|
| 发 PR | `review_ready` | `pr_opened`,必要时预览构建/测试 |
|
||||||
| 部署测试环境 | `validation_ready` | `artifact_built`、`deployed`、健康检查 |
|
| 合并后完整回归 | `release_ready` | `merged`、`regression_passed` |
|
||||||
| 合并后回归但不发布 | `review_ready` 或任务级中间状态 | `merged`、`regression_passed` |
|
| 构建并发布产物 | `released` | `merged`、`regression_passed`、`artifact_built`、`published` |
|
||||||
| 完整正式部署 | `released` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` |
|
| 实际部署 | `deployed` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` |
|
||||||
|
|
||||||
## 每个阶段的要求
|
## 每个阶段的要求
|
||||||
|
|
||||||
- `change_ready`:记录基线 commit、工作树、目标分支、任务范围和风险。
|
- `change_ready`:记录基线 commit、工作树、目标分支、任务范围和风险。
|
||||||
- `verified`:根因或需求验收明确;自动测试和必要的黑盒/真机验证通过。
|
- `verified`:开发阶段根因或需求验收明确;任务相关单元测试、新增功能集成测试和静态检查通过。
|
||||||
- `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。
|
- `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。
|
||||||
- `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。
|
- `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。
|
||||||
- `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。
|
- `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。
|
||||||
@@ -39,7 +43,7 @@ ACK 配置必须使用以下 canonical `stopAt`:
|
|||||||
测试面缺少结果时都不能通过回归。
|
测试面缺少结果时都不能通过回归。
|
||||||
- `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。
|
- `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。
|
||||||
- `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。
|
- `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。
|
||||||
- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过;生产部署还必须进入 `released`。
|
- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过。
|
||||||
|
|
||||||
## 配置和授权
|
## 配置和授权
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
3. 按 JSON 行动:
|
3. 按 JSON 行动:
|
||||||
- `status: done`:写完成报告,结束。
|
- `status: done`:写完成报告,结束。
|
||||||
- `status: blocked` / `ask_user`:告诉用户缺口或问题,等回复。生产部署(`stopAt:
|
- `status: blocked` / `ask_user`:告诉用户缺口或问题,等回复。生产部署(`stopAt:
|
||||||
released` 且下一步是 `deploy`)必须再问一次,除非当前这句话已经授权。
|
deployed` 且下一步是 `deploy`)必须再问一次,除非当前这句话已经授权。
|
||||||
- `actor: manager`:补账本或问用户,再跑 `next_step.py`。
|
- `actor: manager`:补账本或问用户,再跑 `next_step.py`。
|
||||||
- `actor: worker`:开新 agent,等待,把结果写入 `manager.lastStep` /
|
- `actor: worker`:开新 agent,等待,把结果写入 `manager.lastStep` /
|
||||||
`manager.lastResult` / `manager.lastSummary`,按 `targetState` / `taskId` 更新账本,立刻
|
`manager.lastResult` / `manager.lastSummary`,按 `targetState` / `taskId` 更新账本,立刻
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ evidence:
|
|||||||
`delivery.state` 使用 delivery-loop 内部里程碑:`change_ready`、`verified`、
|
`delivery.state` 使用 delivery-loop 内部里程碑:`change_ready`、`verified`、
|
||||||
`pr_opened`、`merged`、`regression_passed`、`artifact_built`、`published`、`deployed`。
|
`pr_opened`、`merged`、`regression_passed`、`artifact_built`、`published`、`deployed`。
|
||||||
|
|
||||||
|
用户可见的 canonical `stopAt` 与内部里程碑映射如下:
|
||||||
|
|
||||||
|
| stopAt | 停止含义 | 内部里程碑 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `develop_verified` | 开发验证完成:单元测试、新增功能集成测试和静态检查通过;不等于完整回归 | `verified` |
|
||||||
|
| `review_ready` | PR/MR 已创建,等待审核 | `pr_opened` |
|
||||||
|
| `release_ready` | PR 已合并且完整回归通过;尚未表示发布或部署 | `regression_passed` |
|
||||||
|
| `released` | 产物已构建并发布 | `published` |
|
||||||
|
| `deployed` | 目标环境实际部署完成并通过健康检查 | `deployed` |
|
||||||
|
|
||||||
|
为兼容旧记录,`verified` 是 `develop_verified` 的别名,`validation_ready` 是
|
||||||
|
`release_ready` 的别名;新记录应使用 canonical 名称。`delivery.state` 内部里程碑
|
||||||
|
名称保持不变。
|
||||||
|
|
||||||
`source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时
|
`source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时
|
||||||
`source.kind: ack` 且 `ref` 为缺陷任务 id。
|
`source.kind: ack` 且 `ref` 为缺陷任务 id。
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ scripts/append_evidence.py 追加后跑 scripts/validate_ship.py。
|
|||||||
验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。
|
验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。
|
||||||
授权:<本步允许的 git/PR/部署动作;未写的不要做>
|
授权:<本步允许的 git/PR/部署动作;未写的不要做>
|
||||||
|
|
||||||
|
测试边界:`verify` / `verify_task` 只负责开发阶段验证,执行任务相关的单元测试、
|
||||||
|
新增功能集成测试和静态检查,不执行合并后的完整 tester 回归;`regress` 只在合并后
|
||||||
|
负责完整回归,必须覆盖发现的每个 tester catalog 根和项目声明的原生测试套件。
|
||||||
|
|
||||||
若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行
|
若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行
|
||||||
`python3 <tester-skill>/scripts/catalog.py roots --root <project-root>`。`verify_task` 按
|
`python3 <tester-skill>/scripts/catalog.py roots --root <project-root>`。`verify_task` 按
|
||||||
任务范围选择相关测试根;`regress` 必须对返回的每个独立测试根分别执行 `validate`、
|
任务范围选择相关测试根;`regress` 必须对返回的每个独立测试根分别执行 `validate`、
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ship_lib import ensure_stage_layout, load_yaml, record_stage_event, stage_for_step
|
from ship_lib import (
|
||||||
|
STOP_AT_STATE,
|
||||||
|
ensure_stage_layout,
|
||||||
|
load_yaml,
|
||||||
|
record_stage_event,
|
||||||
|
stage_for_step,
|
||||||
|
)
|
||||||
|
|
||||||
SPEC_PLACEHOLDERS = (
|
SPEC_PLACEHOLDERS = (
|
||||||
"<用户可见的目标",
|
"<用户可见的目标",
|
||||||
@@ -26,12 +32,6 @@ TASK_PLACEHOLDERS = (
|
|||||||
FAULT_PLACEHOLDER = "<没有故障时删除"
|
FAULT_PLACEHOLDER = "<没有故障时删除"
|
||||||
DONE_TASK = frozenset({"verified", "merged", "done"})
|
DONE_TASK = frozenset({"verified", "merged", "done"})
|
||||||
READY_DEP = frozenset({"verified", "merged", "done"})
|
READY_DEP = frozenset({"verified", "merged", "done"})
|
||||||
STOP_AT_STATE = {
|
|
||||||
"verified": "verified",
|
|
||||||
"review_ready": "pr_opened",
|
|
||||||
"validation_ready": "deployed",
|
|
||||||
"released": "deployed",
|
|
||||||
}
|
|
||||||
DELIVERY_STEPS: tuple[tuple[str, str, str], ...] = (
|
DELIVERY_STEPS: tuple[tuple[str, str, str], ...] = (
|
||||||
("verified", "verify", "tester"),
|
("verified", "verify", "tester"),
|
||||||
("pr_opened", "open_pr", "delivery-loop"),
|
("pr_opened", "open_pr", "delivery-loop"),
|
||||||
@@ -173,6 +173,11 @@ def _next_delivery(
|
|||||||
nxt = DELIVERY_STEPS[start]
|
nxt = DELIVERY_STEPS[start]
|
||||||
state, step, skill = nxt
|
state, step, skill = nxt
|
||||||
summary = f"派 worker 把 delivery.state 推进到 {state},加载 {skill}"
|
summary = f"派 worker 把 delivery.state 推进到 {state},加载 {skill}"
|
||||||
|
if step == "verify":
|
||||||
|
summary = (
|
||||||
|
"派 tester worker 做开发阶段验证:执行单元测试、新增功能集成测试和静态检查;"
|
||||||
|
"不执行合并后的完整 tester 回归"
|
||||||
|
)
|
||||||
if step == "regress":
|
if step == "regress":
|
||||||
summary = (
|
summary = (
|
||||||
"派 tester worker 做合并后的完整回归:先发现父仓库和子项目的所有 tester "
|
"派 tester worker 做合并后的完整回归:先发现父仓库和子项目的所有 tester "
|
||||||
@@ -323,7 +328,10 @@ def _decide_short(
|
|||||||
actor="manager",
|
actor="manager",
|
||||||
step="ask_user",
|
step="ask_user",
|
||||||
status="ask_user",
|
status="ask_user",
|
||||||
summary="stopAt 未配置,主会话向用户确认 verified / review_ready / validation_ready / released",
|
summary=(
|
||||||
|
"stopAt 未配置,主会话向用户确认 develop_verified / review_ready / "
|
||||||
|
"release_ready / released / deployed"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if state == "change_ready" and _needs_hunt(spec_md) and not _has_change_commit(current):
|
if state == "change_ready" and _needs_hunt(spec_md) and not _has_change_commit(current):
|
||||||
last = _manager(current).get("lastStep")
|
last = _manager(current).get("lastStep")
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ DELIVERY_STATE = frozenset(
|
|||||||
"deployed",
|
"deployed",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# User-facing stop points. The two legacy names remain accepted so existing
|
||||||
|
# ship records continue to resolve to the new phase meanings.
|
||||||
|
STOP_AT_STATE = {
|
||||||
|
"develop_verified": "verified",
|
||||||
|
"review_ready": "pr_opened",
|
||||||
|
"release_ready": "regression_passed",
|
||||||
|
"released": "published",
|
||||||
|
"deployed": "deployed",
|
||||||
|
"verified": "verified", # legacy alias for develop_verified
|
||||||
|
"validation_ready": "regression_passed", # legacy alias for release_ready
|
||||||
|
}
|
||||||
|
STOP_AT = frozenset(STOP_AT_STATE)
|
||||||
SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"})
|
SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"})
|
||||||
COMMIT_ROLE = frozenset({"baseline", "merged", "head"})
|
COMMIT_ROLE = frozenset({"baseline", "merged", "head"})
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from ship_lib import (
|
|||||||
DELIVERY_STATE,
|
DELIVERY_STATE,
|
||||||
KIND,
|
KIND,
|
||||||
SHIP_STATUS,
|
SHIP_STATUS,
|
||||||
|
STOP_AT,
|
||||||
SOURCE_KIND,
|
SOURCE_KIND,
|
||||||
TASK_STATUS,
|
TASK_STATUS,
|
||||||
check_ship_id,
|
check_ship_id,
|
||||||
@@ -77,6 +78,17 @@ def validate_ship_dir(ship_dir: Path) -> list[str]:
|
|||||||
if not isinstance(data.get("title"), str) or not str(data.get("title")).strip():
|
if not isinstance(data.get("title"), str) or not str(data.get("title")).strip():
|
||||||
_issue(issues, "title 不能为空")
|
_issue(issues, "title 不能为空")
|
||||||
|
|
||||||
|
for path, value in (
|
||||||
|
("stopAt", data.get("stopAt")),
|
||||||
|
("delivery.stopAt", (data.get("delivery") or {}).get("stopAt")
|
||||||
|
if isinstance(data.get("delivery"), dict) else None),
|
||||||
|
):
|
||||||
|
if value not in (None, "") and value not in STOP_AT:
|
||||||
|
_issue(
|
||||||
|
issues,
|
||||||
|
f"{path} 无效: {value!r}({', '.join(sorted(STOP_AT))})",
|
||||||
|
)
|
||||||
|
|
||||||
source = data.get("source")
|
source = data.get("source")
|
||||||
if source is not None:
|
if source is not None:
|
||||||
src = _mapping(source, "source", issues)
|
src = _mapping(source, "source", issues)
|
||||||
|
|||||||
@@ -244,6 +244,64 @@ def _fill_task(ship_dir: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class NextStepTests(unittest.TestCase):
|
class NextStepTests(unittest.TestCase):
|
||||||
|
def _prepare_delivery_dispatch(
|
||||||
|
self, root: Path, ship_id: str, stop_at: str, state: str
|
||||||
|
) -> None:
|
||||||
|
ship_dir = _create(root, ship_id, "short", stop_at=stop_at)
|
||||||
|
(ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8")
|
||||||
|
_fill_task(ship_dir)
|
||||||
|
_patch_current(ship_dir, delivery_state=state)
|
||||||
|
data = safe_load((ship_dir / "current.yaml").read_text(encoding="utf-8"))
|
||||||
|
data["evidence"]["commits"] = [{"sha": "abc1234deadbeef", "role": "head"}]
|
||||||
|
(ship_dir / "current.yaml").write_text(safe_dump(data), encoding="utf-8")
|
||||||
|
|
||||||
|
def test_stop_at_mappings_cover_each_delivery_boundary(self) -> None:
|
||||||
|
cases = (
|
||||||
|
("develop_verified", "change_ready", "verify", "verified"),
|
||||||
|
("review_ready", "verified", "open_pr", "pr_opened"),
|
||||||
|
("release_ready", "merged", "regress", "regression_passed"),
|
||||||
|
("released", "artifact_built", "publish", "published"),
|
||||||
|
("deployed", "published", "deploy", "deployed"),
|
||||||
|
)
|
||||||
|
for stop_at, state, step, target in cases:
|
||||||
|
with self.subTest(stop_at=stop_at):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
ship_id = f"ship-{stop_at.replace('_', '-')}"
|
||||||
|
self._prepare_delivery_dispatch(root, ship_id, stop_at, state)
|
||||||
|
doc = _next(root, ship_id, "short")
|
||||||
|
self.assertEqual(doc["step"], step)
|
||||||
|
self.assertEqual(doc["targetState"], target)
|
||||||
|
|
||||||
|
def test_legacy_stop_at_names_resolve_to_new_boundaries(self) -> None:
|
||||||
|
cases = (
|
||||||
|
("verified", "change_ready", "verify", "verified"),
|
||||||
|
("validation_ready", "merged", "regress", "regression_passed"),
|
||||||
|
)
|
||||||
|
for stop_at, state, step, target in cases:
|
||||||
|
with self.subTest(stop_at=stop_at):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
ship_id = f"legacy-{stop_at.replace('_', '-')}"
|
||||||
|
self._prepare_delivery_dispatch(root, ship_id, stop_at, state)
|
||||||
|
doc = _next(root, ship_id, "short")
|
||||||
|
self.assertEqual(doc["step"], step)
|
||||||
|
self.assertEqual(doc["targetState"], target)
|
||||||
|
|
||||||
|
def test_invalid_stop_at_fails_ship_validation(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
result = _run(
|
||||||
|
CREATE,
|
||||||
|
"--project-root", str(root),
|
||||||
|
"--ship-id", "invalid-stop-at",
|
||||||
|
"--kind", "short",
|
||||||
|
"--title", "无效停止点",
|
||||||
|
"--stop-at", "not_a_stop_point",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("stopAt 无效", result.stderr)
|
||||||
|
|
||||||
def test_new_short_ship_writes_spec(self) -> None:
|
def test_new_short_ship_writes_spec(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
|
|||||||
Reference in New Issue
Block a user