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>
185 lines
9.1 KiB
Python
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
|