diff --git a/catalog/cache-contract.yml b/catalog/cache-contract.yml index fec568d..d96d7c4 100644 --- a/catalog/cache-contract.yml +++ b/catalog/cache-contract.yml @@ -11,7 +11,77 @@ # `check_cache_contract.py` compiles this fail-closed: an action that can cache # and is not listed here fails the gate, so a new caching dependency has to be # classified rather than inherited. -schema_version: 1 +schema_version: 2 + +trust: + namespace: repository + pull_request_scope: merge-ref + default_branch_fallback: read-only + low_trust_default_branch_access: restore-only + trusted_default_branch_writers: + - push + - workflow_dispatch + - repository_dispatch + - delete + - registry_package + - page_build + - schedule + cache_is_provenance: false + secrets_allowed: false + +keys: + match: exact-first + prefix_restore: explicit-review-only + cross_os_archive_default: false + required_dimensions: + - repository + - operating-system + - architecture + - tool-or-action-version + - dependency-input-digest + conditional_dimensions: + - target-platform + - build-settings + - private-backend-identity + +persistent_runners: + workspace_reuse: forbidden + mutable_cross_job_state: forbidden + allowed_warm_state: + - immutable-runner-image + - checksum-verified-tool-store + - typed-tenant-scoped-cache-backend + cleanup_authority: runner-lifecycle + residue_is_trusted_input: false + +retention: + idle_eviction_days: 7 + default_repository_limit_gb: 10 + eviction_order: least-recently-accessed + upload_rate_per_minute: 200 + download_rate_per_minute: 1500 + cache_miss_correctness_effect: none + +equivalence: + hosted_and_fleet_keys_share_semantics: true + backend_identity_required_for_private_cache: true + hit_is_authoritative_evidence: false + miss_may_fail_job: false + corrupt_entry_action: discard-and-rebuild + +telemetry: + required: + - backend + - key-digest + - hit + - restore-duration + - save-duration + - restored-bytes + - saved-bytes + compare: + - cold + - warm + synthetic_load_allowed: false # Every action in the tree that can write to a cache, the input that decides # whether it does, and what happens with no input at all. diff --git a/docs/04-actions-core.md b/docs/04-actions-core.md index ad874bd..31bdf40 100644 --- a/docs/04-actions-core.md +++ b/docs/04-actions-core.md @@ -127,6 +127,28 @@ strategy: untrusted triggers (2026-06-26) — see [watchlist-2026.md](watchlist-2026.md). - Never cache secrets or credentials. +The executable policy is `catalog/cache-contract.yml`, not this summary. It +classifies every producer and refusal and fixes these cross-run invariants: + +- pull-request writes remain in the merge-ref scope; default-branch fallback is + read-only, and low-trust default-context events are restore-only; +- keys are exact-first and name repository, OS, architecture, tool version and + dependency digest; prefix restore requires an explicit reviewed exception; +- hosted and fleet backends use the same key semantics, while a private backend + identity is an additional dimension rather than an invisible alias; +- a cache hit is never provenance, a miss never changes correctness, and a + corrupt entry is discarded and rebuilt; +- persistent workers do not reuse workspaces or mutable cross-job state. Only + immutable images, checksum-verified tool stores and typed tenant-scoped cache + backends may remain warm; +- real cold/warm jobs record hit, key digest, backend, durations and byte counts. + Synthetic cache traffic is not an acceptance workload. + +Provider facts are checked against GitHub's +[dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching): +idle entries are evicted after seven days, the default repository allowance is +10 GB, and eviction is least-recently-accessed when capacity is exceeded. + ## Step-level parallel execution GitHub introduced step-level parallel execution in public preview on diff --git a/scripts/check_cache_contract.py b/scripts/check_cache_contract.py index 7b6409b..a93b960 100644 --- a/scripts/check_cache_contract.py +++ b/scripts/check_cache_contract.py @@ -39,6 +39,7 @@ """ from __future__ import annotations +import copy from pathlib import Path from typing import Any @@ -50,6 +51,102 @@ CI = ".github/workflows/ci.yml" GATE_JOB = "ci-gate" +EXPECTED_TRUST = { + "namespace": "repository", + "pull_request_scope": "merge-ref", + "default_branch_fallback": "read-only", + "low_trust_default_branch_access": "restore-only", + "trusted_default_branch_writers": [ + "push", "workflow_dispatch", "repository_dispatch", "delete", + "registry_package", "page_build", "schedule", + ], + "cache_is_provenance": False, + "secrets_allowed": False, +} +EXPECTED_KEY_DIMENSIONS = { + "repository", "operating-system", "architecture", + "tool-or-action-version", "dependency-input-digest", +} +EXPECTED_CONDITIONAL_DIMENSIONS = { + "target-platform", "build-settings", "private-backend-identity", +} +EXPECTED_WARM_STATE = { + "immutable-runner-image", "checksum-verified-tool-store", + "typed-tenant-scoped-cache-backend", +} +EXPECTED_TELEMETRY = { + "backend", "key-digest", "hit", "restore-duration", "save-duration", + "restored-bytes", "saved-bytes", +} + + +def _policy_problems(contract: dict[str, Any]) -> list[str]: + problems: list[str] = [] + expected_top = { + "schema_version", "trust", "keys", "persistent_runners", "retention", + "equivalence", "telemetry", "producers", "refusals", + } + if set(contract) != expected_top: + problems.append( + f"cache contract keys must equal {sorted(expected_top)}, got {sorted(contract)}" + ) + if contract.get("schema_version") != 2: + problems.append("cache contract schema_version must be 2") + if contract.get("trust") != EXPECTED_TRUST: + problems.append("cache trust policy drifted from the reviewed GitHub scope model") + + keys = contract.get("keys") or {} + if ( + keys.get("match") != "exact-first" + or keys.get("prefix_restore") != "explicit-review-only" + or keys.get("cross_os_archive_default") is not False + or set(keys.get("required_dimensions") or []) != EXPECTED_KEY_DIMENSIONS + or set(keys.get("conditional_dimensions") or []) != EXPECTED_CONDITIONAL_DIMENSIONS + ): + problems.append("cache key policy is incomplete or permits implicit fallback") + + persistent = contract.get("persistent_runners") or {} + if ( + persistent.get("workspace_reuse") != "forbidden" + or persistent.get("mutable_cross_job_state") != "forbidden" + or persistent.get("cleanup_authority") != "runner-lifecycle" + or persistent.get("residue_is_trusted_input") is not False + or set(persistent.get("allowed_warm_state") or []) != EXPECTED_WARM_STATE + ): + problems.append("persistent-runner residue policy drifted") + + retention = contract.get("retention") or {} + expected_retention = { + "idle_eviction_days": 7, + "default_repository_limit_gb": 10, + "eviction_order": "least-recently-accessed", + "upload_rate_per_minute": 200, + "download_rate_per_minute": 1500, + "cache_miss_correctness_effect": "none", + } + if retention != expected_retention: + problems.append("cache retention/rate policy drifted from the reviewed provider facts") + + equivalence = contract.get("equivalence") or {} + expected_equivalence = { + "hosted_and_fleet_keys_share_semantics": True, + "backend_identity_required_for_private_cache": True, + "hit_is_authoritative_evidence": False, + "miss_may_fail_job": False, + "corrupt_entry_action": "discard-and-rebuild", + } + if equivalence != expected_equivalence: + problems.append("hosted/fleet cache equivalence policy drifted") + + telemetry = contract.get("telemetry") or {} + if ( + set(telemetry.get("required") or []) != EXPECTED_TELEMETRY + or set(telemetry.get("compare") or []) != {"cold", "warm"} + or telemetry.get("synthetic_load_allowed") is not False + ): + problems.append("cache telemetry policy is incomplete or permits synthetic load") + return problems + def _steps(workflow: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: found: list[tuple[str, dict[str, Any]]] = [] @@ -172,6 +269,23 @@ def _caller_ref_cache_problems(producers: dict) -> list[str]: def check() -> list[str]: problems: list[str] = [] contract = strict_load(CONTRACT) + problems += _policy_problems(contract) + adversarial = { + "PR writes default scope": ("trust", "pull_request_scope", "default-branch"), + "prefix fallback implicit": ("keys", "prefix_restore", "implicit"), + "workspace residue trusted": ( + "persistent_runners", "residue_is_trusted_input", True + ), + "cache hit treated as evidence": ( + "equivalence", "hit_is_authoritative_evidence", True + ), + "synthetic cache load": ("telemetry", "synthetic_load_allowed", True), + } + for label, (section, field, value) in adversarial.items(): + candidate = copy.deepcopy(contract) + candidate[section][field] = value + if not _policy_problems(candidate): + problems.append(f"cache policy selftest accepted {label}") producers = {str(entry["action"]): entry for entry in contract["producers"]} refusals = contract["refusals"]