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/tests/test_version_source.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

270 lines
16 KiB
Python

"""The explicit native version source of a `script` component: one `.properties` file and key, read the same way at packaging and through the forge.
UNTESTED: written while test execution was suspended. Source-only: temporary directories, `unittest.mock` for the forge file read, no network, no process.
The literal case is the captured Android library: `VERSION_NAME=0.1.1-SNAPSHOT` in `qqq/android/core/gradle.properties` (and the same value in a second module's file).
"""
import tempfile
import unittest
import urllib.error
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast, override
from unittest import mock
from platform_ci.build_inputs import compute_build_inputs
from platform_ci.configuration import ConfigurationError, parse_project_configuration
from platform_ci.delivery.selection import declared_version_at
from platform_ci.packaging import RunIdentity, collect_outputs
from platform_ci.plan import Target, VersionSourceTarget
from platform_ci.versions import UNDECLARED, UNVERSIONED, parse_declared_version, parse_properties_version, read_declared_version
from platform_ci.workspace import PipelineError
from tests.support import PLATFORM_REVISION, SOURCE_REVISION, SOURCE_TREE, event, plan_for
CORE = "qqq/android/core/gradle.properties"
SOURCE: VersionSourceTarget = {"file": CORE, "property": "VERSION_NAME"}
LITERAL = b"## POM_LICENSE_NAME=Apache License, Version 2.0\nVERSION_NAME=0.1.1-SNAPSHOT\n"
def script_configuration(version: object = SOURCE, tool: str = "script", directory: str = ".") -> dict[str, object]:
"""A one-component configuration whose script (or other) tool optionally declares an explicit version source."""
build: dict[str, object] = {"tool": "script", "commands": [["./scripts/ci/verify.sh"]]} if tool == "script" else {"tool": tool, "arguments": ["verify"]}
component: dict[str, object] = {"directory": directory, "toolchains": {"java": "21"}, "build": build, "artifacts": {"outputs": {"paths": ["out/*.txt"]}}}
if version is not None:
component["version"] = version
return {"schema": 1, "components": {"android": component}}
def absent_file(*_arguments: object) -> None:
"""Stand in for a forge file read that finds no file."""
def unsupported_file(*_arguments: object) -> bytes:
"""Stand in for a forge file read that returns a declaration with unsupported syntax."""
return b"VERSION_NAME=1.0.0 \n"
def planned(**options: Any) -> Target:
"""Plan the configuration against the shared test catalog and return its target."""
return plan_for(parse_project_configuration(script_configuration(**options)), event())["targets"][0]
class PropertiesReaderTest(unittest.TestCase):
"""Only plain literal `key=value` is supported; everything else is `undeclared` (absent or unresolved) or an explicit error (unsupported syntax)."""
def test_the_captured_android_declaration_reads_as_its_literal(self) -> None:
"""A commented-out declaration of other keys is ignored and the key's value is returned exactly."""
self.assertEqual(parse_properties_version(LITERAL, "VERSION_NAME"), "0.1.1-SNAPSHOT")
def test_the_accepted_separator_forms_agree(self) -> None:
"""`=`, `:` and whitespace separators, with or without surrounding blanks, are the same plain declaration."""
for line in ("VERSION_NAME=1.2.3", "VERSION_NAME = 1.2.3", "VERSION_NAME: 1.2.3", " VERSION_NAME 1.2.3", "VERSION_NAME\t=\t1.2.3"):
with self.subTest(line):
self.assertEqual(parse_properties_version(f"{line}\n".encode(), "VERSION_NAME"), "1.2.3")
def test_missing_duplicate_empty_or_unresolved_is_undeclared_never_a_fallback(self) -> None:
"""Nothing is guessed: a version that is not exactly one literal is `undeclared`."""
cases = {
"missing": b"OTHER=1.0.0\n",
"only a prefix key": b"VERSION_NAME_SUFFIX=1.0.0\n",
"commented out": b"# VERSION_NAME=1.0.0\n! VERSION_NAME=1.0.0\n",
"duplicate": b"VERSION_NAME=1.0.0\nVERSION_NAME=1.0.0\n",
"empty": b"VERSION_NAME=\n",
"no value": b"VERSION_NAME\n",
"property reference": b"VERSION_NAME=${major}.0.0\n",
"dollar": b"VERSION_NAME=$major\n",
}
for label, content in cases.items():
with self.subTest(label):
self.assertEqual(parse_properties_version(content, "VERSION_NAME"), UNDECLARED)
def test_unsupported_syntax_is_an_explicit_error(self) -> None:
"""A continuation, an escape in a key, a backslash or trailing blank in the value, a non-literal value or non-UTF-8 text is refused, not misread."""
cases = {
"continuation hides the next line": b"OTHER=a\\\nVERSION_NAME=1.0.0\n",
"escaped key": b"VERSION\\_NAME=1.0.0\n",
"backslash in the value": b"VERSION_NAME=1.0\\u0030\n",
"trailing space": b"VERSION_NAME=1.0.0 \n",
"not a version literal": b"VERSION_NAME=one point oh\n",
"not utf-8": b"VERSION_NAME=1.0.0\n\xff\n",
}
for label, content in cases.items():
with self.subTest(label), self.assertRaises(PipelineError):
parse_properties_version(content, "VERSION_NAME")
def test_an_even_run_of_backslashes_is_not_a_continuation(self) -> None:
"""A line ending in an escaped backslash does not continue, so the next line keeps its meaning."""
self.assertEqual(parse_properties_version(b"PATH=C:\\\\\nVERSION_NAME=2.0.0\n", "VERSION_NAME"), "2.0.0")
def test_the_default_behavior_for_other_tools_is_unchanged(self) -> None:
"""No explicit source: Maven reads its POM, a tool with no version file is unversioned, and a script component without a source is unversioned."""
pom = b'<project xmlns="http://maven.apache.org/POM/4.0.0"><version>3.0.0</version></project>'
self.assertEqual(parse_declared_version("maven", pom), "3.0.0")
self.assertEqual(parse_declared_version("script", b"VERSION_NAME=1.0.0"), UNVERSIONED)
self.assertEqual(parse_declared_version("script", b"VERSION_NAME=1.0.0", (), SOURCE), "1.0.0")
with tempfile.TemporaryDirectory() as directory:
self.assertEqual(read_declared_version("script", Path(directory)), UNVERSIONED)
class LocalReadTest(unittest.TestCase):
"""The packaging-side read of the explicit source stays inside the component directory."""
@override
def setUp(self) -> None:
"""A component directory holding the captured layout."""
scratch = tempfile.TemporaryDirectory()
self.addCleanup(scratch.cleanup)
self.root = Path(scratch.name) / "component"
(self.root / "qqq" / "android" / "core").mkdir(parents=True)
(self.root / CORE).write_bytes(LITERAL)
self.scratch = Path(scratch.name)
def test_the_file_is_read_relative_to_the_component_directory(self) -> None:
"""The declared relative path is resolved under the directory and its key read."""
self.assertEqual(read_declared_version("script", self.root, (), SOURCE), "0.1.1-SNAPSHOT")
def test_a_missing_or_oversized_file_is_undeclared(self) -> None:
"""A file that is not there, or is larger than the cap, never falls back to anything else."""
self.assertEqual(read_declared_version("script", self.root, (), {"file": "missing.properties", "property": "VERSION_NAME"}), UNDECLARED)
(self.root / "big.properties").write_bytes(b"#" * (4 * 1024 * 1024 + 1))
self.assertEqual(read_declared_version("script", self.root, (), {"file": "big.properties", "property": "VERSION_NAME"}), UNDECLARED)
def test_a_link_in_the_path_or_a_link_leaving_the_directory_is_undeclared(self) -> None:
"""A symlinked directory component or file, even one aimed outside, is never followed."""
outside = self.scratch / "outside"
outside.mkdir()
(outside / "gradle.properties").write_bytes(b"VERSION_NAME=9.9.9\n")
try:
(self.root / "linked").symlink_to(outside, target_is_directory=True)
(self.root / "file.properties").symlink_to(outside / "gradle.properties")
except (OSError, NotImplementedError):
self.skipTest("this host cannot create symbolic links")
self.assertEqual(read_declared_version("script", self.root, (), {"file": "linked/gradle.properties", "property": "VERSION_NAME"}), UNDECLARED)
self.assertEqual(read_declared_version("script", self.root, (), {"file": "file.properties", "property": "VERSION_NAME"}), UNDECLARED)
class ConfigurationTest(unittest.TestCase):
"""The declaration is validated, script-only, and part of the target's identity only when present."""
def test_a_script_component_carries_the_source_into_its_target(self) -> None:
"""The plan target holds exactly the declared file and property."""
target = planned()
self.assertEqual(target.get("version_source"), SOURCE)
def test_a_component_without_the_field_has_no_trace_of_it(self) -> None:
"""No `version_source` key at all, so existing targets and their config digests are untouched."""
target = planned(version=None)
self.assertNotIn("version_source", target)
self.assertEqual(read_declared_version(target["build_tool"], Path(), target["commands"], target.get("version_source")), UNVERSIONED)
def test_the_declaration_is_part_of_the_build_input_identity(self) -> None:
"""Changing the file or the property, or adding the declaration, changes the config digest, even for the same tree and version."""
base = compute_build_inputs(planned(), SOURCE_TREE, "1.0.0")
other_file = compute_build_inputs(planned(version={"file": "qqq/android/mobileapp/gradle.properties", "property": "VERSION_NAME"}), SOURCE_TREE, "1.0.0")
other_key = compute_build_inputs(planned(version={"file": CORE, "property": "VERSION"}), SOURCE_TREE, "1.0.0")
none = compute_build_inputs(planned(version=None), SOURCE_TREE, "1.0.0")
self.assertEqual(len({base["config_digest"], other_file["config_digest"], other_key["config_digest"], none["config_digest"]}), 4)
self.assertEqual(compute_build_inputs(planned(), SOURCE_TREE, "1.0.0"), base)
def test_invalid_declarations_are_refused_by_name(self) -> None:
"""Only the script tool, only a literal relative `.properties` path inside the component, and a plain key."""
bad: dict[str, object] = {
"not a mapping": "gradle.properties",
"no file": {"property": "VERSION_NAME"},
"no property": {"file": CORE},
"unknown field": {"file": CORE, "property": "VERSION_NAME", "expression": "1+1"},
"absolute": {"file": "/etc/gradle.properties", "property": "VERSION_NAME"},
"escapes": {"file": "../gradle.properties", "property": "VERSION_NAME"},
"inner escape": {"file": "a/../../gradle.properties", "property": "VERSION_NAME"},
"backslash": {"file": "qqq\\gradle.properties", "property": "VERSION_NAME"},
"glob": {"file": "qqq/*.properties", "property": "VERSION_NAME"},
"wrong suffix": {"file": "qqq/gradle.txt", "property": "VERSION_NAME"},
"hidden start": {"file": ".gradle.properties", "property": "VERSION_NAME"},
"bad key": {"file": CORE, "property": "VERSION NAME"},
"key with expression": {"file": CORE, "property": "${x}"},
}
for label, version in bad.items():
with self.subTest(label), self.assertRaises(ConfigurationError):
parse_project_configuration(script_configuration(version=version))
def test_only_the_script_tool_may_declare_one(self) -> None:
"""Maven and the other tools keep their own version file; an explicit source there is refused rather than ignored."""
with self.assertRaisesRegex(ConfigurationError, "script tool"):
parse_project_configuration(script_configuration(tool="maven"))
class PackagingAndForgeAgreeTest(unittest.TestCase):
"""The same declaration yields the same version when packaged locally and when read independently through the forge."""
@override
def setUp(self) -> None:
"""A checkout with the captured file and one declared artifact."""
scratch = tempfile.TemporaryDirectory()
self.addCleanup(scratch.cleanup)
self.root = Path(scratch.name)
self.source = self.root / "checkout"
(self.source / "qqq" / "android" / "core").mkdir(parents=True)
(self.source / CORE).write_bytes(LITERAL)
(self.source / "out").mkdir()
(self.source / "out" / "library.txt").write_text("bytes", encoding="utf-8")
def test_the_bundle_records_the_declared_version_in_its_build_inputs(self) -> None:
"""`collect_outputs` reads the explicit source and records its literal, with no inference from any artifact."""
target = planned()
manifest = collect_outputs(target, self.source, self.root / "bundle", RunIdentity(SOURCE_REVISION, PLATFORM_REVISION, "7", "1", SOURCE_TREE), build_succeeded=True)
self.assertEqual(cast("dict[str, Any]", manifest["build_inputs"])["version"], "0.1.1-SNAPSHOT")
def test_the_forge_read_names_the_same_path_and_yields_the_same_version(self) -> None:
"""`declared_version_at` asks for directory/file at the commit and parses the same bytes with the same reader."""
target = planned()
requested: list[tuple[str, str, str]] = []
def read(_forge: object, repository: str, path: str, ref: str) -> bytes | None:
requested.append((repository, path, ref))
return (self.source / path).read_bytes()
scope = SimpleNamespace(repository="Org/repo")
with mock.patch("platform_ci.delivery.selection.read_file", read):
version = declared_version_at(cast("Any", object()), cast("Any", scope), target, "a" * 40)
self.assertEqual(requested, [("Org/repo", CORE, "a" * 40)])
self.assertEqual(version, read_declared_version(target["build_tool"], self.source, target["commands"], target.get("version_source")))
def test_a_nested_component_directory_prefixes_the_forge_path(self) -> None:
"""The source path is relative to the component directory on both sides."""
target = planned(directory="android")
requested: list[str] = []
def read(_forge: object, _repository: str, path: str, _ref: str) -> bytes | None:
requested.append(path)
return LITERAL
with mock.patch("platform_ci.delivery.selection.read_file", read):
declared_version_at(cast("Any", object()), cast("Any", SimpleNamespace(repository="Org/repo")), target, "a" * 40)
self.assertEqual(requested, [f"android/{CORE}"])
def test_an_absent_or_unreadable_file_at_the_commit_is_undeclared(self) -> None:
"""A missing file or an HTTP error never falls back to another version source."""
target = planned()
scope = cast("Any", SimpleNamespace(repository="Org/repo"))
with mock.patch("platform_ci.delivery.selection.read_file", absent_file):
self.assertEqual(declared_version_at(cast("Any", object()), scope, target, "a" * 40), UNDECLARED)
def failing(*_arguments: object) -> bytes:
raise urllib.error.HTTPError("https://forge.invalid", 500, "error", cast("Any", None), None)
with mock.patch("platform_ci.delivery.selection.read_file", failing):
self.assertEqual(declared_version_at(cast("Any", object()), scope, target, "a" * 40), UNDECLARED)
def test_a_declaration_with_unsupported_syntax_fails_on_both_sides(self) -> None:
"""The packaging read and the forge read raise the same explicit error, so neither side can disagree by guessing."""
(self.source / CORE).write_bytes(b"VERSION_NAME=1.0.0 \n")
target = planned()
with self.assertRaises(PipelineError):
read_declared_version(target["build_tool"], self.source, target["commands"], target.get("version_source"))
with mock.patch("platform_ci.delivery.selection.read_file", unsupported_file), self.assertRaises(PipelineError):
declared_version_at(cast("Any", object()), cast("Any", SimpleNamespace(repository="Org/repo")), target, "a" * 40)
if __name__ == "__main__":
unittest.main()