From 9e9a22fcec8175e88dfb72a1a20bf95a729eb334 Mon Sep 17 00:00:00 2001 From: yuqei Date: Sun, 20 Sep 2026 14:14:49 +0800 Subject: [PATCH] release: fix RC-04 single-platform archive export --- yuqei-infra/scripts/linux_release_common.py | 29 +--- .../scripts/run-linux-release-build.py | 160 +++++++++--------- .../tests/test_linux_release_build_tools.py | 97 +++++++++++ 3 files changed, 182 insertions(+), 104 deletions(-) diff --git a/yuqei-infra/scripts/linux_release_common.py b/yuqei-infra/scripts/linux_release_common.py index 94d6ed9f..e2cdcfe7 100644 --- a/yuqei-infra/scripts/linux_release_common.py +++ b/yuqei-infra/scripts/linux_release_common.py @@ -1743,29 +1743,14 @@ def inspect_oci_archive(path: Path) -> dict[str, Any]: "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json", }: - nested = _require_mapping(load_json_bytes(manifest_data, label="nested OCI index"), "nested OCI index") - nested_descriptors = _require_list(nested.get("manifests"), "nested OCI index manifests") - matches = [] - for candidate in nested_descriptors: - platform = _require_mapping( - _require_mapping(candidate, "nested OCI descriptor").get("platform"), - "nested OCI descriptor platform", - ) - if platform.get("os") == "linux" and platform.get("architecture") == "amd64": - matches.append(candidate) - if len(matches) != 1: - raise ReleaseValidationError("OCI archive does not contain exactly one linux/amd64 image") - descriptor, manifest_data = _verify_oci_descriptor( - archive, matches[0], label="OCI linux/amd64 manifest descriptor" + raise ReleaseValidationError( + "OCI top-level descriptor must reference one image manifest directly, not an image index" ) - assert manifest_data is not None - media_type = descriptor.get("mediaType") - else: - platform = descriptor.get("platform") - if isinstance(platform, dict) and ( - platform.get("os") != "linux" or platform.get("architecture") != "amd64" - ): - raise ReleaseValidationError("OCI descriptor platform is not linux/amd64") + platform = descriptor.get("platform") + if isinstance(platform, dict) and ( + platform.get("os") != "linux" or platform.get("architecture") != "amd64" + ): + raise ReleaseValidationError("OCI descriptor platform is not linux/amd64") if media_type not in { "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json", diff --git a/yuqei-infra/scripts/run-linux-release-build.py b/yuqei-infra/scripts/run-linux-release-build.py index 1000fd1d..47820142 100644 --- a/yuqei-infra/scripts/run-linux-release-build.py +++ b/yuqei-infra/scripts/run-linux-release-build.py @@ -897,6 +897,64 @@ def validate_release_inputs( } +def _image_build_command( + *, + spec: Mapping[str, Any], + commit: str, + tag: str, + builder_name: str, + source_date_epoch: int, + dockerfile: str | Path, + oci_output: str | Path, + docker_output: str | Path, +) -> list[str]: + """Return the single-platform build command shared by plan and execution. + + ``BUILDKIT_MULTI_PLATFORM=1`` is intentionally absent. Even with one + explicit target platform, that BuildKit option changes the result into a + manifest list, which the required Docker archive exporter rejects. + """ + + role = spec["role"] + image_ref = f"{spec['name']}:{commit}" + return [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "build", + "--builder", + builder_name, + "--platform", + TARGET["platform"], + "--file", + str(dockerfile), + "--tag", + image_ref, + "--label", + f"org.opencontainers.image.revision={commit}", + "--label", + f"org.opencontainers.image.version={tag}", + "--label", + f"org.opencontainers.image.title={spec['name']}", + "--label", + f"io.yuqei.release.role={role}", + "--build-arg", + f"SOURCE_DATE_EPOCH={source_date_epoch}", + "--build-arg", + f"{spec['base_image']['build_arg']}={spec['base_image']['transport_reference']}", + "--pull", + "--no-cache", + "--provenance=false", + "--sbom=false", + "--output", + f"type=oci,dest={oci_output}", + "--output", + f"type=docker,dest={docker_output}", + ".", + ] + + def create_build_plan( *, repo: Path, @@ -980,47 +1038,16 @@ def create_build_plan( "scan": scan_path, }, "commands": [ - [ - PINNED_EXECUTABLES["docker"], - "--host", - LOCAL_DOCKER_HOST, - "buildx", - "build", - "--builder", - builder_name, - "--platform", - TARGET["platform"], - "--file", - spec["dockerfile"], - "--tag", - image_ref, - "--label", - f"org.opencontainers.image.revision={commit}", - "--label", - f"org.opencontainers.image.version={tag}", - "--label", - f"org.opencontainers.image.title={spec['name']}", - "--label", - f"io.yuqei.release.role={role}", - "--build-arg", - f"SOURCE_DATE_EPOCH={source_date_epoch}", - "--build-arg", - "BUILDKIT_MULTI_PLATFORM=1", - "--build-arg", - ( - f"{spec['base_image']['build_arg']}=" - f"{spec['base_image']['transport_reference']}" - ), - "--pull", - "--no-cache", - "--provenance=false", - "--sbom=false", - "--output", - f"type=oci,dest=/{oci_path}", - "--output", - f"type=docker,dest=/{docker_path}", - ".", - ], + _image_build_command( + spec=spec, + commit=commit, + tag=tag, + builder_name=builder_name, + source_date_epoch=source_date_epoch, + dockerfile=spec["dockerfile"], + oci_output=f"/{oci_path}", + docker_output=f"/{docker_path}", + ), [ PINNED_EXECUTABLES["syft"], "scan", @@ -1370,47 +1397,16 @@ def execute_build( scan_path.parent.mkdir(parents=True, exist_ok=True) runner( - [ - PINNED_EXECUTABLES["docker"], - "--host", - LOCAL_DOCKER_HOST, - "buildx", - "build", - "--builder", - builder_name, - "--platform", - TARGET["platform"], - "--file", - str(dockerfile), - "--tag", - image_ref, - "--label", - f"org.opencontainers.image.revision={commit}", - "--label", - f"org.opencontainers.image.version={tag}", - "--label", - f"org.opencontainers.image.title={spec['name']}", - "--label", - f"io.yuqei.release.role={role}", - "--build-arg", - f"SOURCE_DATE_EPOCH={source_date_epoch}", - "--build-arg", - "BUILDKIT_MULTI_PLATFORM=1", - "--build-arg", - ( - f"{spec['base_image']['build_arg']}=" - f"{spec['base_image']['transport_reference']}" - ), - "--pull", - "--no-cache", - "--provenance=false", - "--sbom=false", - "--output", - f"type=oci,dest={oci_path}", - "--output", - f"type=docker,dest={docker_path}", - ".", - ], + _image_build_command( + spec=spec, + commit=commit, + tag=tag, + builder_name=builder_name, + source_date_epoch=source_date_epoch, + dockerfile=dockerfile, + oci_output=oci_path, + docker_output=docker_path, + ), cwd=source_root, timeout_seconds=COMMAND_TIMEOUTS_SECONDS["image_build"], ) diff --git a/yuqei-infra/tests/test_linux_release_build_tools.py b/yuqei-infra/tests/test_linux_release_build_tools.py index 3afb170e..122f7d28 100644 --- a/yuqei-infra/tests/test_linux_release_build_tools.py +++ b/yuqei-infra/tests/test_linux_release_build_tools.py @@ -288,6 +288,52 @@ def _write_oci( return common.inspect_oci_archive(path) +def _wrap_oci_in_nested_index(path: Path, platforms: list[dict[str, str]]) -> None: + with tarfile.open(path, "r") as archive: + entries: dict[str, bytes] = {} + for member in archive.getmembers(): + if not member.isfile(): + continue + source = archive.extractfile(member) + assert source is not None + entries[member.name] = source.read() + outer_index = json.loads(entries["index.json"]) + image_descriptor = outer_index["manifests"][0] + nested = json.dumps( + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + {**image_descriptor, "platform": platform} for platform in platforms + ], + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + nested_digest = hashlib.sha256(nested).hexdigest() + entries[f"blobs/sha256/{nested_digest}"] = nested + entries["index.json"] = json.dumps( + { + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.index.v1+json", + "digest": f"sha256:{nested_digest}", + "size": len(nested), + } + ], + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + with tarfile.open(path, "w") as archive: + for name, data in sorted(entries.items()): + info = tarfile.TarInfo(name) + info.mode = 0o644 + info.size = len(data) + archive.addfile(info, BytesIO(data)) + + def _write_docker_archive( path: Path, role: str, @@ -815,12 +861,38 @@ def test_plan_mode_validates_commit_tag_manifest_archive_and_orders_four_images( assert plan["compose_gate"]["execution_engine"] == "self_bound_release_runner_builtin_v1" assert not (tmp_path / "never-created").exists() for image in plan["images"]: + build_command = image["commands"][0] flattened = " ".join(" ".join(command) for command in image["commands"]) assert "linux/amd64" in flattened assert "spdx-json" in flattened assert "HIGH,CRITICAL" in flattened assert "type=oci" in flattened and "type=docker" in flattened assert "--builder fixture-builder" in flattened + assert build_command.count("--platform") == 1 + platform_index = build_command.index("--platform") + assert build_command[platform_index + 1] == "linux/amd64" + output_indexes = [ + index for index, token in enumerate(build_command) if token == "--output" + ] + assert [build_command[index + 1].split(",", 1)[0] for index in output_indexes] == [ + "type=oci", + "type=docker", + ] + assert "--provenance=false" in build_command + assert "--sbom=false" in build_command + build_arg_indexes = [ + index for index, token in enumerate(build_command) if token == "--build-arg" + ] + assert [build_command[index + 1] for index in build_arg_indexes] == [ + f"SOURCE_DATE_EPOCH={source['epoch']}", + ( + f"{image['base_image']['build_arg']}=" + f"{image['base_image']['transport_reference']}" + ), + ] + # Forcing BuildKit's multi-platform result shape makes the Docker + # archive exporter reject even a one-target result as a manifest list. + assert all("BUILDKIT_MULTI_PLATFORM" not in token for token in build_command) assert common.TRIVY_DB_REPOSITORY_TRANSPORT["reference"] in flattened trivy_command = image["commands"][2] assert trivy_command[trivy_command.index("--input") + 1].endswith(".docker.tar") @@ -1582,6 +1654,31 @@ def test_docker_archive_rejects_layer_content_not_bound_to_config(tmp_path: Path common.inspect_docker_archive(path, expected_repo_tag=image_ref) +@pytest.mark.parametrize( + "platforms", + [ + [{"os": "linux", "architecture": "amd64"}], + [ + {"os": "linux", "architecture": "amd64"}, + {"os": "linux", "architecture": "arm64"}, + ], + ], + ids=["single-item-index", "multi-platform-index"], +) +def test_oci_archive_rejects_nested_image_index( + tmp_path: Path, platforms: list[dict[str, str]] +) -> None: + path = tmp_path / "nested-index.oci.tar" + _write_oci(path, "contract-api", "yuqei/contract-api", "a" * 40, "v2-test") + _wrap_oci_in_nested_index(path, platforms) + + with pytest.raises( + common.ReleaseValidationError, + match="must reference one image manifest directly", + ): + common.inspect_oci_archive(path) + + def test_oci_archive_rejects_descriptor_valid_layer_not_bound_to_config_diff_id( tmp_path: Path, ) -> None: