From 68c9f31110363e69ab8991f2a7d70809c5aea2ac Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Tue, 29 Sep 2026 05:35:10 -0400 Subject: [PATCH 1/3] test(ci): a refused reuse artifact names the contract fields that moved Red: product_provenance_invalid alone is printed for an artifact sealed under another contract. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_reuse_app_host_products.py | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_reuse_app_host_products.py b/tests/test_reuse_app_host_products.py index 82f160103103..03589852183a 100644 --- a/tests/test_reuse_app_host_products.py +++ b/tests/test_reuse_app_host_products.py @@ -755,6 +755,37 @@ 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_pull_request_producer_sealed_at_its_merge_commit_is_reusable(self): """A pull request producer seals the merge commit it checked out. From 06c144ca96ebe6dc818b368614e7f8a2f881f971 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Tue, 29 Sep 2026 05:35:11 -0400 Subject: [PATCH 2/3] fix(ci): say why compiled-product reuse refused an artifact product_provenance_invalid covered a contract mismatch, a producer run mismatch, a revision mismatch and relocation faults, and printed none of them. PR media tours of #14563 refused CI's own product six times on 2026-09-29 with that reason alone. Print the artifact, run and the check that refused it, and for a contract mismatch the fields that differ. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci/reuse_app_host_products.py | 36 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/scripts/ci/reuse_app_host_products.py b/scripts/ci/reuse_app_host_products.py index fb469d01d570..1341bbbbb154 100644 --- a/scripts/ci/reuse_app_host_products.py +++ b/scripts/ci/reuse_app_host_products.py @@ -339,6 +339,27 @@ 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}" + if sealed.get(name) == wanted.get(name): + continue + if isinstance(sealed.get(name), dict) and isinstance(wanted.get(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) @@ -1036,10 +1057,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"] @@ -1059,7 +1082,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 From 585ac0171dde6d14d10f183a1fdfbdaa46ce2b9a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 29 Sep 2026 11:19:46 -0400 Subject: [PATCH 3/3] fix: report missing contract fields precisely --- scripts/ci/reuse_app_host_products.py | 8 ++++++-- tests/test_reuse_app_host_products.py | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/ci/reuse_app_host_products.py b/scripts/ci/reuse_app_host_products.py index 1341bbbbb154..44addd0f0084 100644 --- a/scripts/ci/reuse_app_host_products.py +++ b/scripts/ci/reuse_app_host_products.py @@ -351,9 +351,13 @@ def contract_differences(sealed, wanted, prefix=""): fields = [] for name in sorted(set(sealed) | set(wanted)): path = f"{prefix}{name}" - if sealed.get(name) == wanted.get(name): + sealed_has = name in sealed + wanted_has = name in wanted + if sealed_has and wanted_has and sealed[name] == wanted[name]: continue - if isinstance(sealed.get(name), dict) and isinstance(wanted.get(name), dict): + 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) diff --git a/tests/test_reuse_app_host_products.py b/tests/test_reuse_app_host_products.py index 03589852183a..0a52bc1fe752 100644 --- a/tests/test_reuse_app_host_products.py +++ b/tests/test_reuse_app_host_products.py @@ -786,6 +786,15 @@ def test_contract_differences_names_nested_fields(self): ["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.