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_packaging.py
forge-recoveryandClaude Sonnet 5.5 fd4689fd19
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
feat(ci): reuse verified builds before merge verification
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:54:30 +00:00

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)