Skip to content
Open
Show file tree
Hide file tree
Changes from 23 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,808 changes: 1,808 additions & 0 deletions artifactory/commands/ruby/native_ruby.go

Large diffs are not rendered by default.

784 changes: 784 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
231 changes: 231 additions & 0 deletions artifactory/commands/setup/setup.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package setup

import (
"bytes"
_ "embed"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"

Expand All @@ -18,6 +20,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 +34,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 +65,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 +190,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 +578,229 @@ func (sc *SetupCommand) configureUV() error {
return nil
}

// rubygemsDefaultSource is the public source that RubyGems and Bundler use by default.
// It stays first in ~/.gemrc's :sources: list, and is the source mirrored to Artifactory
// so that unmodified Gemfiles resolve through Artifactory.
const rubygemsDefaultSource = "https://rubygems.org"

// configureRuby points RubyGems and Bundler at Artifactory, so that plain `gem` and
// `bundle` commands resolve and authenticate through it with no edit to the Gemfile.
//
// Everything is written by editing the config files directly, never by shelling out to
// `gem`/`bundle`, because their CLI syntax differs across versions (notably
// `bundle config set`, which does not exist before Bundler 2.0):
//
// 1. ~/.bundle/config — a mirror redirecting https://rubygems.org to the Artifactory
// repository, plus per-host credentials.
// 2. ~/.gemrc — the Artifactory repository added to :sources:, for bare `gem install`.
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)
}

// sourceURL stays credential-free: it is what gets printed for the user to paste into
// a shared Gemfile. authenticatedURL is the same repository with credentials embedded,
// which is what the local config files need.
// The URL must end in a slash. RubyGems resolves index files relative to the source, so
// without one the final path segment is replaced and it requests
// .../api/gems/specs.4.8.gz — losing the repository name — which makes a plain
// `gem install` fail with "server did not return a valid file". Bundler normalises the
// trailing slash itself, so this is equally correct for the mirror and the Gemfile.
if !strings.HasSuffix(repoUrl.Path, "/") {
repoUrl.Path += "/"
}
sourceURL := repoUrl.String()
authenticatedURL := sourceURL
if password != "" {
withCredentials := *repoUrl
withCredentials.User = url.UserPassword(username, password)
authenticatedURL = withCredentials.String()
}
settings := map[string]string{}

// Mirror the public RubyGems source to Artifactory, so a Gemfile that says
// `source "https://rubygems.org"` resolves through Artifactory unchanged. Credentials
// are embedded in the mirror value: Bundler keeps a mirror URI's own userinfo instead
// of looking credentials up separately, which behaves identically on every version.
settings[bundleMirrorKey(rubygemsDefaultSource)] = authenticatedURL

// Per-host credentials, for Gemfiles that name the Artifactory source explicitly.
if password != "" {
credential := username + ":" + password
for _, key := range ruby.BundleCredentialKeys(repoUrl.Hostname()) {
settings[key] = credential
}
}

if bundleErr := writeBundleSettings(settings); bundleErr != nil {
return fmt.Errorf("failed to configure Bundler: %w", bundleErr)
}
log.Info(fmt.Sprintf("Bundler configured: %s is mirrored to %s", rubygemsDefaultSource, sourceURL))

if gemrcErr := addGemrcSource(authenticatedURL); gemrcErr != nil {
return fmt.Errorf("failed to update ~/.gemrc: %w", gemrcErr)
}
log.Info("RubyGems configured: source added to ~/.gemrc")

log.Output(fmt.Sprintf(
"\nBundler and RubyGems now resolve through Artifactory.\n"+
" A Gemfile using `source \"%s\"` needs no change.\n"+
" To depend on this repository explicitly, use:\n source \"%s\"\n",
rubygemsDefaultSource, sourceURL))
return nil
}

// bundleMirrorKey returns the ~/.bundle/config key Bundler reads a mirror from for the
// given upstream source. Bundler builds it from "mirror.<uri>" by normalizing the URI to
// a trailing slash, replacing "." with "__", and upcasing:
//
// https://rubygems.org → BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/
func bundleMirrorKey(sourceURL string) string {
normalized := strings.TrimSuffix(sourceURL, "/") + "/"
return "BUNDLE_" + strings.ToUpper(strings.ReplaceAll("mirror."+normalized, ".", "__"))
}

// writeBundleSettings merges entries into ~/.bundle/config, preserving every setting
// already present. The file holds credentials, so it is written 0600.
func writeBundleSettings(entries map[string]string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
bundleDir := filepath.Join(home, ".bundle")
configPath := filepath.Join(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)
}
}
for key, value := range entries {
config[key] = value
}

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

// marshalBundleConfig renders Bundler's config as YAML that Bundler's own parser accepts.
// Bundler reads this file with a line-based stub serializer rather than a real YAML
// parser: it needs each setting on a single line, and it measures nesting depth in
// two-space units.
func marshalBundleConfig(config map[string]interface{}) ([]byte, error) {
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(config); err != nil {
return nil, err
}
if err := encoder.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

// addGemrcSource adds sourceURL to ~/.gemrc's :sources: list, moving it to the front
// (behind rubygemsDefaultSource, if present) so `gem install` tries it first. Different
// repositories configured across separate runs are meant to coexist here, because
// `gem install` natively searches every listed source.
//
// sourceURL embeds credentials when the server has them: unlike Bundler, RubyGems has no
// separate credential store for installing, so the source URL is the only way a plain
// `gem install` can authenticate. That is why the file is written 0600.
func addGemrcSource(sourceURL string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
gemrcPath := filepath.Join(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)
}
}
}
}

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

out, marshalErr := yaml.Marshal(config)
if marshalErr != nil {
return marshalErr
}
// The source URL may embed credentials, so this file must not be world-readable.
return os.WriteFile(gemrcPath, out, 0600)
}

// gemSourceIdentity strips embedded credentials and any trailing slash from a gem source
// URL, so that two entries pointing at the same repository compare equal even when their
// credentials differ. Without this, re-running setup after a token rotation would leave
// the stale entry behind and `gem install` would keep trying the old credentials.
func gemSourceIdentity(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return strings.TrimSuffix(rawURL, "/")
}
parsed.User = nil
return strings.TrimSuffix(parsed.String(), "/")
}

// reorderGemrcSources puts sourceURL first, replaces any existing entry for the same
// repository, and removes the public RubyGems source.
//
// Removing https://rubygems.org is deliberate. RubyGems queries sources in list order, so
// leaving the public source in front means `gem install` reaches rubygems.org before
// Artifactory and setup has no practical effect. Dropping it matches what the Bundler
// mirror already does, and what `jf setup` does for npm and cargo, which replace the
// public registry outright rather than racing it. The configured repository is expected
// to be virtual or remote-backed so it can still serve public gems.
//
// Artifactory repositories configured across separate runs still coexist, most recently
// configured first, because `gem install` genuinely does search several sources.
func reorderGemrcSources(sources []string, sourceURL string) []string {
target := gemSourceIdentity(sourceURL)
result := make([]string, 0, len(sources)+1)
result = append(result, sourceURL)

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

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