Skip to content

Commit bd6c10d

Browse files
authored
Merge pull request #12 from PyAutoLabs/feature/build-pulse-agent-separation
Pulse owns workspace-integration validation (Stage 4 Phase B, Pulse side)
2 parents 4c73289 + 5f31cbf commit bd6c10d

5 files changed

Lines changed: 303 additions & 2 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
name: Workspace Validation
2+
3+
# Pulse-owned workspace-integration validation (Stage 4 Phase B).
4+
#
5+
# This is the "heavy validation CI" that used to live in PyAutoBuild's
6+
# release.yml (find_scripts / run_scripts / analyze_results). Pulse owns it now:
7+
# Build is a pure executor. It runs every workspace's scripts against the CURRENT
8+
# source `main` of the 5 libraries (source-shadowed via PYTHONPATH), aggregates
9+
# into the same report.json contract, and uploads it as the
10+
# `workspace-validation-report` artifact. Pulse's test_run check reads that run's
11+
# conclusion + timestamp into the authoritative `readiness` verdict (with a
12+
# staleness window).
13+
#
14+
# Run mechanics (script_matrix.py / run_python.py / aggregate_results.py) are
15+
# Build's executor primitives — checked out from PyAutoBuild, not duplicated.
16+
#
17+
# Triggers: weekly schedule (the continuous readiness signal), manual dispatch,
18+
# and workflow_call (so a release path can invoke it on demand). Heavy, so never
19+
# in the <30s pulse tick.
20+
21+
on:
22+
schedule:
23+
- cron: "0 3 * * 1" # Mondays 03:00 UTC
24+
workflow_dispatch:
25+
workflow_call:
26+
27+
permissions:
28+
contents: read
29+
30+
env:
31+
# Workspace pins lag the libraries' source __version__ between releases;
32+
# without this every script fails fast with WorkspaceVersionMismatchError.
33+
# Same bypass run_scripts uses in release.yml.
34+
PYAUTO_SKIP_WORKSPACE_VERSION_CHECK: "1"
35+
36+
jobs:
37+
find_scripts:
38+
runs-on: ubuntu-latest
39+
outputs:
40+
matrix: ${{ steps.script_matrix.outputs.matrix }}
41+
steps:
42+
- name: Checkout PyAutoBuild (run primitives)
43+
uses: actions/checkout@v4
44+
with:
45+
repository: PyAutoLabs/PyAutoBuild
46+
path: PyAutoBuild
47+
- name: Clone workspaces (paths match script_matrix project names)
48+
run: |
49+
set -e
50+
declare -A WS=(
51+
[autofit]=autofit_workspace [autogalaxy]=autogalaxy_workspace [autolens]=autolens_workspace
52+
[autofit_test]=autofit_workspace_test [autogalaxy_test]=autogalaxy_workspace_test
53+
[autolens_test]=autolens_workspace_test
54+
[howtogalaxy]=HowToGalaxy [howtolens]=HowToLens [howtofit]=HowToFit
55+
)
56+
for proj in "${!WS[@]}"; do
57+
git clone --depth 1 "https://github.com/PyAutoLabs/${WS[$proj]}" "$proj"
58+
done
59+
- name: Make script matrix
60+
id: script_matrix
61+
run: |
62+
matrix="$(python3 PyAutoBuild/autobuild/script_matrix.py \
63+
autofit autogalaxy autolens autofit_test autogalaxy_test autolens_test \
64+
howtogalaxy howtolens howtofit)"
65+
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
66+
67+
run_scripts:
68+
runs-on: ubuntu-latest
69+
needs: find_scripts
70+
strategy:
71+
fail-fast: false
72+
matrix:
73+
python-version: ["3.12"]
74+
project: ${{ fromJSON(needs.find_scripts.outputs.matrix) }}
75+
steps:
76+
- name: Checkout PyAutoBuild (run primitives)
77+
uses: actions/checkout@v4
78+
with:
79+
repository: PyAutoLabs/PyAutoBuild
80+
path: PyAutoBuild
81+
- name: Resolve workspace repo for ${{ matrix.project.name }}
82+
id: ws
83+
run: |
84+
case "${{ matrix.project.name }}" in
85+
autofit) echo "repo=autofit_workspace" >> "$GITHUB_OUTPUT" ;;
86+
autogalaxy) echo "repo=autogalaxy_workspace" >> "$GITHUB_OUTPUT" ;;
87+
autolens) echo "repo=autolens_workspace" >> "$GITHUB_OUTPUT" ;;
88+
autofit_test) echo "repo=autofit_workspace_test" >> "$GITHUB_OUTPUT" ;;
89+
autogalaxy_test) echo "repo=autogalaxy_workspace_test" >> "$GITHUB_OUTPUT" ;;
90+
autolens_test) echo "repo=autolens_workspace_test" >> "$GITHUB_OUTPUT" ;;
91+
howtogalaxy) echo "repo=HowToGalaxy" >> "$GITHUB_OUTPUT" ;;
92+
howtolens) echo "repo=HowToLens" >> "$GITHUB_OUTPUT" ;;
93+
howtofit) echo "repo=HowToFit" >> "$GITHUB_OUTPUT" ;;
94+
*) echo "repo=autolens_workspace_test" >> "$GITHUB_OUTPUT" ;;
95+
esac
96+
- name: Checkout workspace
97+
uses: actions/checkout@v4
98+
with:
99+
repository: PyAutoLabs/${{ steps.ws.outputs.repo }}
100+
ref: main
101+
path: workspace
102+
- name: Checkout libraries (current main; source-shadowed via PYTHONPATH)
103+
run: |
104+
set -e
105+
for lib in PyAutoConf PyAutoArray PyAutoFit PyAutoGalaxy PyAutoLens; do
106+
git clone --depth 1 "https://github.com/PyAutoLabs/$lib" "libs/$lib"
107+
done
108+
- name: Set up Python ${{ matrix.python-version }}
109+
uses: actions/setup-python@v5
110+
with:
111+
python-version: ${{ matrix.python-version }}
112+
cache: pip
113+
- name: Install third-party deps (libs run from source)
114+
run: |
115+
# Pull the full transitive dependency set (matplotlib/numba/jax/…) by
116+
# installing the published autolens[optional]; the source checkouts
117+
# below shadow the PyAuto packages via PYTHONPATH.
118+
pip install "autolens[optional]"
119+
pip install "jax>=0.7,<0.11" "jaxlib>=0.7,<0.11"
120+
- name: Run Python scripts
121+
run: |
122+
LIBS="$(pwd)/libs"
123+
export PYTHONPATH="$LIBS/PyAutoConf:$LIBS/PyAutoArray:$LIBS/PyAutoFit:$LIBS/PyAutoGalaxy:$LIBS/PyAutoLens:$(pwd)/PyAutoBuild:$PYTHONPATH"
124+
pushd workspace
125+
python3 "$(pwd)/../PyAutoBuild/autobuild/run_python.py" \
126+
"${{ matrix.project.name }}" "scripts/${{ matrix.project.directory }}" \
127+
--report-dir test-results
128+
- name: Upload script results
129+
if: always()
130+
uses: actions/upload-artifact@v4
131+
with:
132+
name: results-scripts-${{ matrix.project.name }}-${{ matrix.project.directory }}
133+
path: workspace/test-results/
134+
retention-days: 30
135+
136+
analyze:
137+
runs-on: ubuntu-latest
138+
needs: run_scripts
139+
if: always()
140+
steps:
141+
- name: Checkout PyAutoBuild (aggregate_results)
142+
uses: actions/checkout@v4
143+
with:
144+
repository: PyAutoLabs/PyAutoBuild
145+
path: PyAutoBuild
146+
- name: Set up Python
147+
uses: actions/setup-python@v5
148+
with:
149+
python-version: "3.12"
150+
- name: Download all result artifacts
151+
uses: actions/download-artifact@v4
152+
with:
153+
path: all-results
154+
pattern: results-*
155+
merge-multiple: true
156+
- name: Aggregate into report.json
157+
run: |
158+
export PYTHONPATH="$PYTHONPATH:$(pwd)/PyAutoBuild"
159+
python3 PyAutoBuild/autobuild/aggregate_results.py all-results/ \
160+
--output report.json --markdown report.md
161+
- name: Post summary
162+
if: always()
163+
run: cat report.md >> "$GITHUB_STEP_SUMMARY" || true
164+
- name: Upload report (consumed by pyauto-pulse readiness)
165+
if: always()
166+
uses: actions/upload-artifact@v4
167+
with:
168+
name: workspace-validation-report
169+
path: |
170+
report.json
171+
report.md
172+
retention-days: 90
173+
- name: Fail the run if validation is not ready
174+
run: |
175+
python3 -c "import json,sys; sys.exit(0 if json.load(open('report.json')).get('ready') else 1)"

pulse/checks/test_run.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020

2121
from __future__ import annotations
2222

23+
import datetime
2324
import json
25+
import subprocess
2426
import sys
2527
from pathlib import Path
2628
from typing import Any
@@ -32,6 +34,46 @@
3234
TEST_RESULTS_LATEST = PYAUTO_ROOT / "PyAutoBuild" / "test_results" / "latest"
3335
PULSE_STATE_DIR = Path.home() / ".pyauto-pulse"
3436

37+
# The cloud workspace-validation workflow (Pulse-owned) is the continuous source
38+
# of the workspace-integration verdict. The tick reads only its conclusion +
39+
# timestamp via one `gh run list` call (cheap, same budget as ci_status); the
40+
# full report.json detail still comes from a local `autobuild run_all`.
41+
VALIDATION_REPO = "PyAutoLabs/PyAutoPulse"
42+
VALIDATION_WORKFLOW = "workspace-validation.yml"
43+
44+
45+
def _cloud_verdict() -> dict[str, Any] | None:
46+
"""Latest cloud workspace-validation run: {ready, ts, run_id, url} or None.
47+
48+
ready is True/False on a completed run, None while in progress. Never raises;
49+
returns None if gh is unavailable or no run exists."""
50+
try:
51+
out = subprocess.run(
52+
["gh", "run", "list", "--repo", VALIDATION_REPO,
53+
"--workflow", VALIDATION_WORKFLOW, "--limit", "1",
54+
"--json", "conclusion,status,createdAt,databaseId,url"],
55+
capture_output=True, text=True, timeout=30,
56+
)
57+
runs = json.loads(out.stdout or "[]")
58+
except Exception:
59+
return None
60+
if not runs:
61+
return None
62+
r = runs[0]
63+
conclusion = r.get("conclusion")
64+
status = r.get("status")
65+
ready: bool | None
66+
if status != "completed":
67+
ready = None
68+
else:
69+
ready = conclusion == "success"
70+
return {
71+
"ready": ready,
72+
"ts": r.get("createdAt"),
73+
"run_id": r.get("databaseId"),
74+
"url": r.get("url"),
75+
}
76+
3577

3678
def _read_json(path: Path) -> Any:
3779
try:
@@ -100,15 +142,40 @@ def _from_per_job(results_dir: Path) -> dict[str, Any]:
100142
}
101143

102144

103-
def run(results_dir: Path | None = None) -> dict[str, Any]:
145+
def run(results_dir: Path | None = None, fetch_cloud: bool | None = None) -> dict[str, Any]:
146+
default_path = results_dir is None
104147
results_dir = results_dir or TEST_RESULTS_LATEST
148+
if fetch_cloud is None:
149+
fetch_cloud = default_path # only hit the network on the real tick path
150+
105151
summary: dict[str, Any]
106152
report = _read_json(results_dir / "report.json")
107153
if isinstance(report, dict):
108154
summary = _from_report(report)
109155
else:
110156
summary = _from_per_job(results_dir)
111157

158+
report_path = results_dir / "report.json"
159+
if summary and report_path.is_file():
160+
summary["ts"] = datetime.datetime.fromtimestamp(
161+
report_path.stat().st_mtime, datetime.timezone.utc
162+
).isoformat()
163+
164+
# The cloud workspace-validation run is the authoritative continuous verdict;
165+
# let it set ready/ts (the local report still supplies the count detail).
166+
cloud = _cloud_verdict() if fetch_cloud else None
167+
if cloud is not None:
168+
if not summary:
169+
summary = {
170+
"passed": 0, "failed": 0, "skipped": 0, "timeout": 0,
171+
"per_project": {}, "parked_stale_count": 0, "parked_stale": [],
172+
}
173+
summary["ready"] = cloud["ready"]
174+
summary["ts"] = cloud["ts"]
175+
summary["run_label"] = summary.get("run_label") or f"cloud#{cloud['run_id']}"
176+
summary["cloud_url"] = cloud["url"]
177+
summary["source"] = "cloud"
178+
112179
PULSE_STATE_DIR.mkdir(parents=True, exist_ok=True)
113180
(PULSE_STATE_DIR / "test_run.json").write_text(json.dumps(summary, indent=2))
114181
return summary

pulse/readiness.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"install_not_ready": (40, 40),
7777
"install_stale": (10, 10),
7878
"install_unknown": (10, 10),
79+
"test_stale": (10, 10),
7980
}
8081

8182

@@ -98,6 +99,8 @@ def _as_int(v: Any, default: int = 0) -> int:
9899

99100
# install verification older than this many days is treated as stale (YELLOW).
100101
INSTALL_STALE_DAYS = 14
102+
# workspace-validation test run older than this many days is treated as stale.
103+
TEST_STALE_DAYS = 10
101104

102105

103106
def _parse_ts(ts: Any) -> datetime.datetime | None:
@@ -163,7 +166,12 @@ def hit(key: str, n: int = 1) -> None:
163166
if ready is False:
164167
red.append(f"test run not ready ({test_run.get('run_label', '?')})")
165168
hit("test_not_ready")
166-
elif ready is not True:
169+
elif ready is True:
170+
age = _age_days(test_run.get("ts"), ref)
171+
if age is not None and age > TEST_STALE_DAYS:
172+
yellow.append(f"test run stale ({int(age)}d old)")
173+
hit("test_stale")
174+
else:
167175
yellow.append("test run status unknown")
168176
hit("test_unknown")
169177
# parked staleness (YELLOW)

tests/test_readiness.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ def test_one_library_ci_failing_is_red():
5555
assert v["score"] == 70
5656

5757

58+
def test_test_run_stale_is_yellow():
59+
# ready but ~31 days before the snapshot ts → stale caution, not a blocker.
60+
snap = make_snapshot(test_run={"ready": True, "ts": "2026-05-01T00:00:00+00:00",
61+
"parked_stale_count": 0})
62+
v = compute(snap)
63+
assert v["verdict"] == "yellow"
64+
assert any("test run stale" in r for r in v["yellow_reasons"])
65+
66+
67+
def test_test_run_fresh_ready_is_green():
68+
snap = make_snapshot(test_run={"ready": True, "ts": "2026-06-01T00:00:00+00:00",
69+
"parked_stale_count": 0})
70+
v = compute(snap)
71+
assert v["verdict"] == "green"
72+
73+
5874
def test_test_run_not_ready_is_red():
5975
v = compute(make_snapshot(test_run={"ready": False, "run_label": "x"}))
6076
assert v["verdict"] == "red"

tests/test_test_run.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,38 @@ def test_run_falls_back_to_per_job_when_no_report(tmp_path):
6868
def test_run_empty_dir_returns_empty(tmp_path):
6969
out = tr.run(results_dir=tmp_path)
7070
assert out == {}
71+
72+
73+
def test_cloud_verdict_parses_completed_run(monkeypatch):
74+
import types
75+
monkeypatch.setattr(tr.subprocess, "run", lambda *a, **k: types.SimpleNamespace(
76+
stdout=json.dumps([{"conclusion": "success", "status": "completed",
77+
"createdAt": "2026-06-23T00:00:00Z", "databaseId": 42, "url": "u"}])))
78+
v = tr._cloud_verdict()
79+
assert v["ready"] is True and v["run_id"] == 42 and v["ts"] == "2026-06-23T00:00:00Z"
80+
81+
82+
def test_cloud_verdict_in_progress_is_unknown(monkeypatch):
83+
import types
84+
monkeypatch.setattr(tr.subprocess, "run", lambda *a, **k: types.SimpleNamespace(
85+
stdout=json.dumps([{"conclusion": None, "status": "in_progress",
86+
"createdAt": "t", "databaseId": 1, "url": "u"}])))
87+
assert tr._cloud_verdict()["ready"] is None
88+
89+
90+
def test_cloud_verdict_no_runs_is_none(monkeypatch):
91+
import types
92+
monkeypatch.setattr(tr.subprocess, "run", lambda *a, **k: types.SimpleNamespace(stdout="[]"))
93+
assert tr._cloud_verdict() is None
94+
95+
96+
def test_run_cloud_overrides_ready_keeps_local_detail(monkeypatch, tmp_path):
97+
(tmp_path / "report.json").write_text(json.dumps({
98+
"ready": True, "run_label": "local", "summary": {"passed": 5, "failed": 0}}))
99+
monkeypatch.setattr(tr, "_cloud_verdict", lambda: {
100+
"ready": False, "ts": "2026-06-20T00:00:00Z", "run_id": 7, "url": "U"})
101+
out = tr.run(results_dir=tmp_path, fetch_cloud=True)
102+
assert out["ready"] is False # cloud is authoritative
103+
assert out["ts"] == "2026-06-20T00:00:00Z"
104+
assert out["source"] == "cloud"
105+
assert out["passed"] == 5 # detail retained from local report

0 commit comments

Comments
 (0)