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