Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
09e6683
Add native RubyGems/Bundler command with auth injection and build-info
agrasth Jun 29, 2026
cb28370
Merge branch 'main' into RTECO-0000-rubygems-native-support
agrasth Jun 29, 2026
5a2d362
fix: auth bugs + add --repo flag for URL construction
agrasth Jun 29, 2026
57ca858
feat: add scope classification and jf setup for Ruby
agrasth Jul 2, 2026
d694c9f
fix: hybrid checksums, remove gem build collection, gem push auth imp…
agrasth Jul 10, 2026
ffc6d11
fix: remove bundle lock from build-info collection
agrasth Jul 13, 2026
d9085ac
Merge remote-tracking branch 'origin/main' into RTECO-0000-rubygems-n…
agrasth Jul 15, 2026
85f31d8
chore: update build-info-go replace to remote commit for CI
agrasth Jul 15, 2026
fb694e8
fix: allow auth with reference tokens (empty username)
agrasth Jul 15, 2026
f04a79d
fix: error when explicit --server-id is not found
agrasth Jul 15, 2026
f58023d
fix: inject bundle credentials for hostname without port
agrasth Jul 15, 2026
fbf21e7
fix: remove unverified local gem cache checksum lookup
agrasth Jul 21, 2026
8f17e90
docs: add design spec for jf setup ruby credential/gemrc write fix
agrasth Jul 30, 2026
8a00d94
fix: write Bundler/gemrc config directly instead of shelling out in j…
agrasth Jul 30, 2026
9d8fcf6
feat: make jf setup ruby fully transparent and version-proof
agrasth Jul 30, 2026
7566504
fix: stop leaking Artifactory credentials to unrelated gem hosts
agrasth Jul 30, 2026
16b4fc3
build: require the pinned build-info-go instead of replacing it
agrasth Jul 31, 2026
a17c34c
fix: record the real artifact path and stop hiding empty build-info
agrasth Jul 31, 2026
e668867
fix: stop racing rubygems.org in ~/.gemrc, and quieten auth logging
agrasth Aug 3, 2026
f5a9cb0
fix: resolve the Gemfile like Bundler does, and give modules a stable…
agrasth Aug 3, 2026
ea4db1a
fix: honour global flags, the "--" separator, nested groups and inter…
agrasth Aug 4, 2026
5575e29
feat: collect dependencies for gem build from Gemfile.lock
agrasth Aug 4, 2026
409d3eb
fix: write the gem source with a trailing slash so plain `gem install…
agrasth Aug 4, 2026
835d732
chore: satisfy static analysis and drop the design note from the branch
agrasth Aug 4, 2026
8e5aac0
Merge remote-tracking branch 'origin/main' into RTECO-0000-rubygems-n…
agrasth Aug 4, 2026
88c8c0c
fix: add the missing Ruby entry to packageManagerConfigs
agrasth Aug 4, 2026
ce78a4a
build: bump build-info-go to the branch merged with its main
agrasth Aug 4, 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
1,442 changes: 1,442 additions & 0 deletions artifactory/commands/ruby/native_ruby.go

Large diffs are not rendered by default.

615 changes: 615 additions & 0 deletions artifactory/commands/ruby/native_ruby_test.go

Large diffs are not rendered by default.

46 changes: 40 additions & 6 deletions artifactory/commands/ruby/ruby.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,43 @@ package ruby
import (
"net/url"

buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build"
"github.com/jfrog/jfrog-cli-core/v2/utils/config"
"github.com/jfrog/jfrog-client-go/auth"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
)

// RubyCommand runs a native RubyGems (`gem`) or Bundler (`bundle`) command directly,
// injecting Artifactory authentication and optionally collecting build info.
//
// It is a config-less native flow (like `jf uv`): the server is resolved from
// --server-id or the default server, and the Artifactory repository is discovered
// from the project's own Ruby configuration (Gemfile source, .bundle/config,
// gem sources). The `.jfrog/projects/ruby.yaml` produced by `jf ruby-config` is NOT
// read by this command.
type RubyCommand struct {
serverDetails *config.ServerDetails
commandName string
args []string
repository string
// nativeTool is the underlying binary to run: "gem" or "bundle".
nativeTool string
// args are the arguments passed to the native tool, including its sub-command
// (e.g. ["install", "--without", "test"]).
args []string
// serverID is the explicit --server-id, empty for the default server.
serverID string
// repository optionally overrides the Artifactory repo (otherwise auto-discovered).
repository string
buildConfiguration *buildUtils.BuildConfiguration
}

func NewRubyCommand() *RubyCommand {
return &RubyCommand{}
}

func (rc *RubyCommand) SetNativeTool(tool string) *RubyCommand {
rc.nativeTool = tool
return rc
}

func (rc *RubyCommand) SetRepo(repo string) *RubyCommand {
rc.repository = repo
return rc
Expand All @@ -29,8 +50,13 @@ func (rc *RubyCommand) SetArgs(arguments []string) *RubyCommand {
return rc
}

func (rc *RubyCommand) SetCommandName(commandName string) *RubyCommand {
rc.commandName = commandName
func (rc *RubyCommand) SetServerID(serverID string) *RubyCommand {
rc.serverID = serverID
return rc
}

func (rc *RubyCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *RubyCommand {
rc.buildConfiguration = bc
return rc
}

Expand All @@ -40,7 +66,15 @@ func (rc *RubyCommand) SetServerDetails(serverDetails *config.ServerDetails) *Ru
}

func (rc *RubyCommand) ServerDetails() (*config.ServerDetails, error) {
return rc.serverDetails, nil
if rc.serverDetails != nil {
return rc.serverDetails, nil
}
return rubyResolveServerDetails(rc.serverID)
}

// CommandName is the usage-report metric id for native Ruby commands.
func (rc *RubyCommand) CommandName() string {
return "rt_ruby_native"
}

// GetRubyGemsRepoUrlWithCredentials gets the RubyGems repository url and the credentials.
Expand Down
150 changes: 150 additions & 0 deletions artifactory/commands/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
container "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ocicontainer"
"github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/python"
"github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/repository"
"github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ruby"
commandsutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils"
"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils"
"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/maven"
Expand All @@ -31,6 +32,7 @@ import (
"github.com/jfrog/jfrog-client-go/utils/errorutils"
"github.com/jfrog/jfrog-client-go/utils/log"
"golang.org/x/exp/maps"
"gopkg.in/yaml.v3"
)

// packageManagerToRepositoryPackageType maps project types to corresponding Artifactory repository package types.
Expand Down Expand Up @@ -61,6 +63,8 @@ var packageManagerToRepositoryPackageType = map[project.ProjectType]string{

project.Gradle: repository.Gradle,
project.Maven: repository.Maven,

project.Ruby: repository.Gems,
}

// SetupCommand configures registries and authentication for various package manager (npm, Yarn, Pip, Pipenv, Poetry, UV, Go)
Expand Down Expand Up @@ -184,6 +188,8 @@ func (sc *SetupCommand) Run() (err error) {
err = sc.configureMaven()
case project.UV:
err = sc.configureUV()
case project.Ruby:
err = sc.configureRuby()
default:
err = errorutils.CheckErrorf("unsupported package manager: %s", sc.packageManager)
}
Expand Down Expand Up @@ -570,6 +576,150 @@ func (sc *SetupCommand) configureUV() error {
return nil
}

// rubygemsDefaultSource is the default source RubyGems ships with; when present in
// ~/.gemrc's :sources: list, it is always kept first.
const rubygemsDefaultSource = "https://rubygems.org"

// configureRuby configures RubyGems and Bundler to use Artifactory as a gem source.
// It performs:
// 1. Writes per-host Bundler credentials directly to ~/.bundle/config
// 2. Adds the Artifactory source to ~/.gemrc, or prints guidance for Gemfile
//
// Both gem and bundle tools will then authenticate to the Artifactory gems repository.
func (sc *SetupCommand) configureRuby() error {
repoUrl, username, password, err := ruby.GetRubyGemsRepoUrlWithCredentials(sc.serverDetails, sc.repoName)
if err != nil {
return fmt.Errorf("failed to get RubyGems repository URL with credentials: %w", err)
}

// If no credentials are provided, just print guidance.
if username == "" && password == "" {
log.Output(fmt.Sprintf("Add this source to your Gemfile:\n source \"%s\"\n", repoUrl.String()))
return nil
}

host := repoUrl.Hostname()
if repoUrl.Port() != "" {
host += ":" + repoUrl.Port()
}

if bundleErr := writeBundleConfig(host, username, password); bundleErr != nil {
return fmt.Errorf("failed to configure Bundler credentials: %w", bundleErr)
}
log.Info(fmt.Sprintf("Bundler configured: credentials set for host '%s'", host))

// Configure gem: add source to ~/.gemrc.
sourceURL := repoUrl.String()
if gemrcErr := addGemrcSource(sourceURL); gemrcErr != nil {
return fmt.Errorf("failed to update ~/.gemrc: %w", gemrcErr)
}

log.Output(fmt.Sprintf("\nAdd this source to your Gemfile:\n source \"%s\"\n", sourceURL))
return nil
}

// writeBundleConfig writes per-host Bundler credentials directly to ~/.bundle/config,
// bypassing the `bundle config` CLI entirely (its `set` subcommand doesn't exist on
// Bundler 1.x). The config key is derived via ruby.BundleEnvKeyForHost, the same
// normalization `jf ruby bundle install` uses at runtime, so setup-time and
// runtime-injection key formats can never drift apart.
func writeBundleConfig(host, user, password string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
bundleDir := home + "/.bundle"
configPath := bundleDir + "/config"

existing, readErr := os.ReadFile(configPath)
if readErr != nil && !os.IsNotExist(readErr) {
return readErr
}

config := map[string]interface{}{}
if len(existing) > 0 {
if unmarshalErr := yaml.Unmarshal(existing, &config); unmarshalErr != nil {
return fmt.Errorf("parse existing %s: %w", configPath, unmarshalErr)
}
}

config[ruby.BundleEnvKeyForHost(host)] = user + ":" + password

out, marshalErr := yaml.Marshal(config)
if marshalErr != nil {
return marshalErr
}
if mkdirErr := os.MkdirAll(bundleDir, 0755); mkdirErr != nil {
return mkdirErr
}
return os.WriteFile(configPath, out, 0600)
}

// addGemrcSource adds sourceURL to ~/.gemrc's :sources: list. An exact-match existing
// entry is moved to the front (behind rubygemsDefaultSource, if present) rather than
// duplicated; a new entry is prepended. Different repos configured across multiple runs
// are meant to coexist here, since gem install natively searches every listed source.
func addGemrcSource(sourceURL string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
gemrcPath := home + "/.gemrc"

existing, readErr := os.ReadFile(gemrcPath)
if readErr != nil && !os.IsNotExist(readErr) {
return readErr
}

config := map[string]interface{}{}
if len(existing) > 0 {
if unmarshalErr := yaml.Unmarshal(existing, &config); unmarshalErr != nil {
return fmt.Errorf("parse existing %s: %w", gemrcPath, unmarshalErr)
}
}

var currentSources []string
if raw, ok := config[":sources"]; ok {
if rawList, ok := raw.([]interface{}); ok {
for _, item := range rawList {
if s, ok := item.(string); ok {
currentSources = append(currentSources, s)
}
}
}
} else {
currentSources = []string{rubygemsDefaultSource}
}

config[":sources"] = reorderGemrcSources(currentSources, sourceURL)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

out, marshalErr := yaml.Marshal(config)
if marshalErr != nil {
return marshalErr
}
return os.WriteFile(gemrcPath, out, 0644)
}

// reorderGemrcSources returns sources with sourceURL moved to the front (deduplicated if
// already present), keeping rubygemsDefaultSource first when it's in the list.
func reorderGemrcSources(sources []string, sourceURL string) []string {
hasDefault := slices.Contains(sources, rubygemsDefaultSource)

result := make([]string, 0, len(sources)+1)
if hasDefault {
result = append(result, rubygemsDefaultSource)
}
result = append(result, sourceURL)

for _, s := range sources {
if s == rubygemsDefaultSource || s == sourceURL {
continue
}
result = append(result, s)
}
return result
}

// configureHelm configures Helm to use Artifactory as an OCI registry.
// It executes:
//
Expand Down
Loading
Loading