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/versions.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

185 lines
9.1 KiB
Python

"""Read the version a component's own native file declares, with no process and no build.
The build side records this value in the artifact manifest, and the trusted publisher reads the same file at the build and release
commits through the forge to prove the record. A tool with no version file is `unversioned`. A version the file and the declared
command line do not resolve to a literal is `undeclared`, which no versioned release channel accepts.
Maven's CI-friendly versions are honored: a version such as `${revision}${sha1}${changelist}` is resolved from the pom's own
`<properties>`, and a `-Dname=value` in a declared Maven command overrides the pom exactly as it does for the build itself. An
expression that still cannot be resolved (for example `${env.X}`, or a property nothing defines) is `undeclared` rather than guessed.
XML is parsed with the same declaration-refusing expat pass the JUnit reader uses, so a DTD or entity is refused in any encoding.
"""
import json
import re
from collections.abc import Mapping, Sequence
from pathlib import Path, PurePosixPath
from typing import Final, cast
from xml.etree import ElementTree
from xml.parsers import expat
from platform_ci.build_tools import BUILD_TOOLS
from platform_ci.plan import VersionSourceTarget
from platform_ci.safe_xml import ForbiddenDeclarationError, parse_without_declarations
from platform_ci.workspace import PipelineError
UNVERSIONED: Final = "unversioned"
UNDECLARED: Final = "undeclared"
_MAX_VERSION_FILE_BYTES: Final = 4 * 1024 * 1024
_MAX_RESOLUTION_PASSES: Final = 10
_PROPERTY_PATTERN: Final = re.compile(r"\$\{([^${}]+)\}")
_PROPERTY_WHITESPACE: Final = " \t\f"
_PROPERTY_KEY_PATTERN: Final = re.compile(r"[^\s=:]*")
_VERSION_LITERAL_PATTERN: Final = re.compile(r"[0-9A-Za-z][0-9A-Za-z._+-]*")
def version_file_name(build_tool: str) -> str:
"""Return the native file that declares the tool's version, or an empty string when the tool has none."""
return BUILD_TOOLS[build_tool].version_file
def version_file_path(build_tool: str, source: VersionSourceTarget | None = None) -> str:
"""Return the component-relative file that declares the version: the component's explicit `version` source, else the tool's own file (empty when it has none)."""
return source["file"] if source is not None else version_file_name(build_tool)
def read_declared_version(build_tool: str, component_directory: Path, commands: Sequence[Sequence[str]] = (), source: VersionSourceTarget | None = None) -> str:
"""Read the declared version of a component directory; a missing, linked or oversized version file is `undeclared`, not a build failure.
With an explicit `source` (a `.properties` file and a key) that file is read instead of the tool's own: no path component may be a link and the
file must stay inside the component directory, otherwise the version is `undeclared`.
"""
file_name = version_file_path(build_tool, source)
if not file_name:
return UNVERSIONED
path = component_directory
for part in PurePosixPath(file_name).parts:
path = path / part
if source is not None and path.is_symlink():
return UNDECLARED
if source is not None and not path.resolve().is_relative_to(component_directory.resolve()):
return UNDECLARED
if not path.is_file() or path.is_symlink() or path.stat().st_size > _MAX_VERSION_FILE_BYTES:
return UNDECLARED
return parse_declared_version(build_tool, path.read_bytes(), commands, source)
def parse_declared_version(build_tool: str, content: bytes, commands: Sequence[Sequence[str]] = (), source: VersionSourceTarget | None = None) -> str:
"""Return the literal version a version file declares (after Maven property resolution), or `undeclared`."""
if source is not None:
return parse_properties_version(content, source["property"])
file_name = version_file_name(build_tool)
if not file_name:
return UNVERSIONED
return _pom_version(content, command_properties(commands)) if file_name == "pom.xml" else _package_json_version(content)
def parse_properties_version(content: bytes, key: str) -> str:
"""Return the literal value of one key of a Java `.properties` file, or `undeclared`; only plain `key=value` / `key: value` lines are supported.
Missing, duplicated, empty or unresolved (`$`) values are `undeclared`, never guessed. Syntax whose meaning this reader does not implement is an error
instead of a silent misreading: a line continuation (it would change which text a later line means), a backslash in any key or in the version's own
value, trailing whitespace on the value (significant to Java), a value that is not a plain version literal, and text that is not UTF-8. Comment
lines (`#`, `!`) are ignored. No expression is evaluated and nothing is substituted.
"""
try:
text = content.decode("utf-8")
except UnicodeDecodeError as error:
raise PipelineError("the version properties file is not UTF-8") from error
values: list[str] = []
for number, raw in enumerate(re.split(r"\r\n|\r|\n", text), start=1):
line = raw.lstrip(_PROPERTY_WHITESPACE)
if not line or line[0] in "#!":
continue
if (len(raw) - len(raw.rstrip("\\"))) % 2 == 1:
raise PipelineError(f"the version properties file continues line {number} with a backslash, which is not supported")
found = _PROPERTY_KEY_PATTERN.match(line)
name = found[0] if found else ""
if "\\" in name:
raise PipelineError(f"the version properties file has a backslash in a key on line {number}, which is not supported")
remainder = line[len(name) :].lstrip(_PROPERTY_WHITESPACE)
if remainder[:1] in ("=", ":"):
remainder = remainder[1:].lstrip(_PROPERTY_WHITESPACE)
if name != key:
continue
if "\\" in remainder:
raise PipelineError(f"the version value on line {number} contains a backslash, which is not supported")
values.append(remainder)
if len(values) != 1:
return UNDECLARED
value = values[0]
if value != value.rstrip():
raise PipelineError("the version value has trailing whitespace, which Java properties keep and this reader does not support")
if not value or "$" in value:
return UNDECLARED
if _VERSION_LITERAL_PATTERN.fullmatch(value) is None:
raise PipelineError("the version value is not a plain version literal")
return value
def command_properties(commands: Sequence[Sequence[str]]) -> dict[str, str]:
"""Collect `-Dname=value` system properties from declared commands; a later command overrides an earlier one."""
properties: dict[str, str] = {}
for command in commands:
for token in command:
if token.startswith("-D") and "=" in token:
name, _, value = token[2:].partition("=")
if name:
properties[name] = value
return properties
def _pom_version(content: bytes, overrides: Mapping[str, str]) -> str:
"""Return the POM's own version, else its parent's, with `${property}` references resolved; unresolved means undeclared."""
try:
root = parse_without_declarations(content)
except ForbiddenDeclarationError as error:
raise PipelineError("pom.xml declares a DTD or entity, which is not allowed") from error
except expat.ExpatError as error:
raise PipelineError("pom.xml is not well-formed XML") from error
version = (_child_text(root, "version") or _child_text(_child(root, "parent"), "version")).strip()
properties = {**_pom_properties(root), **overrides}
for _ in range(_MAX_RESOLUTION_PASSES):
resolved = _PROPERTY_PATTERN.sub(lambda match: properties.get(match.group(1), match.group(0)), version)
if resolved == version:
break
version = resolved
return version if version and "${" not in version else UNDECLARED
def _pom_properties(root: ElementTree.Element) -> dict[str, str]:
"""Return the POM's `<properties>` as name to trimmed text."""
properties = _child(root, "properties")
if properties is None:
return {}
return {_local_name(element.tag): (element.text or "").strip() for element in properties}
def _child(parent: ElementTree.Element | None, name: str) -> ElementTree.Element | None:
"""Return the first direct child with a local (namespace-free) name."""
if parent is None:
return None
return next((child for child in parent if _local_name(child.tag) == name), None)
def _child_text(parent: ElementTree.Element | None, name: str) -> str:
"""Return a direct child's text, or an empty string."""
child = _child(parent, name)
return "" if child is None else child.text or ""
def _local_name(tag: str) -> str:
"""Strip an XML namespace from a tag."""
return tag.rsplit("}", 1)[-1]
def _package_json_version(content: bytes) -> str:
"""Return package.json's `version` string, or `undeclared` when it has none."""
try:
document: object = json.loads(content)
except (UnicodeDecodeError, ValueError) as error:
raise PipelineError(f"package.json is not valid JSON: {error}") from error
version = cast("dict[str, object]", document).get("version") if isinstance(document, dict) else None
return version if isinstance(version, str) and version else UNDECLARED