Skip to content

Commit 14a8104

Browse files
committed
feat: release-validation report + readiness hard gate (M2)
Add the Heart-owned release-validation tier: a tracked validation_report.json and an ingest-and-judge CLI that proves the exact source about to ship was built, TestPyPI-published, wheel-installed, and exercised at release fidelity. - heart/validate.py: schema_version 1 report + `pyauto-heart validate --ingest`. Folds the M1 TestPyPI rehearsal artifact, a {repo: sha} commit_shas.json, and (from M3) a wheel-based integration report into one report; computes release_ready (pass/fail axis) and persists it to state + a history archive. Ingest-and-judge ONLY: never dispatches a build, never talks to GitHub, never mutates a repo. - heart/readiness.py: hard gate consuming the report. GREEN-for-release now requires a fresh passing report whose commit_shas match the current main HEADs (via ci_status.head_sha) under the `release` profile; absent/stale-by-age/ SHA-mismatch/wrong-profile -> YELLOW; a failed stage -> RED. - state.py aggregates validation_report.json; status.py renders it; bin/pyauto-heart gains the `validate` subcommand. - health_agent/capabilities.yaml registers the `validate` capability + validation_report signal, the release_gate rule, and the Release/Health Agent boundary so the Brain agents surface it manifest-driven. - docs/release_validation.md defines (not yet wired — M3) the `release` env profile and the wheel-install requirement as acceptance criteria. - Tests: tests/test_validate.py (ingest) + readiness gate paths (absent/stale-by-SHA -> YELLOW, fresh-pass -> GREEN, fail -> RED). 166 pass. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0b68613 commit 14a8104

9 files changed

Lines changed: 1094 additions & 12 deletions

File tree

bin/pyauto-heart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ SUBCOMMAND_ORDER=(
2424
readiness
2525
logs
2626
fix
27+
"# Release validation (ingest-and-judge only; never dispatches a build)"
28+
validate
2729
"# Deep checks (on-demand; too slow for the tick)"
2830
verify_install
2931
"# URL hygiene (monitoring only — does not gate releases)"
@@ -40,6 +42,7 @@ declare -A SHORT_DESC=(
4042
[tick]="One-shot refresh of the state cache"
4143
[status]="Print the cached state with green/yellow/red colouring"
4244
[readiness]="Print the release-readiness verdict (green/yellow/red + score)"
45+
[validate]="Ingest release-validation artifacts into validation_report.json"
4346
[logs]="Tail the daemon log"
4447
[fix]="Bundle context for a specific topic and emit a Claude command"
4548
[verify_install]="Deep install-readiness: pip & conda install-path checks (slow)"
@@ -200,6 +203,35 @@ cmd_readiness() {
200203
exec env PYTHONPATH="$HEART_HOME" python3 -m heart.readiness "$@"
201204
}
202205

206+
help_validate() { cat <<EOF
207+
pyauto-heart validate --ingest <artifacts...> [--profile P] [--testpypi-version V]
208+
[--commit-shas FILE] [--out FILE] [--json]
209+
210+
Ingest-and-judge ONLY. Consume the release-validation artifacts the Brain
211+
Release Agent has already collected — the M1 TestPyPI rehearsal artifact
212+
(rehearsal.json / testpypi_version.txt), a {repo: sha} commit_shas.json, and
213+
(from M3) the wheel-based integration report.json — and fold them into a single
214+
\$HEART_STATE_DIR/validation_report.json (plus a validation_history/ archive).
215+
216+
This NEVER dispatches a build, talks to GitHub, or mutates any repo. All
217+
dispatching/polling/downloading is the Brain Release Agent's job; Heart is spec
218+
+ ingest + verdict, credential-free.
219+
220+
The report carries release_ready (pass/fail axis), testpypi_version, profile,
221+
per-repo commit_shas, per-stage status, per-project counts, failures and run
222+
URLs. \`pyauto-heart readiness\` consumes it as a HARD gate: GREEN-for-release
223+
now requires a fresh passing report whose commit_shas match the current main
224+
HEADs under the 'release' profile; absent/stale/mismatch → YELLOW; a failed
225+
stage → RED.
226+
227+
With no --ingest, prints the currently persisted validation_report.json.
228+
EOF
229+
}
230+
cmd_validate() {
231+
mkdir -p "$HEART_STATE_DIR"
232+
exec env PYTHONPATH="$HEART_HOME" python3 -m heart.validate "$@"
233+
}
234+
203235
help_logs() { cat <<EOF
204236
pyauto-heart logs [--tail N] [--follow]
205237

docs/release_validation.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Release validation — the `release` profile and acceptance criteria
2+
3+
This document is the **spec** half of Heart's release-validation tier. Heart
4+
owns the *definition* of what a release-grade validation run must do; it does not
5+
execute the build or the integration run (that is the Brain Release Agent
6+
dispatching Build's `release.yml` and the evolved `workspace-validation.yml`).
7+
8+
M2 (this milestone) ships the report schema, `pyauto-heart validate --ingest`,
9+
and the readiness hard gate. The `release` env profile and the wheel-install
10+
requirement below are **defined here as acceptance criteria for M3** — they are
11+
not yet wired into `workspace-validation.yml`. Capturing them now keeps them from
12+
being lost between milestones.
13+
14+
## Why a distinct profile
15+
16+
The per-PR smoke gate and the release gate are different jobs:
17+
18+
- **smoke** — a fast structural / integration check: *does the model compose and
19+
the script run end-to-end?* Cheap, runs on every PR.
20+
- **release***does the exact source about to ship, installed from the built
21+
wheel, pass at release fidelity?* Slow, runs only for a release rehearsal.
22+
23+
Both tiers' `config/build/env_vars.yaml` today default to smoke values
24+
(`PYAUTO_TEST_MODE=2`, `PYAUTO_SMALL_DATASETS=1`, `PYAUTO_DISABLE_JAX=1`,
25+
`PYAUTO_FAST_PLOTS=1`). That is correct for a per-PR smoke and wrong for a
26+
release gate. The two must be **named, distinct profiles** so a release run
27+
cannot silently inherit smoke fidelity.
28+
29+
## The `release` profile (acceptance criteria, wired in M3)
30+
31+
The intended release-fidelity env is already documented in
32+
`PyAutoBuild/.github/workflows/release.yml`:
33+
34+
| Tier | `PYAUTO_TEST_MODE` | `PYAUTO_SMALL_DATASETS` | `PYAUTO_FAST_PLOTS` |
35+
|----------------------|----------------------------------|-------------------------|---------------------|
36+
| user workspaces | `1` (reduced iterations) | `1` (capped grids) | `1` |
37+
| `*_workspace_test` | `0` (real searches, `n_like_max`)| unset (full-res) | unset |
38+
39+
Per-script `overrides:` still layer **on top of** the selected profile — e.g.
40+
unset `PYAUTO_SMALL_DATASETS` for full-resolution FITS scripts, keep JAX on for
41+
`jax_likelihood_functions/` and `jax_substructure/`. The profile sets the floor;
42+
overrides remain per-script.
43+
44+
The full library env-var surface (canonical entry:
45+
`PyAutoConf/autoconf/test_mode.py`) is 13 `PYAUTO_*` vars, not the 4 smoke
46+
defaults:
47+
48+
```
49+
PYAUTO_TEST_MODE, PYAUTO_SMALL_DATASETS, PYAUTO_FAST_PLOTS, PYAUTO_OUTPUT_MODE,
50+
PYAUTO_DISABLE_JAX, PYAUTO_SKIP_FIT_OUTPUT, PYAUTO_SKIP_VISUALIZATION,
51+
PYAUTO_SKIP_CHECKS, PYAUTO_SKIP_LATENTS, PYAUTO_SKIP_WORKSPACE_VERSION_CHECK,
52+
PYAUTO_LATENT_NAN_INJECT, PYAUTO_DISABLE_IPYTHON_DISPLAY, PYAUTO_LIVE_VIEWER_LOG
53+
```
54+
55+
plus a few per-script switches outside any yaml default (`PYAUTO_MASS_MODE` /
56+
`PYAUTO_MASS_FAST`, `JAX_PILOT` / `JAX_PLATFORM_NAME` / `JAX_PLATFORMS`).
57+
58+
**Explicitly NOT Heart's to set.** `config/general.yaml`'s `test:` block
59+
(`check_likelihood_function`, `lh_timeout_seconds`,
60+
`disable_positions_lh_inversion_check`) and `version:` toggles are
61+
workspace-run/user settings. The release validation runs the scripts **as the
62+
workspace ships them** and does not mutate these. Heart's only version signal is
63+
the existing `version_skew` check. This `release` profile is an env-var profile,
64+
not a Heart-owned config mutation.
65+
66+
## Wheel-install requirement (acceptance criteria, wired in M3)
67+
68+
Two verified gaps the M3 integration run MUST close (they are why the report
69+
carries `profile` and `commit_shas`, so the gate can enforce them):
70+
71+
1. **Test BUILDS, not SOURCE.** Today `workspace-validation.yml` shadows the
72+
PyAuto packages with source checkouts via `PYTHONPATH`, so the gating run
73+
never touches a wheel — the exact blind spot that let the PyAutoFit `[nss]`
74+
git-URL break every TestPyPI upload for weeks. The release run MUST
75+
`pip install` the TestPyPI wheels published by the M1 rehearsal and put **no**
76+
source on `PYTHONPATH`.
77+
78+
2. **Wheel-based config resolution.** autoconf resolves the *workspace's*
79+
`config/` only when scripts run from inside the workspace checkout; a bare
80+
wheel falls back to the library's *packaged* defaults. So the run must
81+
`pip install` the wheels **but still execute scripts from within the workspace
82+
checkout** (for `config/` + `dataset/`), with no source on `PYTHONPATH`.
83+
84+
The integration run also performs `verify_install` A–E against the same wheels.
85+
86+
## How the gate enforces these
87+
88+
`heart/validate.py` records `profile` and per-repo `commit_shas` in
89+
`validation_report.json`; `heart/readiness.py` then requires, for GREEN:
90+
91+
- `release_ready == true` (no stage failed — else RED),
92+
- `profile == release` (else YELLOW — a smoke-fidelity run is not a release gate),
93+
- `commit_shas` matching the current `main` HEADs (else YELLOW — stale source),
94+
- freshness (a rehearsal older than `VALIDATION_STALE_DAYS` is YELLOW).
95+
96+
Until M3 wires the `release` profile into `workspace-validation.yml`, an ingested
97+
rehearsal-only report will (correctly) gate YELLOW: the source was built and
98+
TestPyPI-installed, but not yet exercised at release fidelity.

health_agent/capabilities.yaml

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ primary_query:
2727
persisted_state:
2828
snapshot: "~/.pyauto-heart/state.json"
2929
verdict: "~/.pyauto-heart/release_ready.json"
30+
validation_report: "~/.pyauto-heart/validation_report.json"
3031

3132
# The authoritative gate. The Health Agent REPORTS this verdict; it must not
3233
# re-derive it from raw checks. Heart computes it (heart/readiness.py:compute).
@@ -37,6 +38,12 @@ gate:
3738
yellow: "Mostly healthy. Work may proceed; human review recommended."
3839
red: "Blocked. Do not release until the blocking reasons clear."
3940
rule: "red dominates yellow dominates green; an unknown is YELLOW, never silently green."
41+
release_gate: >-
42+
GREEN-for-release additionally REQUIRES a fresh, passing validation_report
43+
(see the `validate` capability) whose commit_shas match the current `main`
44+
HEADs and whose profile == release. Absent/stale/SHA-mismatch/wrong-profile
45+
→ YELLOW; a failed validation stage → RED. This is the M2 hard gate: no
46+
release rehearsal for the current source can no longer be GREEN.
4047
4148
# Continuous checks — cheap, run every <30s tick (heart/tick.sh).
4249
continuous_checks:
@@ -96,6 +103,31 @@ deep_checks:
96103
measures: "live HTTP HEAD/GET audit of URLs (Colab/Binder reachability)"
97104
gate_role: "monitoring only (weekly cloud workflow)"
98105

106+
# Release validation — the third Heart tier (M2). Not a cheap tick, not the
107+
# existing deep checks: an INGEST-AND-JUDGE capability. Heart defines the report
108+
# spec and folds already-collected artifacts into it; it NEVER dispatches a build
109+
# or talks to GitHub (that is the Brain Release Agent's job — see boundary).
110+
release_validation:
111+
- id: validate
112+
impl: heart/validate.py
113+
cli: "pyauto-heart validate --ingest <artifacts...>"
114+
signal: validation_report # ~/.pyauto-heart/validation_report.json
115+
consumes: >-
116+
the M1 TestPyPI rehearsal artifact (rehearsal.json / testpypi_version.txt),
117+
a {repo: sha} commit_shas.json, and (from M3) the wheel-based integration
118+
report.json — collected and handed over by the Brain Release Agent.
119+
produces: >-
120+
validation_report.json (schema_version 1): release_ready, testpypi_version,
121+
profile, per-repo commit_shas, per-stage status, per-project counts,
122+
failures, run URLs, timestamp — persisted in Heart state + a
123+
validation_history/ archive.
124+
gate_role: >-
125+
HARD readiness gate. release_ready==false → RED; absent / stale-by-age /
126+
commit_shas not matching current main HEADs / profile != release → YELLOW;
127+
fresh pass matching current source under the release profile → GREEN.
128+
orchestrated_by: "PyAutoBrain Release Agent (agents/release/) — dispatch/poll/download"
129+
boundary: "ingest-and-judge only; never dispatches release.yml or workspace-validation.yml"
130+
99131
# GitHub workflows Heart owns (the cloud half of the hybrid CI layer).
100132
workflows:
101133
- file: .github/workflows/lib-tests.yml
@@ -123,7 +155,15 @@ operations:
123155

124156
# Boundary the agent must respect (see pyautobuild_boundary_audit.md).
125157
boundary:
126-
heart: "owns all health/readiness checks and the authoritative readiness verdict"
127-
brain: "reasons over Heart's outputs (this Health Agent); owns no checks"
158+
heart: >-
159+
owns all health/readiness checks, the release-validation report spec + ingest,
160+
and the authoritative readiness verdict. Ingest-and-judge only: it NEVER
161+
dispatches release.yml / workspace-validation.yml or mutates a repo.
162+
brain_health: "reasons over Heart's outputs (the Health Agent, read-only); owns no checks; never dispatches"
163+
brain_release: >-
164+
the Release Agent ORCHESTRATES release validation: dispatches the M1 TestPyPI
165+
rehearsal + (M3) the wheel integration run, polls, downloads the artifacts,
166+
hands them to `pyauto-heart validate --ingest`, then consults the Health Agent
167+
for the verdict. All GitHub dispatch/credentials live here, not in Heart.
128168
build_hands: "pure executor; runs NO readiness checks; acts only on a GREEN/YELLOW/RED decision"
129-
call_chain: "Brain -> Heart (gate) -> Hands (execute)"
169+
call_chain: "Mind -> Release Agent (orchestrate) -> Heart (measure) -> Health Agent (judge) -> Hands/Build (promote on GREEN)"

heart/readiness.py

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,20 @@
1414
``main``, has uncommitted source changes, or is behind origin; or any workspace
1515
is pinned AHEAD of its installed library, has a ``general.yaml`` ↔
1616
``version.txt`` MISMATCH, or an unparseable (BAD) version; or the deep install
17-
verification last reported ``ready == false``.
17+
verification last reported ``ready == false``; or the release-validation
18+
report last ingested reports ``release_ready == false`` (a stage failed).
1819
- **YELLOW** (caution) for soft signals: workspace-validation not passing (the
1920
workspace scripts/notebooks carry standing debt, so this is advisory — never a
2021
hard block), script-timing regressions, stale open PRs, stale parked scripts, a
21-
workspace pinned BEHIND, a stale or never-run install verification, and —
22-
crucially — any *unknown* (missing test-run report, a library absent from the
23-
snapshot). An unknown is never silently treated as green and never escalated to
24-
red.
25-
- **GREEN** otherwise.
22+
workspace pinned BEHIND, a stale or never-run install verification, **no fresh
23+
release-validation report for the current source** (absent, stale by age, or
24+
whose ``commit_shas`` no longer match the current ``main`` HEADs, or which ran
25+
under a profile other than ``release``), and — crucially — any *unknown*
26+
(missing test-run report, a library absent from the snapshot). An unknown is
27+
never silently treated as green and never escalated to red.
28+
- **GREEN** otherwise — which now REQUIRES a fresh, passing release-validation
29+
report matching the current source under the ``release`` profile, not just an
30+
absence of red signals.
2631
2732
Red dominates yellow structurally: reasons are collected into separate lists
2833
and ``verdict = red if red_reasons else yellow if yellow_reasons else green``.
@@ -88,6 +93,12 @@
8893
"install_unknown": (10, 10),
8994
"test_stale": (10, 10),
9095
"ws_ci": (20, 60),
96+
"validation_failed": (40, 40),
97+
"validation_absent": (15, 15),
98+
"validation_stale_sha": (15, 15),
99+
"validation_unknown": (12, 12),
100+
"validation_profile": (12, 12),
101+
"validation_stale": (10, 10),
91102
}
92103

93104

@@ -112,6 +123,29 @@ def _as_int(v: Any, default: int = 0) -> int:
112123
INSTALL_STALE_DAYS = 14
113124
# workspace-validation test run older than this many days is treated as stale.
114125
TEST_STALE_DAYS = 10
126+
# a release-validation rehearsal older than this many days is stale (YELLOW),
127+
# even if its commit_shas still match — releases move fast enough that a
128+
# week-old rehearsal warrants a fresh run.
129+
VALIDATION_STALE_DAYS = 7
130+
131+
# The library repos whose current `main` HEAD must match a validation report's
132+
# recorded commit_shas for the release-validation gate to go GREEN.
133+
_GATE_SHA_LIBS = DEFAULT_LIBRARIES
134+
135+
136+
def _sha_eq(a: Any, b: Any) -> bool:
137+
"""True if two commit SHAs refer to the same commit (prefix-tolerant).
138+
139+
ci_status records a short (7-char) HEAD sha while a report may carry the
140+
full 40-char sha (or vice versa), so compare on the shorter length. A match
141+
needs at least 7 significant hex chars — an empty/too-short value never
142+
matches (it is treated as *unknown*, never a silent pass)."""
143+
sa = str(a or "").strip().lower()
144+
sb = str(b or "").strip().lower()
145+
n = min(len(sa), len(sb))
146+
if n < 7:
147+
return False
148+
return sa[:n] == sb[:n]
115149

116150

117151
def _parse_ts(ts: Any) -> datetime.datetime | None:
@@ -287,6 +321,72 @@ def hit(key: str, n: int = 1) -> None:
287321
yellow.append("install verification not run")
288322
hit("install_unknown")
289323

324+
# --- release-validation gate (hard) -------------------------------------
325+
# GREEN-for-release now REQUIRES a fresh, passing validation_report whose
326+
# commit_shas match the current `main` HEADs and whose profile == release.
327+
# This is the M2 gate: the report proves the exact source about to ship was
328+
# built, published to TestPyPI, installed from the wheel, and exercised at
329+
# release fidelity. Absent/stale/source-not-matching → YELLOW ("no release
330+
# rehearsal for current source"); failing → RED. Pass/fail (release_ready)
331+
# is the RED axis; fidelity+freshness (profile / commit_shas / age) is the
332+
# YELLOW axis — a passing-but-stale report is a caution, not a blocker.
333+
vr = snapshot.get("validation_report")
334+
if isinstance(vr, dict) and vr:
335+
ready = vr.get("release_ready")
336+
if ready is False:
337+
failed_stages = [
338+
n for n, s in (vr.get("stages") or {}).items()
339+
if isinstance(s, dict) and s.get("status") == "fail"
340+
]
341+
red.append(
342+
"release validation FAILED"
343+
+ (f" (stage {', '.join(failed_stages)})" if failed_stages else "")
344+
)
345+
hit("validation_failed")
346+
elif ready is True:
347+
commit_shas = vr.get("commit_shas") or {}
348+
mismatched: list[str] = []
349+
confirmed = 0
350+
for lib in _GATE_SHA_LIBS:
351+
want = commit_shas.get(lib)
352+
cur = ((repos.get(lib) or {}).get("ci_status") or {}).get("head_sha")
353+
if not want or not cur:
354+
continue # can't confirm this repo (unknown), don't over-penalise
355+
if _sha_eq(want, cur):
356+
confirmed += 1
357+
else:
358+
mismatched.append(lib)
359+
profile = str(vr.get("profile") or "").strip().lower()
360+
if not commit_shas:
361+
yellow.append("release validation source unconfirmed (no commit_shas)")
362+
hit("validation_unknown")
363+
elif mismatched:
364+
yellow.append(
365+
"release validation stale: source moved since rehearsal ("
366+
+ ", ".join(mismatched) + ")"
367+
)
368+
hit("validation_stale_sha")
369+
elif confirmed == 0:
370+
yellow.append("release validation source unconfirmed (current HEADs unknown)")
371+
hit("validation_unknown")
372+
elif profile != "release":
373+
yellow.append(
374+
f"release validation profile '{vr.get('profile') or '?'}' is not 'release'"
375+
)
376+
hit("validation_profile")
377+
else:
378+
age = _age_days(vr.get("ts"), ref)
379+
if age is not None and age > VALIDATION_STALE_DAYS:
380+
yellow.append(f"release validation stale ({int(age)}d old)")
381+
hit("validation_stale")
382+
# else: fresh, passing, matching, release profile → GREEN-eligible
383+
else:
384+
yellow.append("release validation status unknown")
385+
hit("validation_unknown")
386+
else:
387+
yellow.append("no release validation for current source")
388+
hit("validation_absent")
389+
290390
# --- script timing (YELLOW) ---
291391
timing = snapshot.get("script_timing", {}) or {}
292392
if _as_int(timing.get("red_count", 0)) > 0:

heart/state.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def aggregate() -> dict[str, Any]:
8484
"version_skew": _read_json_or_default(HEART_STATE_DIR / "version_skew.json", {}),
8585
"verify_install": _read_json_or_default(HEART_STATE_DIR / "verify_install.json", {}),
8686
"url_check": _read_json_or_default(HEART_STATE_DIR / "url_check.json", {}),
87+
"validation_report": _read_json_or_default(HEART_STATE_DIR / "validation_report.json", {}),
8788
}
8889
atomic_write_json(HEART_STATE_FILE, snapshot)
8990
return snapshot

0 commit comments

Comments
 (0)