Archived
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
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
275 lines
15 KiB
Python
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()
|