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>
270 lines
16 KiB
Python
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()
|