Skip to content

Commit c654c15

Browse files
authored
feature: Automatically set CI VCS properties on uploaded artifacts (#348)
1 parent 506dac8 commit c654c15

File tree

13 files changed

+863
-15
lines changed

13 files changed

+863
-15
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package buildinfo
2+
3+
import (
4+
"path"
5+
"strings"
6+
"time"
7+
8+
buildinfo "github.com/jfrog/build-info-go/entities"
9+
"github.com/jfrog/jfrog-client-go/artifactory"
10+
"github.com/jfrog/jfrog-client-go/artifactory/services"
11+
artclientutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils"
12+
"github.com/jfrog/jfrog-client-go/utils/io/content"
13+
"github.com/jfrog/jfrog-client-go/utils/log"
14+
)
15+
16+
const (
17+
maxRetries = 3
18+
retryDelayBase = time.Second
19+
)
20+
21+
// extractArtifactPathsWithWarnings extracts Artifactory paths from build info artifacts.
22+
// Returns the list of paths (may be complete or partial) and count of skipped artifacts.
23+
// Paths are constructed using OriginalDeploymentRepo + Path when available, or Path directly as fallback.
24+
// If property setting fails later due to incomplete paths, warnings will be logged at that point.
25+
func extractArtifactPathsWithWarnings(buildInfo *buildinfo.BuildInfo) ([]string, int) {
26+
var paths []string
27+
var skippedCount int
28+
29+
for _, module := range buildInfo.Modules {
30+
for _, artifact := range module.Artifacts {
31+
fullPath := constructArtifactPathWithFallback(artifact)
32+
if fullPath == "" {
33+
// No path information at all - skip silently (nothing to try)
34+
skippedCount++
35+
continue
36+
}
37+
paths = append(paths, fullPath)
38+
}
39+
}
40+
return paths, skippedCount
41+
}
42+
43+
// constructArtifactPathWithFallback builds the full Artifactory path for an artifact.
44+
// Strategy:
45+
// 1. If OriginalDeploymentRepo is present: use OriginalDeploymentRepo + "/" + Path
46+
// 2. If OriginalDeploymentRepo is missing: use Path directly (it may or may not work)
47+
// 3. If neither available: return empty string (caller should warn and skip)
48+
func constructArtifactPathWithFallback(artifact buildinfo.Artifact) string {
49+
// Primary: Use OriginalDeploymentRepo if available
50+
if artifact.OriginalDeploymentRepo != "" {
51+
if artifact.Path != "" {
52+
return artifact.OriginalDeploymentRepo + "/" + artifact.Path
53+
}
54+
if artifact.Name != "" {
55+
return artifact.OriginalDeploymentRepo + "/" + artifact.Name
56+
}
57+
}
58+
59+
// Fallback: Use Path directly - it might be a complete path or might fail
60+
// If it fails, setPropsOnArtifacts will warn and move on
61+
if artifact.Path != "" {
62+
return artifact.Path
63+
}
64+
65+
// Last resort: just the name (unlikely to work, but let it try)
66+
if artifact.Name != "" {
67+
return artifact.Name
68+
}
69+
70+
// Nothing available
71+
return ""
72+
}
73+
74+
// constructArtifactPath builds the full Artifactory path for an artifact (legacy function).
75+
func constructArtifactPath(artifact buildinfo.Artifact) string {
76+
if artifact.OriginalDeploymentRepo == "" {
77+
return ""
78+
}
79+
if artifact.Path != "" {
80+
return artifact.OriginalDeploymentRepo + "/" + artifact.Path
81+
}
82+
if artifact.Name != "" {
83+
return artifact.OriginalDeploymentRepo + "/" + artifact.Name
84+
}
85+
return ""
86+
}
87+
88+
// setPropsOnArtifacts sets properties on multiple artifacts in a single API call with retry logic.
89+
// This is a major performance optimization over setting properties one by one.
90+
// If property setting fails after retries, logs a warning and continues (does not fail the build).
91+
func setPropsOnArtifacts(
92+
servicesManager artifactory.ArtifactoryServicesManager,
93+
artifactPaths []string,
94+
props string,
95+
) {
96+
if len(artifactPaths) == 0 {
97+
return
98+
}
99+
100+
var lastErr error
101+
for attempt := 0; attempt < maxRetries; attempt++ {
102+
if attempt > 0 {
103+
// Exponential backoff: 1s, 2s, 4s
104+
delay := retryDelayBase * time.Duration(1<<(attempt-1))
105+
log.Debug("Retrying property set for artifacts (attempt", attempt+1, "/", maxRetries, ") after", delay)
106+
time.Sleep(delay)
107+
}
108+
109+
// Create reader for all artifacts
110+
reader, err := createArtifactsReader(artifactPaths)
111+
if err != nil {
112+
log.Debug("Failed to create reader for CI VCS properties:", err)
113+
return
114+
}
115+
116+
params := services.PropsParams{
117+
Reader: reader,
118+
Props: props,
119+
}
120+
121+
successCount, err := servicesManager.SetProps(params)
122+
if closeErr := reader.Close(); closeErr != nil {
123+
log.Debug("Failed to close reader:", closeErr)
124+
}
125+
126+
if err == nil {
127+
log.Info("CI VCS: Successfully set properties on", successCount, "artifacts")
128+
return
129+
}
130+
131+
// Check if error is 404 - artifact path might be incorrect, skip silently
132+
if is404Error(err) {
133+
log.Info("CI VCS: SetProps returned 404 - some artifacts not found (path may be incomplete)")
134+
return
135+
}
136+
137+
// Check if error is 403 - permission issue, skip silently
138+
if is403Error(err) {
139+
if attempt >= 1 {
140+
log.Info("CI VCS: SetProps returned 403 - permission denied")
141+
return
142+
}
143+
}
144+
145+
lastErr = err
146+
log.Info("CI VCS: Batch attempt", attempt+1, "failed:", err)
147+
}
148+
149+
log.Info("CI VCS: Failed to set properties after", maxRetries, "attempts:", lastErr)
150+
}
151+
152+
// createArtifactsReader creates a ContentReader containing all artifact paths for batch processing.
153+
func createArtifactsReader(artifactPaths []string) (*content.ContentReader, error) {
154+
writer, err := content.NewContentWriter("results", true, false)
155+
if err != nil {
156+
return nil, err
157+
}
158+
159+
for _, artifactPath := range artifactPaths {
160+
// Parse path into repo/path/name
161+
parts := strings.SplitN(artifactPath, "/", 2)
162+
if len(parts) < 2 {
163+
log.Debug("Invalid artifact path skipped during reader creation:", artifactPath)
164+
continue
165+
}
166+
167+
repo := parts[0]
168+
pathAndName := parts[1]
169+
dir, name := path.Split(pathAndName)
170+
171+
writer.Write(artclientutils.ResultItem{
172+
Repo: repo,
173+
Path: strings.TrimSuffix(dir, "/"),
174+
Name: name,
175+
Type: "file",
176+
})
177+
}
178+
179+
if err := writer.Close(); err != nil {
180+
return nil, err
181+
}
182+
183+
return content.NewContentReader(writer.GetFilePath(), "results"), nil
184+
}
185+
186+
// is404Error checks if the error indicates a 404 Not Found response.
187+
func is404Error(err error) bool {
188+
if err == nil {
189+
return false
190+
}
191+
errStr := strings.ToLower(err.Error())
192+
return strings.Contains(errStr, "404") ||
193+
strings.Contains(errStr, "not found")
194+
}
195+
196+
// is403Error checks if the error indicates a 403 Forbidden response.
197+
func is403Error(err error) bool {
198+
if err == nil {
199+
return false
200+
}
201+
errStr := strings.ToLower(err.Error())
202+
return strings.Contains(errStr, "403") ||
203+
strings.Contains(errStr, "forbidden")
204+
}

artifactory/commands/buildinfo/publish.go

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ package buildinfo
33
import (
44
"errors"
55
"fmt"
6-
"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/commandsummary"
76
"net/url"
87
"strconv"
98
"strings"
109
"time"
1110

1211
buildinfo "github.com/jfrog/build-info-go/entities"
12+
"github.com/jfrog/build-info-go/utils/cienv"
1313
"github.com/jfrog/jfrog-cli-artifactory/artifactory/formats"
14+
"github.com/jfrog/jfrog-cli-artifactory/artifactory/utils/civcs"
1415
"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
16+
"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/commandsummary"
1517
"github.com/jfrog/jfrog-cli-core/v2/common/build"
1618
"github.com/jfrog/jfrog-cli-core/v2/utils/config"
1719
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
@@ -197,6 +199,11 @@ func (bpc *BuildPublishCommand) Run() error {
197199
return err
198200
}
199201

202+
// Set CI VCS properties on artifacts from build info.
203+
// This only runs if we're in a supported CI environment (GitHub Actions, GitLab CI, etc.)
204+
// Note: This never returns an error - it only logs warnings on failure
205+
bpc.setCIVcsPropsOnArtifacts(servicesManager, buildInfo)
206+
200207
majorVersion, err := utils.GetRtMajorVersion(servicesManager)
201208
if err != nil {
202209
return err
@@ -309,6 +316,54 @@ func (bpc *BuildPublishCommand) getNextBuildNumber(buildName string, servicesMan
309316
return strconv.Itoa(latestBuildNumber), nil
310317
}
311318

319+
// setCIVcsPropsOnArtifacts sets CI VCS properties on all artifacts in the build info.
320+
// This method:
321+
// - Only runs when in a supported CI environment (GitHub Actions, GitLab CI, etc.)
322+
// - Never fails the build publish - only logs warnings on errors
323+
// - Retries transient failures but not 404 errors
324+
// - Does nothing if CI VCS props collection is disabled via JFROG_CLI_CI_VCS_PROPS_DISABLED
325+
func (bpc *BuildPublishCommand) setCIVcsPropsOnArtifacts(
326+
servicesManager artifactory.ArtifactoryServicesManager,
327+
buildInfo *buildinfo.BuildInfo,
328+
) {
329+
// Check if CI VCS props collection is disabled
330+
if civcs.IsCIVcsPropsDisabled() {
331+
return
332+
}
333+
// Check if running in a supported CI environment
334+
// This requires CI=true AND a registered provider (GitHub, GitLab, etc.)
335+
ciVcsInfo := cienv.GetCIVcsInfo()
336+
if ciVcsInfo.IsEmpty() {
337+
// Not in CI or no registered provider - silently skip
338+
return
339+
}
340+
log.Info("CI VCS: Detected provider:", ciVcsInfo.Provider, ", org:", ciVcsInfo.Org, ", repo:", ciVcsInfo.Repo)
341+
342+
// Build props string
343+
props := civcs.BuildCIVcsPropsString(ciVcsInfo)
344+
if props == "" {
345+
log.Info("CI VCS: Empty props string, skipping")
346+
return
347+
}
348+
349+
// Extract artifact paths from build info (with warnings for missing repo paths)
350+
artifactPaths, skippedCount := extractArtifactPathsWithWarnings(buildInfo)
351+
log.Info("CI VCS: Extracted", len(artifactPaths), "artifact paths,", skippedCount, "skipped")
352+
if len(artifactPaths) == 0 && skippedCount == 0 {
353+
log.Info("CI VCS: No artifacts found in build info")
354+
return
355+
}
356+
if len(artifactPaths) == 0 {
357+
// All artifacts were skipped due to missing repo paths
358+
log.Info("CI VCS: All artifacts skipped due to missing repo paths")
359+
return
360+
}
361+
log.Info("CI VCS: Setting properties on", len(artifactPaths), "artifacts with props:", props)
362+
// Set properties on all artifacts in a single batch call
363+
setPropsOnArtifacts(servicesManager, artifactPaths, props)
364+
log.Info("CI VCS: Property setting completed")
365+
}
366+
312367
func recordCommandSummary(buildInfo *buildinfo.BuildInfo, buildLink string) (err error) {
313368
if !commandsummary.ShouldRecordSummary() {
314369
return

0 commit comments

Comments
 (0)