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")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have an option at all? I would think this should just be the default, and only, behavior.

Copy link
Copy Markdown
Member

@lbussell lbussell May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, even in the "dev" scenario mentioned in #2127, you would want to simulate the real publishing behavior, I think. It will likely require #2128 to function without this option, however.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagined that someone might want to be able to run a filtered dev build that produces a manifest tag that only includes the outputs from that specific build, to tinker with.

With harvesting existing images to put in the manifest, maybe someone could end up being confused about which of the images pointed to by the manifest came from the dev build vs. some other build?

For me/Go, it doesn't matter either way, just trying to imagine odd dev cases and keep this change low-impact so it can be merged ASAP. (We're holding off on automated builds until validation is in.)

I'll push another commit that makes it the only behavior to see how that looks.

{
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