From 62aaa046ec88b593b5065527ad9c81e52110c45e Mon Sep 17 00:00:00 2001 From: plancher Date: Fri, 7 Aug 2026 00:35:13 -0400 Subject: [PATCH 1/3] gpu-proof merge: union shard receipts from one commit, re-sign, verifier unchanged --- CHANGELOG.md | 11 +++ docs/sharding.md | 62 ++++++++++++ mkdocs.yml | 1 + pyproject.toml | 2 +- src/pytest_gpu_proof/__init__.py | 2 +- src/pytest_gpu_proof/cli.py | 45 +++++++++ src/pytest_gpu_proof/merge.py | 161 ++++++++++++++++++++++++++++++ tests/test_merge.py | 163 +++++++++++++++++++++++++++++++ 8 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 docs/sharding.md create mode 100644 src/pytest_gpu_proof/merge.py create mode 100644 tests/test_merge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7d47d..5eabec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to pytest-gpu-proof are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/) (pre-1.0: minor bumps may break). +## [0.2.0] — unreleased + +### Added +- `gpu-proof merge`: union N shard receipts from one commit into a single + re-signed receipt (per-module crash isolation / machine sharding). Refuses + shards that disagree on schema, commit SHA, fingerprint, mode, or + environment; duplicate node IDs across shards are a hard error. Records + per-shard provenance under `session.shards` (additive — the verifier is + unchanged). `repo.dirty` is OR-ed; `gpu_info` survives CPU-only shards. + Docs: `docs/sharding.md`. + ## [0.1.0] — 2026-07-07 First public release. diff --git a/docs/sharding.md b/docs/sharding.md new file mode 100644 index 0000000..6af4af8 --- /dev/null +++ b/docs/sharding.md @@ -0,0 +1,62 @@ +# Sharded runs & merging receipts + +Large suites often can't (or shouldn't) run as one pytest session: per-module +subprocesses give crash isolation (one CUDA abort no longer erases the whole +run's results), and big projects split GPU tests across invocations or +machines. Each invocation emits its own receipt; CI wants **one** artifact. + +## Emit one receipt per shard + +Point each invocation at its own output path: + +```bash +pytest tests/gpu/test_a.py --gpu-proof-enable --gpu-proof-out=receipts/a.json +pytest tests/gpu/test_b.py --gpu-proof-enable --gpu-proof-out=receipts/b.json +``` + +Every shard receipt is a complete, individually verifiable receipt. + +## Merge + +```bash +gpu-proof merge --out gpu-proof.json receipts/a.json receipts/b.json +``` + +`merge` unions the shards' `tests`, spans `session.started_at`/`ended_at` +across them, records per-shard provenance under `session.shards` +(`source`, `node_count`, timestamps, and each shard's recorded signer), and +**re-signs the merged payload with your local SSH key**. The result flows +through `gpu-proof verify` completely unchanged — same schema, same seven +checks. + +Options: + +- `--github-user USERNAME` — recorded signer identity for the merged receipt + (default: the first shard's `repo.github_username`). +- `--key PATH` — SSH private key (default: `git config user.signingKey`, then + `~/.ssh/id_ed25519` / `id_ecdsa` / `id_rsa`). +- `--unsigned` — write `signature: null`; verifies only with + `--allow-unsigned`, loudly. + +## What merge refuses + +A merged receipt must mean exactly what a single-session receipt means, so +`merge` hard-refuses shards that disagree on anything a receipt pins: + +- `schema_version`, `repo.commit_sha`, `fingerprint` (digest + paths), + `mode`, or the `environment` the tests ran under (python/pytest/plugin + versions, platform); +- **duplicate node IDs across shards** — two shards attesting the same test is + a sharding bug in the runner, never something to dedupe silently. + +`repo.dirty` is OR-ed: one dirty shard makes the merged attestation dirty, and +your verify-time dirty policy applies honestly. `gpu_info` is taken from the +first shard that has one, so a CPU-only shard doesn't erase the GPU record. + +## Trust model + +Consistent with the [security model](security_model.md): the merged receipt is +an **attestation by the merger**. Shard signatures are recorded as provenance +but not re-verified at merge time (merging is offline); the merged signature +is what CI verifies. If shards were signed by someone else, verification of +the merged receipt attests that *you* vouch for the union. diff --git a/mkdocs.yml b/mkdocs.yml index 11cfcab..0452f38 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - Quickstart: quickstart.md - Local Mode: local_mode.md - CI-GPU Mode: ci_gpu_mode.md + - Sharding & Merge: sharding.md - Architecture: architecture.md - Security Model: security_model.md - Landscape: landscape.md diff --git a/pyproject.toml b/pyproject.toml index d52ac73..14e3aab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytest-gpu-proof" -version = "0.1.0" +version = "0.2.0" description = "pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys" readme = "README.md" requires-python = ">=3.11" diff --git a/src/pytest_gpu_proof/__init__.py b/src/pytest_gpu_proof/__init__.py index 3dc1f76..d3ec452 100644 --- a/src/pytest_gpu_proof/__init__.py +++ b/src/pytest_gpu_proof/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/pytest_gpu_proof/cli.py b/src/pytest_gpu_proof/cli.py index 1d09645..64516a5 100644 --- a/src/pytest_gpu_proof/cli.py +++ b/src/pytest_gpu_proof/cli.py @@ -72,8 +72,53 @@ def main(): "require_gpu = true in [tool.gpu_proof].", ) + # merge + mp = subparsers.add_parser( + "merge", + help="Merge shard receipts from ONE commit into a single re-signed receipt", + description=( + "Union the tests of N shard receipts (same commit SHA, fingerprint, " + "and environment) into one receipt, re-signed with your local SSH " + "key, that flows through `gpu-proof verify` unchanged. Shards that " + "disagree on anything a receipt pins are refused; duplicate node " + "IDs across shards are always an error." + ), + ) + mp.add_argument("shards", nargs="+", metavar="RECEIPT", + help="Shard receipt paths (two or more, typically)") + mp.add_argument("--out", required=True, metavar="PATH", + help="Path for the merged receipt") + mp.add_argument("--github-user", default=None, metavar="USERNAME", + help="Recorded signer identity for the merged receipt " + "(default: the first shard's repo.github_username)") + mp.add_argument("--key", default=None, metavar="PATH", + help="SSH private key to sign with (default: git " + "user.signingKey, then ~/.ssh/id_ed25519 etc.)") + mp.add_argument("--unsigned", action="store_true", default=False, + help="Write signature: null — the merged receipt then " + "verifies only with --allow-unsigned, loudly") + args = parser.parse_args() + if args.command == "merge": + from .merge import MergeError, merge_receipts + + try: + receipt = merge_receipts( + args.shards, args.out, + github_user=args.github_user, + key_path=args.key, + unsigned=args.unsigned, + ) + except MergeError as e: + print(f"gpu-proof merge: {e}", file=sys.stderr) + sys.exit(1) + n = len(receipt.get("tests", [])) + shards = len(receipt.get("session", {}).get("shards", [])) + print(f"merged {shards} shard(s), {n} tests -> {args.out}" + + (" (UNSIGNED)" if args.unsigned else "")) + sys.exit(0) + if args.command == "verify": from .verify import verify_receipt diff --git a/src/pytest_gpu_proof/merge.py b/src/pytest_gpu_proof/merge.py new file mode 100644 index 0000000..b2cf75a --- /dev/null +++ b/src/pytest_gpu_proof/merge.py @@ -0,0 +1,161 @@ +""" +Merge shard receipts from one commit into a single re-signed receipt. + +Motivation: large suites run their GPU tests as several pytest invocations +(per-module crash isolation, machine sharding). Each invocation emits its own +receipt; CI wants ONE artifact to verify. ``gpu-proof merge`` unions the shard +receipts' ``tests`` and re-signs the result with the merger's local SSH key — +so the merged receipt flows through the existing ``gpu-proof verify`` path +completely unchanged (schema_version stays "1"; the only addition is the +OPTIONAL ``session.shards`` provenance list, which the verifier ignores). + +Trust model (consistent with docs/security_model.md): the merged receipt is an +attestation by the MERGER — shard signatures are recorded as provenance but are +NOT verified here (merge is offline by design); the merged receipt's signature +is what CI verifies. Merging refuses to combine shards that disagree on +anything a receipt pins: schema, commit SHA, fingerprint, mode, or the +environment the tests ran under. Duplicate node IDs are always an error — two +shards attesting the same test is a sharding bug, not a merge input. +""" + +import json +import os +from typing import List, Optional + +from .receipt import finalize_receipt, write_receipt + + +class MergeError(ValueError): + """A refusal to merge, with an actionable message.""" + + +def load_receipt(path: str) -> dict: + try: + with open(path) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as e: + raise MergeError(f"{path}: not a readable receipt ({e})") from e + if not isinstance(data, dict) or "tests" not in data: + raise MergeError(f"{path}: not a gpu-proof receipt (no 'tests' key)") + return data + + +def _require_identical(receipts: List[dict], sources: List[str], getter, what: str): + values = [getter(r) for r in receipts] + first = values[0] + for src, val in zip(sources[1:], values[1:]): + if val != first: + raise MergeError( + f"shards disagree on {what}: {sources[0]}={first!r} vs {src}={val!r} — " + f"a merged receipt must come from ONE commit/config; re-run the " + f"divergent shard." + ) + return first + + +def merge_payloads(receipts: List[dict], sources: List[str]) -> dict: + """Union shard payloads into one UNSIGNED payload. Raises MergeError on any + disagreement over what a receipt pins.""" + if len(receipts) < 1: + raise MergeError("nothing to merge") + + _require_identical(receipts, sources, lambda r: r.get("schema_version"), "schema_version") + if receipts[0].get("schema_version") != "1": + raise MergeError( + f"unsupported schema_version {receipts[0].get('schema_version')!r} " + f"(this version merges schema '1' receipts)" + ) + _require_identical(receipts, sources, lambda r: r.get("repo", {}).get("commit_sha"), + "repo.commit_sha") + _require_identical(receipts, sources, lambda r: r.get("fingerprint"), "fingerprint") + _require_identical(receipts, sources, lambda r: r.get("mode"), "mode") + for key in ("python_version", "platform", "pytest_version", "plugin_version"): + _require_identical(receipts, sources, + lambda r, k=key: r.get("environment", {}).get(k), + f"environment.{key}") + + # tests: union; duplicate node ids are a sharding bug, never silently deduped. + tests: List[dict] = [] + seen: dict = {} + for src, r in zip(sources, receipts): + for t in r.get("tests", []): + nid = t.get("node_id") + if nid in seen: + raise MergeError( + f"duplicate node_id {nid!r} in {src} (already attested by " + f"{seen[nid]}) — shards must partition the suite." + ) + seen[nid] = src + tests.append(t) + if not tests: + raise MergeError("merged receipt would contain zero tests") + + sessions = [r.get("session", {}) for r in receipts] + started = min(s.get("started_at") for s in sessions) + ended = max(s.get("ended_at") for s in sessions) + + merged = dict(receipts[0]) + merged.pop("signature", None) + # repo: the commit SHA is asserted identical above; branch/remote come from + # the first shard. `dirty` is OR-ed — one dirty shard makes the merged + # attestation dirty, and the verifier's dirty policy then applies honestly. + merged["repo"] = dict(receipts[0].get("repo", {})) + merged["repo"]["dirty"] = any(r.get("repo", {}).get("dirty") for r in receipts) + # environment: fields asserted identical above; gpu_info from the first + # shard that has one (a CPU-only shard shouldn't erase the GPU record). + merged["environment"] = dict(receipts[0].get("environment", {})) + merged["environment"]["gpu_info"] = next( + (r["environment"]["gpu_info"] for r in receipts + if r.get("environment", {}).get("gpu_info")), None) + merged["tests"] = tests + merged["session"] = { + "started_at": started, + "ended_at": ended, + "node_ids": [t["node_id"] for t in tests], + # Provenance (additive; the verifier ignores unknown session keys). + # Shard signers are recorded, not verified — the merged signature is + # the attestation CI checks. + "shards": [ + { + "source": os.path.basename(src), + "node_count": len(r.get("tests", [])), + "started_at": s.get("started_at"), + "ended_at": s.get("ended_at"), + "signer": (r.get("signature") or {}).get("signer"), + } + for src, r, s in zip(sources, receipts, sessions) + ], + } + return merged + + +def merge_receipts( + paths: List[str], + out: str, + *, + github_user: Optional[str] = None, + key_path: Optional[str] = None, + unsigned: bool = False, +) -> dict: + """Merge receipts at ``paths`` and write the re-signed result to ``out``. + + ``github_user`` overrides the recorded signer identity (default: the first + shard's ``repo.github_username`` — correct when the merger is also the shard + runner). ``unsigned=True`` writes ``signature: null`` (verifies only with + ``--allow-unsigned``, loudly, same as the plugin's 'none' backend). + """ + receipts = [load_receipt(p) for p in paths] + payload = merge_payloads(receipts, list(paths)) + if github_user: + payload["repo"] = dict(payload.get("repo", {})) + payload["repo"]["github_username"] = github_user + + if unsigned: + receipt = dict(payload) + receipt["signature"] = None + else: + from .signers.ed25519 import SSHSigner + signer = SSHSigner(key_path=key_path) + receipt = finalize_receipt(payload, signer) + write_receipt(receipt, out) + return receipt diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 0000000..cf8fd5d --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,163 @@ +"""gpu-proof merge: shard-union semantics, refusal matrix, and end-to-end +verification of a merged receipt.""" + +import datetime +import json +import os +from unittest.mock import patch + +import pytest +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, +) + +from pytest_gpu_proof.config import GpuProofConfig +from pytest_gpu_proof.merge import MergeError, merge_payloads, merge_receipts +from pytest_gpu_proof.receipt import build_receipt_payload, finalize_receipt, write_receipt +from pytest_gpu_proof.signers.ed25519 import SSHSigner, _verify_with_key +from pytest_gpu_proof.verify import verify_receipt + +@pytest.fixture +def signer_with_key(tmp_path, ed25519_keypair): + private_key, public_key = ed25519_keypair + key_path = tmp_path / "id_ed25519" + key_path.write_bytes( + private_key.private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption()) + ) + return SSHSigner(key_path=str(key_path)), public_key, str(key_path) + + +def _utcstamp(minutes_ago: int = 0) -> str: + ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=minutes_ago) + return ts.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _result(node_id, outcome="passed"): + return {"node_id": node_id, "outcome": outcome, "duration_s": 0.01, "checks": []} + + +def _shard(tmp_path, tmp_git_repo, signer, name, results, *, + started_ago=2, ended_ago=1, mutate=None, sign=True): + os.chdir(tmp_git_repo) + config = GpuProofConfig(enabled=True, fingerprint_paths=["src", "tests"]) + payload = build_receipt_payload( + config, results, _utcstamp(started_ago), _utcstamp(ended_ago)) + if mutate is not None: + mutate(payload) + if sign: + receipt = finalize_receipt(payload, signer) + else: + receipt = dict(payload) + receipt["signature"] = None + path = tmp_path / name + write_receipt(receipt, str(path)) + return path + + +def _mock_github_keys(public_key): + def _fake_verify(data, signature, username): + return _verify_with_key(public_key, signature, data) + return patch("pytest_gpu_proof.verify.verify_with_github_keys", + side_effect=_fake_verify) + + +def test_happy_merge_verifies(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key, key_path = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", + [_result("tests/test_add.py::test_add")], started_ago=10, ended_ago=8) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", + [_result("tests/test_add.py::test_sub")], started_ago=5, ended_ago=3) + out = tmp_path / "merged.json" + merged = merge_receipts([str(a), str(b)], str(out), key_path=key_path) + + assert [t["node_id"] for t in merged["tests"]] == [ + "tests/test_add.py::test_add", "tests/test_add.py::test_sub"] + sess = merged["session"] + # span = earliest start .. latest end across shards + assert sess["started_at"] == json.loads(a.read_text())["session"]["started_at"] + assert sess["ended_at"] == json.loads(b.read_text())["session"]["ended_at"] + assert [s["source"] for s in sess["shards"]] == ["a.json", "b.json"] + assert all(s["signer"] for s in sess["shards"]) + + # the merged receipt goes through the UNCHANGED verifier + os.chdir(tmp_git_repo) + with _mock_github_keys(public_key): + assert verify_receipt(receipt_path=str(out), policy_path=None, + repo_root=str(tmp_git_repo), + github_user_override="testuser", + max_age_days=None) + + +def test_refuses_commit_sha_mismatch(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", + [_result("t::a")]) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", + [_result("t::b")], + mutate=lambda p: p["repo"].__setitem__("commit_sha", "deadbeef")) + with pytest.raises(MergeError, match="commit_sha"): + merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], + ["a.json", "b.json"]) + + +def test_refuses_fingerprint_mismatch(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")]) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")], + mutate=lambda p: p["fingerprint"].__setitem__("digest", "0" * 64)) + with pytest.raises(MergeError, match="fingerprint"): + merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], + ["a.json", "b.json"]) + + +def test_refuses_duplicate_node_id(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::same")]) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::same")]) + with pytest.raises(MergeError, match="duplicate node_id"): + merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], + ["a.json", "b.json"]) + + +def test_refuses_environment_mismatch(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")]) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")], + mutate=lambda p: p["environment"].__setitem__("pytest_version", "0.0")) + with pytest.raises(MergeError, match="environment.pytest_version"): + merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], + ["a.json", "b.json"]) + + +def test_unsigned_shards_merge_and_dirty_ors(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")], sign=False) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")], sign=False, + mutate=lambda p: p["repo"].__setitem__("dirty", True)) + merged = merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], + ["a.json", "b.json"]) + assert merged["repo"]["dirty"] is True + assert merged["session"]["shards"][0]["signer"] is None + + +def test_merged_unsigned_flag(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")]) + out = tmp_path / "merged.json" + merged = merge_receipts([str(a)], str(out), unsigned=True) + assert merged["signature"] is None + assert json.loads(out.read_text())["signature"] is None + + +def test_gpu_info_survives_cpu_only_shard(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + gpu = {"name": "FakeGPU", "driver_version": "1", "memory": "1 MiB"} + a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")], + mutate=lambda p: p["environment"].__setitem__("gpu_info", None)) + b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")], + mutate=lambda p: p["environment"].__setitem__("gpu_info", gpu)) + merged = merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], + ["a.json", "b.json"]) + assert merged["environment"]["gpu_info"] == gpu From 8ced75a777b29e6db13c1f78165f7bc7d83de366 Mon Sep 17 00:00:00 2001 From: plancher Date: Fri, 7 Aug 2026 00:45:19 -0400 Subject: [PATCH 2/3] config: tri-state CLI defaults so an explicit flag equal to the built-in default beats toml --- CHANGELOG.md | 6 ++++++ src/pytest_gpu_proof/config.py | 16 +++++++++++----- src/pytest_gpu_proof/plugin.py | 10 +++++----- tests/test_plugin_capture.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eabec1..876853d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ based on [Keep a Changelog](https://keepachangelog.com/); versions follow unchanged). `repo.dirty` is OR-ed; `gpu_info` survives CPU-only shards. Docs: `docs/sharding.md`. +### Fixed +- Explicit CLI values equal to their built-in defaults are no longer silently + ignored in favor of `[tool.gpu_proof]` (value-taking options now register a + `None` sentinel; `--gpu-proof-fail-on-skip` ORs with the toml value since a + store_true flag can only turn it on). + ## [0.1.0] — 2026-07-07 First public release. diff --git a/src/pytest_gpu_proof/config.py b/src/pytest_gpu_proof/config.py index 324cabd..27ed573 100644 --- a/src/pytest_gpu_proof/config.py +++ b/src/pytest_gpu_proof/config.py @@ -47,10 +47,13 @@ def opt(name, default=None): return 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: + """Precedence: CLI flag (when explicitly passed), then [tool.gpu_proof] + in pyproject.toml, then the built-in default. Value-taking options + register with ``default=None`` so an explicit CLI value that happens to + equal the built-in default still wins — previously it was silently + ignored in favor of the toml value.""" + cli = opt(opt_name, None) + if cli is not None: return cli toml_val = toml_cfg.get(toml_key) if toml_val is not None: @@ -74,7 +77,10 @@ def resolve(opt_name, toml_key, default): 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)), + # store_true flag: False just means "not passed", so OR with the toml + # value rather than sentinel-resolving (a CLI flag can only turn it ON). + fail_on_skip=bool(opt("--gpu-proof-fail-on-skip", False) + or toml_cfg.get("fail_on_skip", False)), fingerprint_paths=paths, github_username=resolve("--gpu-proof-github-user", "github_username", None), max_age_days=max_age_days, diff --git a/src/pytest_gpu_proof/plugin.py b/src/pytest_gpu_proof/plugin.py index 051a11b..43b21fe 100644 --- a/src/pytest_gpu_proof/plugin.py +++ b/src/pytest_gpu_proof/plugin.py @@ -201,13 +201,13 @@ def pytest_addoption(parser): ) group.addoption( "--gpu-proof-mode", - default="local", + default=None, choices=["local", "ci-gpu"], help="Execution mode: local (default) or ci-gpu", ) group.addoption( "--gpu-proof-out", - default="gpu-proof.json", + default=None, metavar="PATH", help="Output path for the receipt JSON (default: gpu-proof.json)", ) @@ -219,7 +219,7 @@ def pytest_addoption(parser): ) group.addoption( "--gpu-proof-signing-backend", - default="ed25519", + default=None, choices=["ed25519", "none"], help="Signing backend (default: ed25519 via SSH key)", ) @@ -231,7 +231,7 @@ def pytest_addoption(parser): ) group.addoption( "--gpu-proof-required-marker", - default="gpu_proof", + default=None, help="Marker name that flags a test for the receipt (default: gpu_proof)", ) group.addoption( @@ -242,7 +242,7 @@ def pytest_addoption(parser): ) group.addoption( "--gpu-proof-fingerprint-paths", - default="src,tests", + default=None, metavar="PATHS", help="Comma-separated paths to fingerprint (default: src,tests)", ) diff --git a/tests/test_plugin_capture.py b/tests/test_plugin_capture.py index a050705..9c85144 100644 --- a/tests/test_plugin_capture.py +++ b/tests/test_plugin_capture.py @@ -274,3 +274,34 @@ def test_ok(): result.assert_outcomes(passed=1) assert (pytester.path / "cli-receipt.json").exists() assert not (pytester.path / "toml-receipt.json").exists() + + +def test_cli_equal_to_builtin_default_still_overrides_toml(pytester): + """Tri-state regression (0.2.0): an EXPLICIT CLI value that happens to equal + the built-in default must beat [tool.gpu_proof] — previously it was + silently ignored because "set" was detected by comparing against the + built-in default. --gpu-proof-out=gpu-proof.json IS the built-in default.""" + 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=gpu-proof.json" + ) + result.assert_outcomes(passed=1) + assert (pytester.path / "gpu-proof.json").exists() + assert not (pytester.path / "toml-receipt.json").exists() From ba4d84c4d11681c4fe31247363c348a0e5da3f04 Mon Sep 17 00:00:00 2001 From: plancher Date: Fri, 7 Aug 2026 12:33:26 -0400 Subject: [PATCH 3/3] stamp 0.2.0 release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 876853d..572d225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to pytest-gpu-proof are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/) (pre-1.0: minor bumps may break). -## [0.2.0] — unreleased +## [0.2.0] — 2026-08-07 ### Added - `gpu-proof merge`: union N shard receipts from one commit into a single