diff --git a/eng/docker-tools/CHANGELOG.md b/eng/docker-tools/CHANGELOG.md
index 99dbceb69..5ef04a0d6 100644
--- a/eng/docker-tools/CHANGELOG.md
+++ b/eng/docker-tools/CHANGELOG.md
@@ -4,6 +4,15 @@ All breaking changes and new features in `eng/docker-tools` will be documented i
---
+## 2026-06-01: Post_Build validates manifest list platform completeness
+
+- Pull request: [#2126](https://github.com/dotnet/docker-tools/pull/2126)
+
+The `Create Manifest Lists` step in the `Post_Build` stage now fails if a generated multi-arch manifest tag would omit platforms expected by `manifest.json`.
+This is evaluated only after attempting to import missing platform images into staging in the case of a path-filtered build.
+
+---
+
## 2026-04-02: Extra Docker build options can be passed through ImageBuilder
- Pull request: [#2063](https://github.com/dotnet/docker-tools/pull/2063)
diff --git a/eng/docker-tools/DEV-GUIDE.md b/eng/docker-tools/DEV-GUIDE.md
index 28ddc471b..035116fc4 100644
--- a/eng/docker-tools/DEV-GUIDE.md
+++ b/eng/docker-tools/DEV-GUIDE.md
@@ -143,6 +143,7 @@ Build Stage
▼
Post_Build Stage
├── Merge image info files
+ ├── Create multi-arch manifests
└── Consolidate SBOMs
│
▼
@@ -191,7 +192,7 @@ Common patterns:
- `"publish"` - Publish only (when re-running a failed publish from a previous build)
- `"build,test,sign,publish"` - Full pipeline
-**Note:** The `Post_Build` stage is implicitly included whenever `build` is in the stages list. You don't need to specify it separately—it automatically runs after Build to merge image info files and consolidate SBOMs.
+**Note:** The `Post_Build` stage is implicitly included whenever `build` is in the stages list. You don't need to specify it separately—it automatically runs after Build to merge image info files, create and validate multi-arch manifests, and consolidate SBOMs.
The stages variable is useful for:
- Re-running just the publish stage after fixing a transient failure
diff --git a/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs b/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs
index 0164637b8..3fee0d7aa 100644
--- a/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs
+++ b/src/ImageBuilder.Tests/CreateManifestListCommandTests.cs
@@ -496,6 +496,64 @@ public async Task ExecuteAsync_DoesNotPortImagesThatAreAbsentFromImageInfo()
Times.Never);
}
+ ///
+ /// Verifies that a missing manifest platform is reported when the old-build
+ /// import path has no platform to port forward.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_ThrowsMissingPlatform_WhenNoOldBuildImportExists()
+ {
+ Mock manifestServiceMock = new(MockBehavior.Strict);
+ Mock dockerServiceMock = new();
+ Mock copyImageServiceMock = new();
+
+ CreateManifestListCommand command = CreateCommand(
+ CreateManifestServiceFactoryMock(manifestServiceMock),
+ dockerServiceMock,
+ copyImageServiceMock,
+ Mock.Of());
+
+ using TempFolderContext tempFolderContext = TestHelper.UseTempFolder();
+
+ string dockerfileAmd64 = CreateDockerfile("1.0/repo/linux-amd64", tempFolderContext);
+ string dockerfileArm64 = CreateDockerfile("1.0/repo/linux-arm64", tempFolderContext);
+
+ Manifest manifest = CreateManifest(
+ CreateRepo("repo",
+ CreateImage(
+ ["sharedtag"],
+ CreatePlatform(dockerfileAmd64, ["tag-amd64"]),
+ CreatePlatform(dockerfileArm64, ["tag-arm64"], architecture: Architecture.ARM64))));
+
+ ImageArtifactDetails imageArtifactDetails = CreateImageArtifactDetails(
+ CreateRepoData("repo",
+ CreateImageData(
+ CreatePlatform(dockerfileAmd64, simpleTags: ["tag-amd64"]))));
+
+ SetupCommand(command, manifest, imageArtifactDetails, tempFolderContext);
+
+ InvalidOperationException exception = await Should.ThrowAsync(
+ () => command.ExecuteAsync());
+
+ exception.Message.ShouldContain("Generated manifest list tags are missing expected platforms defined in the manifest");
+ exception.Message.ShouldContain("repo:sharedtag");
+ exception.Message.ShouldContain("linux/arm64");
+ exception.Message.ShouldContain(dockerfileArm64);
+
+ manifestServiceMock.Verify(
+ o => o.GetManifestAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ copyImageServiceMock.Verify(o => o.ImportImageAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ dockerServiceMock.Verify(o => o.CreateManifestList(
+ It.IsAny(), It.IsAny>(), It.IsAny()),
+ Times.Never);
+ }
+
///
/// Verifies that when a manifest-declared platform is missing from image-info AND
/// the source registry returns 404 for its tag (e.g. a misconfigured --path
diff --git a/src/ImageBuilder.Tests/ManifestListHelperTests.cs b/src/ImageBuilder.Tests/ManifestListHelperTests.cs
index 6e8bd4593..b902d26e6 100644
--- a/src/ImageBuilder.Tests/ManifestListHelperTests.cs
+++ b/src/ImageBuilder.Tests/ManifestListHelperTests.cs
@@ -102,6 +102,79 @@ public void GetManifestListsForImages_OnlyIncludesBuiltPlatforms()
results[0].PlatformTags.ShouldNotContain("repo:tag-windows");
}
+ ///
+ /// Verifies that validation reports generated manifest lists that are missing expected platforms.
+ ///
+ [Fact]
+ public void GetManifestListPlatformValidationIssues_MissingPlatform()
+ {
+ using TempFolderContext tempFolderContext = TestHelper.UseTempFolder();
+
+ string dockerfileAmd64 = CreateDockerfile("1.0/repo/linux-amd64", tempFolderContext);
+ string dockerfileArm64 = CreateDockerfile("1.0/repo/linux-arm64", tempFolderContext);
+ string dockerfileWindows = CreateDockerfile("1.0/repo/windows", tempFolderContext);
+
+ Manifest manifest = CreateManifest(
+ CreateRepo("repo",
+ CreateImage(
+ ["sharedtag"],
+ CreatePlatform(dockerfileAmd64, ["tag-amd64"]),
+ CreatePlatform(dockerfileArm64, ["tag-arm64"], architecture: Architecture.ARM64),
+ CreatePlatform(dockerfileWindows, ["tag-windows"], os: OS.Windows, osVersion: "ltsc2022"))));
+
+ ImageArtifactDetails imageArtifactDetails = CreateImageArtifactDetails(
+ CreateRepoData("repo",
+ CreateImageData(
+ ["sharedtag"],
+ CreatePlatform(dockerfileAmd64, simpleTags: ["tag-amd64"]),
+ CreatePlatform(dockerfileArm64, simpleTags: ["tag-arm64"], architecture: "arm64"))));
+
+ ManifestInfo manifestInfo = LoadManifest(manifest, tempFolderContext);
+ ImageArtifactDetails linkedImageInfo = LoadImageInfo(imageArtifactDetails, manifestInfo, tempFolderContext);
+
+ IReadOnlyList issues =
+ ManifestListHelper.GetManifestListPlatformValidationIssues(
+ manifestInfo, linkedImageInfo, repoPrefix: null);
+
+ issues.Count.ShouldBe(1);
+ issues[0].ManifestListTag.ShouldBe("repo:sharedtag");
+ issues[0].MissingPlatforms.Count.ShouldBe(1);
+ issues[0].MissingPlatforms[0].ShouldContain("windows/amd64");
+ issues[0].MissingPlatforms[0].ShouldContain(dockerfileWindows);
+ }
+
+ ///
+ /// Verifies that validation succeeds when every generated manifest list has all expected platforms.
+ ///
+ [Fact]
+ public void ValidateManifestListPlatforms_AllPlatformsPresent()
+ {
+ using TempFolderContext tempFolderContext = TestHelper.UseTempFolder();
+
+ string dockerfileAmd64 = CreateDockerfile("1.0/repo/linux-amd64", tempFolderContext);
+ string dockerfileArm64 = CreateDockerfile("1.0/repo/linux-arm64", tempFolderContext);
+
+ Manifest manifest = CreateManifest(
+ CreateRepo("repo",
+ CreateImage(
+ ["sharedtag"],
+ CreatePlatform(dockerfileAmd64, ["tag-amd64"]),
+ CreatePlatform(dockerfileArm64, ["tag-arm64"], architecture: Architecture.ARM64))));
+
+ ImageArtifactDetails imageArtifactDetails = CreateImageArtifactDetails(
+ CreateRepoData("repo",
+ CreateImageData(
+ ["sharedtag"],
+ CreatePlatform(dockerfileAmd64, simpleTags: ["tag-amd64"]),
+ CreatePlatform(dockerfileArm64, simpleTags: ["tag-arm64"], architecture: "arm64"))));
+
+ ManifestInfo manifestInfo = LoadManifest(manifest, tempFolderContext);
+ ImageArtifactDetails linkedImageInfo = LoadImageInfo(imageArtifactDetails, manifestInfo, tempFolderContext);
+
+ Should.NotThrow(() => ManifestListHelper.ValidateManifestListPlatforms(
+ manifestInfo, linkedImageInfo, repoPrefix: null));
+ }
+
///
/// Verifies that no manifest list is returned when an image has shared tags
/// but no platforms exist in image-info.
diff --git a/src/ImageBuilder/Commands/CreateManifestListCommand.cs b/src/ImageBuilder/Commands/CreateManifestListCommand.cs
index 22ae8c061..213941dd1 100644
--- a/src/ImageBuilder/Commands/CreateManifestListCommand.cs
+++ b/src/ImageBuilder/Commands/CreateManifestListCommand.cs
@@ -90,6 +90,9 @@ await _registryCredentialsProvider.ExecuteWithCredentialsAsync(
AddPlatformToImageInfo(platformToImport);
}
+ ManifestListHelper.ValidateManifestListPlatforms(
+ Manifest, imageArtifactDetails, Options.RepoPrefix);
+
// Build the manifest-list definitions from the now-complete image-info.
IReadOnlyList manifestLists =
ManifestListHelper.GetManifestListsForImages(Manifest, imageArtifactDetails, Options.RepoPrefix);
@@ -192,7 +195,7 @@ imageData.Manifest is not null
ManifestQueryResult result = await _manifestService.Value.GetManifestAsync(sourceName, isDryRun: false);
- var platformImportData = new PlatformImportData(
+ PlatformImportData platformImportData = new(
Repo: imageData.ManifestRepo,
Image: imageData.ManifestImage,
Platform: platform,
diff --git a/src/ImageBuilder/ManifestListHelper.cs b/src/ImageBuilder/ManifestListHelper.cs
index 97ef8128b..91ff86ac5 100644
--- a/src/ImageBuilder/ManifestListHelper.cs
+++ b/src/ImageBuilder/ManifestListHelper.cs
@@ -18,6 +18,13 @@ namespace Microsoft.DotNet.ImageBuilder;
/// The fully-qualified platform image tags included in this manifest list.
public record ManifestListInfo(string Tag, IReadOnlyList PlatformTags);
+///
+/// Describes platforms that are expected by the manifest but missing from a generated manifest list.
+///
+/// The fully-qualified manifest list tag.
+/// Descriptions of the expected platforms missing from the tag.
+public record ManifestListPlatformValidationIssue(string ManifestListTag, IReadOnlyList MissingPlatforms);
+
///
/// Determines which Docker manifest lists should be created based on
/// the manifest definition and which platforms were actually built.
@@ -35,7 +42,65 @@ public static IReadOnlyList GetManifestListsForImages(
ImageArtifactDetails imageArtifactDetails,
string? repoPrefix)
{
- IEnumerable<(RepoInfo Repo, ImageInfo Image)> imagesWithBuiltPlatforms = manifest.FilteredRepos
+ IEnumerable<(RepoInfo Repo, ImageInfo Image)> imagesWithBuiltPlatforms =
+ GetImagesWithBuiltPlatforms(manifest, imageArtifactDetails);
+
+ return imagesWithBuiltPlatforms
+ .SelectMany(pair => GetManifestListsForImage(pair.Repo, pair.Image, manifest, imageArtifactDetails, repoPrefix))
+ .ToList()
+ .AsReadOnly();
+ }
+
+ ///
+ /// Validates that each generated manifest list contains every platform expected by the manifest.
+ ///
+ ///
+ /// Thrown when one or more generated manifest list tags would omit expected platforms.
+ ///
+ public static void ValidateManifestListPlatforms(
+ ManifestInfo manifest,
+ ImageArtifactDetails imageArtifactDetails,
+ string? repoPrefix)
+ {
+ IReadOnlyList issues = GetManifestListPlatformValidationIssues(
+ manifest, imageArtifactDetails, repoPrefix);
+
+ if (issues.Count == 0)
+ {
+ return;
+ }
+
+ string details = string.Join(
+ Environment.NewLine,
+ issues.Select(issue =>
+ $"- {issue.ManifestListTag}: {string.Join(", ", issue.MissingPlatforms)}"));
+
+ throw new InvalidOperationException(
+ $"Generated manifest list tags are missing expected platforms defined in the manifest:{Environment.NewLine}{details}");
+ }
+
+ ///
+ /// Gets validation issues for generated manifest lists that would omit expected platforms.
+ ///
+ public static IReadOnlyList GetManifestListPlatformValidationIssues(
+ ManifestInfo manifest,
+ ImageArtifactDetails imageArtifactDetails,
+ string? repoPrefix)
+ {
+ IEnumerable<(RepoInfo Repo, ImageInfo Image)> imagesWithBuiltPlatforms =
+ GetImagesWithBuiltPlatforms(manifest, imageArtifactDetails);
+
+ return imagesWithBuiltPlatforms
+ .SelectMany(pair => GetManifestListPlatformValidationIssuesForImage(
+ pair.Repo, pair.Image, manifest, imageArtifactDetails, repoPrefix))
+ .ToList()
+ .AsReadOnly();
+ }
+
+ private static IEnumerable<(RepoInfo Repo, ImageInfo Image)> GetImagesWithBuiltPlatforms(
+ ManifestInfo manifest,
+ ImageArtifactDetails imageArtifactDetails) =>
+ manifest.FilteredRepos
.SelectMany(repo =>
repo.FilteredImages
.Where(image => image.SharedTags.Any())
@@ -45,12 +110,6 @@ public static IReadOnlyList GetManifestListsForImages(
.Select(image => (repo, image)))
.ToList();
- return imagesWithBuiltPlatforms
- .SelectMany(pair => GetManifestListsForImage(pair.Repo, pair.Image, manifest, imageArtifactDetails, repoPrefix))
- .ToList()
- .AsReadOnly();
- }
-
private static IEnumerable GetManifestListsForImage(
RepoInfo repo,
ImageInfo image,
@@ -86,6 +145,39 @@ private static IEnumerable GetManifestListsForImage(
return primaryManifestLists.Concat(syndicatedManifestLists);
}
+ private static IEnumerable GetManifestListPlatformValidationIssuesForImage(
+ RepoInfo repo,
+ ImageInfo image,
+ ManifestInfo manifest,
+ ImageArtifactDetails imageArtifactDetails,
+ string? repoPrefix)
+ {
+ IEnumerable primaryManifestListIssues = GetManifestListPlatformValidationIssuesForTags(
+ repo, image, imageArtifactDetails,
+ image.SharedTags.Select(tag => tag.Name),
+ tag => DockerHelper.GetImageName(manifest.Registry, repoPrefix + repo.Name, tag),
+ platform => platform.Tags.First());
+
+ IEnumerable> syndicatedTagGroups = image.SharedTags
+ .Where(tag => tag.SyndicatedRepo != null)
+ .GroupBy(tag => tag.SyndicatedRepo);
+
+ IEnumerable syndicatedManifestListIssues = syndicatedTagGroups
+ .SelectMany(syndicatedTags =>
+ {
+ string syndicatedRepo = syndicatedTags.Key;
+ IEnumerable destinationTags = syndicatedTags.SelectMany(tag => tag.SyndicatedDestinationTags);
+
+ return GetManifestListPlatformValidationIssuesForTags(
+ repo, image, imageArtifactDetails,
+ destinationTags,
+ tag => DockerHelper.GetImageName(manifest.Registry, repoPrefix + syndicatedRepo, tag),
+ platform => platform.Tags.FirstOrDefault(tag => tag.SyndicatedRepo == syndicatedRepo));
+ });
+
+ return primaryManifestListIssues.Concat(syndicatedManifestListIssues);
+ }
+
private static IEnumerable GetManifestListsForTags(
RepoInfo repo,
ImageInfo image,
@@ -99,6 +191,20 @@ private static IEnumerable GetManifestListsForTags(
.OfType();
}
+ private static IEnumerable GetManifestListPlatformValidationIssuesForTags(
+ RepoInfo repo,
+ ImageInfo image,
+ ImageArtifactDetails imageArtifactDetails,
+ IEnumerable tags,
+ Func getImageName,
+ Func getTagRepresentative)
+ {
+ return tags
+ .Select(tag => BuildManifestListPlatformValidationIssue(
+ repo, image, imageArtifactDetails, tag, getImageName, getTagRepresentative))
+ .OfType();
+ }
+
private static ManifestListInfo? BuildManifestListInfo(
RepoInfo repo,
ImageInfo image,
@@ -122,56 +228,101 @@ private static IEnumerable GetManifestListsForTags(
}
TagInfo? imageTag;
- if (platform.Tags.Any())
+ imageTag = GetPlatformTagRepresentative(repo, image, platform, getTagRepresentative, throwIfMissing: true);
+
+ if (imageTag is not null)
{
- imageTag = getTagRepresentative(platform);
+ platformTags.Add(getImageName(imageTag.Name));
}
- else
+ }
+
+ if (platformTags.Count == 0)
+ {
+ return null;
+ }
+
+ return new ManifestListInfo(manifestListTag, platformTags.AsReadOnly());
+ }
+
+ private static ManifestListPlatformValidationIssue? BuildManifestListPlatformValidationIssue(
+ RepoInfo repo,
+ ImageInfo image,
+ ImageArtifactDetails imageArtifactDetails,
+ string tag,
+ Func getImageName,
+ Func getTagRepresentative)
+ {
+ string manifestListTag = getImageName(tag);
+ List missingPlatforms = [];
+ bool hasExpectedPlatform = false;
+
+ foreach (PlatformInfo platform in image.AllPlatforms)
+ {
+ TagInfo? imageTag = GetPlatformTagRepresentative(repo, image, platform, getTagRepresentative, throwIfMissing: false);
+ if (imageTag is null)
{
- // This platform has no tags of its own (it's a "tagless" platform included
- // only via shared tags). To reference it in the manifest list, we need a tag.
- // Search all images in the repo for a different platform that builds the same
- // Dockerfile/OS/arch and does have tags, then borrow one of its tags.
- (ImageInfo Image, PlatformInfo Platform)? matchingPlatform =
- repo.AllImages
- .SelectMany(candidateImage =>
- candidateImage.AllPlatforms
+ continue;
+ }
+
+ hasExpectedPlatform = true;
+
+ if (ImageInfoHelper.GetMatchingPlatformData(platform, repo, imageArtifactDetails) is null)
+ {
+ missingPlatforms.Add(GetPlatformDescription(platform));
+ }
+ }
+
+ if (!hasExpectedPlatform || missingPlatforms.Count == 0)
+ {
+ return null;
+ }
+
+ return new ManifestListPlatformValidationIssue(manifestListTag, missingPlatforms.AsReadOnly());
+ }
+
+ private static TagInfo? GetPlatformTagRepresentative(
+ RepoInfo repo,
+ ImageInfo image,
+ PlatformInfo platform,
+ Func getTagRepresentative,
+ bool throwIfMissing)
+ {
+ if (platform.Tags.Any())
+ {
+ return getTagRepresentative(platform);
+ }
+
+ // Tagless platforms included by shared tags need a matching concrete tag to reference in the manifest list.
+ (ImageInfo Image, PlatformInfo Platform)? matchingPlatform =
+ repo.AllImages
+ .SelectMany(candidateImage =>
+ candidateImage.AllPlatforms
.Select(candidatePlatform => (Image: candidateImage, Platform: candidatePlatform))
.Where(candidate =>
- // Exclude the current platform itself
platform != candidate.Platform
- // Must be the same Dockerfile, OS, and architecture
&& PlatformInfo.AreMatchingPlatforms(
- image1: candidateImage,
+ image1: image,
platform1: platform,
image2: candidate.Image,
platform2: candidate.Platform)
- // Must actually have tags we can borrow
- && candidate.Platform.Tags.Any()
- )
- )
- // Cast to nullable so FirstOrDefault returns null (not a default struct)
- // when no match is found, allowing the ?? to throw.
- .Cast<(ImageInfo Image, PlatformInfo Platform)?>()
- .FirstOrDefault()
- ?? throw new InvalidOperationException(
- $"Could not find a platform with concrete tags for"
- + $" '{platform.DockerfilePathRelativeToManifest}'.");
-
- imageTag = getTagRepresentative(matchingPlatform.Value.Platform);
- }
+ && candidate.Platform.Tags.Any()))
+ .Cast<(ImageInfo Image, PlatformInfo Platform)?>()
+ .FirstOrDefault();
- if (imageTag is not null)
- {
- platformTags.Add(getImageName(imageTag.Name));
- }
+ if (matchingPlatform is not null)
+ {
+ return getTagRepresentative(matchingPlatform.Value.Platform);
}
- if (platformTags.Count == 0)
+ if (throwIfMissing)
{
- return null;
+ throw new InvalidOperationException(
+ $"Could not find a platform with concrete tags for '{platform.DockerfilePathRelativeToManifest}'.");
}
- return new ManifestListInfo(manifestListTag, platformTags.AsReadOnly());
+ return null;
}
+
+ private static string GetPlatformDescription(PlatformInfo platform) =>
+ $"{platform.PlatformLabel} ({platform.DockerfilePathRelativeToManifest})";
}