pytest-gpu-proof 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/CHANGELOG.md +17 -0
  2. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/PKG-INFO +1 -1
  3. pytest_gpu_proof-0.2.0/docs/sharding.md +62 -0
  4. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/mkdocs.yml +1 -0
  5. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/pyproject.toml +1 -1
  6. pytest_gpu_proof-0.2.0/src/pytest_gpu_proof/__init__.py +1 -0
  7. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/cli.py +45 -0
  8. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/config.py +11 -5
  9. pytest_gpu_proof-0.2.0/src/pytest_gpu_proof/merge.py +161 -0
  10. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/plugin.py +5 -5
  11. pytest_gpu_proof-0.2.0/tests/test_merge.py +163 -0
  12. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/test_plugin_capture.py +31 -0
  13. pytest_gpu_proof-0.1.0/src/pytest_gpu_proof/__init__.py +0 -1
  14. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/.github/workflows/docs.yml +0 -0
  16. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/.github/workflows/publish-testpypi.yml +0 -0
  17. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/.github/workflows/publish.yml +0 -0
  18. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/.gitignore +0 -0
  19. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/CLAUDE.md +0 -0
  20. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/LICENSE +0 -0
  21. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/README.md +0 -0
  22. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/RELEASING.md +0 -0
  23. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/ROADMAP.md +0 -0
  24. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/docs/architecture.md +0 -0
  25. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/docs/ci_gpu_mode.md +0 -0
  26. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/docs/index.md +0 -0
  27. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/docs/landscape.md +0 -0
  28. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/docs/local_mode.md +0 -0
  29. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/docs/quickstart.md +0 -0
  30. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/docs/security_model.md +0 -0
  31. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_ctypes_matmul/Makefile +0 -0
  32. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_ctypes_matmul/README.md +0 -0
  33. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_ctypes_matmul/cuda_ctypes_matmul.py +0 -0
  34. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_ctypes_matmul/matmul_kernel.cu +0 -0
  35. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_ctypes_matmul/test_cuda_ctypes_matmul.py +0 -0
  36. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_nanobind_matmul/CMakeLists.txt +0 -0
  37. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_nanobind_matmul/README.md +0 -0
  38. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_nanobind_matmul/src/bindings.cpp +0 -0
  39. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_nanobind_matmul/src/matmul_cuda.h +0 -0
  40. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_nanobind_matmul/src/matmul_kernel.cu +0 -0
  41. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_nanobind_matmul/test_cuda_nanobind_matmul.py +0 -0
  42. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_pybind11_matmul/CMakeLists.txt +0 -0
  43. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_pybind11_matmul/README.md +0 -0
  44. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_pybind11_matmul/src/bindings.cpp +0 -0
  45. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_pybind11_matmul/src/matmul_cuda.h +0 -0
  46. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_pybind11_matmul/src/matmul_kernel.cu +0 -0
  47. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/cuda_pybind11_matmul/test_cuda_pybind11_matmul.py +0 -0
  48. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/github_gpu_runner/.github/workflows/gpu-test.yml +0 -0
  49. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/jax_ffi_cuda_matmul/CMakeLists.txt +0 -0
  50. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/jax_ffi_cuda_matmul/README.md +0 -0
  51. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/jax_ffi_cuda_matmul/jax_ffi_matmul.py +0 -0
  52. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/jax_ffi_cuda_matmul/src/matmul_ffi.cu +0 -0
  53. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/jax_ffi_cuda_matmul/test_jax_ffi_cuda_matmul.py +0 -0
  54. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/local_receipt_verify/.github/workflows/verify.yml +0 -0
  55. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/minimal_python_only/conftest.py +0 -0
  56. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/minimal_python_only/test_minimal.py +0 -0
  57. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/wrapped_cuda_ctypes/fake_cuda_lib.py +0 -0
  58. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/examples/wrapped_cuda_ctypes/test_cuda_wrapper.py +0 -0
  59. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/install.sh +0 -0
  60. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/requirements-dev.txt +0 -0
  61. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/requirements.txt +0 -0
  62. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/__main__.py +0 -0
  63. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/compare.py +0 -0
  64. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/fingerprint.py +0 -0
  65. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/gitutils.py +0 -0
  66. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/receipt.py +0 -0
  67. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/signers/__init__.py +0 -0
  68. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/signers/base.py +0 -0
  69. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/signers/ed25519.py +0 -0
  70. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/src/pytest_gpu_proof/verify.py +0 -0
  71. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/conftest.py +0 -0
  72. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/test_compare.py +0 -0
  73. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/test_fingerprint.py +0 -0
  74. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/test_gitutils.py +0 -0
  75. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/test_receipt.py +0 -0
  76. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/test_signing.py +0 -0
  77. {pytest_gpu_proof-0.1.0 → pytest_gpu_proof-0.2.0}/tests/test_verifier.py +0 -0
@@ -4,6 +4,23 @@ All notable changes to pytest-gpu-proof are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); versions follow
5
5
  [SemVer](https://semver.org/) (pre-1.0: minor bumps may break).
6
6
 
7
+ ## [0.2.0] — 2026-08-07
8
+
9
+ ### Added
10
+ - `gpu-proof merge`: union N shard receipts from one commit into a single
11
+ re-signed receipt (per-module crash isolation / machine sharding). Refuses
12
+ shards that disagree on schema, commit SHA, fingerprint, mode, or
13
+ environment; duplicate node IDs across shards are a hard error. Records
14
+ per-shard provenance under `session.shards` (additive — the verifier is
15
+ unchanged). `repo.dirty` is OR-ed; `gpu_info` survives CPU-only shards.
16
+ Docs: `docs/sharding.md`.
17
+
18
+ ### Fixed
19
+ - Explicit CLI values equal to their built-in defaults are no longer silently
20
+ ignored in favor of `[tool.gpu_proof]` (value-taking options now register a
21
+ `None` sentinel; `--gpu-proof-fail-on-skip` ORs with the toml value since a
22
+ store_true flag can only turn it on).
23
+
7
24
  ## [0.1.0] — 2026-07-07
8
25
 
9
26
  First public release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-gpu-proof
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys
5
5
  Project-URL: Homepage, https://github.com/A2R-Lab/pytest-gpu-proof
6
6
  Project-URL: Documentation, https://a2r-lab.github.io/pytest-gpu-proof/
@@ -0,0 +1,62 @@
1
+ # Sharded runs & merging receipts
2
+
3
+ Large suites often can't (or shouldn't) run as one pytest session: per-module
4
+ subprocesses give crash isolation (one CUDA abort no longer erases the whole
5
+ run's results), and big projects split GPU tests across invocations or
6
+ machines. Each invocation emits its own receipt; CI wants **one** artifact.
7
+
8
+ ## Emit one receipt per shard
9
+
10
+ Point each invocation at its own output path:
11
+
12
+ ```bash
13
+ pytest tests/gpu/test_a.py --gpu-proof-enable --gpu-proof-out=receipts/a.json
14
+ pytest tests/gpu/test_b.py --gpu-proof-enable --gpu-proof-out=receipts/b.json
15
+ ```
16
+
17
+ Every shard receipt is a complete, individually verifiable receipt.
18
+
19
+ ## Merge
20
+
21
+ ```bash
22
+ gpu-proof merge --out gpu-proof.json receipts/a.json receipts/b.json
23
+ ```
24
+
25
+ `merge` unions the shards' `tests`, spans `session.started_at`/`ended_at`
26
+ across them, records per-shard provenance under `session.shards`
27
+ (`source`, `node_count`, timestamps, and each shard's recorded signer), and
28
+ **re-signs the merged payload with your local SSH key**. The result flows
29
+ through `gpu-proof verify` completely unchanged — same schema, same seven
30
+ checks.
31
+
32
+ Options:
33
+
34
+ - `--github-user USERNAME` — recorded signer identity for the merged receipt
35
+ (default: the first shard's `repo.github_username`).
36
+ - `--key PATH` — SSH private key (default: `git config user.signingKey`, then
37
+ `~/.ssh/id_ed25519` / `id_ecdsa` / `id_rsa`).
38
+ - `--unsigned` — write `signature: null`; verifies only with
39
+ `--allow-unsigned`, loudly.
40
+
41
+ ## What merge refuses
42
+
43
+ A merged receipt must mean exactly what a single-session receipt means, so
44
+ `merge` hard-refuses shards that disagree on anything a receipt pins:
45
+
46
+ - `schema_version`, `repo.commit_sha`, `fingerprint` (digest + paths),
47
+ `mode`, or the `environment` the tests ran under (python/pytest/plugin
48
+ versions, platform);
49
+ - **duplicate node IDs across shards** — two shards attesting the same test is
50
+ a sharding bug in the runner, never something to dedupe silently.
51
+
52
+ `repo.dirty` is OR-ed: one dirty shard makes the merged attestation dirty, and
53
+ your verify-time dirty policy applies honestly. `gpu_info` is taken from the
54
+ first shard that has one, so a CPU-only shard doesn't erase the GPU record.
55
+
56
+ ## Trust model
57
+
58
+ Consistent with the [security model](security_model.md): the merged receipt is
59
+ an **attestation by the merger**. Shard signatures are recorded as provenance
60
+ but not re-verified at merge time (merging is offline); the merged signature
61
+ is what CI verifies. If shards were signed by someone else, verification of
62
+ the merged receipt attests that *you* vouch for the union.
@@ -33,6 +33,7 @@ nav:
33
33
  - Quickstart: quickstart.md
34
34
  - Local Mode: local_mode.md
35
35
  - CI-GPU Mode: ci_gpu_mode.md
36
+ - Sharding & Merge: sharding.md
36
37
  - Architecture: architecture.md
37
38
  - Security Model: security_model.md
38
39
  - Landscape: landscape.md
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-gpu-proof"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -72,8 +72,53 @@ def main():
72
72
  "require_gpu = true in [tool.gpu_proof].",
73
73
  )
74
74
 
75
+ # merge
76
+ mp = subparsers.add_parser(
77
+ "merge",
78
+ help="Merge shard receipts from ONE commit into a single re-signed receipt",
79
+ description=(
80
+ "Union the tests of N shard receipts (same commit SHA, fingerprint, "
81
+ "and environment) into one receipt, re-signed with your local SSH "
82
+ "key, that flows through `gpu-proof verify` unchanged. Shards that "
83
+ "disagree on anything a receipt pins are refused; duplicate node "
84
+ "IDs across shards are always an error."
85
+ ),
86
+ )
87
+ mp.add_argument("shards", nargs="+", metavar="RECEIPT",
88
+ help="Shard receipt paths (two or more, typically)")
89
+ mp.add_argument("--out", required=True, metavar="PATH",
90
+ help="Path for the merged receipt")
91
+ mp.add_argument("--github-user", default=None, metavar="USERNAME",
92
+ help="Recorded signer identity for the merged receipt "
93
+ "(default: the first shard's repo.github_username)")
94
+ mp.add_argument("--key", default=None, metavar="PATH",
95
+ help="SSH private key to sign with (default: git "
96
+ "user.signingKey, then ~/.ssh/id_ed25519 etc.)")
97
+ mp.add_argument("--unsigned", action="store_true", default=False,
98
+ help="Write signature: null — the merged receipt then "
99
+ "verifies only with --allow-unsigned, loudly")
100
+
75
101
  args = parser.parse_args()
76
102
 
103
+ if args.command == "merge":
104
+ from .merge import MergeError, merge_receipts
105
+
106
+ try:
107
+ receipt = merge_receipts(
108
+ args.shards, args.out,
109
+ github_user=args.github_user,
110
+ key_path=args.key,
111
+ unsigned=args.unsigned,
112
+ )
113
+ except MergeError as e:
114
+ print(f"gpu-proof merge: {e}", file=sys.stderr)
115
+ sys.exit(1)
116
+ n = len(receipt.get("tests", []))
117
+ shards = len(receipt.get("session", {}).get("shards", []))
118
+ print(f"merged {shards} shard(s), {n} tests -> {args.out}"
119
+ + (" (UNSIGNED)" if args.unsigned else ""))
120
+ sys.exit(0)
121
+
77
122
  if args.command == "verify":
78
123
  from .verify import verify_receipt
79
124
 
@@ -47,10 +47,13 @@ def load_config(pytest_config) -> GpuProofConfig:
47
47
  return default
48
48
 
49
49
  def resolve(opt_name, toml_key, default):
50
- """Precedence: CLI flag (when it differs from its built-in default),
51
- then [tool.gpu_proof] in pyproject.toml, then the built-in default."""
52
- cli = opt(opt_name, default)
53
- if cli != default:
50
+ """Precedence: CLI flag (when explicitly passed), then [tool.gpu_proof]
51
+ in pyproject.toml, then the built-in default. Value-taking options
52
+ register with ``default=None`` so an explicit CLI value that happens to
53
+ equal the built-in default still wins — previously it was silently
54
+ ignored in favor of the toml value."""
55
+ cli = opt(opt_name, None)
56
+ if cli is not None:
54
57
  return cli
55
58
  toml_val = toml_cfg.get(toml_key)
56
59
  if toml_val is not None:
@@ -74,7 +77,10 @@ def load_config(pytest_config) -> GpuProofConfig:
74
77
  signing_backend=resolve("--gpu-proof-signing-backend", "signing_backend", "ed25519"),
75
78
  policy_path=resolve("--gpu-proof-policy", "policy_path", None),
76
79
  required_marker=resolve("--gpu-proof-required-marker", "required_marker", "gpu_proof"),
77
- fail_on_skip=bool(resolve("--gpu-proof-fail-on-skip", "fail_on_skip", False)),
80
+ # store_true flag: False just means "not passed", so OR with the toml
81
+ # value rather than sentinel-resolving (a CLI flag can only turn it ON).
82
+ fail_on_skip=bool(opt("--gpu-proof-fail-on-skip", False)
83
+ or toml_cfg.get("fail_on_skip", False)),
78
84
  fingerprint_paths=paths,
79
85
  github_username=resolve("--gpu-proof-github-user", "github_username", None),
80
86
  max_age_days=max_age_days,
@@ -0,0 +1,161 @@
1
+ """
2
+ Merge shard receipts from one commit into a single re-signed receipt.
3
+
4
+ Motivation: large suites run their GPU tests as several pytest invocations
5
+ (per-module crash isolation, machine sharding). Each invocation emits its own
6
+ receipt; CI wants ONE artifact to verify. ``gpu-proof merge`` unions the shard
7
+ receipts' ``tests`` and re-signs the result with the merger's local SSH key —
8
+ so the merged receipt flows through the existing ``gpu-proof verify`` path
9
+ completely unchanged (schema_version stays "1"; the only addition is the
10
+ OPTIONAL ``session.shards`` provenance list, which the verifier ignores).
11
+
12
+ Trust model (consistent with docs/security_model.md): the merged receipt is an
13
+ attestation by the MERGER — shard signatures are recorded as provenance but are
14
+ NOT verified here (merge is offline by design); the merged receipt's signature
15
+ is what CI verifies. Merging refuses to combine shards that disagree on
16
+ anything a receipt pins: schema, commit SHA, fingerprint, mode, or the
17
+ environment the tests ran under. Duplicate node IDs are always an error — two
18
+ shards attesting the same test is a sharding bug, not a merge input.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ from typing import List, Optional
24
+
25
+ from .receipt import finalize_receipt, write_receipt
26
+
27
+
28
+ class MergeError(ValueError):
29
+ """A refusal to merge, with an actionable message."""
30
+
31
+
32
+ def load_receipt(path: str) -> dict:
33
+ try:
34
+ with open(path) as f:
35
+ data = json.load(f)
36
+ except (OSError, json.JSONDecodeError) as e:
37
+ raise MergeError(f"{path}: not a readable receipt ({e})") from e
38
+ if not isinstance(data, dict) or "tests" not in data:
39
+ raise MergeError(f"{path}: not a gpu-proof receipt (no 'tests' key)")
40
+ return data
41
+
42
+
43
+ def _require_identical(receipts: List[dict], sources: List[str], getter, what: str):
44
+ values = [getter(r) for r in receipts]
45
+ first = values[0]
46
+ for src, val in zip(sources[1:], values[1:]):
47
+ if val != first:
48
+ raise MergeError(
49
+ f"shards disagree on {what}: {sources[0]}={first!r} vs {src}={val!r} — "
50
+ f"a merged receipt must come from ONE commit/config; re-run the "
51
+ f"divergent shard."
52
+ )
53
+ return first
54
+
55
+
56
+ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict:
57
+ """Union shard payloads into one UNSIGNED payload. Raises MergeError on any
58
+ disagreement over what a receipt pins."""
59
+ if len(receipts) < 1:
60
+ raise MergeError("nothing to merge")
61
+
62
+ _require_identical(receipts, sources, lambda r: r.get("schema_version"), "schema_version")
63
+ if receipts[0].get("schema_version") != "1":
64
+ raise MergeError(
65
+ f"unsupported schema_version {receipts[0].get('schema_version')!r} "
66
+ f"(this version merges schema '1' receipts)"
67
+ )
68
+ _require_identical(receipts, sources, lambda r: r.get("repo", {}).get("commit_sha"),
69
+ "repo.commit_sha")
70
+ _require_identical(receipts, sources, lambda r: r.get("fingerprint"), "fingerprint")
71
+ _require_identical(receipts, sources, lambda r: r.get("mode"), "mode")
72
+ for key in ("python_version", "platform", "pytest_version", "plugin_version"):
73
+ _require_identical(receipts, sources,
74
+ lambda r, k=key: r.get("environment", {}).get(k),
75
+ f"environment.{key}")
76
+
77
+ # tests: union; duplicate node ids are a sharding bug, never silently deduped.
78
+ tests: List[dict] = []
79
+ seen: dict = {}
80
+ for src, r in zip(sources, receipts):
81
+ for t in r.get("tests", []):
82
+ nid = t.get("node_id")
83
+ if nid in seen:
84
+ raise MergeError(
85
+ f"duplicate node_id {nid!r} in {src} (already attested by "
86
+ f"{seen[nid]}) — shards must partition the suite."
87
+ )
88
+ seen[nid] = src
89
+ tests.append(t)
90
+ if not tests:
91
+ raise MergeError("merged receipt would contain zero tests")
92
+
93
+ sessions = [r.get("session", {}) for r in receipts]
94
+ started = min(s.get("started_at") for s in sessions)
95
+ ended = max(s.get("ended_at") for s in sessions)
96
+
97
+ merged = dict(receipts[0])
98
+ merged.pop("signature", None)
99
+ # repo: the commit SHA is asserted identical above; branch/remote come from
100
+ # the first shard. `dirty` is OR-ed — one dirty shard makes the merged
101
+ # attestation dirty, and the verifier's dirty policy then applies honestly.
102
+ merged["repo"] = dict(receipts[0].get("repo", {}))
103
+ merged["repo"]["dirty"] = any(r.get("repo", {}).get("dirty") for r in receipts)
104
+ # environment: fields asserted identical above; gpu_info from the first
105
+ # shard that has one (a CPU-only shard shouldn't erase the GPU record).
106
+ merged["environment"] = dict(receipts[0].get("environment", {}))
107
+ merged["environment"]["gpu_info"] = next(
108
+ (r["environment"]["gpu_info"] for r in receipts
109
+ if r.get("environment", {}).get("gpu_info")), None)
110
+ merged["tests"] = tests
111
+ merged["session"] = {
112
+ "started_at": started,
113
+ "ended_at": ended,
114
+ "node_ids": [t["node_id"] for t in tests],
115
+ # Provenance (additive; the verifier ignores unknown session keys).
116
+ # Shard signers are recorded, not verified — the merged signature is
117
+ # the attestation CI checks.
118
+ "shards": [
119
+ {
120
+ "source": os.path.basename(src),
121
+ "node_count": len(r.get("tests", [])),
122
+ "started_at": s.get("started_at"),
123
+ "ended_at": s.get("ended_at"),
124
+ "signer": (r.get("signature") or {}).get("signer"),
125
+ }
126
+ for src, r, s in zip(sources, receipts, sessions)
127
+ ],
128
+ }
129
+ return merged
130
+
131
+
132
+ def merge_receipts(
133
+ paths: List[str],
134
+ out: str,
135
+ *,
136
+ github_user: Optional[str] = None,
137
+ key_path: Optional[str] = None,
138
+ unsigned: bool = False,
139
+ ) -> dict:
140
+ """Merge receipts at ``paths`` and write the re-signed result to ``out``.
141
+
142
+ ``github_user`` overrides the recorded signer identity (default: the first
143
+ shard's ``repo.github_username`` — correct when the merger is also the shard
144
+ runner). ``unsigned=True`` writes ``signature: null`` (verifies only with
145
+ ``--allow-unsigned``, loudly, same as the plugin's 'none' backend).
146
+ """
147
+ receipts = [load_receipt(p) for p in paths]
148
+ payload = merge_payloads(receipts, list(paths))
149
+ if github_user:
150
+ payload["repo"] = dict(payload.get("repo", {}))
151
+ payload["repo"]["github_username"] = github_user
152
+
153
+ if unsigned:
154
+ receipt = dict(payload)
155
+ receipt["signature"] = None
156
+ else:
157
+ from .signers.ed25519 import SSHSigner
158
+ signer = SSHSigner(key_path=key_path)
159
+ receipt = finalize_receipt(payload, signer)
160
+ write_receipt(receipt, out)
161
+ return receipt
@@ -201,13 +201,13 @@ def pytest_addoption(parser):
201
201
  )
202
202
  group.addoption(
203
203
  "--gpu-proof-mode",
204
- default="local",
204
+ default=None,
205
205
  choices=["local", "ci-gpu"],
206
206
  help="Execution mode: local (default) or ci-gpu",
207
207
  )
208
208
  group.addoption(
209
209
  "--gpu-proof-out",
210
- default="gpu-proof.json",
210
+ default=None,
211
211
  metavar="PATH",
212
212
  help="Output path for the receipt JSON (default: gpu-proof.json)",
213
213
  )
@@ -219,7 +219,7 @@ def pytest_addoption(parser):
219
219
  )
220
220
  group.addoption(
221
221
  "--gpu-proof-signing-backend",
222
- default="ed25519",
222
+ default=None,
223
223
  choices=["ed25519", "none"],
224
224
  help="Signing backend (default: ed25519 via SSH key)",
225
225
  )
@@ -231,7 +231,7 @@ def pytest_addoption(parser):
231
231
  )
232
232
  group.addoption(
233
233
  "--gpu-proof-required-marker",
234
- default="gpu_proof",
234
+ default=None,
235
235
  help="Marker name that flags a test for the receipt (default: gpu_proof)",
236
236
  )
237
237
  group.addoption(
@@ -242,7 +242,7 @@ def pytest_addoption(parser):
242
242
  )
243
243
  group.addoption(
244
244
  "--gpu-proof-fingerprint-paths",
245
- default="src,tests",
245
+ default=None,
246
246
  metavar="PATHS",
247
247
  help="Comma-separated paths to fingerprint (default: src,tests)",
248
248
  )
@@ -0,0 +1,163 @@
1
+ """gpu-proof merge: shard-union semantics, refusal matrix, and end-to-end
2
+ verification of a merged receipt."""
3
+
4
+ import datetime
5
+ import json
6
+ import os
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+ from cryptography.hazmat.primitives.serialization import (
11
+ Encoding,
12
+ NoEncryption,
13
+ PrivateFormat,
14
+ )
15
+
16
+ from pytest_gpu_proof.config import GpuProofConfig
17
+ from pytest_gpu_proof.merge import MergeError, merge_payloads, merge_receipts
18
+ from pytest_gpu_proof.receipt import build_receipt_payload, finalize_receipt, write_receipt
19
+ from pytest_gpu_proof.signers.ed25519 import SSHSigner, _verify_with_key
20
+ from pytest_gpu_proof.verify import verify_receipt
21
+
22
+ @pytest.fixture
23
+ def signer_with_key(tmp_path, ed25519_keypair):
24
+ private_key, public_key = ed25519_keypair
25
+ key_path = tmp_path / "id_ed25519"
26
+ key_path.write_bytes(
27
+ private_key.private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption())
28
+ )
29
+ return SSHSigner(key_path=str(key_path)), public_key, str(key_path)
30
+
31
+
32
+ def _utcstamp(minutes_ago: int = 0) -> str:
33
+ ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=minutes_ago)
34
+ return ts.strftime("%Y-%m-%dT%H:%M:%SZ")
35
+
36
+
37
+ def _result(node_id, outcome="passed"):
38
+ return {"node_id": node_id, "outcome": outcome, "duration_s": 0.01, "checks": []}
39
+
40
+
41
+ def _shard(tmp_path, tmp_git_repo, signer, name, results, *,
42
+ started_ago=2, ended_ago=1, mutate=None, sign=True):
43
+ os.chdir(tmp_git_repo)
44
+ config = GpuProofConfig(enabled=True, fingerprint_paths=["src", "tests"])
45
+ payload = build_receipt_payload(
46
+ config, results, _utcstamp(started_ago), _utcstamp(ended_ago))
47
+ if mutate is not None:
48
+ mutate(payload)
49
+ if sign:
50
+ receipt = finalize_receipt(payload, signer)
51
+ else:
52
+ receipt = dict(payload)
53
+ receipt["signature"] = None
54
+ path = tmp_path / name
55
+ write_receipt(receipt, str(path))
56
+ return path
57
+
58
+
59
+ def _mock_github_keys(public_key):
60
+ def _fake_verify(data, signature, username):
61
+ return _verify_with_key(public_key, signature, data)
62
+ return patch("pytest_gpu_proof.verify.verify_with_github_keys",
63
+ side_effect=_fake_verify)
64
+
65
+
66
+ def test_happy_merge_verifies(tmp_path, tmp_git_repo, signer_with_key):
67
+ signer, public_key, key_path = signer_with_key
68
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json",
69
+ [_result("tests/test_add.py::test_add")], started_ago=10, ended_ago=8)
70
+ b = _shard(tmp_path, tmp_git_repo, signer, "b.json",
71
+ [_result("tests/test_add.py::test_sub")], started_ago=5, ended_ago=3)
72
+ out = tmp_path / "merged.json"
73
+ merged = merge_receipts([str(a), str(b)], str(out), key_path=key_path)
74
+
75
+ assert [t["node_id"] for t in merged["tests"]] == [
76
+ "tests/test_add.py::test_add", "tests/test_add.py::test_sub"]
77
+ sess = merged["session"]
78
+ # span = earliest start .. latest end across shards
79
+ assert sess["started_at"] == json.loads(a.read_text())["session"]["started_at"]
80
+ assert sess["ended_at"] == json.loads(b.read_text())["session"]["ended_at"]
81
+ assert [s["source"] for s in sess["shards"]] == ["a.json", "b.json"]
82
+ assert all(s["signer"] for s in sess["shards"])
83
+
84
+ # the merged receipt goes through the UNCHANGED verifier
85
+ os.chdir(tmp_git_repo)
86
+ with _mock_github_keys(public_key):
87
+ assert verify_receipt(receipt_path=str(out), policy_path=None,
88
+ repo_root=str(tmp_git_repo),
89
+ github_user_override="testuser",
90
+ max_age_days=None)
91
+
92
+
93
+ def test_refuses_commit_sha_mismatch(tmp_path, tmp_git_repo, signer_with_key):
94
+ signer, _, key_path = signer_with_key
95
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json",
96
+ [_result("t::a")])
97
+ b = _shard(tmp_path, tmp_git_repo, signer, "b.json",
98
+ [_result("t::b")],
99
+ mutate=lambda p: p["repo"].__setitem__("commit_sha", "deadbeef"))
100
+ with pytest.raises(MergeError, match="commit_sha"):
101
+ merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())],
102
+ ["a.json", "b.json"])
103
+
104
+
105
+ def test_refuses_fingerprint_mismatch(tmp_path, tmp_git_repo, signer_with_key):
106
+ signer, _, key_path = signer_with_key
107
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")])
108
+ b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")],
109
+ mutate=lambda p: p["fingerprint"].__setitem__("digest", "0" * 64))
110
+ with pytest.raises(MergeError, match="fingerprint"):
111
+ merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())],
112
+ ["a.json", "b.json"])
113
+
114
+
115
+ def test_refuses_duplicate_node_id(tmp_path, tmp_git_repo, signer_with_key):
116
+ signer, _, key_path = signer_with_key
117
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::same")])
118
+ b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::same")])
119
+ with pytest.raises(MergeError, match="duplicate node_id"):
120
+ merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())],
121
+ ["a.json", "b.json"])
122
+
123
+
124
+ def test_refuses_environment_mismatch(tmp_path, tmp_git_repo, signer_with_key):
125
+ signer, _, key_path = signer_with_key
126
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")])
127
+ b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")],
128
+ mutate=lambda p: p["environment"].__setitem__("pytest_version", "0.0"))
129
+ with pytest.raises(MergeError, match="environment.pytest_version"):
130
+ merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())],
131
+ ["a.json", "b.json"])
132
+
133
+
134
+ def test_unsigned_shards_merge_and_dirty_ors(tmp_path, tmp_git_repo, signer_with_key):
135
+ signer, _, key_path = signer_with_key
136
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")], sign=False)
137
+ b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")], sign=False,
138
+ mutate=lambda p: p["repo"].__setitem__("dirty", True))
139
+ merged = merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())],
140
+ ["a.json", "b.json"])
141
+ assert merged["repo"]["dirty"] is True
142
+ assert merged["session"]["shards"][0]["signer"] is None
143
+
144
+
145
+ def test_merged_unsigned_flag(tmp_path, tmp_git_repo, signer_with_key):
146
+ signer, _, key_path = signer_with_key
147
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")])
148
+ out = tmp_path / "merged.json"
149
+ merged = merge_receipts([str(a)], str(out), unsigned=True)
150
+ assert merged["signature"] is None
151
+ assert json.loads(out.read_text())["signature"] is None
152
+
153
+
154
+ def test_gpu_info_survives_cpu_only_shard(tmp_path, tmp_git_repo, signer_with_key):
155
+ signer, _, key_path = signer_with_key
156
+ gpu = {"name": "FakeGPU", "driver_version": "1", "memory": "1 MiB"}
157
+ a = _shard(tmp_path, tmp_git_repo, signer, "a.json", [_result("t::a")],
158
+ mutate=lambda p: p["environment"].__setitem__("gpu_info", None))
159
+ b = _shard(tmp_path, tmp_git_repo, signer, "b.json", [_result("t::b")],
160
+ mutate=lambda p: p["environment"].__setitem__("gpu_info", gpu))
161
+ merged = merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())],
162
+ ["a.json", "b.json"])
163
+ assert merged["environment"]["gpu_info"] == gpu
@@ -274,3 +274,34 @@ def test_cli_overrides_toml_defaults(pytester):
274
274
  result.assert_outcomes(passed=1)
275
275
  assert (pytester.path / "cli-receipt.json").exists()
276
276
  assert not (pytester.path / "toml-receipt.json").exists()
277
+
278
+
279
+ def test_cli_equal_to_builtin_default_still_overrides_toml(pytester):
280
+ """Tri-state regression (0.2.0): an EXPLICIT CLI value that happens to equal
281
+ the built-in default must beat [tool.gpu_proof] — previously it was
282
+ silently ignored because "set" was detected by comparing against the
283
+ built-in default. --gpu-proof-out=gpu-proof.json IS the built-in default."""
284
+ pytester.makepyprojecttoml(
285
+ """
286
+ [tool.pytest.ini_options]
287
+
288
+ [tool.gpu_proof]
289
+ signing_backend = "none"
290
+ output = "toml-receipt.json"
291
+ """
292
+ )
293
+ pytester.makepyfile(
294
+ """
295
+ import pytest
296
+
297
+ @pytest.mark.gpu_proof
298
+ def test_ok():
299
+ assert True
300
+ """
301
+ )
302
+ result = pytester.runpytest(
303
+ "--gpu-proof-enable", "--gpu-proof-out=gpu-proof.json"
304
+ )
305
+ result.assert_outcomes(passed=1)
306
+ assert (pytester.path / "gpu-proof.json").exists()
307
+ assert not (pytester.path / "toml-receipt.json").exists()
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"