Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions eng/docker-tools/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All breaking changes and new features in `eng/docker-tools` will be documented i

---

## 2026-05-26: Post_Build can validate manifest list platform completeness

The build/test templates now accept `validateManifestListPlatforms` (boolean, default `false`). When enabled, Post_Build passes `--validate-manifest-list-platforms` to ImageBuilder's `createManifestList` command and fails if a generated multi-arch manifest tag would omit platforms expected by `manifest.json`.

Enable this for normal official production builds where manifest tags should represent the full manifest-defined platform set. Leave it disabled for intentionally partial runs such as PR validation, filtered builds, platform bring-up, or temporary infrastructure recovery.

---

## 2026-04-02: Extra Docker build options can be passed through ImageBuilder

- Pull request: [#2063](https://github.com/dotnet/docker-tools/pull/2063)
Expand Down
5 changes: 4 additions & 1 deletion eng/docker-tools/DEV-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ Build Stage
Post_Build Stage
├── Merge image info files
├── Create multi-arch manifests
└── Consolidate SBOMs
Expand Down Expand Up @@ -191,7 +192,9 @@ 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 multi-arch manifests, and consolidate SBOMs.

Set `validateManifestListPlatforms: true` on the build/test template to make Post_Build fail if any generated multi-arch manifest tag would omit platforms that are expected by `manifest.json`. This is recommended for normal official production builds, where `manifest.json` is the source of truth for the image platform surface. Leave it disabled for intentionally partial runs, such as PR validation, filtered builds, platform bring-up, or temporary infrastructure recovery where producing a partial manifest tag is expected.

The stages variable is useful for:
- Re-running just the publish stage after fixing a transient failure
Expand Down
6 changes: 6 additions & 0 deletions eng/docker-tools/templates/jobs/post-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ parameters:
publicProjectName: null
customInitSteps: []
publishConfig: null
validateManifestListPlatforms: false

jobs:
- job: Build
Expand All @@ -14,6 +15,10 @@ jobs:
imageInfosContainerDir: "$(artifactsPath)$(imageInfosSubDir)"
imageInfosOutputSubDir: "/output"
sbomOutputDir: "$(Build.ArtifactStagingDirectory)/sbom"
${{ if eq(parameters.validateManifestListPlatforms, true) }}:
validateManifestListPlatformsArg: "--validate-manifest-list-platforms"
${{ else }}:
validateManifestListPlatformsArg: ""
steps:
- template: /eng/docker-tools/templates/steps/init-common.yml@self
parameters:
Expand Down Expand Up @@ -95,6 +100,7 @@ jobs:
--architecture '*'
--manifest '$(manifest)'
--registry-override '${{ parameters.publishConfig.BuildRegistry.server }}'
$(validateManifestListPlatformsArg)
$(manifestVariables)
- template: /eng/docker-tools/templates/steps/publish-artifact.yml@self
parameters:
Expand Down
2 changes: 2 additions & 0 deletions eng/docker-tools/templates/stages/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ parameters:
linuxArmTestJobTimeout: 60
windowsAmdTestJobTimeout: 60
noCache: false
validateManifestListPlatforms: false
publishConfig: null

internalProjectName: null
Expand Down Expand Up @@ -221,6 +222,7 @@ stages:
publicProjectName: ${{ parameters.publicProjectName }}
customInitSteps: ${{ parameters.customInitSteps }}
publishConfig: ${{ parameters.publishConfig }}
validateManifestListPlatforms: ${{ parameters.validateManifestListPlatforms }}

################################################################################
# Sign Images
Expand Down
2 changes: 2 additions & 0 deletions eng/docker-tools/templates/stages/dotnet/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ parameters:

# Build parameters
noCache: false
validateManifestListPlatforms: false
publishConfig: null
buildMatrixType: platformDependencyGraph
buildMatrixCustomBuildLegGroupArgs: ""
Expand All @@ -46,6 +47,7 @@ stages:
- template: /eng/docker-tools/templates/stages/build-and-test.yml@self
parameters:
noCache: ${{ parameters.noCache }}
validateManifestListPlatforms: ${{ parameters.validateManifestListPlatforms }}
publishConfig: ${{ parameters.publishConfig }}
internalProjectName: ${{ parameters.internalProjectName }}
publicProjectName: ${{ parameters.publicProjectName }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ parameters:

# Build parameters
noCache: false
validateManifestListPlatforms: false
publishConfig: null
buildMatrixType: platformDependencyGraph
buildMatrixCustomBuildLegGroupArgs: ""
Expand Down Expand Up @@ -50,6 +51,7 @@ stages:
customCopyBaseImagesInitSteps: ${{ parameters.customCopyBaseImagesInitSteps }}
# Build
noCache: ${{ parameters.noCache }}
validateManifestListPlatforms: ${{ parameters.validateManifestListPlatforms }}
publishConfig: ${{ parameters.publishConfig }}
buildMatrixType: ${{ parameters.buildMatrixType }}
buildMatrixCustomBuildLegGroupArgs: ${{ parameters.buildMatrixCustomBuildLegGroupArgs }}
Expand Down
44 changes: 44 additions & 0 deletions src/ImageBuilder.Tests/CreateManifestListCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,50 @@ public async Task ExecuteAsync_OnlyCreatesForBuiltPlatforms()
false));
}

/// <summary>
/// Verifies that platform validation fails before creating partial manifest lists when enabled.
/// </summary>
[Fact]
public async Task ExecuteAsync_ValidateManifestListPlatforms_MissingPlatformThrows()
{
Mock<IManifestService> manifestServiceMock = CreateManifestServiceMock();
Mock<IManifestServiceFactory> manifestServiceFactory = CreateManifestServiceFactoryMock(manifestServiceMock);
Mock<IDockerService> dockerServiceMock = new();

CreateManifestListCommand command = CreateCommand(
manifestServiceFactory, dockerServiceMock, Mock.Of<IDateTimeService>());
command.Options.ValidateManifestListPlatforms = true;

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"]))));

SetupCommand(command, manifest, imageArtifactDetails, tempFolderContext);

InvalidOperationException exception = await Should.ThrowAsync<InvalidOperationException>(command.ExecuteAsync);

exception.Message.ShouldContain("repo:sharedtag");
exception.Message.ShouldContain("linux/arm64");
exception.Message.ShouldContain(dockerfileArm64);
dockerServiceMock.Verify(
o => o.CreateManifestList(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()),
Times.Never);
}

private static CreateManifestListCommand CreateCommand(
Mock<IManifestServiceFactory> manifestServiceFactory,
Mock<IDockerService> dockerServiceMock,
Expand Down
73 changes: 73 additions & 0 deletions src/ImageBuilder.Tests/ManifestListHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,79 @@ public void GetManifestListsForImages_OnlyIncludesBuiltPlatforms()
results[0].PlatformTags.ShouldNotContain("repo:tag-windows");
}

/// <summary>
/// Verifies that validation reports generated manifest lists that are missing expected platforms.
/// </summary>
[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<ManifestListPlatformValidationIssue> 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);
}

/// <summary>
/// Verifies that validation succeeds when every generated manifest list has all expected platforms.
/// </summary>
[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));
}

/// <summary>
/// Verifies that no manifest list is returned when an image has shared tags
/// but no platforms exist in image-info.
Expand Down
14 changes: 10 additions & 4 deletions src/ImageBuilder/Commands/CreateManifestListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,20 @@ public override async Task ExecuteAsync()

ImageArtifactDetails imageArtifactDetails = ImageInfoHelper.LoadFromFile(Options.ImageInfoPath, Manifest);

IReadOnlyList<ManifestListInfo> manifestLists =
ManifestListHelper.GetManifestListsForImages(
Manifest, imageArtifactDetails, Options.RepoPrefix);

if (Options.ValidateManifestListPlatforms)
{
ManifestListHelper.ValidateManifestListPlatforms(
Manifest, imageArtifactDetails, Options.RepoPrefix);
}

await _registryCredentialsProvider.ExecuteWithCredentialsAsync(
Options.IsDryRun,
async () =>
{
IReadOnlyList<ManifestListInfo> manifestLists =
ManifestListHelper.GetManifestListsForImages(
Manifest, imageArtifactDetails, Options.RepoPrefix);

foreach (ManifestListInfo manifestListInfo in manifestLists)
{
_dockerService.CreateManifestList(manifestListInfo.Tag, manifestListInfo.PlatformTags, Options.IsDryRun);
Expand Down
8 changes: 8 additions & 0 deletions src/ImageBuilder/Commands/CreateManifestListOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,24 @@ public class CreateManifestListOptions : ManifestOptions, IFilterableOptions
public ManifestFilterOptions FilterOptions { get; set; } = new ManifestFilterOptions();
public RegistryCredentialsOptions CredentialsOptions { get; set; } = new RegistryCredentialsOptions();
public string ImageInfoPath { get; set; } = string.Empty;
public bool ValidateManifestListPlatforms { get; set; }

private static readonly Argument<string> ImageInfoPathArgument = new(nameof(ImageInfoPath))
{
Description = "Path to the image info file to read and update with manifest list digests"
};

private static readonly Option<bool> ValidateManifestListPlatformsOption = new("--validate-manifest-list-platforms")
Comment thread
lbussell marked this conversation as resolved.
Outdated
{
Description = "Validate that generated manifest list tags include every expected platform defined in the manifest"
};

public override IEnumerable<Option> GetCliOptions() =>
[
..base.GetCliOptions(),
..FilterOptions.GetCliOptions(),
..CredentialsOptions.GetCliOptions(),
ValidateManifestListPlatformsOption,
];

public override IEnumerable<Argument> GetCliArguments() =>
Expand All @@ -40,5 +47,6 @@ public override void Bind(ParseResult result)
FilterOptions.Bind(result);
CredentialsOptions.Bind(result);
ImageInfoPath = result.GetValue(ImageInfoPathArgument) ?? string.Empty;
ValidateManifestListPlatforms = result.GetValue(ValidateManifestListPlatformsOption);
}
}
Loading
Loading