@@ -676,13 +676,16 @@ func (rh *RouteHandler) GetReferrers(response http.ResponseWriter, request *http
676676
677677// UpdateManifest godoc
678678// @Summary Update image manifest
679- // @Description Update an image's manifest given a reference or a digest
679+ // @Description Update an image's manifest given a reference or a digest. On digest pushes with `tag=` query
680+ // @Description parameters, 201 responses repeat the `OCI-Tag` header once per tag value.
680681// @Accept json
681682// @Produce json
682683// @Param name path string true "repository name"
683684// @Param reference path string true "image reference or digest"
684- // @Header 201 {object} constants.DistContentDigestKey
685+ // @Param tag query []string false "additional tag(s) for digest pushes" collectionFormat(multi)
685686// @Success 201 {string} string "created"
687+ // @Header 201 {object} constants.DistContentDigestKey
688+ // @Header 201 {string} constants.OCITagResponseKey "echoed tag= value (one header per query tag)"
686689// @Failure 400 {string} string "bad request"
687690// @Failure 404 {string} string "not found"
688691// @Failure 500 {string} string "internal server error"
@@ -717,6 +720,30 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
717720 return
718721 }
719722
723+ var digestQueryTags []string
724+
725+ rawTagQuery := request .URL .Query ()["tag" ]
726+ if len (rawTagQuery ) > 0 {
727+ if len (rawTagQuery ) > constants .MaxManifestDigestQueryTags {
728+ e := apiErr .NewError (apiErr .MANIFEST_INVALID ).AddDetail (map [string ]string {
729+ "reason" : fmt .Sprintf ("too many tag query parameters (max %d)" , constants .MaxManifestDigestQueryTags ),
730+ })
731+ zcommon .WriteJSON (response , http .StatusRequestURITooLong , apiErr .NewErrorList (e ))
732+
733+ return
734+ }
735+
736+ var normErr error
737+
738+ digestQueryTags , normErr = normalizeManifestExtraTags (rawTagQuery )
739+ if normErr != nil {
740+ err := apiErr .NewError (apiErr .MANIFEST_INVALID ).AddDetail (map [string ]string {"reason" : normErr .Error ()})
741+ zcommon .WriteJSON (response , http .StatusBadRequest , apiErr .NewErrorList (err ))
742+
743+ return
744+ }
745+ }
746+
720747 body , err := io .ReadAll (request .Body )
721748 // hard to reach test case, injected error (simulates an interrupted image manifest upload)
722749 // err could be io.ErrUnexpectedEOF
@@ -727,7 +754,16 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
727754 return
728755 }
729756
730- digest , subjectDigest , err := imgStore .PutImageManifest (name , reference , mediaType , body )
757+ if len (digestQueryTags ) > 0 && ! zcommon .IsDigest (reference ) {
758+ err := apiErr .NewError (apiErr .MANIFEST_INVALID ).AddDetail (map [string ]string {
759+ "reason" : "tag query parameters are only valid when pushing a manifest by digest" ,
760+ })
761+ zcommon .WriteJSON (response , http .StatusBadRequest , apiErr .NewErrorList (err ))
762+
763+ return
764+ }
765+
766+ digest , subjectDigest , err := imgStore .PutImageManifest (name , reference , mediaType , body , digestQueryTags )
731767 if err != nil {
732768 details := zerr .GetDetails (err )
733769 if errors .Is (err , zerr .ErrRepoNotFound ) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
@@ -769,12 +805,22 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
769805 }
770806
771807 if rh .c .MetaDB != nil {
772- err := meta .OnUpdateManifest (request .Context (), name , reference , mediaType ,
773- digest , body , rh .c .StoreController , rh .c .MetaDB , rh .c .Log )
774- if err != nil {
775- response .WriteHeader (http .StatusInternalServerError )
808+ if len (digestQueryTags ) > 0 {
809+ err := meta .OnUpdateManifestDigestTags (request .Context (), name , digestQueryTags , mediaType ,
810+ digest , body , rh .c .StoreController , rh .c .MetaDB , rh .c .Log )
811+ if err != nil {
812+ response .WriteHeader (http .StatusInternalServerError )
776813
777- return
814+ return
815+ }
816+ } else {
817+ err := meta .OnUpdateManifest (request .Context (), name , reference , mediaType ,
818+ digest , body , rh .c .StoreController , rh .c .MetaDB , rh .c .Log )
819+ if err != nil {
820+ response .WriteHeader (http .StatusInternalServerError )
821+
822+ return
823+ }
778824 }
779825 }
780826
@@ -784,9 +830,42 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
784830
785831 response .Header ().Set ("Location" , fmt .Sprintf ("/v2/%s/manifests/%s" , name , digest ))
786832 response .Header ().Set (constants .DistContentDigestKey , digest .String ())
833+
834+ for _ , tag := range digestQueryTags {
835+ response .Header ().Add (constants .OCITagResponseKey , tag ) //nolint:canonicalheader
836+ }
837+
787838 response .WriteHeader (http .StatusCreated )
788839}
789840
841+ // normalizeManifestExtraTags deduplicates tag query values in order, rejects empty components, and
842+ // requires each value to match the OCI distribution-spec tag grammar (zreg.IsDistributionSpecTag).
843+ func normalizeManifestExtraTags (raw []string ) ([]string , error ) {
844+ seen := map [string ]struct {}{}
845+
846+ out := make ([]string , 0 , len (raw ))
847+
848+ for _ , rawTag := range raw {
849+ cleanedTag := strings .TrimSpace (rawTag )
850+ if cleanedTag == "" {
851+ return nil , zerr .ErrEmptyManifestTagQuery
852+ }
853+
854+ if ! zreg .IsDistributionSpecTag (cleanedTag ) {
855+ return nil , zerr .ErrInvalidManifestTagQuery
856+ }
857+
858+ if _ , ok := seen [cleanedTag ]; ok {
859+ continue
860+ }
861+
862+ seen [cleanedTag ] = struct {}{}
863+ out = append (out , cleanedTag )
864+ }
865+
866+ return out , nil
867+ }
868+
790869// DeleteManifest godoc
791870// @Summary Delete image manifest
792871// @Description Delete an image's manifest given a reference or a digest
0 commit comments