From ce40ffd33dcff2e7868bdb163a55f4f8bb3929cb Mon Sep 17 00:00:00 2001 From: Raj Date: Tue, 21 Oct 2025 13:03:46 -0700 Subject: [PATCH] add support for multiple --tag flags in build (#785) - Closes #732. ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context Adds support for specifying multiple `-t` / `--tag` flags with the `build` command. All specified tags will point to the same built image. Example: `container build -t myapp:latest -t myapp:v1.0.0 -t myapp:stable .` ## Testing - [x] Tested locally - [x] Added/updated tests - [x] Added/updated docs --- Sources/ContainerBuild/Builder.swift | 10 ++++--- Sources/ContainerCommands/BuildCommand.swift | 26 +++++++++++++------ .../Subcommands/Build/CLIBuildBase.swift | 26 ++++++++++++++++--- .../Subcommands/Build/CLIBuilderTest.swift | 26 ++++++++++++++++++- docs/command-reference.md | 5 +++- 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index 797e70da..14cc5ffa 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -243,7 +243,7 @@ public struct Builder: Sendable { public let noCache: Bool public let platforms: [Platform] public let terminal: Terminal? - public let tag: String + public let tags: [String] public let target: String public let quiet: Bool public let exports: [BuildExport] @@ -260,7 +260,7 @@ public struct Builder: Sendable { noCache: Bool, platforms: [Platform], terminal: Terminal?, - tag: String, + tags: [String], target: String, quiet: Bool, exports: [BuildExport], @@ -276,7 +276,7 @@ public struct Builder: Sendable { self.noCache = noCache self.platforms = platforms self.terminal = terminal - self.tag = tag + self.tags = tags self.target = target self.quiet = quiet self.exports = exports @@ -312,9 +312,11 @@ extension CallOptions { ("context", URL(filePath: config.contextDir).path(percentEncoded: false)), ("dockerfile", config.dockerfile.base64EncodedString()), ("progress", config.terminal != nil ? "tty" : "plain"), - ("tag", config.tag), ("target", config.target), ] + for tag in config.tags { + headers.append(("tag", tag)) + } for platform in config.platforms { headers.append(("platforms", platform.description)) } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index cc91a07b..2a326f9d 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -106,7 +106,9 @@ extension Application { var quiet: Bool = false @Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) - var targetImageName: String = UUID().uuidString.lowercased() + var targetImageNames: [String] = { + [UUID().uuidString.lowercased()] + }() @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) var target: String = "" @@ -195,11 +197,11 @@ extension Application { try? FileManager.default.removeItem(at: tempURL) } - let imageName: String = try { - let parsedReference = try Reference.parse(targetImageName) + let imageNames: [String] = try targetImageNames.map { name in + let parsedReference = try Reference.parse(name) parsedReference.normalize() return parsedReference.description - }() + } var terminal: Terminal? switch self.progress { @@ -267,7 +269,7 @@ extension Application { noCache: noCache, platforms: [Platform](platforms), terminal: terminal, - tag: imageName, + tags: imageNames, target: target, quiet: quiet, exports: exports, @@ -294,7 +296,7 @@ extension Application { } unpackProgress.start() - var finalMessage = "Successfully built \(imageName)" + var finalMessage = "Successfully built \(imageNames.joined(separator: ", "))" let taskManager = ProgressTaskCoordinator() // Currently, only a single export can be specified. for exp in exports { @@ -311,6 +313,12 @@ extension Application { for image in loaded { try Task.checkCancellation() try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) + + // Tag the unpacked image with all requested tags + for tagName in imageNames { + try Task.checkCancellation() + _ = try await image.tag(new: tagName) + } } case "tar": guard let dest = exp.destination else { @@ -349,8 +357,10 @@ extension Application { guard FileManager.default.fileExists(atPath: contextDir) else { throw ValidationError("context dir does not exist \(contextDir)") } - guard let _ = try? Reference.parse(targetImageName) else { - throw ValidationError("invalid reference \(targetImageName)") + for name in targetImageNames { + guard let _ = try? Reference.parse(name) else { + throw ValidationError("invalid reference \(name)") + } } } } diff --git a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift index e812a92e..d8c6305c 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift @@ -83,7 +83,23 @@ class TestCLIBuildBase: CLITest { otherArgs: [String] = [] ) throws -> String { try buildWithPaths( - tag: tag, + tags: [tag], + tempContext: tempDir, + tempDockerfileContext: tempDir, + buildArgs: buildArgs, + otherArgs: otherArgs + ) + } + + @discardableResult + func build( + tags: [String], + tempDir: URL, + buildArgs: [String] = [], + otherArgs: [String] = [] + ) throws -> String { + try buildWithPaths( + tags: tags, tempContext: tempDir, tempDockerfileContext: tempDir, buildArgs: buildArgs, @@ -95,7 +111,7 @@ class TestCLIBuildBase: CLITest { // the dockerfile path. If both paths are the same, use `build` func above. @discardableResult func buildWithPaths( - tag: String, + tags: [String], tempContext: URL, tempDockerfileContext: URL, buildArgs: [String] = [], @@ -107,9 +123,11 @@ class TestCLIBuildBase: CLITest { "build", "-f", tempDockerfileContext.appendingPathComponent("Dockerfile").path, - "-t", - tag, ] + for tag in tags { + args.append("-t") + args.append(tag) + } for arg in buildArgs { args.append("--build-arg") args.append(arg) diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift index 1cd7dd2c..980a84b7 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -390,7 +390,7 @@ extension TestCLIBuildBase { let imageName = "registry.local/build-diff-context:\(UUID().uuidString)" #expect(throws: Never.self) { - try self.buildWithPaths(tag: imageName, tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir) + try self.buildWithPaths(tags: [imageName], tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir) } #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @@ -432,5 +432,29 @@ extension TestCLIBuildBase { "expected platforms \(expected), got \(actual)" ) } + + @Test func testBuildMultipleTags() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM scratch + + ADD emptyFile / + """ + let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + + let uuid = UUID().uuidString + let tag1 = "registry.local/multi-tag-test:\(uuid)" + let tag2 = "registry.local/multi-tag-test:latest" + let tag3 = "registry.local/multi-tag-test:v1.0.0" + + try self.build(tags: [tag1, tag2, tag3], tempDir: tempDir) + + // Verify all three tags exist and point to the same image + #expect(try self.inspectImage(tag1) == tag1, "expected to have successfully built \(tag1)") + #expect(try self.inspectImage(tag2) == tag2, "expected to have successfully built \(tag2)") + #expect(try self.inspectImage(tag3) == tag3, "expected to have successfully built \(tag3)") + } } } diff --git a/docs/command-reference.md b/docs/command-reference.md index f0f66dce..3b9f67dd 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -102,7 +102,7 @@ container build [OPTIONS] [CONTEXT-DIR] * `--platform `: Add the platform to the build (takes precedence over --os and --arch) * `--progress `: Progress type (format: auto|plain|tty)] (default: auto) * `-q, --quiet`: Suppress build output -* `-t, --tag `: Name for the built image +* `-t, --tag `: Name for the built image (can be specified multiple times) * `--target `: Set the target build stage * `--vsock-port `: Builder shim vsock port (default: 8088) * `--version`: Show the version. @@ -122,6 +122,9 @@ container build --build-arg NODE_VERSION=18 -t my-app . # build the production stage only and disable cache container build --target production --no-cache -t my-app:prod . + +# build with multiple tags +container build -t my-app:latest -t my-app:v1.0.0 -t my-app:stable . ``` ## Container Management