Skip to content

Commit f4f14bb

Browse files
Pigbibiclaudecursoragent
authored
ci(lifecycle): add evidence gate and drift check workflows (#64)
Add PR evidence validation when catalog status is promoted and daily drift detection with GitHub issue sync for crypto strategies. Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d1e25ba commit f4f14bb

4 files changed

Lines changed: 299 additions & 0 deletions

File tree

.github/workflows/drift-check.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Drift Check
2+
3+
# Daily drift detection for crypto strategies.
4+
5+
on:
6+
schedule:
7+
- cron: "0 6 * * *"
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
issues: write
13+
14+
jobs:
15+
drift:
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 15
18+
env:
19+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
20+
STRATEGY_DOMAIN: crypto
21+
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v6
25+
26+
- name: Checkout QuantPlatformKit
27+
uses: actions/checkout@v6
28+
with:
29+
repository: QuantStrategyLab/QuantPlatformKit
30+
ref: main
31+
path: external/QuantPlatformKit
32+
33+
- name: Set up Python
34+
uses: actions/setup-python@v6
35+
with:
36+
python-version: "3.11"
37+
38+
- name: Install dependencies
39+
run: |
40+
set -euo pipefail
41+
python -m pip install --upgrade pip
42+
python -m pip install -e . pandas
43+
python -m pip install --no-deps -e external/QuantPlatformKit
44+
45+
- name: Run drift detection
46+
run: quant-lifecycle drift --domain crypto --no-alerts
47+
48+
- name: Sync drift alerts to GitHub Issues
49+
env:
50+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51+
GITHUB_REPOSITORY: ${{ github.repository }}
52+
run: python scripts/run_drift_github_issues.py
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Evidence Gate
2+
3+
# Block PRs that promote catalog status without a valid evidence package.
4+
5+
on:
6+
pull_request:
7+
types: [opened, synchronize, reopened, ready_for_review]
8+
paths:
9+
- "src/**/catalog.py"
10+
- "src/**/combo_manifests.py"
11+
- "docs/evidence/**"
12+
- "evidence/**"
13+
14+
permissions:
15+
contents: read
16+
pull-requests: read
17+
18+
concurrency:
19+
group: evidence-gate-${{ github.event.pull_request.number }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
gate:
24+
if: github.event.pull_request.draft == false
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 10
27+
env:
28+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
29+
30+
steps:
31+
- name: Checkout
32+
uses: actions/checkout@v6
33+
with:
34+
fetch-depth: 0
35+
36+
- name: Resolve QuantPlatformKit ref
37+
id: quant-platform-kit-ref
38+
run: |
39+
set -euo pipefail
40+
ref="main"
41+
if [ -n "${GITHUB_HEAD_REF:-}" ]; then
42+
case "${GITHUB_HEAD_REF}" in
43+
dependabot/*)
44+
;;
45+
*)
46+
if git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then
47+
ref="${GITHUB_HEAD_REF}"
48+
fi
49+
;;
50+
esac
51+
fi
52+
echo "ref=${ref}" >> "$GITHUB_OUTPUT"
53+
54+
- name: Checkout QuantPlatformKit
55+
uses: actions/checkout@v6
56+
with:
57+
repository: QuantStrategyLab/QuantPlatformKit
58+
ref: ${{ steps.quant-platform-kit-ref.outputs.ref }}
59+
path: external/QuantPlatformKit
60+
61+
- name: Set up Python
62+
uses: actions/setup-python@v6
63+
with:
64+
python-version: "3.11"
65+
66+
- name: Install dependencies
67+
run: |
68+
set -euo pipefail
69+
python -m pip install --upgrade pip
70+
python -m pip install -e . pandas
71+
python -m pip install --no-deps -e external/QuantPlatformKit
72+
73+
- name: Evaluate Evidence Gate
74+
env:
75+
GITHUB_BASE_REF: ${{ github.base_ref }}
76+
run: python scripts/gate_evidence_package.py

scripts/gate_evidence_package.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env python3
2+
"""PR gate: require a valid evidence package when catalog status is promoted."""
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import re
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
12+
GATE_STAGES = frozenset(
13+
{
14+
"ai_monitored_candidate",
15+
"shadow_candidate",
16+
"live_candidate",
17+
"runtime_enabled",
18+
}
19+
)
20+
STATUS_ADDED_RE = re.compile(r'^\+.*status="([^"]+)"')
21+
EVIDENCE_SUFFIXES = {".json", ".toml"}
22+
23+
24+
def _git_diff(base_ref: str) -> str:
25+
result = subprocess.run(
26+
["git", "diff", f"origin/{base_ref}...HEAD", "--", "src"],
27+
capture_output=True,
28+
text=True,
29+
check=False,
30+
)
31+
if result.returncode != 0:
32+
result = subprocess.run(
33+
["git", "diff", f"{base_ref}...HEAD", "--", "src"],
34+
capture_output=True,
35+
text=True,
36+
check=True,
37+
)
38+
return result.stdout
39+
40+
41+
def _promotion_detected(diff: str) -> bool:
42+
if "status=" not in diff:
43+
return False
44+
return any(match.group(1) in GATE_STAGES for line in diff.splitlines() if (match := STATUS_ADDED_RE.match(line)))
45+
46+
47+
def _evidence_paths_from_diff(diff: str) -> list[Path]:
48+
paths: list[Path] = []
49+
for line in diff.splitlines():
50+
if not line.startswith("+++ b/"):
51+
continue
52+
candidate = Path(line[6:])
53+
if candidate.suffix.lower() not in EVIDENCE_SUFFIXES:
54+
continue
55+
if "evidence" in candidate.parts or candidate.parent.name == "evidence":
56+
paths.append(candidate)
57+
return paths
58+
59+
60+
def _discover_evidence_files(diff: str) -> list[Path]:
61+
discovered = _evidence_paths_from_diff(diff)
62+
for folder in (Path("docs/evidence"), Path("evidence")):
63+
if folder.is_dir():
64+
discovered.extend(path for path in folder.iterdir() if path.suffix.lower() in EVIDENCE_SUFFIXES)
65+
explicit = os.environ.get("EVIDENCE_PACKAGE_PATH", "").strip()
66+
if explicit:
67+
discovered.append(Path(explicit))
68+
return sorted({path for path in discovered if path.exists()})
69+
70+
71+
def _validate_with_lifecycle(path: Path) -> tuple[bool, list[str]]:
72+
from quant_platform_kit.strategy_lifecycle.evidence_gate import validate_evidence_package_file
73+
74+
result = validate_evidence_package_file(path)
75+
issues = list(result.issues)
76+
return result.valid, issues
77+
78+
79+
def _validate_with_promotion_standard(path: Path) -> tuple[bool, list[str]]:
80+
script = Path("external/QuantPlatformKit/scripts/validate_strategy_evidence_package.py")
81+
if not script.exists():
82+
return True, []
83+
result = subprocess.run(
84+
[sys.executable, str(script), str(path)],
85+
capture_output=True,
86+
text=True,
87+
check=False,
88+
)
89+
if result.returncode == 0:
90+
return True, []
91+
issues = [line for line in result.stderr.splitlines() if line.strip()]
92+
issues.extend(line for line in result.stdout.splitlines() if line.strip())
93+
return False, issues or ["promotion evidence package validation failed"]
94+
95+
96+
def main() -> int:
97+
base_ref = os.environ.get("GITHUB_BASE_REF", "main").strip() or "main"
98+
diff = _git_diff(base_ref)
99+
100+
if not _promotion_detected(diff):
101+
print("[evidence-gate] No lifecycle status promotion detected; skipping validation")
102+
return 0
103+
104+
evidence_files = _discover_evidence_files(diff)
105+
if not evidence_files:
106+
print(
107+
"::error::Catalog status promotion detected but no evidence package file was found. "
108+
"Add docs/evidence/<profile>.json with the 11 required artifacts.",
109+
file=sys.stderr,
110+
)
111+
return 1
112+
113+
failed = False
114+
for path in evidence_files:
115+
lifecycle_ok, lifecycle_issues = _validate_with_lifecycle(path)
116+
standard_ok, standard_issues = _validate_with_promotion_standard(path)
117+
if lifecycle_ok and standard_ok:
118+
print(f"[evidence-gate] PASS {path}")
119+
continue
120+
failed = True
121+
print(f"[evidence-gate] FAIL {path}", file=sys.stderr)
122+
for issue in lifecycle_issues + standard_issues:
123+
print(f" - {issue}", file=sys.stderr)
124+
125+
return 1 if failed else 0
126+
127+
128+
if __name__ == "__main__":
129+
raise SystemExit(main())

scripts/run_drift_github_issues.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python3
2+
"""Create GitHub issues for drift alerts after quant-lifecycle drift detection."""
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import sys
8+
9+
10+
def main() -> int:
11+
domain = os.environ.get("STRATEGY_DOMAIN", "").strip()
12+
if not domain:
13+
print("::error::STRATEGY_DOMAIN is required", file=sys.stderr)
14+
return 1
15+
16+
repository = os.environ.get("GITHUB_REPOSITORY", "").strip()
17+
if "/" in repository:
18+
owner, repo = repository.split("/", 1)
19+
os.environ.setdefault("CODEX_AUDIT_ORG", owner)
20+
os.environ.setdefault("CODEX_AUDIT_ORCHESTRATOR_REPO", repo)
21+
22+
from quant_platform_kit.strategy_lifecycle.codex_integration import create_issues_for_domain
23+
from quant_platform_kit.strategy_lifecycle.drift_detector import run_drift_detection
24+
25+
drifts = run_drift_detection(domain)
26+
critical = sum(1 for item in drifts if getattr(getattr(item, "status", None), "value", "") == "critical")
27+
review = sum(1 for item in drifts if getattr(getattr(item, "status", None), "value", "") == "review")
28+
print(f"[drift-check] domain={domain} checked={len(drifts)} review={review} critical={critical}")
29+
30+
results = create_issues_for_domain(domain, dry_run=False)
31+
created = [item for item in results if item.get("issue_url")]
32+
errors = [item for item in results if item.get("error")]
33+
print(f"[drift-check] issues_created={len(created)} errors={len(errors)}")
34+
for item in created:
35+
print(f" - {item.get('issue_url')}")
36+
for item in errors:
37+
print(f"::warning::{item.get('title')}: {item.get('error')}", file=sys.stderr)
38+
return 0
39+
40+
41+
if __name__ == "__main__":
42+
raise SystemExit(main())

0 commit comments

Comments
 (0)