Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,6 @@ Testing/
*.fatbin

# Specifics
gpu-proof.json
gpu-proof.json
# Local working notes (never committed)
docs/open-tasks/
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,57 @@ 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.4.1] — 2026-10-12

### Fixed

- Shard entries record `dirty` (the recording tree's state); merge ORs shard
flags into the merged `repo.dirty`, carry-forward refuses a dirty shard, and
verification refuses any dirty shard unless `allow_dirty` is set.
- `gpu-proof merge --carry-from` refuses a source receipt recorded with a dirty
working tree. The merged `repo.dirty` is OR-ed over the fresh inputs only, so a
carried shard from a dirty recording was laundered into a clean-looking merged
receipt (found by GRiD's split driver, 2026-10-10). Carried shards now record
`carried.original_dirty`, and verification refuses a carried shard recorded
dirty unless `allow_dirty` is set.

- The expected-skips mismatch names the delta (unexpected skips and stale
baseline entries) instead of only reporting that the sets differ.
- The receipt's `environment.gpu_info` falls back to the same torch probe the
`gpu_required` marker uses when nvidia-smi is absent or fails (`source`
records which probe answered), so a torch-only box no longer records
`gpu_info: null` and then fails a `require_gpu` policy.

- Verification refuses a receipt whose session started more than five
minutes before its commit existed (committer time): a cheap replay and
forgery guard that needs no nonce.

- `mode: ci-gpu` is now bound to the signing key: a ci-gpu receipt verifies
only when the policy lists `allowed_key_fingerprints` and the receipt was
signed by one of them (previously the mode was an unverified label).
- Without an explicit `carried_max_age_days`, a carried shard's original run
may not be older than the receipt's `max_age_days` (was a fixed 30 days).

### Added

- `gpu-proof merge --verify-inputs`: authenticate every input shard against
its signer's current GitHub keys before merging (unsigned or tampered inputs
are refused).
- `gpu-proof status PATH...`: one-line summaries of receipt files or
directories (commit, dirty, signed, outcome, counts, shards, carried).
- `gpu-proof durations RECEIPT... --out map.json`: fold per-test durations
into a JSON map for shard bin-packing.
- Command-line equivalents for the toml-only options:
`--gpu-proof-no-gpu-uuid`, `--gpu-proof-require-gpu`,
`--gpu-proof-max-age-days N`.
- `gpu-proof plan --receipt OLD [--repo .] [--json]`: a dry run of the
carry-forward conditions against the current tree, reporting per shard
whether it could be carried or must be re-run (and why). Drivers that plan
partial re-runs no longer reimplement the checks.
- `record_gpu_uuid` (`[tool.gpu_proof]`, default `true`): set `false` to omit each GPU's
nvidia-smi UUID from the receipt environment while keeping name, driver, memory and
compute capability. Verification does not read the UUID.

## [0.4.0] — 2026-08-17

### Added
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,23 @@ fingerprint scope, no selected tests, or write failure makes pytest fail and
leaves no stale receipt behind. `--gpu-proof-best-effort` is an explicit
development-only opt-out.

## Four interfaces
## Five interfaces

The project exposes four small interfaces:
The project exposes five small interfaces:

1. **Markers** select receipt tests.
2. **`gpu_proof_check`** compares a reference callable with a candidate.
2. **`gpu_proof_check`** compares a reference callable with a candidate
(optional; every current user selects tests with the marker alone).
3. **`gpu-proof verify`** validates a receipt and repository policy.
4. **`gpu-proof merge`** combines separately executed shards and optionally
carries unchanged shards forward.
5. **`gpu-proof plan`** reports, before a partial re-run, which shards of an
old receipt could still be carried and which must be re-run.

Two maintenance helpers round it out: `gpu-proof status PATH...` summarizes
receipt files (commit, dirty, signed, outcome, counts, shards), and
`gpu-proof durations RECEIPT... --out map.json` folds per-test durations into
a map that shard bin-packers can calibrate from.

### Markers

Expand Down Expand Up @@ -163,6 +171,7 @@ fingerprint_excluded_paths = ["gpu-proof.json"]
required_marker = "gpu_proof"
max_age_days = 30
require_gpu = true
record_gpu_uuid = false # keep device name/driver/memory, omit the card's UUID
```

By default, the fingerprint covers every tracked file in the repository,
Expand Down Expand Up @@ -270,6 +279,15 @@ runtime environments, duplicate tests, and duplicate shard names. It records
input provenance but does not verify each input signature; the merger signs
for the union. See [sharding and carry-forward](docs/sharding.md).

Carry-forward (`gpu-proof merge ... --carry-from last-green.json`) grafts an
absent old shard only when its receipt commit is an ancestor of the current
one, both the receipt and the shard were recorded with a clean tree, its
narrow fingerprint recomputes identically, and it introduces no duplicate
test. `gpu-proof plan --receipt last-green.json` prints the same verdicts
without merging. Generated inputs named through `fingerprint_extra_paths`
should be gitignored: an untracked generated file makes the recording tree
dirty, and dirty recordings are never carried.

## Receipt schema

New receipts use schema `"3"`. Signer identity is inside the signed payload,
Expand Down
5 changes: 4 additions & 1 deletion docs/ci_gpu_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

CI-GPU mode uses the same receipt and verifier but records `mode: ci-gpu`.
Choose it when GPU tests must run on a controlled runner rather than a
developer machine.
developer machine. The verifier binds the mode to the runner's signing key:
a `ci-gpu` receipt verifies only when the repository policy lists
`allowed_key_fingerprints` and the receipt was signed by one of them, so the
label cannot be claimed by an arbitrary developer key.

## Dedicated signing identity

Expand Down
4 changes: 2 additions & 2 deletions docs/policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ schema 3. Repositories that have finished migrating should pin
| `allowed_key_fingerprints` | SSH SHA-256 fingerprints accepted in restricted mode |
| `min_schema` | Minimum acceptable receipt schema version (1, 2, or 3) |
| `max_age_days` | Maximum age of the merged/session receipt |
| `require_mode` | Required receipt mode: `local` or `ci-gpu` |
| `require_mode` | Required receipt mode: `local` or `ci-gpu` (a `ci-gpu` receipt additionally requires `allowed_key_fingerprints` and must be signed by a listed key) |
| `allow_dirty` | Permit dirty recording or verification trees |
| `required_fingerprint_paths` | Exact tracked path list |
| `required_fingerprint_extra_paths` | Exact generated/ignored path list |
| `required_fingerprint_excluded_paths` | Exact receipt/self-reference exclusion list |
| `required_test_manifest` | Repository-relative file containing exact node IDs |
| `required_shard_fingerprints` | Exact paths/extras for every named shard |
| `allow_carried` | Permit carry-forward shards |
| `carried_max_age_days` | Maximum age of each carried shard's original run |
| `carried_max_age_days` | Maximum age of each carried shard's original run (default: `max_age_days`, capped at 30) |

Example shard scope:

Expand Down
10 changes: 7 additions & 3 deletions docs/security_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ escaping, or unmerged inputs fail closed.
Schema 3 rejects dirty recording and verification trees by default. A receipt
may verify at its exact commit or a descendant only when the source manifest
still matches. Freshness limits replay duration but no server-issued nonce is
currently used.
currently used; a session that started more than five minutes before its
commit's committer time is refused, so a receipt cannot be back-dated onto a
commit that did not yet exist.

`allow_dirty: true`, long age limits, `--allow-skipped`, and
`--allow-unsigned` weaken guarantees. Unsigned mode authenticates no signer,
Expand All @@ -98,8 +100,10 @@ each input signature. The merger's signature attests to the union. A review
workflow that needs independent shard authentication should verify every
input before merging.

Carry-forward means a test passed at an ancestor commit and its declared shard
inputs are byte-identical now. It does not establish that omitted dependencies
Carry-forward means a test passed at an ancestor commit, with a clean working
tree, and its declared shard inputs are byte-identical now. A receipt recorded
dirty is never a carry source: the merged `repo.dirty` is computed from fresh
inputs only, so carrying from it would launder the dirty flag. It does not establish that omitted dependencies
or the old environment remain equivalent. Carried shards are rejected unless
policy opts in and bounds their original age.

Expand Down
22 changes: 18 additions & 4 deletions docs/sharding.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ session failed.

The merged receipt records input source names, counts, times, and recorded
signers, then signs the result with the merger's key. Input signatures are
provenance, not independently verified during offline merge. The merger
attests to the union.
provenance, not verified during an offline merge, unless `--verify-inputs` is
passed: then every input must verify against its signer's current GitHub keys
(unsigned inputs are refused) before the union is formed. The merger attests
to the union either way.

## Carry forward unchanged shards

Expand All @@ -55,10 +57,22 @@ An absent old shard is carried only when:
1. its receipt commit is the current commit or an ancestor;
2. its stored narrow manifest—including explicit extra paths—recomputes
identically at the current tree;
3. it does not duplicate a fresh node ID;
4. every claimed node ID exists in the old tests list.
3. its receipt was recorded with a clean working tree (`repo.dirty` false): a
dirty recording may have exercised uncommitted bytes outside the shard's
narrow manifest, and the merged `repo.dirty` covers fresh inputs only;
4. it does not duplicate a fresh node ID;
5. every claimed node ID exists in the old tests list.

Every shard entry records `dirty` (the recording tree's state), merge ORs
shard flags into the merged `repo.dirty`, carry-forward refuses a dirty shard,
each carried shard records `carried.original_dirty`, and verification refuses
any dirty shard unless `allow_dirty` is set.

Freshly rerun shards always win. Changed or malformed scopes require a rerun.
`gpu-proof plan --receipt old.json --repo .` applies the same conditions as a
dry run and reports, per shard, `carryable` or `stale` with the reason (add
`--json` for drivers), so a partial re-run can be planned without reimplementing
the checks.

Verification rejects carried shards by default:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pytest-gpu-proof"
version = "0.4.0"
version = "0.4.1"
description = "pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/pytest_gpu_proof/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.0"
__version__ = "0.4.1"
108 changes: 108 additions & 0 deletions src/pytest_gpu_proof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,116 @@ def main():
"allow_carried (default: rejected).")
mp.add_argument("--repo", default=".", metavar="PATH",
help="Repo root for carry-from fingerprint/ancestry checks")
mp.add_argument("--verify-inputs", action="store_true", default=False,
help="Authenticate every input shard's signature against the "
"signer's current GitHub keys before merging (unsigned inputs "
"are refused)")

# status
sp = subparsers.add_parser(
"status",
help="Summarize receipt files (commit, dirty, outcome, tests, shards, signed)",
)
sp.add_argument("paths", nargs="+", metavar="PATH",
help="Receipt files or directories (every *.json inside)")
sp.add_argument("--json", action="store_true", default=False)

# durations
dp = subparsers.add_parser(
"durations",
help="Fold per-test durations from receipts into a JSON map (bin-packing calibration)",
)
dp.add_argument("receipts", nargs="+", metavar="RECEIPT")
dp.add_argument("--out", required=True, metavar="PATH",
help="JSON map node_id -> seconds; merged into an existing file")

# plan
pp = subparsers.add_parser(
"plan",
help="Dry run of carry-forward: which shards of an old receipt could be "
"carried against the current tree and which must be re-run",
)
pp.add_argument("--receipt", required=True, metavar="PATH",
help="Older sharded receipt (the one you would --carry-from)")
pp.add_argument("--repo", default=".", metavar="PATH",
help="Repository root for fingerprint/ancestry checks (default: .)")
pp.add_argument("--json", action="store_true", default=False,
help="Print the plan as JSON instead of one line per shard")

args = parser.parse_args()

if args.command == "plan":
import json

from .merge import MergeError, load_receipt, plan_carry

try:
report = plan_carry(load_receipt(args.receipt), repo_root=args.repo)
except MergeError as e:
print(f"gpu-proof plan: {e}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
r = report["receipt"]
print(f"receipt {str(r.get('commit_sha'))[:12]} vs HEAD {str(r.get('head'))[:12]}: "
f"{'carryable' if r['carryable'] else 'NOT carryable'} ({r['reason']})")
for s in report["shards"]:
print(f" {s['verdict']:9s} {s['name']} ({s['node_count']} tests): {s['reason']}")
n = sum(1 for s in report["shards"] if s["verdict"] == "carryable")
print(f"{n}/{len(report['shards'])} shard(s) carryable")
sys.exit(0)

if args.command == "status":
import json
import os

from .merge import receipt_status

files = []
for p in args.paths:
if os.path.isdir(p):
files += sorted(os.path.join(p, f) for f in os.listdir(p) if f.endswith(".json"))
else:
files.append(p)
rows = [receipt_status(f) for f in files]
if args.json:
print(json.dumps(rows, indent=2, sort_keys=True))
else:
for r in rows:
if "error" in r:
print(f"{r['file']}: {r['error']}")
continue
print(f"{r['file']}: schema {r['schema']} @ {str(r['commit_sha'])[:12]}"
f"{' DIRTY' if r['dirty'] else ''}{'' if r['signed'] else ' UNSIGNED'}"
f" {r['outcome'] or '-'} tests={r['tests']} failed={r['failed']} "
f"skipped={r['skipped']} shards={len(r['shards'])} carried={r['carried']} "
f"ended={r['ended_at']}")
sys.exit(0)

if args.command == "durations":
import json

from .merge import MergeError, collect_durations, load_receipt

try:
receipts = [load_receipt(p) for p in args.receipts]
except MergeError as e:
print(f"gpu-proof durations: {e}", file=sys.stderr)
sys.exit(1)
existing = {}
try:
with open(args.out) as f:
existing = json.load(f)
except (OSError, ValueError):
existing = {}
merged = collect_durations(receipts, existing if isinstance(existing, dict) else {})
with open(args.out, "w") as f:
json.dump(merged, f, indent=2, sort_keys=True)
f.write("\n")
print(f"durations: {len(merged)} node(s) -> {args.out}")
sys.exit(0)

if args.command == "merge":
from .merge import MergeError, merge_receipts

Expand All @@ -120,6 +227,7 @@ def main():
unsigned=args.unsigned,
carry_from=args.carry_from,
repo_root=args.repo,
verify_inputs=args.verify_inputs,
)
except MergeError as e:
print(f"gpu-proof merge: {e}", file=sys.stderr)
Expand Down
11 changes: 9 additions & 2 deletions src/pytest_gpu_proof/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class GpuProofConfig:
github_username: Optional[str] = None
max_age_days: int = 30
require_gpu: bool = False
# Record each device's nvidia-smi UUID in the receipt environment. Off keeps the
# device name, driver, memory and compute capability but not the card identity.
record_gpu_uuid: bool = True
# Sharded emission: a declared shard name + its narrow
# fingerprint paths (None -> the global fingerprint_paths).
shard_name: Optional[str] = None
Expand Down Expand Up @@ -92,7 +95,7 @@ def path_list(opt_name, toml_key, default=None):
return [p.strip() for p in raw.split(",") if p.strip()]
return [str(p) for p in raw]

max_age = toml_cfg.get("max_age_days")
max_age = resolve("--gpu-proof-max-age-days", "max_age_days", None)
max_age_days = int(max_age) if max_age is not None else 30

return GpuProofConfig(
Expand All @@ -117,7 +120,11 @@ def path_list(opt_name, toml_key, default=None):
),
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)),
# store_true flags can only turn a setting ON (or, for the UUID, OFF).
require_gpu=bool(opt("--gpu-proof-require-gpu", False)
or toml_cfg.get("require_gpu", False)),
record_gpu_uuid=bool(toml_cfg.get("record_gpu_uuid", True))
and not bool(opt("--gpu-proof-no-gpu-uuid", False)),
shard_name=resolve("--gpu-proof-shard", "shard_name", None),
shard_fingerprint_paths=shard_paths,
shard_fingerprint_extra_paths=(
Expand Down
9 changes: 9 additions & 0 deletions src/pytest_gpu_proof/gitutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ def get_branch(root: str = ".") -> Optional[str]:
return _git("rev-parse", "--abbrev-ref", "HEAD", root=root)


def get_commit_time(sha: str, root: str = ".") -> Optional[int]:
"""Committer time (Unix seconds) of ``sha``, or None when git cannot answer."""
value = _git("show", "-s", "--format=%ct", sha, root=root)
try:
return int(value) if value else None
except ValueError:
return None


def is_dirty(
root: str = ".",
*,
Expand Down
Loading
Loading