diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index abc10bd9..bd5bd006 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -187,6 +187,9 @@ jobs: - name: Validate Dotnet Idempotency Contract run: python3 tools/validate_dotnet_idempotency_contract_v1.py + - name: Validate Dotnet CICD Chain Contract + run: python3 tools/validate_dotnet_cicd_chain_contract_v1.py + - name: Build Calibration Priority Backlog diff --git a/AGENTS.md b/AGENTS.md index bd4059d8..3512b48f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ - `docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml`: WBS-10 scheduler state machine 계약. - `docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml`: WBS-10 normalization/read model 계약. - `docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml`: WBS-10 idempotency/lock 계약. +- `docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml`: WBS-10 CI/CD 순차 게이트 계약. - `tools/validate_dotnet_migration_roadmap_v1.py`: WBS-10 상세 로드맵 YAML validator. - `tools/validate_dotnet_migration_execution_plan_v1.py`: WBS-10 실행 분해 계획 validator. - `tools/validate_dotnet_parity_contract_v1.py`: WBS-10 parity 계약 validator. @@ -111,6 +112,7 @@ - `tools/validate_dotnet_scheduler_contract_v1.py`: WBS-10 scheduler 계약 validator. - `tools/validate_dotnet_normalization_contract_v1.py`: WBS-10 normalization 계약 validator. - `tools/validate_dotnet_idempotency_contract_v1.py`: WBS-10 idempotency 계약 validator. +- `tools/validate_dotnet_cicd_chain_contract_v1.py`: WBS-10 CI/CD chain 계약 validator. - `Temp/snapshot_admin_approval_packet_v1.json`: snapshot admin approval packet export. - `Temp/snapshot_admin_approval_packet_v1.md`: snapshot admin approval packet summary. - `Temp/`: 실행 결과와 캐시. 라우팅 대상은 아니며 runtime consumer만 읽는다. diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 3c02437d..0b5770cf 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -1473,6 +1473,7 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행) > scheduler contract: [WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml](./WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml) > normalization contract: [WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml](./WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml) > idempotency contract: [WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml](./WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml) +> ci/cd chain contract: [WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml](./WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml) > 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준. > Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Razor Pages 어드민 대시보드 기본 구현 완료. diff --git a/docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml b/docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml new file mode 100644 index 00000000..05d47ef0 --- /dev/null +++ b/docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml @@ -0,0 +1,48 @@ +formula_id: WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1 +owner: QuantEngine +status: draft +goal: "CI, prepare-release, deploy-prod 순차 게이트를 고정한다." + +workflows: + ci: + file: .gitea/workflows/ci.yml + name: "Validators (Pushes and Pull Requests)" + triggers: + - push: main + - pull_request: main + role: "upstream validator" + prepare_release: + file: .gitea/workflows/prepare-release.yml + name: "Prepare Release" + triggers: + - workflow_run: Validators (Pushes and Pull Requests) + - workflow_dispatch + role: "release builder" + upstream_gate: "Validators (Pushes and Pull Requests) success" + deploy_prod: + file: .gitea/workflows/deploy-prod.yml + name: "Deploy to Production" + triggers: + - workflow_run: Prepare Release + - workflow_dispatch + role: "production deployer" + upstream_gate: "Prepare Release success" + +dependency_chain: + - "Validators (Pushes and Pull Requests) -> Prepare Release -> Deploy to Production" + +required_guards: + - "prepare-release는 Validators 성공 없이는 실행 금지" + - "deploy-prod는 Prepare Release 성공 없이는 실행 금지" + - "deploy-prod는 upstream CI SHA를 release tag와 대조" + - "모든 단계는 concurrency group을 사용해 동일 SHA 중복 실행을 차단" + +health_checks: + - "upstream workflow conclusion == success" + - "release tag sha matches workflow_run head_sha" + - "artifact 존재 확인" + - "SSH/Gitea secret 존재 확인" + +notes: + - "순차 게이트는 workflow_run 연결과 검증 스텝 둘 다 필요하다." + - "병렬 실행은 금지된다." diff --git a/spec/41_release_dag.yaml b/spec/41_release_dag.yaml index 6922291e..eb9f9eef 100644 --- a/spec/41_release_dag.yaml +++ b/spec/41_release_dag.yaml @@ -2393,6 +2393,22 @@ dag: - Temp/wbs_10_dotnet_idempotency_contract_v1.json strict: true timeout_sec: 60 + validate_dotnet_cicd_chain_contract: + artifact_policy: keep + cache_key: validate_dotnet_cicd_chain_contract_v1 + command: + - python + - tools/validate_dotnet_cicd_chain_contract_v1.py + depends_on: [] + id: validate_dotnet_cicd_chain_contract + inputs: + - tools/validate_dotnet_cicd_chain_contract_v1.py + - docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml + note: WBS-10 CI → Prepare Release → Deploy to Production 순차 게이트를 검증한다. + outputs: + - Temp/wbs_10_dotnet_cicd_chain_contract_v1.json + strict: true + timeout_sec: 60 validate_specs: artifact_policy: keep cache_key: validate_specs_v1 diff --git a/tests/unit/test_validate_dotnet_cicd_chain_contract_v1.py b/tests/unit/test_validate_dotnet_cicd_chain_contract_v1.py new file mode 100644 index 00000000..5b72b07c --- /dev/null +++ b/tests/unit/test_validate_dotnet_cicd_chain_contract_v1.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def test_validate_dotnet_cicd_chain_contract_passes() -> None: + root = Path(__file__).resolve().parents[2] + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_cicd_chain_contract_v1.py")], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + payload = json.loads(proc.stdout) + assert payload["gate"] == "PASS" + + +def test_validate_dotnet_cicd_chain_contract_reports_missing_chain() -> None: + root = Path(__file__).resolve().parents[2] + temp = root / "Temp" / "test_wbs_10_cicd_chain_contract_bad.yaml" + temp.write_text( + "formula_id: WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1\ngoal: CI, prepare-release, deploy-prod 순차 게이트를 고정한다.\nworkflows:\n ci:\n name: Validators (Pushes and Pull Requests)\n prepare_release:\n name: Prepare Release\n deploy_prod:\n name: Deploy to Production\ndependency_chain: []\nrequired_guards: [a,b,c,d]\nhealth_checks: [a,b,c,d]\n", + encoding="utf-8", + ) + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_cicd_chain_contract_v1.py"), "--contract", str(temp)], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + payload = json.loads(proc.stdout) + assert payload["gate"] == "FAIL" + assert "dependency_chain" in payload["missing"] diff --git a/tools/validate_dotnet_cicd_chain_contract_v1.py b/tools/validate_dotnet_cicd_chain_contract_v1.py new file mode 100644 index 00000000..5a25bf16 --- /dev/null +++ b/tools/validate_dotnet_cicd_chain_contract_v1.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import yaml + + +EXPECTED = { + "ci": "Validators (Pushes and Pull Requests)", + "prepare_release": "Prepare Release", + "deploy_prod": "Deploy to Production", +} + + +def load_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(path) + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet CICD chain contract") + parser.add_argument("--contract", default="docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml") + args = parser.parse_args(argv) + + contract_path = Path(args.contract).resolve() + payload: dict[str, Any] = { + "formula_id": "WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1", + "gate": "FAIL", + "missing": [], + "evidence": {"contract": str(contract_path)}, + } + + try: + data = load_yaml(contract_path) + except FileNotFoundError: + payload["missing"].append("contract missing") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 1 + + if data.get("formula_id") != "WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1": + payload["missing"].append("formula_id") + if data.get("goal") != "CI, prepare-release, deploy-prod 순차 게이트를 고정한다.": + payload["missing"].append("goal") + + workflows = data.get("workflows") or {} + for key, expected_name in EXPECTED.items(): + node = workflows.get(key) or {} + if node.get("name") != expected_name: + payload["missing"].append(f"workflows.{key}.name") + + chain = data.get("dependency_chain") or [] + if "Validators (Pushes and Pull Requests) -> Prepare Release -> Deploy to Production" not in chain: + payload["missing"].append("dependency_chain") + + guards = data.get("required_guards") or [] + if len(guards) < 4: + payload["missing"].append("required_guards") + + checks = data.get("health_checks") or [] + if len(checks) < 4: + payload["missing"].append("health_checks") + + payload["gate"] = "PASS" if not payload["missing"] else "FAIL" + payload["message"] = ( + "WBS-10 dotnet CICD chain contract validation passed." + if payload["gate"] == "PASS" + else "WBS-10 dotnet CICD chain contract validation failed." + ) + + out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_cicd_chain_contract_v1.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload["gate"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())