@@ -279,18 +279,100 @@ extension ImageStore {
279279 ///
280280 public func push( reference: String , platform: Platform ? = nil , insecure: Bool = false , auth: Authentication ? = nil , progress: ProgressHandler ? = nil ) async throws {
281281 let matcher = createPlatformMatcher ( for: platform)
282- let img = try await self . get ( reference: reference)
282+ let client = try RegistryClient ( reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils . makeEnvironmentAwareTLSConfiguration ( ) )
283+ try await self . pushSingle ( reference: reference, client: client, matcher: matcher, progress: progress)
284+ }
285+
286+ /// Push multiple image references to a remote registry, sharing a single ``RegistryClient``.
287+ ///
288+ /// All references must resolve to the same registry host. Passing references that target
289+ /// different hosts throws a ``ContainerizationError`` with code ``invalidArgument``.
290+ ///
291+ /// - Parameters:
292+ /// - references: An array of fully qualified image reference strings to push.
293+ /// Each must include a host (e.g., `"ghcr.io/myrepo/myimage:v1"`).
294+ /// - platform: An optional parameter to indicate the platform to be pushed for each image.
295+ /// Defaults to `nil` signifying that layers for all supported platforms will be pushed.
296+ /// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
297+ /// Defaults to false, meaning the connection to the registry will be over https.
298+ /// - auth: An object that implements the `Authentication` protocol,
299+ /// used to add any credentials to the HTTP requests that are made to the registry.
300+ /// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
301+ /// - maxConcurrentUploads: Maximum number of concurrent tag pushes. Defaults to 3.
302+ /// - progress: An optional handler over which progress update events about the push operations can be received.
303+ ///
304+ public func push(
305+ references: [ String ] , platform: Platform ? = nil , insecure: Bool = false ,
306+ auth: Authentication ? = nil , maxConcurrentUploads: Int = 3 , progress: ProgressHandler ? = nil
307+ ) async throws {
308+ guard let firstReference = references. first else {
309+ return
310+ }
311+
312+ // Parse all references upfront: validate hosts and avoid re-parsing inside tasks.
313+ let parsed = try references. map { ref in try Reference . parse ( ref) }
314+ let hosts = parsed. compactMap { $0. resolvedDomain }
315+ guard hosts. count == references. count else {
316+ throw ContainerizationError ( . invalidArgument, message: " all references must include a host " )
317+ }
318+ let uniqueHosts = Set ( hosts)
319+ guard uniqueHosts. count == 1 else {
320+ throw ContainerizationError (
321+ . invalidArgument,
322+ message: " all references must target the same registry host, got: \( uniqueHosts. sorted ( ) . joined ( separator: " , " ) ) " )
323+ }
324+
325+ let matcher = createPlatformMatcher ( for: platform)
326+ let client = try RegistryClient (
327+ reference: firstReference, insecure: insecure, auth: auth,
328+ tlsConfiguration: TLSUtils . makeEnvironmentAwareTLSConfiguration ( ) )
329+
330+ let pushOne : @Sendable ( String) async -> ( String , String ? ) = { reference in
331+ do {
332+ try await self . pushSingle ( reference: reference, client: client, matcher: matcher, progress: progress)
333+ return ( reference, nil )
334+ } catch {
335+ return ( reference, String ( describing: error) )
336+ }
337+ }
338+
339+ var iterator = references. makeIterator ( )
340+ var failures : [ ( reference: String , message: String ) ] = [ ]
341+
342+ await withTaskGroup ( of: ( String, String? ) . self) { group in
343+ for _ in 0 ..< maxConcurrentUploads {
344+ guard let reference = iterator. next ( ) else { break }
345+ group. addTask { await pushOne ( reference) }
346+ }
347+ for await (ref, error) in group {
348+ if let error {
349+ failures. append ( ( ref, error) )
350+ }
351+ if let reference = iterator. next ( ) {
352+ group. addTask { await pushOne ( reference) }
353+ }
354+ }
355+ }
356+
357+ if !failures. isEmpty {
358+ let details = failures. map { " \( $0. reference) : \( $0. message) " } . joined ( separator: " \n " )
359+ throw ContainerizationError ( . internalError, message: " failed to push one or more images: \n \( details) " )
360+ }
361+ }
362+
363+ private func pushSingle(
364+ reference: String , client: ContentClient , matcher: @Sendable ( Platform ) -> Bool , progress: ProgressHandler ?
365+ ) async throws {
283366 let allowedMediaTypes = [ MediaTypes . dockerManifestList, MediaTypes . index]
367+ let img = try await self . get ( reference: reference)
284368 guard allowedMediaTypes. contains ( img. mediaType) else {
285- throw ContainerizationError ( . internalError, message: " cannot push image \( reference) with Index media type \( img. mediaType) " )
369+ throw ContainerizationError ( . internalError, message: " cannot push image \( reference) : unsupported media type \( img. mediaType) , expected an index or manifest list " )
286370 }
287371 let ref = try Reference . parse ( reference)
288- let name = ref. path
289372 guard let tag = ref. tag ?? ref. digest else {
290373 throw ContainerizationError ( . invalidArgument, message: " invalid tag/digest for image reference \( reference) " )
291374 }
292- let client = try RegistryClient ( reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils . makeEnvironmentAwareTLSConfiguration ( ) )
293- let operation = ExportOperation ( name: name, tag: tag, contentStore: self . contentStore, client: client, progress: progress)
375+ let operation = ExportOperation ( name: ref. path, tag: tag, contentStore: self . contentStore, client: client, progress: progress)
294376 try await operation. export ( index: img. descriptor, platforms: matcher)
295377 }
296378}
0 commit comments