This repository has been archived on 2026-11-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
platform-ci/platform_ci/caching.py
forge-recoveryandClaude Sonnet 5.5 a36f17b857
KofTwentyTwo/ci-policy: ci / fork-guard (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-nix-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / gradle-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / gradle-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-docker-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-maven-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / manifests-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / nix-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / node-8 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / python-9 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-14 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / not-enrolled (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / content-validate-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / standards-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / node-matrix-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-15 (pull_request) Blocked by required conditions
KofTwentyTwo/ci-policy: Shared source and config CI / verify (pull_request) Blocked by required conditions
KofTwentyTwo/ci-policy: Shared source and config CI / secrets (pull_request) Blocked by required conditions
KofTwentyTwo/ci-policy: Shared source and config CI / gate (pull_request) Waiting for needed jobs
KofTwentyTwo/ci-policy: ci / ci-result (pull_request) Waiting for needed jobs
KofTwentyTwo/ci-policy: native / fork-deny (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-8 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-9 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-11 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-12 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-14 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-15 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-16 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-17 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-19 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / native-result (pull_request) Canceled after 6s
feat(ci): advance shared pipeline capabilities
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:30:25 +00:00

275 lines
15 KiB
Python

"""Decide the dependency-cache key, directory and save eligibility for one verification target.
Only downloaded dependency artifacts are ever cached: the Maven local repository and the npm download cache.
Build outputs, `node_modules`, credentials and settings files are never selected. The key is made from the
toolchain, image, platform, component and the dependency descriptors (never application source), so there is
no broad fallback key. This module reads and hashes files only; the cache service itself is the native Actions
cache, reached by the workflow, and the repository's cache namespace comes from that service, not from here.
"""
import hashlib
import re
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, date, datetime
from pathlib import Path
from typing import Final
from urllib.parse import urlsplit
from platform_ci.build_tools import BUILD_TOOLS
from platform_ci.plan import Target
from platform_ci.workspace import (
GIT_METADATA_DIRECTORY_NAME,
PLATFORM_CHECKOUT_DIRECTORY_NAME,
PipelineError,
resolve_component_directory,
)
CACHE_KEY_VERSION = "v1"
_BRANCH_REFERENCE_PREFIX = "refs/heads/"
_KEY_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,400}")
_BUNDLE_DIRECTORY_NAME = "ci-output"
_COMMON_SKIPPED_DIRECTORIES = frozenset({GIT_METADATA_DIRECTORY_NAME, PLATFORM_CHECKOUT_DIRECTORY_NAME, _BUNDLE_DIRECTORY_NAME})
_SKIPPED_DIRECTORIES_BY_BUILD_TOOL = {
"maven": _COMMON_SKIPPED_DIRECTORIES | {"target"},
"node": _COMMON_SKIPPED_DIRECTORIES | {"node_modules"},
}
# the only places dependency downloads land, relative to the build's home directory
_DOWNLOAD_DIRECTORY_BY_BUILD_TOOL: Final = {"maven": (".m2", "repository"), "node": (".npm", "_cacache")}
_CACHE_FAMILY_BY_TOOLCHAIN: Final = {"java": "maven", "node": "node"}
TRIVY_CACHE_DIRECTORY: Final = (".cache", "trivy")
"""The scanner's own cache directory relative to the home directory; the scan is told to use it with `--cache-dir`, so restore, scan and save agree."""
# a cache directory may never be, or sit inside, any of these
_FORBIDDEN_PATH_PARTS = frozenset({"node_modules", "target", "settings.xml", "settings-security.xml", ".npmrc", GIT_METADATA_DIRECTORY_NAME, PLATFORM_CHECKOUT_DIRECTORY_NAME})
_MAVEN_CONFIG_FILES = frozenset({"maven.config", "jvm.config", "extensions.xml"})
_NODE_DESCRIPTOR_FILES = frozenset({"package.json", "package-lock.json", "npm-shrinkwrap.json"})
_NODE_LOCK_FILES = ("package-lock.json", "npm-shrinkwrap.json")
class CacheError(PipelineError):
"""Raised when a cache key or directory cannot be derived safely; the build then runs without a cache."""
@dataclass(frozen=True)
class CacheMetadata:
"""What the workflow needs to restore and save one target's dependency cache."""
key: str
path: str
save: bool
trivy_key: str = ""
trivy_path: str = ""
"""The vulnerability-database cache of a target that packages images; both are empty for every other target."""
def build_cache_metadata(target: Target, source_directory: Path, environment: Mapping[str, str], today: date | None = None) -> CacheMetadata:
"""Return the exact key, the download directory and whether this event may save, for one target.
The target's directory and image come from the plan's own matrix JSON. Nothing here depends on the repository's name. A target that packages
images also gets the key and directory of the scanner's vulnerability database (`today` is the UTC day, injectable for tests).
"""
descriptors = dependency_descriptors(target, source_directory)
home = environment.get("HOME", "")
if not home:
raise CacheError("HOME is not set, so the dependency download directory is unknown")
trivy_key, trivy_path = "", ""
if target["images"]:
trivy_key = trivy_cache_key(target, today or datetime.now(UTC).date())
trivy_path = trivy_cache_directory(Path(home), source_directory).as_posix()
return CacheMetadata(
key=cache_key(target, descriptors),
path=cache_directory(cache_family(target), Path(home), source_directory).as_posix(),
save=save_allowed(environment.get("CI_EVENT_NAME", ""), environment.get("CI_EVENT_REF", ""), environment.get("CI_DEFAULT_BRANCH", "")),
trivy_key=trivy_key,
trivy_path=trivy_path,
)
def cache_family(target: Target) -> str:
"""Return which dependency cache a target uses: its build tool's own, or, for a tool that runs a program in a declared toolchain, that toolchain's.
A `script` component with the java toolchain downloads through Maven and one with the node toolchain through npm, so each reuses that
toolchain's download directory and descriptors (never pretending the script is Maven); the control toolchain has no dependency cache.
"""
if target["build_tool"] in _DOWNLOAD_DIRECTORY_BY_BUILD_TOOL or not BUILD_TOOLS[target["build_tool"]].toolchain_choices:
return target["build_tool"]
return _CACHE_FAMILY_BY_TOOLCHAIN.get(target["toolchain"], "")
def trivy_cache_key(target: Target, day: date) -> str:
"""Return the exact key of the vulnerability database cache: the UTC day and the runtime image (which fixes the scanner version).
There is no fallback key, so a restored database is never older than the day it was saved, and the scanner still checks the database's own
freshness metadata and downloads a newer one when needed. Application source and dependency descriptors are not inputs.
"""
key = "-".join(("platform-ci-trivy", CACHE_KEY_VERSION, day.strftime("%Y%m%d"), hashlib.sha256(target["image"].encode("utf-8")).hexdigest()[:16]))
if _KEY_PATTERN.fullmatch(key) is None:
raise CacheError(f"cache key {key!r} contains characters the cache service does not accept")
return key
def trivy_cache_directory(home: Path, source_directory: Path) -> Path:
"""Return the scanner's cache directory (`<home>/.cache/trivy`) after the same safety proof the dependency directory gets."""
if not home.is_absolute() or ".." in home.parts:
raise CacheError(f"home directory {home} must be an absolute path without '..'")
return _proven_cache_path(home.joinpath(*TRIVY_CACHE_DIRECTORY), source_directory)
def cache_unsupported_reason(target: Target) -> str | None:
"""Return why no dependency cache is defined for the target's build tool, or None when one is.
A tool without a safe, tested download directory is built uncached rather than failing the cache step.
"""
if cache_family(target) in _DOWNLOAD_DIRECTORY_BY_BUILD_TOOL:
return None
return f"cache unavailable: no dependency cache is defined for build tool {target['build_tool']!r} with toolchain {target['toolchain']!r}, so nothing is restored or saved; the build and tests still run"
def cache_unavailable_reason(environment: Mapping[str, str]) -> str | None:
"""Return why the native cache cannot be used for this job, or None when the runner provided an endpoint.
The endpoint is read only from the runner-provided `ACTIONS_CACHE_URL`; this platform defines no address, setting or credential
of its own and keeps no allowlist, because the native cache's own network policy and per-job authorization bound where it can point.
"""
endpoint = environment.get("ACTIONS_CACHE_URL", "").strip()
if not endpoint:
return "cache unavailable: the runner provided no native cache endpoint, so no cache is restored or saved; the build and tests still run"
parsed = urlsplit(endpoint)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return "cache unavailable: the runner-provided cache endpoint is not an http or https URL, so no cache is restored or saved; the build and tests still run"
return None
def cache_save_unneeded_reason(directory: Path) -> str | None:
"""Return why there is nothing to save, or None when the download directory exists and holds something.
A project with no dependencies never creates its download directory (an npm package with none has no `_cacache`), so saving would
only warn. Absence is reported as absence: it is never treated as a cache hit or a successful save, and no dependency is added to hide it.
"""
if directory.is_symlink() or not directory.is_dir():
return "cache directory was not created by the build, so there is nothing to save"
if not any(directory.iterdir()):
return "cache directory is empty, so there is nothing to save"
return None
def save_allowed(event_name: str, event_ref: str, default_branch: str) -> bool:
"""Return whether this event may write the cache: only a push to the event's own default branch.
Pull requests, tags, feature branches and unknown default-branch metadata never write.
"""
return event_name == "push" and bool(default_branch) and event_ref == f"{_BRANCH_REFERENCE_PREFIX}{default_branch}"
def cache_key(target: Target, descriptors: Mapping[str, str]) -> str:
"""Return the exact key: toolchain, image, platform, component and the digest of the dependency descriptors.
A change to the toolchain version, image digest, platform, component or any descriptor changes the key. Application source is not an input.
"""
descriptor_digest = hashlib.sha256("".join(f"{path}\0{digest}\n" for path, digest in sorted(descriptors.items())).encode("utf-8")).hexdigest()
image_digest = hashlib.sha256(target["image"].encode("utf-8")).hexdigest()
key = "-".join(
(
"platform-ci-deps",
CACHE_KEY_VERSION,
target["build_tool"] if target["build_tool"] in _DOWNLOAD_DIRECTORY_BY_BUILD_TOOL else f"{target['build_tool']}-{target['toolchain']}",
target["toolchain_version"],
target["platform"],
image_digest[:16],
target["component"],
descriptor_digest[:32],
)
)
if _KEY_PATTERN.fullmatch(key) is None:
raise CacheError(f"cache key {key!r} contains characters the cache service does not accept")
return key
def cache_directory(build_tool: str, home: Path, source_directory: Path) -> Path:
"""Return the one download directory for the build tool, after proving it is safe to restore into and save from.
Only the Maven local repository and the npm download cache qualify. The directory must be absolute, outside the
checkout, not a symbolic link, and free of build-output, dependency-install, credential and settings names.
"""
if build_tool not in _DOWNLOAD_DIRECTORY_BY_BUILD_TOOL:
raise CacheError(f"no dependency cache is defined for build tool {build_tool!r}")
if not home.is_absolute() or ".." in home.parts:
raise CacheError(f"home directory {home} must be an absolute path without '..'")
return _proven_cache_path(home.joinpath(*_DOWNLOAD_DIRECTORY_BY_BUILD_TOOL[build_tool]), source_directory)
def _proven_cache_path(path: Path, source_directory: Path) -> Path:
"""Return the path after proving it carries no excluded name, no symbolic link and no overlap with the checkout."""
if _FORBIDDEN_PATH_PARTS.intersection(path.parts):
raise CacheError(f"cache directory {path} contains an excluded path name")
if any(candidate.is_symlink() for candidate in (path.parent, path)):
raise CacheError(f"cache directory {path} is, or sits inside, a symbolic link")
resolved, source_root = path.resolve(), source_directory.resolve()
if resolved.is_relative_to(source_root) or source_root.is_relative_to(resolved):
raise CacheError(f"cache directory {path} overlaps the source checkout")
return path
def dependency_descriptors(target: Target, source_directory: Path) -> dict[str, str]:
"""Return each dependency descriptor's component-relative path mapped to its SHA-256.
Maven: every reactor `pom.xml` plus `.mvn/maven.config`, `.mvn/jvm.config` and `.mvn/extensions.xml`.
Node: every `package.json` plus the lock file. `settings.xml`, `.npmrc` and every other file are never read.
A symbolic link in the place of a descriptor, or in place of a directory that might hold one, is an error.
"""
build_tool = cache_family(target)
if build_tool not in _SKIPPED_DIRECTORIES_BY_BUILD_TOOL:
raise CacheError(f"no dependency cache is defined for build tool {build_tool!r}")
component_directory = resolve_component_directory(source_directory, target["directory"])
skipped = _SKIPPED_DIRECTORIES_BY_BUILD_TOOL[build_tool]
found: dict[str, Path] = {}
for directory, directory_names, file_names in component_directory.walk(follow_symlinks=False):
directory_names[:] = _walkable_directories(directory, directory_names, skipped, component_directory)
for name in file_names:
path = directory / name
relative_path = path.relative_to(component_directory).as_posix()
# with follow_symlinks=False, a linked directory is listed here rather than among the directories
if name not in skipped and path.is_symlink() and path.is_dir():
raise CacheError(f"{relative_path} is a symbolic link, so its dependency descriptors cannot be verified")
if not _is_descriptor(build_tool, directory, name):
continue
if path.is_symlink() or not path.is_file():
raise CacheError(f"dependency descriptor {relative_path} is a symbolic link or not a regular file")
found[relative_path] = path
_require_root_descriptor(build_tool, found)
return {relative_path: _sha256_of_file(path) for relative_path, path in found.items()}
def _walkable_directories(directory: Path, directory_names: list[str], skipped: frozenset[str], component_directory: Path) -> list[str]:
"""Return the sorted subdirectories to descend into; a symbolic link that is not skipped cannot be proven safe."""
walkable: list[str] = []
for name in sorted(directory_names):
if name in skipped:
continue
if (directory / name).is_symlink():
raise CacheError(f"{(directory / name).relative_to(component_directory).as_posix()} is a symbolic link, so its dependency descriptors cannot be verified")
walkable.append(name)
return walkable
def _is_descriptor(build_tool: str, directory: Path, name: str) -> bool:
"""Return whether a file is a dependency descriptor for the build tool."""
if build_tool == "maven":
return name == "pom.xml" or (directory.name == ".mvn" and name in _MAVEN_CONFIG_FILES)
return name in _NODE_DESCRIPTOR_FILES
def _require_root_descriptor(build_tool: str, found: Mapping[str, Path]) -> None:
"""Fail unless the component root has the descriptor its native command needs, so a key never rests on nothing."""
if build_tool == "maven" and "pom.xml" not in found:
raise CacheError("the component directory has no pom.xml")
if build_tool == "node" and not any(name in found for name in _NODE_LOCK_FILES):
raise CacheError("the component directory has no package-lock.json or npm-shrinkwrap.json")
def _sha256_of_file(path: Path) -> str:
"""Return the SHA-256 of a file without loading it into memory."""
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()