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
40 changes: 36 additions & 4 deletions scripts/ci/reuse_app_host_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,31 @@ def key(value):
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()


def contract_differences(sealed, wanted, prefix=""):
"""The dotted contract fields where a sealed receipt and this job differ.

Only names: a receipt found under this job's key but sealed with another
contract means the producer's contract changed between naming its artifact
and sealing it, and the field says which input moved.
"""
if not isinstance(sealed, dict) or not isinstance(wanted, dict):
return [prefix or "contract"]
fields = []
for name in sorted(set(sealed) | set(wanted)):
path = f"{prefix}{name}"
sealed_has = name in sealed
wanted_has = name in wanted
if sealed_has and wanted_has and sealed[name] == wanted[name]:
continue
if (sealed_has and wanted_has
and isinstance(sealed[name], dict)
and isinstance(wanted[name], dict)):
fields.extend(contract_differences(sealed[name], wanted[name], f"{path}."))
else:
fields.append(path)
return fields or [prefix or "contract"]


def github_product_identity(api, revision):
"""Recompute one revision's product identity from GitHub-owned Git objects."""
cache = getattr(api, "_product_identity_cache", None)
Expand Down Expand Up @@ -1036,10 +1061,12 @@ def restore(api, value, derived, current_run, current_identity, current_attempt=
root = staging / "Build/Products"
try:
receipt = json.loads((root / RECEIPT).read_text())
if (receipt["contract"] != value
or receipt["run_id"] != str(run["id"])
if receipt["contract"] != value:
raise ValueError("artifact producer contract mismatch in "
+ ", ".join(contract_differences(receipt["contract"], value)))
if (receipt["run_id"] != str(run["id"])
or receipt["run_attempt"] != str(run["run_attempt"])):
raise ValueError("artifact producer contract mismatch")
raise ValueError("artifact producer run mismatch")
# Bind the candidate-authored receipt back to a GitHub-attested
# producer revision, re-fingerprinting whatever it names.
revision = receipt["revision"]
Expand All @@ -1059,7 +1086,12 @@ def restore(api, value, derived, current_run, current_identity, current_attempt=
# Relocate once more from staging into the actual consumer location.
products.stamp(staging, current_identity)
except (TypeError, AttributeError, ValueError, KeyError, OSError,
subprocess.SubprocessError):
subprocess.SubprocessError) as error:
# The reason alone cannot tell a stale receipt from a relocation
# or disk fault, and every candidate records it once; name the
# artifact and the check that refused it.
print(f"Compiled-product reuse refused artifact {artifact.get('id')} of run "
f"{run.get('id')}: {type(error).__name__}: {str(error)[:300]}")
record_reason(reasons, "product_provenance_invalid")
continue

Expand Down
40 changes: 40 additions & 0 deletions tests/test_reuse_app_host_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,46 @@ def seal_at(self, revision):
self.identity = {**self.identity, "revision": revision}
self.seal()

def test_a_receipt_sealed_under_another_contract_names_the_fields_that_moved(self):
"""An artifact found by this job's key but sealed with another contract
says which contract fields differ, not only product_provenance_invalid.

On 2026-09-29 PR media tours of #14563 found CI's artifact by name six
times and refused it each time with that reason alone (e.g. run
36540512350): the producer named its artifact before sealing a
receipt whose contract hashes differently, and nothing said why.
"""
sealed = {**self.contract, "tools": {**self.contract["tools"], "zig": "0.16.0"}}
root = self.producer / "Build/Products"
receipt = json.loads((root / reuse.RECEIPT).read_text())
(root / reuse.RECEIPT).write_text(json.dumps({**receipt, "contract": sealed}))
self.api.artifact["digest"] = self.package(self.producer, self.api.archive)
report = {}
output = io.StringIO()
with mock.patch("sys.stdout", output):
self.assertFalse(self.restore_reuse(report=report))
self.assertIn("product_provenance_invalid", report["miss_reasons"])
self.assertIn("contract mismatch in tools.zig", output.getvalue())
self.assertIn(f"artifact {self.api.artifact['id']} of run {self.api.run['id']}", output.getvalue())

def test_contract_differences_names_nested_fields(self):
self.assertEqual(
reuse.contract_differences(
{"a": 1, "tools": {"zig": "1", "go": "absent"}, "only_sealed": 1},
{"a": 1, "tools": {"zig": "2", "go": "absent"}, "only_wanted": 2},
),
["only_sealed", "only_wanted", "tools.zig"],
)

def test_contract_differences_names_missing_field_when_other_value_is_none(self):
self.assertEqual(
reuse.contract_differences(
{"tools": {"zig": None}},
{"tools": {}},
),
["tools.zig"],
)

def test_pull_request_producer_sealed_at_its_merge_commit_is_reusable(self):
"""A pull request producer seals the merge commit it checked out.

Expand Down
Loading