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 3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
146 lines
11 KiB
Python
146 lines
11 KiB
Python
"""Configuration validation: valid examples load, anything unknown or unimplemented is rejected clearly."""
|
|
|
|
import tempfile
|
|
import unittest
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
from platform_ci.configuration import ConfigurationError, Report, load_project_configuration, parse_project_configuration
|
|
from tests.support import EXAMPLES_DIRECTORY, example_configuration, example_mapping
|
|
|
|
Mapping = dict[str, object]
|
|
|
|
|
|
def _library(configuration: Mapping) -> Mapping:
|
|
"""Return the Maven example's component mapping for in-place mutation."""
|
|
return cast("Mapping", cast("Mapping", configuration["components"])["library"])
|
|
|
|
|
|
def _snapshot_channel(configuration: Mapping) -> Mapping:
|
|
"""Return the Maven example's release channel mapping for in-place mutation."""
|
|
return cast("Mapping", cast("Mapping", cast("Mapping", configuration["release"])["channels"])["snapshot"])
|
|
|
|
|
|
def _rename_component(configuration: Mapping) -> None:
|
|
"""Rename the component to an invalid identifier."""
|
|
components = cast("Mapping", configuration["components"])
|
|
components["Library"] = components.pop("library")
|
|
|
|
|
|
def _use_commands(configuration: Mapping, commands: object) -> None:
|
|
"""Replace the legacy build arguments with a `commands` value."""
|
|
build = cast("Mapping", _library(configuration)["build"])
|
|
del build["arguments"]
|
|
build["commands"] = commands
|
|
|
|
|
|
def _set_reports(configuration: Mapping, reports: Mapping) -> None:
|
|
"""Replace the component's reports."""
|
|
_library(configuration)["reports"] = reports
|
|
|
|
|
|
REJECTED_CONFIGURATIONS: dict[str, tuple[Callable[[Mapping], object], str]] = {
|
|
"unknown root field": (lambda c: c.update(unexpected=1), "configuration.unexpected: unsupported field"),
|
|
"other schema version": (lambda c: c.update(schema=2), "only schema 1 is supported"),
|
|
"artifact source": (lambda c: cast("Mapping", cast("Mapping", _library(c)["artifacts"])["packages"]).update(source="reactor-outputs"), "source: described by the approved design but not implemented"),
|
|
"unknown release versioning rule": (lambda c: _snapshot_channel(c).update(versioning="whatever"), "unsupported rule 'whatever'"),
|
|
"unsupported artifact format": (lambda c: cast("Mapping", cast("Mapping", _library(c)["artifacts"])["packages"]).update(format="rpm"), "unsupported artifact format 'rpm'"),
|
|
"skip tests setting": (lambda c: _library(c).update(skip_tests=True), "components.library.skip_tests: unsupported field"),
|
|
"unsupported build tool": (lambda c: cast("Mapping", _library(c)["build"]).update(tool="bazel"), "unsupported build tool 'bazel'"),
|
|
"unquoted toolchain version": (lambda c: cast("Mapping", _library(c)["toolchains"]).update(java=21), "expected a non-empty quoted string"),
|
|
"toolchain of another tool": (lambda c: _library(c).update(toolchains={"node": "22"}), "components.library.toolchains.node: unsupported field"),
|
|
"directory escaping the repository": (lambda c: _library(c).update(directory="../elsewhere"), "expected a relative path inside the repository"),
|
|
"unsupported platform": (lambda c: _library(c).update(platforms=["windows-amd64"]), "unsupported platform 'windows-amd64'"),
|
|
"empty build arguments": (lambda c: cast("Mapping", _library(c)["build"]).update(arguments=[]), "expected a non-empty list"),
|
|
"arguments and commands together": (lambda c: cast("Mapping", _library(c)["build"]).update(commands=[["verify"]]), "mutually exclusive"),
|
|
"neither arguments nor commands": (lambda c: cast("Mapping", _library(c)["build"]).pop("arguments"), "one of arguments or commands is required"),
|
|
"empty commands": (lambda c: _use_commands(c, []), "commands: expected a non-empty list"),
|
|
"empty command": (lambda c: _use_commands(c, cast("list[list[str]]", [[]])), "commands[0]: expected a non-empty list"),
|
|
"flat commands list": (lambda c: _use_commands(c, ["verify"]), "commands[0]: expected a non-empty list"),
|
|
"non-text command argument": (lambda c: _use_commands(c, [["verify", 1]]), "commands[0][1]: expected a non-empty quoted string"),
|
|
"executable field": (lambda c: cast("Mapping", _library(c)["build"]).update(executable="sh"), "build.executable: unsupported field"),
|
|
"report of unknown format": (lambda c: _set_reports(c, {"junit": {"format": "xunit", "paths": ["a.xml"]}}), "unsupported report format 'xunit'"),
|
|
"report without format": (lambda c: _set_reports(c, {"junit": {"paths": ["a.xml"]}}), "reports.junit.format: required field is missing"),
|
|
"report without paths": (lambda c: _set_reports(c, {"junit": {"format": "junit"}}), "reports.junit.paths: required field is missing"),
|
|
"report with empty paths": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": []}}), "paths: expected a non-empty list"),
|
|
"report with escaping path": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": ["../a.xml"]}}), "expected a relative path inside the repository"),
|
|
"report required not boolean": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": ["a.xml"], "required": "no"}}), "required: expected true or false"),
|
|
"report unknown field": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": ["a.xml"], "optional": True}}), "reports.junit.optional: unsupported field"),
|
|
"report neither string nor mapping": (lambda c: _set_reports(c, {"junit": 5}), "reports.junit: expected a mapping"),
|
|
"missing build": (lambda c: _library(c).pop("build"), "components.library.build: required field is missing"),
|
|
"invalid component name": (_rename_component, "names are lowercase letters"),
|
|
"release branch glob with a character class": (lambda c: cast("Mapping", _snapshot_channel(c)["trigger"]).update(push_branches=["release/[ab]"]), "expected a branch name or a glob using only * and **"),
|
|
"verification branch glob with a character class": (lambda c: c.update(verification={"push": {"branches": ["release/[ab]"]}}), "verification.push.branches: expected a branch name or a glob using only * and **"),
|
|
"verification branch glob with a run of stars": (lambda c: c.update(verification={"push": {"branches": ["release/***"]}}), "verification.push.branches: expected a branch name or a glob using only * and **"),
|
|
"verification tag character class": (lambda c: c.update(verification={"push": {"tags": ["v[0-9]"]}}), "verification.push.tags: expected a tag name"),
|
|
"verification unknown field": (lambda c: c.update(verification={"push": {"skip": True}}), "verification.push.skip: unsupported field"),
|
|
"verification pull request switch": (lambda c: c.update(verification={"pull_request": False}), "verification.pull_request: unsupported field"),
|
|
"verification branches not a list": (lambda c: c.update(verification={"push": {"branches": "main"}}), "expected a list"),
|
|
}
|
|
|
|
|
|
class ConfigurationValidationTest(unittest.TestCase):
|
|
"""Strict validation of `.ci.yaml`."""
|
|
|
|
def test_examples_are_valid(self) -> None:
|
|
"""Both shipped examples validate and describe what they claim."""
|
|
self.assertEqual(example_configuration("maven.ci.yaml").components[0].build_tool, "maven")
|
|
self.assertEqual(example_configuration("node.ci.yaml").components[0].build_tool, "node")
|
|
|
|
def test_legacy_arguments_normalize_to_one_command(self) -> None:
|
|
"""`build.arguments` becomes a single canonical command; `build.commands` keeps its order."""
|
|
self.assertEqual(example_configuration("maven.ci.yaml").components[0].build_commands, (("--batch-mode", "verify"),))
|
|
self.assertEqual(example_configuration("node-commands.ci.yaml").components[0].build_commands, (("run", "lint"), ("test",), ("run", "build")))
|
|
|
|
def test_legacy_reports_normalize_to_typed_reports(self) -> None:
|
|
"""A `junit` string is a required junit report; any other name is a required file report; objects keep their fields."""
|
|
legacy = example_configuration("maven.ci.yaml").components[0].reports
|
|
self.assertEqual(legacy, {"junit": Report("junit", ("**/target/surefire-reports/*.xml",), required=True)})
|
|
mapping = example_mapping("maven.ci.yaml")
|
|
_set_reports(mapping, {"coverage": "target/jacoco.xml"})
|
|
self.assertEqual(parse_project_configuration(mapping).components[0].reports["coverage"], Report("file", ("target/jacoco.xml",), required=True))
|
|
explicit = example_configuration("maven-reports.ci.yaml").components[0].reports
|
|
self.assertEqual(explicit["junit"], Report("junit", ("**/target/surefire-reports/*.xml", "**/target/failsafe-reports/*.xml"), required=False))
|
|
self.assertTrue(explicit["coverage"].required)
|
|
|
|
def test_push_selection_defaults_and_examples(self) -> None:
|
|
"""Omitting `verification` selects no extra branches and every tag; the examples list their selections explicitly."""
|
|
mapping = example_mapping("maven.ci.yaml")
|
|
del mapping["verification"]
|
|
defaults = parse_project_configuration(mapping).push
|
|
self.assertEqual((defaults.branches, defaults.tags), ((), ("*",)))
|
|
self.assertEqual(example_configuration("maven.ci.yaml").push.branches, ("staging",))
|
|
|
|
def test_unknown_or_unimplemented_configuration_is_rejected(self) -> None:
|
|
"""Each invalid or unimplemented request fails with a message naming the field."""
|
|
for description, (mutate, expected_message) in REJECTED_CONFIGURATIONS.items():
|
|
with self.subTest(description):
|
|
configuration = example_mapping("maven.ci.yaml")
|
|
mutate(configuration)
|
|
with self.assertRaises(ConfigurationError) as raised:
|
|
parse_project_configuration(configuration)
|
|
self.assertIn(expected_message, str(raised.exception))
|
|
|
|
def test_duplicate_keys_are_rejected_instead_of_keeping_the_last_value(self) -> None:
|
|
"""A repeated key, at the root or nested, fails naming the key; the earlier value is never silently dropped."""
|
|
valid_text = (EXAMPLES_DIRECTORY / "maven.ci.yaml").read_text(encoding="utf-8")
|
|
duplicated = {
|
|
"root key": valid_text + "schema: 1\n",
|
|
"nested key": valid_text.replace(' java: "21"\n', ' java: "17"\n java: "21"\n'),
|
|
}
|
|
for description, content in duplicated.items():
|
|
with self.subTest(description), tempfile.TemporaryDirectory() as directory:
|
|
Path(directory, ".ci.yaml").write_text(content, encoding="utf-8")
|
|
with self.assertRaisesRegex(ConfigurationError, "duplicate key"):
|
|
load_project_configuration(Path(directory))
|
|
|
|
def test_missing_empty_and_malformed_files_are_errors(self) -> None:
|
|
"""A repository without usable configuration never passes as an empty build."""
|
|
for description, content in {"missing": None, "empty": "", "malformed": "components: [unclosed"}.items():
|
|
with self.subTest(description), tempfile.TemporaryDirectory() as directory:
|
|
if content is not None:
|
|
Path(directory, ".ci.yaml").write_text(content, encoding="utf-8")
|
|
with self.assertRaises(ConfigurationError):
|
|
load_project_configuration(Path(directory))
|