From 0e61493fd560cf9b03b0e1e34dd163788ff048ec Mon Sep 17 00:00:00 2001 From: forkwright Date: Thu, 3 Sep 2026 16:07:45 -0500 Subject: [PATCH] fix(release): stop demanding SHA-256 from honestly unpinned inventory components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Finding The 0.6.0 Release Candidate run on the release squash commit failed at the freeze step: build-release-candidate: CycloneDX component lacks a SHA-256 hash: '@playwright/test' `normalize_and_validate_sbom` requires every SBOM component to carry a SHA-256. That rule predates the lock-derived inventory: when #189 introduced it, every `release/components.json` entry was a repository file or an external distribution, both of which always carry a digest. #194 then added the npm/PyPI/toolcache tools as `registry-version-pin` entries, which deliberately carry no hash — ci/tool-lock.toml states the contract plainly: npm exposes no per-install integrity value, and the pin "is not a hash wearing a different name". The first real freeze since that change (no release PR had merged in the interim) hit the contradiction and refused. ## Change Scope the freeze-time hash requirement to components outside the authoritative inventory. Inventory components already carry every digest their hash source can honestly provide: `validated_component_inventory` refuses a repository-file or external-distribution entry without one, so the invariant is enforced at construction rather than re-checked here; a registry-version-pin entry honestly has none to carry. Components from any other source must still present a SHA-256 — the fixture's rejection cases (a foreign library component missing hashes, a nested incomplete component) are unchanged and still enforced. ## Verification - `ci/check-release-candidate.py` gains a registry-version-pin component in its fixture inventory and asserts it survives freeze with no synthesized `hashes` key — the regression this fixes — and passes. - ci/check-release-{config,workflows,lock,handoff,published-release}.py all pass locally. Refs forkwright/typikon#58, forkwright/typikon#70. Co-authored-by: forkwright --- ci/build-release-candidate.py | 13 ++++++++++++- ci/check-release-candidate.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/ci/build-release-candidate.py b/ci/build-release-candidate.py index 3ee4f78..968c644 100644 --- a/ci/build-release-candidate.py +++ b/ci/build-release-candidate.py @@ -406,7 +406,18 @@ def normalize_and_validate_sbom( raise CandidateError( f"CycloneDX component lacks a license: {component.get('name')!r}" ) - if not component_has_sha256(component): + # WHY the hash rule splits on inventory membership: an inventory + # component already carries every digest its hash source can honestly + # provide -- validated_component_inventory refuses a repository-file or + # external-distribution entry without one, while a registry-version-pin + # entry records that no upstream per-install integrity value exists + # (ci/tool-lock.toml states the same contract). Demanding a SHA-256 for + # those here would require a digest that cannot exist; a component from + # any other source still must carry one. + if ( + not component_has_sha256(component) + and str(component.get("purl")) not in inventory_purls + ): raise CandidateError( f"CycloneDX component lacks a SHA-256 hash: {component.get('name')!r}" ) diff --git a/ci/check-release-candidate.py b/ci/check-release-candidate.py index 30d89c0..c83a6cb 100644 --- a/ci/check-release-candidate.py +++ b/ci/check-release-candidate.py @@ -110,6 +110,17 @@ def main() -> int: "sha256": "1" * 64, }, }, + { + "name": "Fixture registry pin", + "version": "3.0.0", + "purl": "pkg:npm/fixture-registry-pin@3.0.0", + "scope": "required", + "license": "MIT", + "hash": { + "kind": "registry-version-pin", + "registry": "npm", + }, + }, ], }, indent=2, @@ -180,7 +191,14 @@ def main() -> int: assert {component["purl"] for component in sbom_doc["components"]} == { "pkg:generic/fixture-font@1.0.0?file_name=fixture.woff2", "pkg:generic/fixture-renderer@2.0.0?arch=x86_64&os=linux", + "pkg:npm/fixture-registry-pin@3.0.0", } + registry_pin = next( + component + for component in sbom_doc["components"] + if component["purl"] == "pkg:npm/fixture-registry-pin@3.0.0" + ) + assert "hashes" not in registry_pin dependency_rows = { row["ref"]: row["dependsOn"] for row in sbom_doc["dependencies"] }