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>
186 lines
11 KiB
Python
186 lines
11 KiB
Python
"""Output collection: required and nonempty, hashed, bound to the source revision, and unable to build."""
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import cast, override
|
|
from unittest import mock
|
|
|
|
from platform_ci import packaging
|
|
from platform_ci.packaging import MANIFEST_FILE_NAME, PackagingError, RunIdentity, collect_outputs, manifest_digest
|
|
from platform_ci.plan import Target
|
|
from tests.support import PLATFORM_REVISION, SOURCE_REVISION, SOURCE_TREE, event, example_configuration, plan_for
|
|
|
|
REPORT_CONTENT = b"<testsuite tests='1'/>"
|
|
JAR_CONTENT = b"packaged bytes"
|
|
|
|
|
|
def _files(manifest: dict[str, object]) -> list[dict[str, object]]:
|
|
"""Return the manifest's file entries with a usable type."""
|
|
return cast("list[dict[str, object]]", manifest["files"])
|
|
|
|
|
|
class OutputCollectionTest(unittest.TestCase):
|
|
"""Collection reads what the verification build left behind and records evidence about it."""
|
|
|
|
@override
|
|
def setUp(self) -> None:
|
|
"""Create a checkout holding Maven-shaped build output and the Maven example's target."""
|
|
directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(directory.cleanup)
|
|
self.source = Path(directory.name) / "checkout"
|
|
self.output = Path(directory.name) / "bundle"
|
|
(self.source / "target" / "surefire-reports").mkdir(parents=True)
|
|
(self.source / "target" / "surefire-reports" / "TEST-a.xml").write_bytes(REPORT_CONTENT)
|
|
(self.source / "target" / "app.jar").write_bytes(JAR_CONTENT)
|
|
self.target = plan_for(example_configuration("maven.ci.yaml"), event())["targets"][0]
|
|
self.identity = RunIdentity(SOURCE_REVISION, PLATFORM_REVISION, "42", "1", SOURCE_TREE)
|
|
|
|
def test_collects_declared_outputs_with_hashes_and_run_metadata(self) -> None:
|
|
"""Every declared file is copied and recorded with its size and SHA-256, plus source and run identity."""
|
|
manifest = collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
hashes = {entry["path"]: entry["sha256"] for entry in _files(manifest)}
|
|
self.assertEqual(hashes["artifact/packages/target/app.jar"], hashlib.sha256(JAR_CONTENT).hexdigest())
|
|
self.assertEqual(hashes["report/junit/target/surefire-reports/TEST-a.xml"], hashlib.sha256(REPORT_CONTENT).hexdigest())
|
|
self.assertEqual((self.output / "artifact" / "packages" / "target" / "app.jar").read_bytes(), JAR_CONTENT)
|
|
self.assertEqual((manifest["source_revision"], manifest["platform_revision"], manifest["run_id"]), (SOURCE_REVISION, PLATFORM_REVISION, "42"))
|
|
self.assertEqual(json.loads((self.output / MANIFEST_FILE_NAME).read_text(encoding="utf-8")), manifest)
|
|
|
|
def test_digest_is_bound_to_source_revision_and_file_hashes(self) -> None:
|
|
"""Changing the source revision or any file hash changes the manifest digest."""
|
|
manifest = collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
self.assertEqual(manifest["digest"], manifest_digest(manifest))
|
|
|
|
other_source = copy.deepcopy(manifest)
|
|
other_source["source_revision"] = "c" * 40
|
|
self.assertNotEqual(manifest_digest(other_source), manifest["digest"])
|
|
|
|
tampered = copy.deepcopy(manifest)
|
|
_files(tampered)[0]["sha256"] = "0" * 64
|
|
self.assertNotEqual(manifest_digest(tampered), manifest["digest"])
|
|
|
|
def test_missing_and_empty_outputs_fail_after_a_successful_build(self) -> None:
|
|
"""A declared artifact or report that is absent or empty is an error, never a silent pass."""
|
|
(self.source / "target" / "app.jar").write_bytes(b"")
|
|
with self.assertRaisesRegex(PackagingError, "is empty"):
|
|
collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
(self.source / "target" / "app.jar").unlink()
|
|
with self.assertRaisesRegex(PackagingError, "artifact 'packages' matched no files"):
|
|
collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
self.assertFalse(self.output.exists())
|
|
|
|
def test_failed_build_keeps_reports_only(self) -> None:
|
|
"""After a failed build the reports are kept for diagnosis and no artifact is collected."""
|
|
manifest = collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=False)
|
|
self.assertFalse(manifest["build_succeeded"])
|
|
self.assertEqual([entry["kind"] for entry in _files(manifest)], ["report"])
|
|
self.assertFalse((self.output / "artifact").exists())
|
|
|
|
def _optional_junit_target(self) -> Target:
|
|
"""Return the Maven example's target with its JUnit report optional and a second required file report."""
|
|
target = copy.deepcopy(self.target)
|
|
target["reports"] = {"junit": {"format": "junit", "paths": ["**/target/surefire-reports/*.xml"], "required": False}}
|
|
return target
|
|
|
|
def test_absent_optional_report_passes_and_present_one_is_retained(self) -> None:
|
|
"""An optional report that matches nothing is fine after a successful build; one that matches is kept."""
|
|
target = self._optional_junit_target()
|
|
kept = collect_outputs(target, self.source, self.output, self.identity, build_succeeded=True)
|
|
self.assertIn("report/junit/target/surefire-reports/TEST-a.xml", {entry["path"] for entry in _files(kept)})
|
|
(self.source / "target" / "surefire-reports" / "TEST-a.xml").unlink()
|
|
absent = collect_outputs(target, self.source, self.source.parent / "bundle-absent", self.identity, build_succeeded=True)
|
|
self.assertEqual([entry["kind"] for entry in _files(absent)], ["artifact"])
|
|
|
|
def test_missing_required_report_fails_after_a_successful_build(self) -> None:
|
|
"""A required report (the default) that matches nothing fails, even with every artifact present."""
|
|
(self.source / "target" / "surefire-reports" / "TEST-a.xml").unlink()
|
|
with self.assertRaisesRegex(PackagingError, "report 'junit' matched no files"):
|
|
collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
self.assertFalse(self.output.exists())
|
|
|
|
def test_every_path_of_a_report_is_collected(self) -> None:
|
|
"""A typed report with several globs retains the files of all of them."""
|
|
(self.source / "target" / "failsafe-reports").mkdir()
|
|
(self.source / "target" / "failsafe-reports" / "TEST-b.xml").write_bytes(REPORT_CONTENT)
|
|
target = copy.deepcopy(self.target)
|
|
target["reports"]["junit"]["paths"] = ["target/surefire-reports/*.xml", "target/failsafe-reports/*.xml"]
|
|
manifest = collect_outputs(target, self.source, self.output, self.identity, build_succeeded=True)
|
|
reports = {str(entry["path"]) for entry in _files(manifest) if entry["kind"] == "report"}
|
|
self.assertEqual(reports, {"report/junit/target/surefire-reports/TEST-a.xml", "report/junit/target/failsafe-reports/TEST-b.xml"})
|
|
|
|
def test_failed_build_with_optional_report_and_missing_artifact_keeps_available_reports(self) -> None:
|
|
"""A failed build never requires anything: available reports are kept, nothing else, and no error is raised."""
|
|
(self.source / "target" / "app.jar").unlink()
|
|
manifest = collect_outputs(self._optional_junit_target(), self.source, self.output, self.identity, build_succeeded=False)
|
|
self.assertEqual([entry["kind"] for entry in _files(manifest)], ["report"])
|
|
|
|
def test_artifact_requirements_still_fail_when_reports_are_optional(self) -> None:
|
|
"""Making reports optional does not relax artifacts: a missing artifact still fails a successful build."""
|
|
(self.source / "target" / "app.jar").unlink()
|
|
with self.assertRaisesRegex(PackagingError, "artifact 'packages' matched no files"):
|
|
collect_outputs(self._optional_junit_target(), self.source, self.output, self.identity, build_succeeded=True)
|
|
|
|
def _broad_target(self) -> Target:
|
|
"""Return the Maven target with an artifact glob that matches every file under the checkout."""
|
|
target = copy.deepcopy(self.target)
|
|
target["artifacts"] = {"everything": ["**/*"]}
|
|
return target
|
|
|
|
def test_broad_glob_excludes_git_metadata_platform_checkout_and_output(self) -> None:
|
|
"""`**/*` collects build files only, and never copies the bundle into itself."""
|
|
(self.source / ".git").mkdir()
|
|
(self.source / ".git" / "config").write_bytes(b"[core]")
|
|
(self.source / ".platform-ci" / "platform_ci").mkdir(parents=True)
|
|
(self.source / ".platform-ci" / "platform_ci" / "packaging.py").write_bytes(b"implementation")
|
|
output = self.source / "ci-output"
|
|
manifest = collect_outputs(self._broad_target(), self.source, output, self.identity, build_succeeded=True)
|
|
collected = {str(entry["path"]) for entry in _files(manifest) if entry["kind"] == "artifact"}
|
|
self.assertEqual(collected, {"artifact/everything/target/app.jar", "artifact/everything/target/surefire-reports/TEST-a.xml"})
|
|
self.assertFalse(any(".git" in path or ".platform-ci" in path or "ci-output" in path for path in collected))
|
|
|
|
def test_symlink_escaping_the_component_directory_is_rejected(self) -> None:
|
|
"""A link to a file or directory outside the checkout fails collection and leaves no bundle."""
|
|
outside = Path(self.source.parent) / "outside"
|
|
outside.mkdir()
|
|
(outside / "secret.txt").write_bytes(b"not part of the build")
|
|
for name, destination in {"stolen-file.jar": outside / "secret.txt", "stolen-directory.jar": outside}.items():
|
|
with self.subTest(name):
|
|
link = self.source / "target" / name
|
|
try:
|
|
link.symlink_to(destination, target_is_directory=destination.is_dir())
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("this platform does not allow the test to create symbolic links")
|
|
with self.assertRaisesRegex(PackagingError, "resolves outside the component directory"):
|
|
collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
self.assertFalse(self.output.exists())
|
|
link.unlink()
|
|
|
|
def test_symlink_into_git_metadata_is_rejected(self) -> None:
|
|
"""A link inside the checkout that points at Git metadata is an error, not a collected file."""
|
|
(self.source / ".git").mkdir()
|
|
(self.source / ".git" / "config").write_bytes(b"[core]")
|
|
link = self.source / "target" / "config.jar"
|
|
try:
|
|
link.symlink_to(self.source / ".git" / "config")
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("this platform does not allow the test to create symbolic links")
|
|
with self.assertRaisesRegex(PackagingError, "resolves into Git metadata"):
|
|
collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
|
|
def test_packaging_cannot_invoke_a_build(self) -> None:
|
|
"""Collection works with every way of starting a process disabled, and the module has no such capability."""
|
|
|
|
def refuse(*_arguments: object, **_keywords: object) -> None:
|
|
raise AssertionError("packaging must not start a process")
|
|
|
|
with mock.patch.object(subprocess, "run", refuse), mock.patch.object(subprocess, "Popen", refuse), mock.patch.object(os, "system", refuse):
|
|
collect_outputs(self.target, self.source, self.output, self.identity, build_succeeded=True)
|
|
source_text = Path(packaging.__file__).read_text(encoding="utf-8")
|
|
for forbidden in ("subprocess", "os.system", "platform_ci.execution"):
|
|
self.assertNotIn(forbidden, source_text)
|