From 13dbe882b4e6352e4a670700ec66bfec8acf0ce1 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:01:54 -0400 Subject: [PATCH 01/10] bump requires-python to 3.11 (datetime.UTC usage) --- README.md | 4 ++-- install.sh | 8 ++++---- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2d8f086..68545c7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A pytest plugin that lets you run GPU equivalence tests locally, sign the result The trust model is simple: if you can push to GitHub, you can sign a receipt. Verification fetches your public keys from `github.com/{username}.keys`, exactly as SSH does. -> **Requirements:** Python 3.8+ · pytest 7.0+ · cryptography 41.0+ +> **Requirements:** Python 3.11+ · pytest 7.0+ · cryptography 41.0+ > The package is not yet on PyPI — install from source with `pip install -e .` (see [Installation](#installation)). --- @@ -330,7 +330,7 @@ Tests are CPU-only. No GPU or network access required. ## Compatibility -- Python 3.8+ +- Python 3.11+ - pytest 7.0+ - `cryptography` 41.0+ - `pytest-xdist` is **not** supported in v1 (parallel workers would write conflicting receipts) diff --git a/install.sh b/install.sh index 05a9292..5a3c9a9 100644 --- a/install.sh +++ b/install.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash set -e -# Check Python version — requires 3.8+ -PY_TOO_OLD=$(python3 -c "import sys; print(sys.version_info < (3, 8))" 2>/dev/null || echo "True") +# Check Python version — requires 3.11+ +PY_TOO_OLD=$(python3 -c "import sys; print(sys.version_info < (3, 11))" 2>/dev/null || echo "True") if [ "$PY_TOO_OLD" = "True" ]; then - echo "ERROR: pytest-gpu-proof requires Python 3.8 or later." + echo "ERROR: pytest-gpu-proof requires Python 3.11 or later." echo " You are running: $(python3 --version 2>&1)" - echo " Please switch to Python 3.8+ before installing." + echo " Please switch to Python 3.11+ before installing." exit 1 fi diff --git a/pyproject.toml b/pyproject.toml index f5631ae..cb897c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pytest-gpu-proof" version = "0.1.0" description = "pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.11" license = { text = "MIT" } keywords = ["pytest", "gpu", "cuda", "testing", "signing", "attestation"] classifiers = [ From ef02debd612a2f8559fa78dfc54a5557c1589084 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:08:04 -0400 Subject: [PATCH 02/10] read [tool.gpu_proof] defaults from pyproject.toml, CLI overrides --- src/pytest_gpu_proof/config.py | 66 ++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/src/pytest_gpu_proof/config.py b/src/pytest_gpu_proof/config.py index bb71639..324cabd 100644 --- a/src/pytest_gpu_proof/config.py +++ b/src/pytest_gpu_proof/config.py @@ -1,5 +1,7 @@ +import tomllib from dataclasses import dataclass, field -from typing import List, Optional +from pathlib import Path +from typing import Any, Dict, List, Optional, Union @dataclass @@ -15,32 +17,66 @@ class GpuProofConfig: fingerprint_paths: List[str] = field(default_factory=lambda: ["src", "tests"]) github_username: Optional[str] = None max_age_days: int = 30 + require_gpu: bool = False + + +def load_toml_defaults(root: Union[str, Path]) -> Dict[str, Any]: + """Read the ``[tool.gpu_proof]`` table from ``/pyproject.toml``. + + Returns an empty dict when the file or table is absent or unparseable. + """ + pyproject = Path(root) / "pyproject.toml" + if not pyproject.is_file(): + return {} + try: + with open(pyproject, "rb") as f: + data = tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError): + return {} + table = data.get("tool", {}).get("gpu_proof", {}) + return table if isinstance(table, dict) else {} def load_config(pytest_config) -> GpuProofConfig: + toml_cfg = load_toml_defaults(pytest_config.rootpath) + def opt(name, default=None): try: return pytest_config.getoption(name) except ValueError: return default - def ini(name, default=None): - val = pytest_config.getini(name) - return val if val else default + def resolve(opt_name, toml_key, default): + """Precedence: CLI flag (when it differs from its built-in default), + then [tool.gpu_proof] in pyproject.toml, then the built-in default.""" + cli = opt(opt_name, default) + if cli != default: + return cli + toml_val = toml_cfg.get(toml_key) + if toml_val is not None: + return toml_val + return default + + raw_paths = resolve("--gpu-proof-fingerprint-paths", "fingerprint_paths", "src,tests") + if isinstance(raw_paths, str): + paths = [p.strip() for p in raw_paths.split(",") if p.strip()] + else: + paths = [str(p) for p in raw_paths] - raw_paths = opt("--gpu-proof-fingerprint-paths", "src,tests") - paths = [p.strip() for p in raw_paths.split(",") if p.strip()] + max_age = toml_cfg.get("max_age_days") + max_age_days = int(max_age) if max_age is not None else 30 return GpuProofConfig( enabled=bool(opt("--gpu-proof-enable", False)), - mode=opt("--gpu-proof-mode", "local"), - output=opt("--gpu-proof-out", "gpu-proof.json"), - key_path=opt("--gpu-proof-key", None), - signing_backend=opt("--gpu-proof-signing-backend", "ed25519"), - policy_path=opt("--gpu-proof-policy", None), - required_marker=opt("--gpu-proof-required-marker", "gpu_proof"), - fail_on_skip=bool(opt("--gpu-proof-fail-on-skip", False)), + mode=resolve("--gpu-proof-mode", "mode", "local"), + output=resolve("--gpu-proof-out", "output", "gpu-proof.json"), + key_path=resolve("--gpu-proof-key", "key_path", None), + signing_backend=resolve("--gpu-proof-signing-backend", "signing_backend", "ed25519"), + policy_path=resolve("--gpu-proof-policy", "policy_path", None), + required_marker=resolve("--gpu-proof-required-marker", "required_marker", "gpu_proof"), + fail_on_skip=bool(resolve("--gpu-proof-fail-on-skip", "fail_on_skip", False)), fingerprint_paths=paths, - github_username=opt("--gpu-proof-github-user", None), - max_age_days=30, + github_username=resolve("--gpu-proof-github-user", "github_username", None), + max_age_days=max_age_days, + require_gpu=bool(toml_cfg.get("require_gpu", False)), ) From bcfe39c8f6883f1cf90d3a844e551f11a6edfb6d Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:08:04 -0400 Subject: [PATCH 03/10] implement signing backend none, fail-on-skip, marker wiring, skip+checks capture --- src/pytest_gpu_proof/plugin.py | 87 ++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/src/pytest_gpu_proof/plugin.py b/src/pytest_gpu_proof/plugin.py index 6e2135e..051a11b 100644 --- a/src/pytest_gpu_proof/plugin.py +++ b/src/pytest_gpu_proof/plugin.py @@ -39,6 +39,7 @@ class GpuProofPlugin: def __init__(self, pytest_config): self.gpu_proof_config: GpuProofConfig = load_config(pytest_config) self.test_results: List[Dict[str, Any]] = [] + self.skipped_required: List[str] = [] self.started_at: str = "" # ------------------------------------------------------------------ @@ -51,6 +52,21 @@ def pytest_sessionstart(self, session): def pytest_sessionfinish(self, session, exitstatus): if not self.gpu_proof_config.enabled: return + + skipped_marked = [ + t["node_id"] for t in self.test_results if t.get("outcome") == "skipped" + ] + if self.gpu_proof_config.fail_on_skip and (skipped_marked or self.skipped_required): + names = sorted(set(skipped_marked + self.skipped_required)) + print( + "\n[gpu-proof] --gpu-proof-fail-on-skip: " + f"{len(names)} marked test(s) were skipped:\n" + + "".join(f" - {n}\n" for n in names) + + " No receipt was written; session marked as failed." + ) + session.exitstatus = 1 + return + if not self.test_results: print( "\n[gpu-proof] --gpu-proof-enable is set but no gpu_proof tests were found.\n" @@ -65,26 +81,66 @@ def pytest_sessionfinish(self, session, exitstatus): # result collection # ------------------------------------------------------------------ + def _is_marked(self, item) -> bool: + return bool( + item.get_closest_marker(self.gpu_proof_config.required_marker) + or item.get_closest_marker("gpu_equivalence") + ) + @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(self, item, call): outcome = yield + report = outcome.get_result() + + if call.when == "setup" and report.skipped: + # Skipped tests never reach the "call" phase — record them here so + # they are visible in the receipt (and to --gpu-proof-fail-on-skip) + # instead of being silently dropped. + if item.get_closest_marker("gpu_required") and not self._is_marked(item): + self.skipped_required.append(item.nodeid) + if self._is_marked(item): + self.test_results.append( + { + "node_id": item.nodeid, + "outcome": "skipped", + "duration_s": round(call.duration, 4), + "checks": [], + } + ) + return + + if call.when == "teardown": + # The gpu_proof_check fixture records its checks during fixture + # teardown, which happens *after* the call-phase report. Patch the + # already-recorded entry here so checks are not silently dropped. + checks = getattr(item, "_gpu_proof_checks", None) + if checks is not None: + for t in reversed(self.test_results): + if t["node_id"] == item.nodeid: + t["checks"] = checks + break + return + if call.when != "call": return - checks = getattr(item, "_gpu_proof_checks", None) - if checks is None and not ( - item.get_closest_marker("gpu_proof") - or item.get_closest_marker("gpu_equivalence") - ): + uses_fixture = "gpu_proof_check" in getattr(item, "fixturenames", ()) + if not uses_fixture and not self._is_marked(item): return - report = outcome.get_result() + if report.passed: + outcome_str = "passed" + elif report.skipped: + outcome_str = "skipped" + else: + outcome_str = "failed" + self.test_results.append( { "node_id": item.nodeid, - "outcome": "passed" if report.passed else "failed", + "outcome": outcome_str, "duration_s": round(call.duration, 4), - "checks": checks or [], + "checks": [], # filled in at teardown once the fixture finalizes } ) @@ -100,6 +156,21 @@ def _emit_receipt(self): ended_at = _utcnow() cfg = self.gpu_proof_config + if cfg.signing_backend == "none": + try: + payload = build_receipt_payload(cfg, self.test_results, self.started_at, ended_at) + receipt = dict(payload) + receipt["signature"] = None + write_receipt(receipt, cfg.output) + print(f"\n[gpu-proof] Receipt written to {cfg.output}") + print( + "[gpu-proof] WARNING: signing backend is 'none' — the receipt is UNSIGNED\n" + " and will fail verification unless --allow-unsigned is passed." + ) + except Exception as e: + warnings.warn(f"[gpu-proof] Failed to write receipt: {e}", stacklevel=1) + return + try: signer = SSHSigner(key_path=cfg.key_path) except VerifierError as e: From b484daa0627d68cdb38e88d7efc54cbddfebbb48 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:08:04 -0400 Subject: [PATCH 04/10] derive signature algorithm from actual key type --- src/pytest_gpu_proof/receipt.py | 2 +- src/pytest_gpu_proof/signers/base.py | 5 +++++ src/pytest_gpu_proof/signers/ed25519.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/pytest_gpu_proof/receipt.py b/src/pytest_gpu_proof/receipt.py index a09f91f..f048f43 100644 --- a/src/pytest_gpu_proof/receipt.py +++ b/src/pytest_gpu_proof/receipt.py @@ -116,7 +116,7 @@ def finalize_receipt(payload: dict, signer) -> dict: receipt = dict(payload) receipt["signature"] = { - "algorithm": "ed25519", + "algorithm": signer.algorithm(), "backend": "ssh-local", "signer": payload["repo"].get("github_username") or "unknown", "key_fingerprint": signer.key_fingerprint(), diff --git a/src/pytest_gpu_proof/signers/base.py b/src/pytest_gpu_proof/signers/base.py index eb165a7..6f2cb53 100644 --- a/src/pytest_gpu_proof/signers/base.py +++ b/src/pytest_gpu_proof/signers/base.py @@ -10,6 +10,11 @@ def sign(self, data: bytes) -> bytes: def key_fingerprint(self) -> str: ... + @abstractmethod + def algorithm(self) -> str: + """Name of the signature algorithm actually used (e.g. 'ed25519').""" + ... + class VerifierError(Exception): pass diff --git a/src/pytest_gpu_proof/signers/ed25519.py b/src/pytest_gpu_proof/signers/ed25519.py index 0c421d7..8363657 100644 --- a/src/pytest_gpu_proof/signers/ed25519.py +++ b/src/pytest_gpu_proof/signers/ed25519.py @@ -165,3 +165,14 @@ def sign(self, data: bytes) -> bytes: def key_fingerprint(self) -> str: return _public_key_fingerprint(self._public_key) + + def algorithm(self) -> str: + if isinstance(self._private_key, Ed25519PrivateKey): + return "ed25519" + if isinstance(self._private_key, EllipticCurvePrivateKey): + return "ecdsa-sha256" + if isinstance(self._private_key, RSAPrivateKey): + return "rsa-pss-sha256" + raise VerifierError( + f"Unsupported private key type: {type(self._private_key).__name__}" + ) From 8a62accb74e95d5c5b36bc8d4219172faa944c50 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:08:04 -0400 Subject: [PATCH 05/10] verifier: allow-unsigned/allow-skipped/require-gpu flags, digest guard, policy loader, max-age zero fix --- src/pytest_gpu_proof/cli.py | 24 ++++++ src/pytest_gpu_proof/verify.py | 150 ++++++++++++++++++++++++--------- 2 files changed, 133 insertions(+), 41 deletions(-) diff --git a/src/pytest_gpu_proof/cli.py b/src/pytest_gpu_proof/cli.py index c0ed5f0..3200a4a 100644 --- a/src/pytest_gpu_proof/cli.py +++ b/src/pytest_gpu_proof/cli.py @@ -40,6 +40,27 @@ def main(): metavar="N", help="Override max receipt age in days", ) + vp.add_argument( + "--allow-unsigned", + action="store_true", + default=False, + help="Accept UNSIGNED receipts (signature: null). Unsigned receipts prove " + "nothing about who ran the tests — use only if you accept that.", + ) + vp.add_argument( + "--allow-skipped", + action="store_true", + default=False, + help="Accept receipts that contain skipped marked tests (default: reject)", + ) + vp.add_argument( + "--require-gpu", + action="store_true", + default=None, + help="Fail verification if the receipt's environment.gpu_info is null/absent " + "(modest hardening, not proof of GPU execution). Can also be set via " + "require_gpu = true in [tool.gpu_proof].", + ) args = parser.parse_args() @@ -52,6 +73,9 @@ def main(): repo_root=args.repo, github_user_override=args.github_user, max_age_days=args.max_age_days, + allow_unsigned=args.allow_unsigned, + allow_skipped=args.allow_skipped, + require_gpu=args.require_gpu, ) sys.exit(0 if ok else 1) diff --git a/src/pytest_gpu_proof/verify.py b/src/pytest_gpu_proof/verify.py index 808506e..91b1f92 100644 --- a/src/pytest_gpu_proof/verify.py +++ b/src/pytest_gpu_proof/verify.py @@ -29,14 +29,17 @@ class VerificationError(Exception): def _load_policy(policy_path: Optional[str]) -> dict: if not policy_path: return {} + text = Path(policy_path).read_text() + if policy_path.endswith(".json"): + return json.loads(text) or {} try: import yaml # type: ignore - with open(policy_path) as f: - return yaml.safe_load(f) or {} except ImportError: - import json as _json - with open(policy_path) as f: - return _json.load(f) + raise VerificationError( + f"Policy file {policy_path!r} is YAML but PyYAML is not installed. " + "Install it with 'pip install pyyaml', or use a .json policy file." + ) + return yaml.safe_load(text) or {} def _receipt_payload_without_sig(receipt: dict) -> bytes: @@ -77,9 +80,21 @@ def verify_receipt( repo_root: str = ".", github_user_override: Optional[str] = None, max_age_days: Optional[int] = None, + allow_unsigned: bool = False, + allow_skipped: bool = False, + require_gpu: Optional[bool] = None, ) -> bool: try: - _verify(receipt_path, policy_path, repo_root, github_user_override, max_age_days) + _verify( + receipt_path, + policy_path, + repo_root, + github_user_override, + max_age_days, + allow_unsigned=allow_unsigned, + allow_skipped=allow_skipped, + require_gpu=require_gpu, + ) return True except VerificationError as e: print(f"[gpu-proof] FAIL: {e}", file=sys.stderr) @@ -92,7 +107,14 @@ def _verify( repo_root: str, github_user_override: Optional[str], max_age_days_override: Optional[int], + allow_unsigned: bool = False, + allow_skipped: bool = False, + require_gpu: Optional[bool] = None, ): + from .config import load_toml_defaults + + toml_cfg = load_toml_defaults(repo_root) + # --- load receipt --- receipt_text = Path(receipt_path).read_text() receipt = json.loads(receipt_text) @@ -103,41 +125,51 @@ def _verify( sig_block = receipt.get("signature") if not sig_block: - raise VerificationError("Receipt has no signature block") - - sig_b64 = sig_block.get("value", "") - if not sig_b64: - raise VerificationError("Signature value is missing") + if not allow_unsigned: + raise VerificationError( + "Receipt is UNSIGNED (no signature block). Unsigned receipts prove " + "nothing about who ran the tests. Pass --allow-unsigned only if you " + "explicitly accept that." + ) + print( + "[gpu-proof] WARNING: receipt is UNSIGNED and --allow-unsigned was passed.\n" + "[gpu-proof] WARNING: signature verification SKIPPED — this receipt proves\n" + "[gpu-proof] WARNING: nothing about who ran the tests or on what machine." + ) + else: + sig_b64 = sig_block.get("value", "") + if not sig_b64: + raise VerificationError("Signature value is missing") - try: - signature = base64.b64decode(sig_b64) - except Exception: - raise VerificationError("Signature value is not valid base64") - - # --- verify signature via GitHub public keys --- - github_username = ( - github_user_override - or sig_block.get("signer") - or receipt.get("repo", {}).get("github_username") - ) - if not github_username: - raise VerificationError( - "Cannot determine GitHub username. Pass --github-user=USERNAME." + try: + signature = base64.b64decode(sig_b64) + except Exception: + raise VerificationError("Signature value is not valid base64") + + # --- verify signature via GitHub public keys --- + github_username = ( + github_user_override + or sig_block.get("signer") + or receipt.get("repo", {}).get("github_username") ) + if not github_username: + raise VerificationError( + "Cannot determine GitHub username. Pass --github-user=USERNAME." + ) - payload_bytes = _receipt_payload_without_sig(receipt) + payload_bytes = _receipt_payload_without_sig(receipt) - print(f"[gpu-proof] Fetching public keys for @{github_username} …") - try: - ok = verify_with_github_keys(payload_bytes, signature, github_username) - except _VerifierError as e: - raise VerificationError(str(e)) + print(f"[gpu-proof] Fetching public keys for @{github_username} …") + try: + ok = verify_with_github_keys(payload_bytes, signature, github_username) + except _VerifierError as e: + raise VerificationError(str(e)) - if not ok: - raise VerificationError( - f"Signature does not match any SSH key registered by @{github_username} on GitHub" - ) - print(f"[gpu-proof] Signature valid (signer: @{github_username})") + if not ok: + raise VerificationError( + f"Signature does not match any SSH key registered by @{github_username} on GitHub" + ) + print(f"[gpu-proof] Signature valid (signer: @{github_username})") # --- recompute fingerprint --- repo = receipt.get("repo", {}) @@ -147,9 +179,12 @@ def _verify( from .fingerprint import compute_fingerprint # noqa: PLC0415 (local import ok here) current_fp = compute_fingerprint(fp_paths, root=repo_root) - if current_fp["digest"] != stored_fp.get("digest"): + stored_digest = stored_fp.get("digest") + if not stored_digest: + raise VerificationError("Receipt fingerprint block is missing its digest") + if current_fp["digest"] != stored_digest: raise VerificationError( - f"Fingerprint mismatch: stored={stored_fp.get('digest')[:12]}… " + f"Fingerprint mismatch: stored={stored_digest[:12]}… " f"current={current_fp['digest'][:12]}…\n" "The code under src/ or tests/ has changed since the receipt was generated." ) @@ -187,15 +222,48 @@ def _verify( if not tests: raise VerificationError("Receipt contains no test results") - failed = [t["node_id"] for t in tests if t.get("outcome") != "passed"] + failed = [ + t["node_id"] for t in tests if t.get("outcome") not in ("passed", "skipped") + ] if failed: raise VerificationError( f"{len(failed)} test(s) did not pass: {', '.join(failed)}" ) - print(f"[gpu-proof] All {len(tests)} test(s) passed") + skipped = [t["node_id"] for t in tests if t.get("outcome") == "skipped"] + if skipped and not allow_skipped: + raise VerificationError( + f"{len(skipped)} marked test(s) were skipped: {', '.join(skipped)}. " + "Skipped tests prove nothing; pass --allow-skipped to accept them." + ) + if skipped: + print( + f"[gpu-proof] WARNING: {len(skipped)} skipped test(s) accepted " + "(--allow-skipped)" + ) + print(f"[gpu-proof] All {len(tests) - len(skipped)} executed test(s) passed") + + # --- gpu_info policy (modest hardening, not proof) --- + if require_gpu is None: + require_gpu = bool(toml_cfg.get("require_gpu", False)) + if require_gpu: + gpu_info = (receipt.get("environment") or {}).get("gpu_info") + if not gpu_info: + raise VerificationError( + "Receipt's environment.gpu_info is missing/null but the policy " + "requires GPU info (--require-gpu). The recording machine had no " + "visible GPU (or nvidia-smi failed)." + ) + print(f"[gpu-proof] GPU info present ({gpu_info.get('name')})") # --- freshness --- - max_days = max_age_days_override or policy.get("max_age_days", 30) + if max_age_days_override is not None: + max_days = max_age_days_override + elif policy.get("max_age_days") is not None: + max_days = policy["max_age_days"] + elif toml_cfg.get("max_age_days") is not None: + max_days = toml_cfg["max_age_days"] + else: + max_days = 30 signed_at_str = receipt.get("session", {}).get("ended_at") if signed_at_str: try: From 54a807289e988ed02323e5bacb97748e262205c5 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:20:04 -0400 Subject: [PATCH 06/10] tests for backend none, fail-on-skip, marker wiring, skip capture, toml defaults, verifier flags; rewrite stale-receipt test --- tests/test_plugin_capture.py | 205 +++++++++++++++++++++++++++++++++++ tests/test_signing.py | 23 ++++ tests/test_verifier.py | 170 +++++++++++++++++++++++++---- 3 files changed, 375 insertions(+), 23 deletions(-) diff --git a/tests/test_plugin_capture.py b/tests/test_plugin_capture.py index d544930..a050705 100644 --- a/tests/test_plugin_capture.py +++ b/tests/test_plugin_capture.py @@ -69,3 +69,208 @@ def test_needs_gpu(): def test_markers_registered(pytester): result = pytester.runpytest("--markers") result.stdout.fnmatch_lines(["*gpu_proof*", "*gpu_required*", "*gpu_equivalence*"]) + + +def _read_receipt(pytester, name="gpu-proof.json"): + import json + + path = pytester.path / name + assert path.exists(), f"receipt {name} was not written" + return json.loads(path.read_text()) + + +def test_backend_none_writes_unsigned_receipt(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.mark.gpu_proof + def test_ok(): + assert True + """ + ) + result = pytester.runpytest( + "--gpu-proof-enable", "--gpu-proof-signing-backend=none" + ) + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*UNSIGNED*"]) + receipt = _read_receipt(pytester) + assert receipt["signature"] is None + assert receipt["tests"][0]["outcome"] == "passed" + + +def test_skipped_marked_test_recorded_in_receipt(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.mark.gpu_proof + def test_ok(): + assert True + + @pytest.mark.skip(reason="no hardware") + @pytest.mark.gpu_proof + def test_skipped(): + assert True + """ + ) + result = pytester.runpytest( + "--gpu-proof-enable", "--gpu-proof-signing-backend=none" + ) + result.assert_outcomes(passed=1, skipped=1) + receipt = _read_receipt(pytester) + outcomes = {t["node_id"].split("::")[-1]: t["outcome"] for t in receipt["tests"]} + assert outcomes["test_skipped"] == "skipped" + assert outcomes["test_ok"] == "passed" + + +def test_fail_on_skip_fails_session_and_suppresses_receipt(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.mark.skip(reason="no hardware") + @pytest.mark.gpu_proof + def test_skipped(): + assert True + """ + ) + result = pytester.runpytest( + "--gpu-proof-enable", + "--gpu-proof-signing-backend=none", + "--gpu-proof-fail-on-skip", + ) + assert result.ret != 0 + result.stdout.fnmatch_lines(["*fail-on-skip*"]) + assert not (pytester.path / "gpu-proof.json").exists() + + +def test_fail_on_skip_covers_gpu_required(pytester, monkeypatch): + # Make the plugin believe no GPU is present. + monkeypatch.setattr("pytest_gpu_proof.plugin._has_gpu", lambda: False) + pytester.makepyfile( + """ + import pytest + + @pytest.mark.gpu_required + def test_needs_gpu(): + assert True + """ + ) + result = pytester.runpytest_inprocess( + "--gpu-proof-enable", + "--gpu-proof-signing-backend=none", + "--gpu-proof-fail-on-skip", + ) + assert result.ret != 0 + assert not (pytester.path / "gpu-proof.json").exists() + + +def test_custom_required_marker_is_wired(pytester): + pytester.makeini( + """ + [pytest] + markers = + my_gpu: custom receipt marker + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.mark.my_gpu + def test_custom_marked(): + assert True + + def test_unmarked(): + assert True + """ + ) + result = pytester.runpytest( + "--gpu-proof-enable", + "--gpu-proof-signing-backend=none", + "--gpu-proof-required-marker=my_gpu", + ) + result.assert_outcomes(passed=2) + receipt = _read_receipt(pytester) + node_ids = [t["node_id"] for t in receipt["tests"]] + assert any("test_custom_marked" in n for n in node_ids) + assert not any("test_unmarked" in n for n in node_ids) + + +def test_fixture_checks_recorded_in_receipt(pytester): + pytester.makepyfile( + """ + def test_with_fixture(gpu_proof_check): + gpu_proof_check( + name="add", + reference=lambda x: x + 1, + candidate=lambda x: x + 1, + args=(5,), + metadata={"kernel": "add"}, + ) + """ + ) + result = pytester.runpytest( + "--gpu-proof-enable", "--gpu-proof-signing-backend=none" + ) + result.assert_outcomes(passed=1) + receipt = _read_receipt(pytester) + assert len(receipt["tests"]) == 1 + checks = receipt["tests"][0]["checks"] + assert checks == [{"name": "add", "outcome": "passed", "metadata": {"kernel": "add"}}] + + +def test_tool_gpu_proof_toml_defaults(pytester): + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + markers = ["my_gpu: custom receipt marker"] + + [tool.gpu_proof] + signing_backend = "none" + output = "toml-receipt.json" + required_marker = "my_gpu" + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.mark.my_gpu + def test_custom_marked(): + assert True + """ + ) + result = pytester.runpytest("--gpu-proof-enable") + result.assert_outcomes(passed=1) + receipt = _read_receipt(pytester, "toml-receipt.json") + assert receipt["signature"] is None + assert any("test_custom_marked" in t["node_id"] for t in receipt["tests"]) + + +def test_cli_overrides_toml_defaults(pytester): + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + + [tool.gpu_proof] + signing_backend = "none" + output = "toml-receipt.json" + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.mark.gpu_proof + def test_ok(): + assert True + """ + ) + result = pytester.runpytest( + "--gpu-proof-enable", "--gpu-proof-out=cli-receipt.json" + ) + result.assert_outcomes(passed=1) + assert (pytester.path / "cli-receipt.json").exists() + assert not (pytester.path / "toml-receipt.json").exists() diff --git a/tests/test_signing.py b/tests/test_signing.py index add331e..3681ba3 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -67,6 +67,29 @@ def test_key_fingerprint_format(ssh_key_file): assert len(fp) > 10 +def test_algorithm_derived_from_key_type(ssh_key_file, tmp_path): + key_path, _ = ssh_key_file + assert SSHSigner(key_path=str(key_path)).algorithm() == "ed25519" + + from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1, generate_private_key + + ec_key = generate_private_key(SECP256R1()) + ec_path = tmp_path / "id_ecdsa" + ec_path.write_bytes( + ec_key.private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption()) + ) + assert SSHSigner(key_path=str(ec_path)).algorithm() == "ecdsa-sha256" + + +def test_receipt_algorithm_matches_key(ssh_key_file): + from pytest_gpu_proof.receipt import finalize_receipt + + key_path, _ = ssh_key_file + signer = SSHSigner(key_path=str(key_path)) + receipt = finalize_receipt({"repo": {}, "tests": []}, signer) + assert receipt["signature"]["algorithm"] == "ed25519" + + def test_missing_key_raises(tmp_path): from pytest_gpu_proof.signers.base import VerifierError with pytest.raises(VerifierError, match="No SSH private key found"): diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 7bf58c9..385834f 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -2,10 +2,10 @@ Verifier tests. GitHub key fetching is patched so tests run offline and fast. """ -import base64 +import datetime import json import os -from pathlib import Path +import sys from unittest.mock import patch import pytest @@ -14,7 +14,6 @@ from pytest_gpu_proof.receipt import ( build_receipt_payload, - canonicalize, finalize_receipt, write_receipt, ) @@ -38,14 +37,27 @@ def signer_with_key(tmp_path, keypair): return SSHSigner(key_path=str(key_path)), public_key -@pytest.fixture -def good_receipt(tmp_path, tmp_git_repo, signer_with_key): +def _utcstamp(days_ago: int = 0) -> str: + ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_ago) + return ts.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _make_receipt( + tmp_path, + tmp_git_repo, + signer, + results=None, + ended_at=None, + mutate=None, + sign=True, +): + """Build a receipt against tmp_git_repo, optionally mutating the payload + *before* signing (so the signature stays valid).""" from pytest_gpu_proof.config import GpuProofConfig os.chdir(tmp_git_repo) - signer, public_key = signer_with_key config = GpuProofConfig(enabled=True, fingerprint_paths=["src", "tests"]) - results = [ + results = results or [ { "node_id": "tests/test_add.py::test_add", "outcome": "passed", @@ -53,12 +65,24 @@ def good_receipt(tmp_path, tmp_git_repo, signer_with_key): "checks": [], } ] - import datetime - now = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") - payload = build_receipt_payload(config, results, now, now) - receipt = finalize_receipt(payload, signer) + now = _utcstamp() + payload = build_receipt_payload(config, results, now, ended_at or now) + if mutate is not None: + mutate(payload) + if sign: + receipt = finalize_receipt(payload, signer) + else: + receipt = dict(payload) + receipt["signature"] = None path = tmp_path / "gpu-proof.json" write_receipt(receipt, str(path)) + return path + + +@pytest.fixture +def good_receipt(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key = signer_with_key + path = _make_receipt(tmp_path, tmp_git_repo, signer) return path, public_key @@ -117,22 +141,122 @@ def test_verify_fails_on_modified_receipt(good_receipt, tmp_git_repo): _verify(str(path), None, str(tmp_git_repo), "testuser", None) -def test_verify_fails_on_stale_receipt(good_receipt, tmp_git_repo): - path, public_key = good_receipt - receipt = json.loads(path.read_text()) - # Re-sign with an ancient timestamp - receipt["session"]["ended_at"] = "2020-01-01T00:00:00Z" - # We must re-sign after modifying - from pytest_gpu_proof.signers.ed25519 import _sign_with_key - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as _K - +def test_verify_fails_on_stale_receipt(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key = signer_with_key + # Genuinely-signed receipt whose ended_at is far in the past: every check + # up to freshness must pass, and freshness must be the one that fails. + path = _make_receipt( + tmp_path, tmp_git_repo, signer, ended_at="2020-01-01T00:00:00Z" + ) with _mock_github_keys(public_key): - # The modified receipt (unsigned change) will fail on signature first - path.write_text(json.dumps(receipt)) - with pytest.raises(VerificationError): + with pytest.raises(VerificationError, match=r"days old; policy allows max 30"): _verify(str(path), None, str(tmp_git_repo), "testuser", 30) +def test_verify_max_age_zero_is_respected(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key = signer_with_key + path = _make_receipt(tmp_path, tmp_git_repo, signer, ended_at=_utcstamp(days_ago=5)) + with _mock_github_keys(public_key): + # An explicit override of 0 must not silently fall back to 30. + with pytest.raises(VerificationError, match=r"policy allows max 0"): + _verify(str(path), None, str(tmp_git_repo), "testuser", 0) + + +def test_verify_rejects_unsigned_receipt(tmp_path, tmp_git_repo, signer_with_key): + signer, _ = signer_with_key + path = _make_receipt(tmp_path, tmp_git_repo, signer, sign=False) + with pytest.raises(VerificationError, match="UNSIGNED"): + _verify(str(path), None, str(tmp_git_repo), "testuser", None) + + +def test_verify_accepts_unsigned_receipt_with_allow_unsigned( + tmp_path, tmp_git_repo, signer_with_key +): + signer, _ = signer_with_key + path = _make_receipt(tmp_path, tmp_git_repo, signer, sign=False) + _verify(str(path), None, str(tmp_git_repo), "testuser", None, allow_unsigned=True) + + +def test_verify_rejects_skipped_tests(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key = signer_with_key + results = [ + {"node_id": "tests/test_add.py::test_add", "outcome": "passed", + "duration_s": 0.01, "checks": []}, + {"node_id": "tests/test_add.py::test_skipped", "outcome": "skipped", + "duration_s": 0.0, "checks": []}, + ] + path = _make_receipt(tmp_path, tmp_git_repo, signer, results=results) + with _mock_github_keys(public_key): + with pytest.raises(VerificationError, match="skipped"): + _verify(str(path), None, str(tmp_git_repo), "testuser", None) + # Explicit opt-in accepts them. + _verify( + str(path), None, str(tmp_git_repo), "testuser", None, allow_skipped=True + ) + + +def test_verify_missing_digest_raises_clean_error(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key = signer_with_key + + def drop_digest(payload): + del payload["fingerprint"]["digest"] + + path = _make_receipt(tmp_path, tmp_git_repo, signer, mutate=drop_digest) + with _mock_github_keys(public_key): + with pytest.raises(VerificationError, match="missing its digest"): + _verify(str(path), None, str(tmp_git_repo), "testuser", None) + + +def test_yaml_policy_without_pyyaml_raises(tmp_path, monkeypatch): + from pytest_gpu_proof.verify import _load_policy + + policy = tmp_path / "policy.yaml" + policy.write_text("max_age_days: 7\n") + monkeypatch.setitem(sys.modules, "yaml", None) # force ImportError + with pytest.raises(VerificationError, match="PyYAML"): + _load_policy(str(policy)) + + +def test_json_policy_works_without_pyyaml(tmp_path, monkeypatch): + from pytest_gpu_proof.verify import _load_policy + + policy = tmp_path / "policy.json" + policy.write_text('{"max_age_days": 7}') + monkeypatch.setitem(sys.modules, "yaml", None) + assert _load_policy(str(policy)) == {"max_age_days": 7} + + +def test_require_gpu_rejects_null_gpu_info(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key = signer_with_key + + def null_gpu(payload): + payload["environment"]["gpu_info"] = None + + path = _make_receipt(tmp_path, tmp_git_repo, signer, mutate=null_gpu) + with _mock_github_keys(public_key): + with pytest.raises(VerificationError, match="gpu_info"): + _verify( + str(path), None, str(tmp_git_repo), "testuser", None, require_gpu=True + ) + # Off by default: same receipt verifies fine. + _verify(str(path), None, str(tmp_git_repo), "testuser", None) + + +def test_require_gpu_accepts_present_gpu_info(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key = signer_with_key + + def fake_gpu(payload): + payload["environment"]["gpu_info"] = { + "name": "NVIDIA Test GPU", + "driver_version": "555.0", + "memory": "8192 MiB", + } + + path = _make_receipt(tmp_path, tmp_git_repo, signer, mutate=fake_gpu) + with _mock_github_keys(public_key): + _verify(str(path), None, str(tmp_git_repo), "testuser", None, require_gpu=True) + + def test_verify_fails_on_failed_test(tmp_path, tmp_git_repo, signer_with_key): from pytest_gpu_proof.config import GpuProofConfig From 056a85b04d9a04d6e20127702ea8c143d3c95a9e Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:41:57 -0400 Subject: [PATCH 07/10] add MIT LICENSE, fix example install lines, document new flags and key caveats --- LICENSE | 21 ++++++++++++ README.md | 34 +++++++++++++++++-- .../.github/workflows/gpu-test.yml | 2 +- .../.github/workflows/verify.yml | 2 +- pyproject.toml | 11 ++++-- 5 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd5b390 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 A2R Lab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 68545c7..72332fa 100644 --- a/README.md +++ b/README.md @@ -180,11 +180,25 @@ gpu_proof_check( | `--gpu-proof-mode` | `local` | `local` or `ci-gpu` | | `--gpu-proof-out` | `gpu-proof.json` | Receipt output path | | `--gpu-proof-key` | auto | SSH private key path | -| `--gpu-proof-signing-backend` | `ed25519` | `ed25519` or `none` | +| `--gpu-proof-signing-backend` | `ed25519` | `ed25519` or `none` (writes an **unsigned** receipt with `"signature": null`; the verifier rejects it unless `--allow-unsigned` is passed) | +| `--gpu-proof-required-marker` | `gpu_proof` | Marker name that flags a test for the receipt | | `--gpu-proof-fingerprint-paths` | `src,tests` | Comma-separated paths to fingerprint | | `--gpu-proof-github-user` | auto | GitHub username (auto-detected from git remote) | | `--gpu-proof-policy` | — | Path to policy YAML | -| `--gpu-proof-fail-on-skip` | off | Fail if any `gpu_required` test is skipped | +| `--gpu-proof-fail-on-skip` | off | Exit non-zero and write no receipt if any marked or `gpu_required` test is skipped | + +Defaults for most of these can also be set in your project's `pyproject.toml` +under `[tool.gpu_proof]` (CLI flags take precedence): + +```toml +[tool.gpu_proof] +mode = "local" +output = "gpu-proof.json" +fingerprint_paths = ["src", "tests"] +required_marker = "gpu_proof" +max_age_days = 30 # used by the verifier +require_gpu = false # used by the verifier (see below) +``` --- @@ -211,9 +225,16 @@ python -m pytest_gpu_proof verify --receipt gpu-proof.json 1. **Signature** — fetches `github.com/{signer}.keys`, verifies Ed25519/ECDSA/RSA signature 2. **Fingerprint** — recomputes SHA-256 digest of `src/` and `tests/`, compares to receipt 3. **Commit SHA** — compares receipt commit SHA to current HEAD -4. **Test outcomes** — all tests recorded in the receipt must have passed +4. **Test outcomes** — all tests recorded in the receipt must have passed; skipped marked tests fail verification unless `--allow-skipped` is passed 5. **Freshness** — receipt must be younger than `max_age_days` (default: 30) 6. **Dirty policy** — configurable via policy file +7. **GPU info** (optional) — with `--require-gpu` (or `require_gpu = true` in `[tool.gpu_proof]`), the receipt's `environment.gpu_info` must be present + +Additional flags: + +- `--allow-unsigned` — accept receipts with `"signature": null` (produced by `--gpu-proof-signing-backend=none`). This disables the entire trust story; the verifier prints a loud warning. +- `--allow-skipped` — accept receipts that contain skipped marked tests. +- `--require-gpu` — reject receipts whose `environment.gpu_info` is null/absent. This is **modest hardening, not proof**: `gpu_info` is self-reported by the recording machine, so it only guards against accidentally signing on a GPU-less box, not against a dishonest signer. --- @@ -291,6 +312,13 @@ A signed receipt proves that **an accepted signer attested to a specific test ru This is appropriate for **team workflows where the signer is a trusted team member** and the goal is to avoid paying for GPU CI on every merge, not to provide adversarial security guarantees. +The optional `--require-gpu` verifier flag adds a modest extra check — the receipt must contain self-reported `environment.gpu_info` — but this is hardening against mistakes (signing on a GPU-less machine), not proof of GPU execution. + +**SSH key support caveats:** + +- The plugin signs the raw receipt bytes with the key loaded from disk — it does **not** produce SSHSIG-format signatures, so `ssh-keygen -Y verify` cannot validate receipts. Use `gpu-proof verify` instead. +- Keys that live only in an SSH agent, and FIDO/hardware-backed `sk-ssh-ed25519`/`sk-ecdsa` keys, are **not** supported: signing needs direct access to a private key file readable by the `cryptography` library. + See [docs/security_model.md](docs/security_model.md) for a full discussion. --- diff --git a/examples/github_gpu_runner/.github/workflows/gpu-test.yml b/examples/github_gpu_runner/.github/workflows/gpu-test.yml index 4172a02..f3bb0fc 100644 --- a/examples/github_gpu_runner/.github/workflows/gpu-test.yml +++ b/examples/github_gpu_runner/.github/workflows/gpu-test.yml @@ -21,7 +21,7 @@ jobs: - name: Install dependencies run: | - pip install pytest-gpu-proof + pip install "git+https://github.com/A2R-Lab/pytest-gpu-proof.git" # Install your project's GPU dependencies here, e.g.: # pip install torch --index-url https://download.pytorch.org/whl/cu121 diff --git a/examples/local_receipt_verify/.github/workflows/verify.yml b/examples/local_receipt_verify/.github/workflows/verify.yml index 08cf981..f2629a7 100644 --- a/examples/local_receipt_verify/.github/workflows/verify.yml +++ b/examples/local_receipt_verify/.github/workflows/verify.yml @@ -17,7 +17,7 @@ jobs: python-version: "3.11" - name: Install pytest-gpu-proof - run: pip install pytest-gpu-proof + run: pip install "git+https://github.com/A2R-Lab/pytest-gpu-proof.git" - name: Verify receipt # The receipt (gpu-proof.json) is committed to the repo. diff --git a/pyproject.toml b/pyproject.toml index cb897c6..ed4302f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,8 +42,13 @@ packages = ["src/pytest_gpu_proof"] testpaths = ["tests"] [tool.gpu_proof] -# Default plugin configuration — can override here or in pytest.ini +# Default plugin/verifier configuration, read from the rootdir pyproject.toml. +# CLI flags take precedence over values set here. # mode = "local" -# fingerprint_paths = ["src", "tests"] # output = "gpu-proof.json" -# max_age_days = 30 +# signing_backend = "ed25519" +# required_marker = "gpu_proof" +# fail_on_skip = false +# fingerprint_paths = ["src", "tests"] +# max_age_days = 30 # verifier freshness default +# require_gpu = false # verifier: require environment.gpu_info in the receipt From 23fed520f7d57e827fb57fde7f72719f8aba1456 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:49:27 -0400 Subject: [PATCH 08/10] add CI workflow and mkdocs-material docs site with gh-pages deploy --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ .github/workflows/docs.yml | 29 +++++++++++++++++++++++++++++ docs/index.md | 33 +++++++++++++++++++++++++++++++++ mkdocs.yml | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 docs/index.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ce31694 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev extras + run: pip install -e ".[dev]" + + - name: Run test suite (CPU-only) + run: python -m pytest tests/ -q diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..005dcd0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +name: Docs + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install mkdocs + run: pip install mkdocs-material + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Deploy to gh-pages + run: mkdocs gh-deploy --force diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..b24892e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +# pytest-gpu-proof + +A pytest plugin that lets you run GPU equivalence tests locally, sign the results with your existing SSH key, and have GitHub Actions verify the receipt — **without re-running the GPU tests in CI**. + +The trust model is simple: if you can push to GitHub, you can sign a receipt. Verification fetches your public keys from `github.com/{username}.keys`, exactly as SSH does. + +> **Requirements:** Python 3.11+ · pytest 7.0+ · cryptography 41.0+ +> The package is not yet on PyPI — install from source with `pip install -e .` or `pip install "git+https://github.com/A2R-Lab/pytest-gpu-proof.git"`. + +## How it works + +``` +Local machine (GPU) GitHub Actions (CPU only) +───────────────────────────── ────────────────────────────────────── +pytest --gpu-proof-enable → → gpu-proof verify --receipt gpu-proof.json + runs your GPU tests fetches your public keys from + computes code fingerprint github.com/{you}.keys + signs receipt with SSH key verifies signature + fingerprint + writes gpu-proof.json exits 0 (pass) or 1 (fail) +``` + +**Zero new key management.** The plugin uses the SSH key you already have in `~/.ssh/` (the same one you use to push to GitHub). Your public key is already on GitHub. The verifier reads it from there. + +## Documentation + +- [Quickstart](quickstart.md) — local CUDA proof, GitHub verification, end to end +- [Local Mode](local_mode.md) — the default workflow: sign locally, verify in CI +- [CI-GPU Mode](ci_gpu_mode.md) — run the tests on a GitHub-hosted GPU runner instead +- [Architecture](architecture.md) — package layout and data flow +- [Security Model](security_model.md) — what a receipt does and does not prove +- [Landscape](landscape.md) — why this tool exists rather than an existing one + +See the [README on GitHub](https://github.com/A2R-Lab/pytest-gpu-proof#readme) for the full CLI reference, receipt format, and examples. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..11cfcab --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,38 @@ +site_name: pytest-gpu-proof +site_description: >- + pytest plugin for GPU equivalence testing with signed receipts verified via + GitHub SSH keys +repo_url: https://github.com/A2R-Lab/pytest-gpu-proof +repo_name: A2R-Lab/pytest-gpu-proof + +theme: + name: material + palette: + - scheme: default + primary: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.sections + - content.code.copy + +markdown_extensions: + - admonition + - pymdownx.superfences + - pymdownx.highlight + - tables + +nav: + - Home: index.md + - Quickstart: quickstart.md + - Local Mode: local_mode.md + - CI-GPU Mode: ci_gpu_mode.md + - Architecture: architecture.md + - Security Model: security_model.md + - Landscape: landscape.md From 8328e35a62b3d762b4a40be900c21cc61d1c8946 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 10:51:08 -0400 Subject: [PATCH 09/10] update verifier docstring for new checks --- src/pytest_gpu_proof/verify.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pytest_gpu_proof/verify.py b/src/pytest_gpu_proof/verify.py index 91b1f92..ddeb98a 100644 --- a/src/pytest_gpu_proof/verify.py +++ b/src/pytest_gpu_proof/verify.py @@ -3,11 +3,14 @@ Checks: 1. Signature — fetches signer's public keys from github.com/{username}.keys + (unsigned receipts are rejected unless allow_unsigned is set) 2. Fingerprint — recomputes and compares digest 3. Commit SHA — compares against current repo state - 4. Test outcomes — all tests in receipt must have passed - 5. Freshness — receipt must not be older than max_age_days - 6. Dirty policy — reject dirty-tree receipts if policy requires clean + 4. Test outcomes — all tests in receipt must have passed; skipped marked + tests are rejected unless allow_skipped is set + 5. GPU info — optionally require environment.gpu_info (require_gpu) + 6. Freshness — receipt must not be older than max_age_days + 7. Dirty policy — reject dirty-tree receipts if policy requires clean """ import base64 From 11267f842cec963ec4c5f4f8ce67ca72114002e3 Mon Sep 17 00:00:00 2001 From: plancher Date: Thu, 2 Jul 2026 11:12:17 -0400 Subject: [PATCH 10/10] add roadmap (pypi publishing deferred to own session) --- ROADMAP.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..6dbd1c8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,8 @@ +# Roadmap + +- **PyPI publishing (own session):** register the `pytest-gpu-proof` name, trusted + publishing via GitHub Actions (OIDC), README as long_description, versioning + + release discipline, CHANGELOG. Prereqs (CI, LICENSE, docs site, 3.11 floor) + landed on `fixes-and-ci` 2026-07-02. +- SSHSIG-compatible signing (`ssh-keygen -Y verify` interop; agent-only + FIDO keys). +- CI-issued nonce / challenge mode for stronger replay protection.