Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
da20529
XRAY-139364 - Automatically set VSC properties on uploaded artifacts …
attiasas Jun 7, 2026
d188f60
XRAY-139364 - Add tests
attiasas Jun 7, 2026
67201ee
fix windows tests
attiasas Jun 7, 2026
9cca0f7
fix early return
attiasas Jun 7, 2026
c740d76
add more place to detect vsc
attiasas Jun 7, 2026
72202e0
Add VSC collection for build publish cmd
attiasas Jun 7, 2026
3be1513
Add VSC collection for conan cmd
attiasas Jun 7, 2026
ace3fd2
Add VSC collection for flexpack cmd
attiasas Jun 7, 2026
1f23953
fix npm search dir
attiasas Jun 7, 2026
d1bba49
Merge remote-tracking branch 'origin/local_git_detection' into expend…
attiasas Jun 7, 2026
636127c
Add VSC collection for npm cmd
attiasas Jun 7, 2026
f0ea071
Add VSC collection for nix cmd
attiasas Jun 7, 2026
144184a
Add VSC collection for pnpm cmd
attiasas Jun 7, 2026
f7cf8f3
fix not needed if
attiasas Jun 7, 2026
97e21a9
Add VSC collection for python cmd
attiasas Jun 7, 2026
e96b000
Merge remote-tracking branch 'upstream/main' into local_git_detection
attiasas Jun 9, 2026
b879889
Merge remote-tracking branch 'upstream/main' into local_git_detection
attiasas Jun 10, 2026
b1dd2fa
Merge remote-tracking branch 'origin/local_git_detection' into expend…
attiasas Jun 14, 2026
8ecc061
normalize local url
attiasas Jun 14, 2026
168b70f
fix static
attiasas Jun 14, 2026
9c6e0af
Merge remote-tracking branch 'origin/local_git_detection' into expend…
attiasas Jun 14, 2026
5c7cac0
revert normalization
attiasas Jun 15, 2026
a81d7ce
Merge remote-tracking branch 'origin/local_git_detection' into expend…
attiasas Jun 15, 2026
e7ae00a
fix tests
attiasas Jun 15, 2026
4746a49
fix(build-publish): set local git VCS props on artifacts without repo…
attiasas Jun 15, 2026
628b7b6
fix tests for repoless
attiasas Jun 15, 2026
2ece497
Merge remote-tracking branch 'upstream/main' into local_git_detection
attiasas Jun 18, 2026
e1d2d84
Merge remote-tracking branch 'origin/local_git_detection' into expend…
attiasas Jun 18, 2026
d7a0a7b
Merge remote-tracking branch 'upstream/main' into local_git_detection
attiasas Jun 23, 2026
d967791
Merge remote-tracking branch 'origin/local_git_detection' into expend…
attiasas Jun 23, 2026
237fc80
CR changes
attiasas Jun 25, 2026
d6932dc
Merge remote-tracking branch 'origin/local_git_detection' into expend…
attiasas Jun 25, 2026
3412ebd
tag only artifacts in local repo
attiasas Jun 28, 2026
8d4654c
fix static
attiasas Jun 28, 2026
59ed241
Merge remote-tracking branch 'upstream/main' into expend_vsc_detection
attiasas Jul 1, 2026
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
80 changes: 74 additions & 6 deletions artifactory/commands/buildinfo/civcs_props.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,29 @@ func extractArtifactPathsWithWarnings(buildInfo *buildinfo.BuildInfo) ([]string,
// constructArtifactPathWithFallback builds the full Artifactory path for an artifact.
// Strategy:
// 1. If OriginalDeploymentRepo is present: use OriginalDeploymentRepo + "/" + Path
// 2. If OriginalDeploymentRepo is missing: use Path directly (it may or may not work)
// 2. If OriginalDeploymentRepo is missing: prefix Path with "*/" so search resolves
// across repositories. At set-props time this is restricted to local repos when possible,
// with a warning logged (Gradle extractor often omits originalDeploymentRepo in build-info).
// 3. If neither available: return empty string (caller should warn and skip)
func constructArtifactPathWithFallback(artifact buildinfo.Artifact) string {
// Primary: Use OriginalDeploymentRepo if available
if artifact.OriginalDeploymentRepo != "" {
if artifact.Path != "" {
return artifact.OriginalDeploymentRepo + "/" + artifact.Path
return artifact.OriginalDeploymentRepo + "/" + strings.TrimPrefix(artifact.Path, "/")
}
if artifact.Name != "" {
return artifact.OriginalDeploymentRepo + "/" + artifact.Name
}
}

// Fallback: Use Path directly - it might be a complete path or might fail
// If it fails, setPropsOnArtifacts will warn and move on
// Fallback: path-only from build-info — use wildcard repo for search-based SetProps
if artifact.Path != "" {
return artifact.Path
return "*/" + strings.TrimPrefix(artifact.Path, "/")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When the Gradle extractor omits OriginalDeploymentRepo, the path */com/example/foo/1.0/foo.jar is used for SetItemProperties. Artifactory will match this pattern across all repos — local deployment repos, virtual repos that shadow the same path, remote repo caches, etc. This silently stamps VCS metadata on artifacts the user never intended to tag.

In practice this is mostly harmless (virtual repos are usually read-only for property writes), but if an org has a virtual repo backed by multiple locals and the same artifact was released at the same path in a different local repo, both get tagged.

We can look for a way to restrict the search to local repos only in the AQL or SetProps call when using */? If not, at minimum log a warning when the wildcard fallback is used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added a log and a call to get all local repositories

}

// Last resort: just the name (unlikely to work, but let it try)
if artifact.Name != "" {
return artifact.Name
return "*/" + artifact.Name
}

// Nothing available
Expand All @@ -84,6 +85,60 @@ func constructArtifactPath(artifact buildinfo.Artifact) string {
return ""
}

const wildcardRepoPrefix = "*/"

// isWildcardRepoPath reports whether the path uses the repo-less fallback prefix.
func isWildcardRepoPath(path string) bool {
return strings.HasPrefix(path, wildcardRepoPrefix)
}

// expandWildcardPathsToLocalRepos replaces "*/<path>" patterns with "<local-repo>/<path>" for each
// local repository. This avoids matching virtual repos and remote caches when OriginalDeploymentRepo
// is missing from build-info (common with the Gradle extractor).
func expandWildcardPathsToLocalRepos(servicesManager artifactory.ArtifactoryServicesManager, artifactPaths []string) ([]string, error) {
if !hasWildcardRepoPaths(artifactPaths) {
return artifactPaths, nil
}
repos, err := servicesManager.GetAllRepositoriesFiltered(services.RepositoriesFilterParams{RepoType: "local"})
if err != nil {
return artifactPaths, err
}
if repos == nil || len(*repos) == 0 {
return artifactPaths, nil
}
expanded := make([]string, 0, len(artifactPaths))
for _, artifactPath := range artifactPaths {
if !isWildcardRepoPath(artifactPath) {
expanded = append(expanded, artifactPath)
continue
}
artifactOnlyPath := strings.TrimPrefix(artifactPath, wildcardRepoPrefix)
for _, repo := range *repos {
expanded = append(expanded, repo.Key+"/"+artifactOnlyPath)
}
}
return expanded, nil
}

func hasWildcardRepoPaths(artifactPaths []string) bool {
for _, path := range artifactPaths {
if isWildcardRepoPath(path) {
return true
}
}
return false
}

func countWildcardRepoPaths(artifactPaths []string) int {
count := 0
for _, path := range artifactPaths {
if isWildcardRepoPath(path) {
count++
}
}
return count
}

// setPropsOnArtifacts sets properties on multiple artifacts using search-based resolution.
// This uses the same search mechanism as 'jf rt set-props', which resolves virtual repository
// paths to their underlying local repositories before setting properties.
Expand All @@ -92,6 +147,19 @@ func setPropsOnArtifacts(servicesManager artifactory.ArtifactoryServicesManager,
if len(artifactPaths) == 0 {
return
}
wildcardCount := countWildcardRepoPaths(artifactPaths)
if wildcardCount > 0 {
expanded, err := expandWildcardPathsToLocalRepos(servicesManager, artifactPaths)
switch {
case err != nil:
log.Warn("CI VCS:", wildcardCount, "artifact path(s) missing OriginalDeploymentRepo; searching across all repositories (failed to list local repos:", err, "). Artifacts at the same path in other repositories may be tagged unintentionally.")
case hasWildcardRepoPaths(expanded):
log.Warn("CI VCS:", wildcardCount, "artifact path(s) missing OriginalDeploymentRepo; searching across all repositories. Artifacts at the same path in other repositories may be tagged unintentionally.")
default:
artifactPaths = expanded
log.Warn("CI VCS:", wildcardCount, "artifact path(s) missing OriginalDeploymentRepo; property search restricted to local repositories. The same path in multiple local repos may still be tagged unintentionally.")
}
}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
Expand Down
71 changes: 68 additions & 3 deletions artifactory/commands/buildinfo/civcs_props_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"testing"

buildinfo "github.com/jfrog/build-info-go/entities"
"github.com/jfrog/jfrog-client-go/artifactory"
"github.com/jfrog/jfrog-client-go/artifactory/services"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -80,19 +82,27 @@ func TestConstructArtifactPathWithFallback(t *testing.T) {
expected: "my-repo/file.jar",
},
{
name: "without OriginalDeploymentRepo - fallback to Path",
name: "without OriginalDeploymentRepo - wildcard repo search prefix",
artifact: buildinfo.Artifact{
Path: "my-repo/path/to/file.jar",
Name: "file.jar",
},
expected: "my-repo/path/to/file.jar",
expected: "*/my-repo/path/to/file.jar",
},
{
name: "gradle extractor path without OriginalDeploymentRepo",
artifact: buildinfo.Artifact{
Path: "minimal-example/1.0/minimal-example-1.0.jar",
Name: "minimal-example-1.0.jar",
},
expected: "*/minimal-example/1.0/minimal-example-1.0.jar",
},
{
name: "without OriginalDeploymentRepo or Path - fallback to Name",
artifact: buildinfo.Artifact{
Name: "file.jar",
},
expected: "file.jar",
expected: "*/file.jar",
},
{
name: "empty artifact",
Expand Down Expand Up @@ -211,6 +221,61 @@ func TestCivcsExtractArtifactPathsWithWarnings(t *testing.T) {
}
}

func TestExpandWildcardPathsToLocalRepos(t *testing.T) {
t.Run("no wildcard paths", func(t *testing.T) {
paths := []string{"libs-release/com/example/foo.jar"}
expanded, err := expandWildcardPathsToLocalRepos(nil, paths)
assert.NoError(t, err)
assert.Equal(t, paths, expanded)
})

t.Run("expands wildcard paths to local repos", func(t *testing.T) {
mockSM := &mockReposServicesManager{
repos: []services.RepositoryDetails{
{Key: "libs-release"},
{Key: "libs-snapshot-local"},
},
}
paths := []string{
"libs-release/com/example/foo.jar",
"*/com/example/bar.jar",
}
expanded, err := expandWildcardPathsToLocalRepos(mockSM, paths)
assert.NoError(t, err)
assert.Equal(t, []string{
"libs-release/com/example/foo.jar",
"libs-release/com/example/bar.jar",
"libs-snapshot-local/com/example/bar.jar",
}, expanded)
})

t.Run("returns error when listing repos fails", func(t *testing.T) {
mockSM := &mockReposServicesManager{err: assert.AnError}
paths := []string{"*/com/example/foo.jar"}
expanded, err := expandWildcardPathsToLocalRepos(mockSM, paths)
assert.Error(t, err)
assert.Equal(t, paths, expanded)
})
}

type mockReposServicesManager struct {
artifactory.EmptyArtifactoryServicesManager
repos []services.RepositoryDetails
err error
}

func (m *mockReposServicesManager) GetAllRepositoriesFiltered(services.RepositoriesFilterParams) (*[]services.RepositoryDetails, error) {
if m.err != nil {
return nil, m.err
}
return &m.repos, nil
}

func TestIsWildcardRepoPath(t *testing.T) {
assert.True(t, isWildcardRepoPath("*/com/example/foo.jar"))
assert.False(t, isWildcardRepoPath("libs-release/com/example/foo.jar"))
}

func TestCivcsIs404Error(t *testing.T) {
tests := []struct {
name string
Expand Down
45 changes: 21 additions & 24 deletions artifactory/commands/buildinfo/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"

buildinfo "github.com/jfrog/build-info-go/entities"
"github.com/jfrog/build-info-go/utils/cienv"
"github.com/jfrog/jfrog-cli-artifactory/artifactory/formats"
"github.com/jfrog/jfrog-cli-artifactory/artifactory/utils/civcs"
"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
Expand Down Expand Up @@ -239,7 +239,7 @@ func (bpc *BuildPublishCommand) Run() error {
// Set CI VCS properties on artifacts from build info.
// This only runs if we're in a supported CI environment (GitHub Actions, GitLab CI, etc.)
// Note: This never returns an error - it only logs warnings on failure
bpc.setCIVcsPropsOnArtifacts(servicesManager, buildInfo)
bpc.setVcsPropsOnArtifacts(servicesManager, buildInfo)

majorVersion, err := utils.GetRtMajorVersion(servicesManager)
if err != nil {
Expand Down Expand Up @@ -359,52 +359,49 @@ func (bpc *BuildPublishCommand) getNextBuildNumber(buildName string, servicesMan
return strconv.Itoa(latestBuildNumber), nil
}

// setCIVcsPropsOnArtifacts sets CI VCS properties on all artifacts in the build info.
// setVcsPropsOnArtifacts sets VCS properties on all artifacts in the build info.
// This method:
// - Only runs when in a supported CI environment (GitHub Actions, GitLab CI, etc.)
// - Merges CI and local git VCS props via mergeVcsPropsForBuildPublish
// - Never fails the build publish - only logs warnings on errors
// - Retries transient failures but not 404 errors
// - Does nothing if CI VCS props collection is disabled via JFROG_CLI_CI_VCS_PROPS_DISABLED
func (bpc *BuildPublishCommand) setCIVcsPropsOnArtifacts(
// - Does nothing if VCS props collection is disabled via JFROG_CLI_CI_VCS_PROPS_DISABLED
func (bpc *BuildPublishCommand) setVcsPropsOnArtifacts(
servicesManager artifactory.ArtifactoryServicesManager,
buildInfo *buildinfo.BuildInfo,
) {
// Check if CI VCS props collection is disabled
if civcs.IsCIVcsPropsDisabled() {
return
}
// Check if running in a supported CI environment
// This requires CI=true AND a registered provider (GitHub, GitLab, etc.)
ciVcsInfo := cienv.GetCIVcsInfo()
if ciVcsInfo.IsEmpty() {
// Not in CI or no registered provider - silently skip
return
searchDir := bpc.dotGitPath
if searchDir == "" {
var err error
searchDir, err = os.Getwd()
if err != nil {
searchDir = "."
}
}
log.Debug("CI VCS: Detected provider:", ciVcsInfo.Provider, ", org:", ciVcsInfo.Org, ", repo:", ciVcsInfo.Repo)

// Build props string
props := civcs.BuildCIVcsPropsString(ciVcsInfo)
// Build props string (CI env + local git fallback).
props := civcs.GetCIVcsPropsString(searchDir)
if props == "" {
log.Debug("CI VCS: Empty props string, skipping")
log.Debug("VCS: Empty props string, skipping")
return
}

// Extract artifact paths from build info (with warnings for missing repo paths)
artifactPaths, skippedCount := extractArtifactPathsWithWarnings(buildInfo)
log.Debug("CI VCS: Extracted", len(artifactPaths), "artifact paths,", skippedCount, "skipped")
log.Debug("VCS: Extracted", len(artifactPaths), "artifact paths,", skippedCount, "skipped")
if len(artifactPaths) == 0 && skippedCount == 0 {
log.Debug("CI VCS: No artifacts found in build info")
log.Debug("VCS: No artifacts found in build info")
return
}
if len(artifactPaths) == 0 {
// All artifacts were skipped due to missing repo paths
log.Debug("CI VCS: All artifacts skipped due to missing repo paths")
log.Debug("VCS: All artifacts skipped due to missing repo paths")
return
}
log.Debug("CI VCS: Setting properties on", len(artifactPaths), "artifacts with props:", props)
log.Debug("VCS: Setting properties on", len(artifactPaths), "artifacts with props:", props)
// Set properties on all artifacts in a single batch call
setPropsOnArtifacts(servicesManager, artifactPaths, props)
log.Debug("CI VCS: Property setting completed")
log.Debug("VCS: Property setting completed")
}

func (bpc *BuildPublishCommand) excludeDependenciesByScope(buildInfo *buildinfo.BuildInfo) {
Expand Down
11 changes: 7 additions & 4 deletions artifactory/commands/buildinfo/publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ func TestSetCIVcsPropsOnArtifacts(t *testing.T) {

// 2. Mock services manager
mockSM := new(mockServicesManager)
expectedProps := "vcs.provider=github;vcs.org=jfrog;vcs.repo=jfrog-cli"
// Use a non-git directory so only CI env props are collected (not local git url/revision/branch).
nonGitDir := t.TempDir()
expectedProps := civcs.GetCIVcsPropsString(nonGitDir)

searchReader, cleanup := createTestSearchReader(t)
defer cleanup()
Expand Down Expand Up @@ -95,7 +97,8 @@ func TestSetCIVcsPropsOnArtifacts(t *testing.T) {

// 4. Run command
bpc := NewBuildPublishCommand()
bpc.setCIVcsPropsOnArtifacts(mockSM, bi)
bpc.SetDotGitPath(nonGitDir)
bpc.setVcsPropsOnArtifacts(mockSM, bi)

// 5. Verify
mockSM.AssertExpectations(t)
Expand Down Expand Up @@ -234,7 +237,7 @@ func TestExtractArtifactPathsWithWarnings(t *testing.T) {
},
},
},
expectedPaths: []string{"com/example/file1.jar"},
expectedPaths: []string{"*/com/example/file1.jar"},
expectedSkipped: 0,
},
{
Expand All @@ -249,7 +252,7 @@ func TestExtractArtifactPathsWithWarnings(t *testing.T) {
},
},
},
expectedPaths: []string{"libs-release/com/example/file1.jar", "com/example/file2.jar"},
expectedPaths: []string{"libs-release/com/example/file1.jar", "*/com/example/file2.jar"},
expectedSkipped: 0,
},
{
Expand Down
Loading
Loading