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
72 changes: 71 additions & 1 deletion catalog/cache-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions docs/04-actions-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions scripts/check_cache_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"""
from __future__ import annotations

import copy
from pathlib import Path
from typing import Any

Expand All @@ -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]]] = []
Expand Down Expand Up @@ -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"]

Expand Down
Loading