From eb00d86781bb9a35e5e7bb0130fda7eea839d1c8 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 15 Jul 2026 12:59:38 +0100 Subject: [PATCH] fix(readiness): ingest Stage 3's verify_install result into the install leg Stage 3 (workspace-validation, mode=release) has always run verify_install A-E against the TestPyPI wheels, but the sidecar was consulted only in the failure direction: ready==false forced the stage to fail, while a PASS contributed nothing and was discarded. Nothing ever wrote the verify_install.json that heart/state.py reads into the snapshot, so the readiness leg reported "install verification not run" no matter how many times the check passed. That is one of three legs holding Heart at YELLOW 70. The armed nightly release driver's gate requires GREEN and has deliberately no force input, so a leg that can never be satisfied means the nightly can never ship unattended -- 2026.7.15.1 shipped only because a human forced it. Carry the sidecar through as evidence and persist it on ingest: - verify_install.sh records `index` (testpypi|pypi) in the sidecar. - to_stage_report() embeds the sidecar in the stage report; the existing force-fail path is unchanged. - validate --ingest persists the block to HEART_STATE_DIR/verify_install.json (newest ts wins, so re-ingesting an older artifact cannot walk the leg backwards). It stays out of validation_report.json, which is schema v1 and does not describe it; ingest() is split into _fold/_distill so run() can reach the fold state without changing ingest's signature or purity. - readiness names the index in every reason line. A --testpypi run proves the wheels about to ship install -- the right evidence for a release gate (human decision, 2026-07-15) -- but must never silently read as proof that installing from PyPI works. readiness gate logic is otherwise untouched: the leg simply starts receiving evidence through the path it already read. workspace-validation.yml needs no change; it already passes --verify-install. Verified end to end by driving the real CI sequence (verify_install.sh -> --emit-stage-report -> --ingest, with the intermediate sidecar deleted before ingest to prove the result travels inside the stage report): the leg goes from "install verification not run" to satisfied, and reverts when the sidecar is removed. The 6 new validate tests and 3 new readiness tests were confirmed to fail against unfixed source. Refs PyAutoLabs/PyAutoHeart#76 --- docs/release_validation.md | 19 ++++ heart/checks/verify_install.sh | 12 ++- heart/readiness.py | 17 +++- heart/validate.py | 137 +++++++++++++++++++++++--- tests/test_readiness.py | 83 ++++++++++++++++ tests/test_validate.py | 143 ++++++++++++++++++++++++++++ tests/test_verify_install_script.py | 16 ++++ 7 files changed, 411 insertions(+), 16 deletions(-) diff --git a/docs/release_validation.md b/docs/release_validation.md index 561b6e3..c7d2192 100644 --- a/docs/release_validation.md +++ b/docs/release_validation.md @@ -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 @@ -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 @@ -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 diff --git a/heart/checks/verify_install.sh b/heart/checks/verify_install.sh index eee9ce3..eb030d2 100755 --- a/heart/checks/verify_install.sh +++ b/heart/checks/verify_install.sh @@ -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 } @@ -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 = [] @@ -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"] diff --git a/heart/readiness.py b/heart/readiness.py index b70e5e4..c47f04c 100644 --- a/heart/readiness.py +++ b/heart/readiness.py @@ -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: diff --git a/heart/validate.py b/heart/validate.py index 1d78173..3414c99 100644 --- a/heart/validate.py +++ b/heart/validate.py @@ -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") @@ -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 @@ -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): @@ -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: @@ -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(), @@ -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], *, @@ -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. @@ -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 {} @@ -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 @@ -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 @@ -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") @@ -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: @@ -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)) diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 7049e70..a8e2236 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib import json import pytest @@ -185,6 +186,88 @@ def test_install_verification_fresh_pass_is_green(): assert not any("install" in r for r in v["reasons"]) +def test_install_verification_reasons_name_the_index(): + """A testpypi pass must never read as proof that installing from PyPI works. + + Stage 3 verifies the about-to-ship wheels from TestPyPI, which satisfies this + leg for a release gate (human decision, 2026-07-15) — but the verdict has to + say which install path it actually verified. + """ + stale_snap = make_snapshot(verify_install={ + "ready": True, "ts": "2026-05-01T00:00:00+00:00", # ~31d before snapshot ts + "index": "testpypi", "checks": [], + }) + assert any( + "install verification stale (testpypi," in r + for r in compute(stale_snap)["stale_reasons"] + ) + + red_snap = make_snapshot(verify_install={ + "ready": False, "ts": "2026-06-01T00:00:00+00:00", "index": "testpypi", + "checks": [{"check": "B", "status": "FAIL"}], + }) + assert any( + "install verification FAILED (testpypi;" in r + for r in compute(red_snap)["red_reasons"] + ) + + +def test_install_verification_without_index_reports_unknown(): + """A sidecar written before `index` existed must not be guessed at.""" + snap = make_snapshot(verify_install={ + "ready": True, "ts": "2026-05-01T00:00:00+00:00", "checks": [], + }) + assert any( + "install verification stale (index unknown," in r + for r in compute(snap)["stale_reasons"] + ) + + +def test_ingested_stage_verify_install_clears_the_not_run_leg(tmp_path, monkeypatch): + """The gap this task closes, end to end: Stage 3 pass -> leg satisfied. + + Before this, Stage 3 ran verify_install against the wheels and passed, the + result was discarded, and readiness reported "install verification not run" + forever — which held Heart at YELLOW and meant the GREEN-gated nightly could + never ship unattended. + """ + monkeypatch.setenv("HEART_STATE_DIR", str(tmp_path)) + import heart.state as state_mod + importlib.reload(state_mod) + import heart.validate as v_mod + importlib.reload(v_mod) + + stage_artifact = { + "stage": "integrate", + "status": "pass", + "profile": "release", + "summary": {"passed": 543, "failed": 0, "skipped": 87, "timeout": 0}, + "verify_install": { + "ts": "2026-07-15T10:00:00+00:00", "ready": True, "index": "testpypi", + "version": "2026.7.15.1.dev66201", + "checks": [{"check": "A", "status": "PASS", "detail": "pip install"}], + }, + } + src = tmp_path / "artifacts" + src.mkdir() + (src / "integrate.json").write_text(json.dumps(stage_artifact)) + + v_mod.run([src]) + + # readiness reads the sidecar through the snapshot, exactly as in production. + snap = make_snapshot( + ts="2026-07-15T12:00:00+00:00", + validation_report=_green_validation_report("2026-07-15T10:00:00+00:00"), + verify_install=json.loads((tmp_path / "verify_install.json").read_text()), + ) + v = compute(snap) + assert not any("install verification not run" in r for r in v["stale_reasons"]) + assert not any("install" in r for r in v["reasons"]) + + importlib.reload(state_mod) + importlib.reload(v_mod) + + # --- release-validation hard gate (M2) ----------------------------------- diff --git a/tests/test_validate.py b/tests/test_validate.py index 82be781..8c7a072 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -425,3 +425,146 @@ def test_run_out_override(tmp_path, monkeypatch): importlib.reload(state_mod) importlib.reload(v_mod) + + +# --- verify_install: carried as evidence, not only as a veto ---------------- +# +# Stage 3 has always run verify_install A-E against the wheels, but the sidecar +# was consulted only in the failure direction, so a PASS contributed nothing and +# the readiness leg reported "install verification not run" forever. These cover +# the carry + persist path that closes that gap. + +VERIFY_INSTALL_PASS = { + "ts": "2026-07-15T10:00:00+00:00", + "ready": True, + "version": "2026.7.15.1.dev66201", + "index": "testpypi", + "checks": [ + {"check": "A", "status": "PASS", "detail": "pip install + start_here.py"}, + {"check": "C", "status": "SKIP", "detail": "conda not on PATH"}, + ], +} + + +def test_normalize_verify_install_rejects_unusable_sidecars(): + # An absent or malformed result must never read as a pass — it has to leave + # the leg saying "not run" rather than inventing evidence. + assert validate.normalize_verify_install(None) is None + assert validate.normalize_verify_install({}) is None + assert validate.normalize_verify_install("nope") is None + assert validate.normalize_verify_install({"checks": []}) is None + + +def test_normalize_verify_install_keeps_index_and_coerces_ready(): + vi = validate.normalize_verify_install(VERIFY_INSTALL_PASS) + assert vi["ready"] is True + assert vi["index"] == "testpypi" + assert vi["version"] == "2026.7.15.1.dev66201" + assert [c["check"] for c in vi["checks"]] == ["A", "C"] + # Strict: only a real True is a pass (a stray truthy string is not). + assert validate.normalize_verify_install({"ready": "false"})["ready"] is False + # A pre-index sidecar stays unknown rather than being guessed at. + assert validate.normalize_verify_install({"ready": True})["index"] is None + + +def test_to_stage_report_carries_passing_verify_install(): + report = validate.to_stage_report( + AGGREGATE_PASS, stage="integrate", verify_install=VERIFY_INSTALL_PASS + ) + # A pass is carried as evidence and does NOT flip the stage's pass/fail axis. + assert report["status"] == "pass" + assert report["verify_install"]["ready"] is True + assert report["verify_install"]["index"] == "testpypi" + + +def test_to_stage_report_omits_verify_install_when_absent(): + report = validate.to_stage_report(AGGREGATE_PASS, stage="integrate") + assert "verify_install" not in report + + +def test_cli_emit_stage_report_carries_verify_install(tmp_path): + agg_path = _write(tmp_path / "report.json", AGGREGATE_PASS) + vi_path = _write(tmp_path / "verify_install.json", VERIFY_INSTALL_PASS) + out_path = tmp_path / "stage_report.json" + rc = validate.main([ + "--emit-stage-report", str(agg_path), + "--verify-install", str(vi_path), + "--out", str(out_path), + ]) + assert rc == 0 + written = json.loads(out_path.read_text()) + assert written["status"] == "pass" + assert written["verify_install"]["index"] == "testpypi" + + +def test_run_persists_verify_install_sidecar_from_stage_artifact(tmp_path, monkeypatch): + """The end-to-end gap this task closes: Stage 3 pass -> readiness sidecar.""" + monkeypatch.setenv("HEART_STATE_DIR", str(tmp_path)) + import heart.state as state_mod + importlib.reload(state_mod) + import heart.validate as v_mod + importlib.reload(v_mod) + + src = tmp_path / "artifacts" + src.mkdir() + stage = dict(INTEGRATE) + stage["verify_install"] = VERIFY_INSTALL_PASS + _write(src / "integrate.json", stage) + + report = v_mod.run([src]) + + sidecar = tmp_path / "verify_install.json" + assert sidecar.is_file(), "ingest must write the sidecar readiness reads" + written = json.loads(sidecar.read_text()) + assert written["ready"] is True + assert written["index"] == "testpypi" + # It stays out of validation_report.json — not part of that schema. + assert "verify_install" not in report + + importlib.reload(state_mod) + importlib.reload(v_mod) + + +def test_run_without_verify_install_leaves_sidecar_untouched(tmp_path, monkeypatch): + monkeypatch.setenv("HEART_STATE_DIR", str(tmp_path)) + import heart.state as state_mod + importlib.reload(state_mod) + import heart.validate as v_mod + importlib.reload(v_mod) + + src = tmp_path / "artifacts" + src.mkdir() + _write(src / "integrate.json", INTEGRATE) # no verify_install block + v_mod.run([src]) + assert not (tmp_path / "verify_install.json").exists() + + importlib.reload(state_mod) + importlib.reload(v_mod) + + +def test_run_keeps_newest_verify_install_across_artifacts(tmp_path, monkeypatch): + """Re-ingesting an older artifact must not walk the leg backwards.""" + monkeypatch.setenv("HEART_STATE_DIR", str(tmp_path)) + import heart.state as state_mod + importlib.reload(state_mod) + import heart.validate as v_mod + importlib.reload(v_mod) + + src = tmp_path / "artifacts" + src.mkdir() + old = dict(INTEGRATE) + old["verify_install"] = dict(VERIFY_INSTALL_PASS, ts="2026-07-01T00:00:00+00:00", + ready=False) + new = dict(INTEGRATE) + new["stage"] = "integrate" + new["verify_install"] = VERIFY_INSTALL_PASS # ts 2026-07-15 + _write(src / "a_old.json", old) + _write(src / "b_new.json", new) + + v_mod.run([src]) + written = json.loads((tmp_path / "verify_install.json").read_text()) + assert written["ts"] == "2026-07-15T10:00:00+00:00" + assert written["ready"] is True + + importlib.reload(state_mod) + importlib.reload(v_mod) diff --git a/tests/test_verify_install_script.py b/tests/test_verify_install_script.py index 4cfcc27..c956a94 100644 --- a/tests/test_verify_install_script.py +++ b/tests/test_verify_install_script.py @@ -48,3 +48,19 @@ def test_check_f_wired_into_selection_and_runner(): def test_no_stale_workspace_owner(): # The workspaces moved Jammy2211 -> PyAutoLabs; clones must use the new owner. assert "github.com/Jammy2211/autolens_workspace" not in SCRIPT.read_text() + + +def test_sidecar_records_the_package_index(): + # readiness reports this index, so a --testpypi run never reads as proof that + # installing from PyPI works. Static check: running the writer for real means + # a venv + a PyPI install, which this file deliberately never does. + text = SCRIPT.read_text() + assert 'vi_index=testpypi; else vi_index=pypi' in text + assert 'VI_INDEX="$vi_index"' in text + assert '"index": os.environ.get("VI_INDEX") or "pypi",' in text + + +def test_help_documents_the_sidecar_index(): + result = run("--help") + assert result.returncode == 0 + assert "index" in result.stdout