Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/release_validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ untouched, default `mode: smoke` per-PR path):
`release-stage-report` artifact for the Release Agent to feed into
`pyauto-heart validate --ingest`.

The `verify_install` result is folded in **both directions**: a failure forces
the stage to `fail`, and the sidecar itself is carried into the stage report as
evidence, which `--ingest` persists to `~/.pyauto-heart/verify_install.json` —
the install-verification readiness leg's input. Until 2026-07-15 only the
failure direction existed, so a passing Stage 3 check was discarded and the leg
reported `install verification not run` no matter how often it passed, holding
Heart at YELLOW.

`mode: release` is scoped to the `autofit`/`autogalaxy`/`autolens` workspaces
and their `*_workspace_test` siblings only — the HowTo* tutorial repos have no
`env_vars_release.yaml` and stay out of the release-fidelity script matrix
Expand Down Expand Up @@ -105,6 +113,10 @@ carries `profile` and `commit_shas`, so the gate can enforce them):
checkout** (for `config/` + `dataset/`), with no source on `PYTHONPATH`.

The integration run also performs `verify_install` A–E against the same wheels.
That result feeds the install-verification readiness leg (see "How the gate
enforces these"), tagged `index: testpypi` — it proves the wheels **about to
ship** install, which is the right evidence for a release gate, and is reported
as such rather than as proof that installing from PyPI works today.

## How the gate enforces these

Expand All @@ -116,6 +128,13 @@ The integration run also performs `verify_install` A–E against the same wheels
- `commit_shas` matching the current `main` HEADs (else YELLOW — stale source),
- freshness (a rehearsal older than `VALIDATION_STALE_DAYS` is YELLOW).

The install-verification leg is separate and reads
`~/.pyauto-heart/verify_install.json`, written either by a local `pyauto-heart
verify_install --report-json` run (`index: pypi`) or by `--ingest` folding the
block out of a Stage 3 artifact (`index: testpypi`). Both satisfy the leg; the
index is named in every reason line so the verdict states which install path it
actually verified.

Before M3 (or if the Release Agent only runs the M1 rehearsal and skips
dispatching `workspace-validation.yml` in `mode: release`), an ingested
rehearsal-only report still (correctly) gates YELLOW: the source was built and
Expand Down
12 changes: 10 additions & 2 deletions heart/checks/verify_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ Options:
non-PyAuto deps (applies to A, C, D, E). Use this for a
pre-release rehearsal against a TestPyPI dry-run upload.
--keep Don't clean up venvs / conda envs / clones at the end.
--report-json PATH Also write a machine-readable {ts,ready,version,checks}
JSON sidecar to PATH (consumed by pyauto-heart readiness).
--report-json PATH Also write a machine-readable {ts,ready,version,index,
checks} JSON sidecar to PATH (consumed by pyauto-heart
readiness; `index` is testpypi or pypi).
-h, --help Show this help.
USAGE
}
Expand Down Expand Up @@ -691,8 +692,14 @@ fi

if [ -n "$REPORT_JSON" ]; then
if [ "$n_fail" -eq 0 ]; then ready_bool=true; else ready_bool=false; fi
# Which index the wheels came from travels with the result. A --testpypi run
# proves the about-to-ship wheels install; it says nothing about the current
# PyPI release. Readiness reports the index rather than flattening the two,
# so the verdict never claims more than was verified.
if [ "$USE_TESTPYPI" -eq 1 ]; then vi_index=testpypi; else vi_index=pypi; fi
printf '%s\n' "${RESULTS[@]}" | \
VI_READY="$ready_bool" VI_VERSION="$TARGET_VERSION" VI_REPORT_JSON="$REPORT_JSON" \
VI_INDEX="$vi_index" \
python3 -c '
import datetime, json, os, sys
checks = []
Expand All @@ -706,6 +713,7 @@ out = {
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"ready": os.environ["VI_READY"] == "true",
"version": os.environ.get("VI_VERSION") or None,
"index": os.environ.get("VI_INDEX") or "pypi",
"checks": checks,
}
path = os.environ["VI_REPORT_JSON"]
Expand Down
17 changes: 14 additions & 3 deletions heart/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,22 +363,33 @@ def scope_local(msg: str, key: str) -> None:
hit("manifest_drift")

# --- install verification (deep check: RED on fail, YELLOW if stale/unrun) ---
#
# The sidecar arrives either from a local `verify_install --report-json` run
# (index=pypi) or folded out of a release Stage 3 artifact by `validate
# --ingest` (index=testpypi — the about-to-ship wheels). Both satisfy this
# leg: for a release gate the wheels about to ship are the right evidence
# (human decision, 2026-07-15). The index is named in every reason line so
# the verdict states which install path it actually verified — a TestPyPI
# pass must never silently read as proof that installing from PyPI works.
vi = snapshot.get("verify_install")
if isinstance(vi, dict) and "ready" in vi:
index = str(vi.get("index") or "index unknown")
if vi.get("ready") is False:
failed = [
str(c.get("check"))
for c in (vi.get("checks") or [])
if isinstance(c, dict) and str(c.get("status")).upper() == "FAIL"
]
red.append(f"install verification FAILED (checks {', '.join(failed) or '?'})")
red.append(
f"install verification FAILED ({index}; checks {', '.join(failed) or '?'})"
)
hit("install_not_ready")
else:
age = _age_days(vi.get("ts"), ref)
if age is None or age > INSTALL_STALE_DAYS:
stale.append(
"install verification stale "
+ ("(age unknown)" if age is None else f"({int(age)}d old)")
f"install verification stale ({index}, "
+ ("age unknown)" if age is None else f"{int(age)}d old)")
)
hit("install_stale")
else:
Expand Down
137 changes: 126 additions & 11 deletions heart/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@

VALIDATION_REPORT_FILE = state.HEART_STATE_DIR / "validation_report.json"
VALIDATION_HISTORY_DIR = state.HEART_STATE_DIR / "validation_history"
# The install-verification sidecar readiness reads (see heart/state.py's
# snapshot). Written either by `verify_install --report-json` directly (a local
# run) or by `--ingest` folding the block out of a Stage 3 artifact.
VERIFY_INSTALL_FILE = state.HEART_STATE_DIR / "verify_install.json"

_COUNT_KEYS = ("passed", "failed", "skipped", "timeout")

Expand Down Expand Up @@ -188,6 +192,10 @@ def __init__(self) -> None:
self.per_project: dict[str, dict[str, int]] = {}
self.failures: list[dict[str, Any]] = []
self.run_urls: dict[str, str] = {}
# The verify_install sidecar a stage artifact carried, if any. Kept out
# of validation_report.json (it is not part of that schema) — `run()`
# persists it to the separate sidecar path readiness reads.
self.verify_install: dict[str, Any] | None = None
self._explicit_ready: bool | None = None
# True once a real stage artifact (add_stage) has contributed counts.
# add_report() consults this so merging an old validation_report.json
Expand Down Expand Up @@ -250,6 +258,24 @@ def add_stage(self, data: dict[str, Any]) -> None:
if isinstance(f, dict):
self.failures.append(f)
self.add_commit_shas(data.get("commit_shas"))
self.add_verify_install(data.get("verify_install"))

def add_verify_install(self, sidecar: Any) -> None:
"""Keep the newest verify_install block seen across ingested artifacts.

Newest-by-``ts`` so that re-ingesting an older artifact alongside a newer
one cannot walk the readiness leg backwards. A block without a usable
``ts`` only seeds an empty slot; it never displaces a timestamped one.
"""
vi = normalize_verify_install(sidecar)
if vi is None:
return
if self.verify_install is None:
self.verify_install = vi
return
new_ts, cur_ts = vi.get("ts"), self.verify_install.get("ts")
if new_ts and (not cur_ts or str(new_ts) > str(cur_ts)):
self.verify_install = vi

def add_commit_shas(self, shas: Any) -> None:
if not isinstance(shas, dict):
Expand Down Expand Up @@ -308,20 +334,19 @@ def release_ready(self) -> bool:
return bool(rehearse and rehearse.get("status") == "pass")


def ingest(
def _fold(
sources: Sequence[str | Path],
*,
profile: str | None = None,
testpypi_version: str | None = None,
commit_shas: dict[str, str] | None = None,
now: datetime.datetime | None = None,
) -> dict[str, Any]:
"""Fold the given artifacts into a single ``validation_report`` dict.
) -> _Accumulator:
"""Fold the given artifacts into an accumulator (reads only; no writes).

Pure (no I/O side effects beyond reading the source files); ``run`` persists
the result. Explicit ``profile`` / ``testpypi_version`` / ``commit_shas``
override / seed whatever the artifacts carry — the Release Agent uses these
to inject the HEADs it built from.
Split out of ``ingest`` so ``run`` can reach the fold state itself — the
``verify_install`` sidecar a stage artifact carries is deliberately NOT part
of the ``validation_report`` schema, so ``ingest``'s return value cannot
carry it and ``run`` persists it from here instead.
"""
acc = _Accumulator()
if commit_shas:
Expand Down Expand Up @@ -366,6 +391,11 @@ def ingest(
if profile:
acc.profile = profile

return acc


def _distill(acc: _Accumulator, now: datetime.datetime | None = None) -> dict[str, Any]:
"""Render an accumulator as a ``validation_report`` dict."""
return {
"schema_version": SCHEMA_VERSION,
"release_ready": acc.release_ready(),
Expand All @@ -381,6 +411,66 @@ def ingest(
}


def ingest(
sources: Sequence[str | Path],
*,
profile: str | None = None,
testpypi_version: str | None = None,
commit_shas: dict[str, str] | None = None,
now: datetime.datetime | None = None,
) -> dict[str, Any]:
"""Fold the given artifacts into a single ``validation_report`` dict.

Pure (no I/O side effects beyond reading the source files); ``run`` persists
the result. Explicit ``profile`` / ``testpypi_version`` / ``commit_shas``
override / seed whatever the artifacts carry — the Release Agent uses these
to inject the HEADs it built from.
"""
return _distill(
_fold(
sources,
profile=profile,
testpypi_version=testpypi_version,
commit_shas=commit_shas,
),
now=now,
)


def normalize_verify_install(sidecar: Any) -> dict[str, Any] | None:
"""Normalize a ``verify_install.json`` sidecar into the block we carry/persist.

Returns ``None`` for anything that isn't a usable sidecar, so a missing or
malformed file leaves the readiness leg reporting "not run" — an absent
result and a bad one must never read as a pass.

``index`` records which package index the wheels came from. A ``--testpypi``
run proves the about-to-ship wheels install; it is not evidence about the
current PyPI release. Sidecars written before this field existed carry no
index, which stays ``None`` (unknown) rather than being guessed at.
"""
if not isinstance(sidecar, dict) or "ready" not in sidecar:
return None
checks: list[dict[str, Any]] = []
for c in sidecar.get("checks") or []:
if isinstance(c, dict):
checks.append(
{
"check": c.get("check"),
"status": c.get("status"),
"detail": c.get("detail"),
}
)
index = sidecar.get("index")
return {
"ts": sidecar.get("ts"),
"ready": sidecar.get("ready") is True,
"version": sidecar.get("version"),
"index": str(index) if index else None,
"checks": checks,
}


def to_stage_report(
aggregate: dict[str, Any],
*,
Expand All @@ -391,6 +481,7 @@ def to_stage_report(
run_url: str | None = None,
extra_failures: Sequence[dict[str, Any]] | None = None,
force_fail: bool = False,
verify_install: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Translate a Build ``aggregate_results.py`` report.json into a stage report.

Expand All @@ -406,6 +497,13 @@ def to_stage_report(
``force_fail`` lets the caller fold a result Build's aggregate step knows
nothing about (e.g. ``verify_install`` A-E against the same wheels) into the
stage's pass/fail axis without inventing a second stage.

``verify_install`` carries that same sidecar through as *evidence* rather
than only as a veto. Before this existed the sidecar was consulted solely in
the failure direction, so a **passing** run contributed nothing and the
readiness leg it feeds reported "install verification not run" forever — the
check ran in CI and its result was discarded. ``--ingest`` persists this
block to the sidecar path ``heart.readiness`` reads.
"""
summary_raw = aggregate.get("summary")
summary_raw = summary_raw if isinstance(summary_raw, dict) else {}
Expand Down Expand Up @@ -450,6 +548,9 @@ def to_stage_report(
report["summary"] = summary
report["per_project"] = per_project
report["failures"] = failures
vi = normalize_verify_install(verify_install)
if vi is not None:
report["verify_install"] = vi
return report


Expand All @@ -474,20 +575,30 @@ def run(
Persistence stays entirely inside ``~/.pyauto-heart/`` — the canonical report
plus an append-only ``validation_history/`` archive so Heart tracks release
health over time without ever mutating a source repo.

Also persists any ``verify_install`` block an ingested stage artifact carried
to ``VERIFY_INSTALL_FILE``, which is what actually feeds the readiness leg
(``heart/state.py`` reads that path into the snapshot). Stage 3 has always
run the check; before this it had nowhere to land, so the leg reported
"install verification not run" no matter how many times it passed.
"""
report = ingest(
acc = _fold(
sources,
profile=profile,
testpypi_version=testpypi_version,
commit_shas=commit_shas,
now=now,
)
report = _distill(acc, now=now)
target = out or VALIDATION_REPORT_FILE
state.atomic_write_json(target, report)
try:
state.atomic_write_json(VALIDATION_HISTORY_DIR / _archive_name(report), report)
except OSError:
pass # history is best-effort; the canonical report is what matters
# `out` redirects the report for inspection (tests, dry runs); the sidecar is
# a live readiness input, so it is only written on a real ingest.
if acc.verify_install is not None and out is None:
state.atomic_write_json(VERIFY_INSTALL_FILE, acc.verify_install)
return report


Expand Down Expand Up @@ -540,7 +651,9 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--stage", default="integrate", help="stage name for --emit-stage-report (default: integrate)")
ap.add_argument(
"--verify-install", default=None, metavar="FILE",
help="verify_install.json sidecar; ready==false forces --emit-stage-report to fail",
help="verify_install.json sidecar; carried into the stage report as evidence "
"(--ingest persists it for the readiness leg), and ready==false additionally "
"forces --emit-stage-report to fail",
)
ap.add_argument("--run-url", default=None, help="CI run URL attached to the stage / its failures")
ap.add_argument("--profile", default=None, help="override the env profile the integration tier ran under")
Expand All @@ -560,6 +673,7 @@ def main(argv: list[str] | None = None) -> int:
aggregate = _read_json(Path(ns.emit_stage_report)) or {}
force_fail = False
extra_failures: list[dict[str, Any]] = []
vi: Any = None
if ns.verify_install:
vi = _read_json(Path(ns.verify_install))
if isinstance(vi, dict) and vi.get("ready") is False:
Expand All @@ -577,6 +691,7 @@ def main(argv: list[str] | None = None) -> int:
run_url=ns.run_url,
extra_failures=extra_failures,
force_fail=force_fail,
verify_install=vi,
)
out_path = Path(ns.out) if ns.out else Path("stage_report.json")
out_path.write_text(json.dumps(stage_report, indent=2, sort_keys=True))
Expand Down
Loading
Loading