Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
081f066
[BE-1876] Integrate nixpkgs plugin
marcell-vida Nov 11, 2025
eb05dd2
Check for version in index, remove plugin version pinning, fixes
marcell-vida Nov 11, 2025
1dfba3d
*
marcell-vida Nov 12, 2025
3738f29
Fix test version
marcell-vida Nov 12, 2025
ebe94d2
Rename tool
marcell-vida Nov 12, 2025
fce56eb
Remove index checking, use listing
marcell-vida Nov 12, 2025
d4c3efd
*
marcell-vida Nov 12, 2025
c177efe
Timeout on test
marcell-vida Nov 12, 2025
9aaccad
Simplify things
ofalvai Nov 13, 2025
95548fe
Get rid of MiseExecutor, make ExecEnv mockable instead
ofalvai Nov 14, 2025
adc95ec
Change plugin ID to final value
ofalvai Nov 14, 2025
24fd4b5
Unit tests
ofalvai Nov 14, 2025
aca4ce3
Add force flag for integration testing
ofalvai Nov 14, 2025
a2d8363
Integration tests
ofalvai Nov 14, 2025
972e50b
Add check before executing nix commands
ofalvai Nov 17, 2025
59c8133
Make feature flag value true instead of 1
ofalvai Nov 17, 2025
7e4bff6
AI reviewer fixes
marcell-vida Nov 18, 2025
945d3a3
Merge branch 'master' into BE-1876-integrate-nixpkgs-plugin
marcell-vida Nov 18, 2025
7d750f5
chore: trigger CI checks
marcell-vida Nov 18, 2025
d6e6b20
Raise timeout, install backend even with force install
marcell-vida Nov 18, 2025
67f994c
Lint
marcell-vida Nov 19, 2025
6f8f212
Fix tests, update plugin
marcell-vida Nov 19, 2025
488b138
Decrease validate timeout
marcell-vida Nov 19, 2025
45d52d4
Fix tests
marcell-vida Nov 19, 2025
dec4eb0
*
marcell-vida Nov 20, 2025
28bb814
Tests working on stable stacks too, consistent commenting
marcell-vida Nov 21, 2025
68e3e49
Remove nix availability check
marcell-vida Nov 21, 2025
e9574f3
Fake nix checking in tests instead of env var override
ofalvai Nov 24, 2025
ba0feab
Merge branch 'master' into BE-1876-integrate-nixpkgs-plugin
marcell-vida Nov 24, 2025
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
6 changes: 5 additions & 1 deletion bitrise.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ workflows:
- bundle::run_integration_tests: { }
meta:
bitrise.io:
stack: osx-xcode-16.4.x
stack: osx-xcode-edge # TODO: temporary
machine_type_id: g2.mac.large

run_integration_tests_linux:
Expand All @@ -86,6 +86,10 @@ workflows:
- workflow_id: unpin-go-version
- bundle::setup_go_junit_report: { }
- bundle::run_integration_tests: { }
meta:
bitrise.io:
stack: ubuntu-noble-24.04-bitrise-2025-android
machine_type_id: g2.linux.x-large

run_docker_integration_tests_linux:
steps:
Expand Down
95 changes: 95 additions & 0 deletions integrationtests/toolprovider/mise/install_nixpkgs_ruby_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//go:build linux_and_mac
// +build linux_and_mac

package mise

import (
"testing"

"github.com/bitrise-io/bitrise/v2/toolprovider/mise"
"github.com/bitrise-io/bitrise/v2/toolprovider/provider"
"github.com/stretchr/testify/require"
)

func TestMiseInstallNixpkgsRuby(t *testing.T) {
tests := []struct {
name string
tool string
version string
resolutionStrategy provider.ResolutionStrategy
want string
wantErr bool
}{
{
name: "Install specific version",
tool: "ruby",
version: "3.3.9",
resolutionStrategy: provider.ResolutionStrategyStrict,
want: "3.3.9",
},
{
name: "Install fuzzy version and released strategy",
tool: "ruby",
version: "3.1", // EOL version, won't receive new patch versions suddenly
resolutionStrategy: provider.ResolutionStrategyLatestReleased,
want: "3.1.7",
},
{
name: "Install fuzzy version and installed strategy",
tool: "ruby",
version: "3.1", // EOL version, won't receive new patch versions
resolutionStrategy: provider.ResolutionStrategyLatestInstalled,
want: "3.1.7",
},
{
name: "Nonexistent version in nixpkgs index",
tool: "ruby",
version: "0.1.999",
resolutionStrategy: provider.ResolutionStrategyStrict,
wantErr: true,
},
{
name: "Install some other tool with forced nixpkgs backend",
tool: "node",
version: "22.22.1",
resolutionStrategy: provider.ResolutionStrategyStrict,
wantErr: true,
},
}

t.Setenv("BITRISE_TOOLSETUP_FAST_INSTALL", "true")
t.Setenv("BITRISE_TOOLSETUP_FAST_INSTALL_FORCE", "true")

for _, tt := range tests {
miseInstallDir := t.TempDir()
miseDataDir := t.TempDir()
miseProvider, err := mise.NewToolProvider(miseInstallDir, miseDataDir)
require.NoError(t, err)

err = miseProvider.Bootstrap()
require.NoError(t, err)

t.Run(tt.name, func(t *testing.T) {
request := provider.ToolRequest{
ToolName: provider.ToolID(tt.tool),
UnparsedVersion: tt.version,
ResolutionStrategy: tt.resolutionStrategy,
}
result, installErr := miseProvider.InstallTool(request)

if tt.wantErr {
require.Error(t, installErr)
return
}
require.NoError(t, installErr)
if tt.tool == "ruby" {
// We purposely return ruby with the nixpkgs: prefix for environment activation later
require.Equal(t, provider.ToolID("nixpkgs:ruby"), result.ToolName)
} else {
require.Equal(t, provider.ToolID(tt.tool), result.ToolName)
}
require.Equal(t, tt.want, result.ConcreteVersion)
require.False(t, result.IsAlreadyInstalled)
})
}
}
2 changes: 1 addition & 1 deletion toolprovider/asdf/install_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (a *AsdfToolProvider) InstallPlugin(tool provider.ToolRequest) error {
log.Warnf("Failed to check if plugin is already installed: %v", err)
}
if installed {
log.Debugf("Tool plugin %s is already installed, skipping installation.", tool.ToolName)
log.Debugf("[TOOLPROVIDER] Tool plugin %s is already installed, skipping installation.", tool.ToolName)
return nil
}

Expand Down
20 changes: 10 additions & 10 deletions toolprovider/mise/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ const fallbackDownloadURLBase = "https://storage.googleapis.com/mise-release-mir
func isMiseInstalled(targetDir string) bool {
misePath := filepath.Join(targetDir, "bin", "mise")

// Check if the file exists and is executable
// Check if the file exists and is executable.
info, err := os.Stat(misePath)
if err != nil {
return false
}

// Check if it's a regular file and has execute permissions
// Check if it's a regular file and has execute permissions.
if !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 {
return false
}

// Try to run mise --version to verify it's actually runnable
// Try to run mise --version to verify it's actually runnable.
cmd := exec.Command(misePath, "--version")
err = cmd.Run()
return err == nil
Expand Down Expand Up @@ -106,7 +106,7 @@ func downloadAndVerify(url, expectedChecksum string) (string, error) {
_ = tempFile.Close()
}()

// Compute SHA256 hash of the downloaded file and store contents in the temp file
// Compute SHA256 hash of the downloaded file and store contents in the temp file.
hash := sha256.New()
multiWriter := io.MultiWriter(tempFile, hash)
if _, err := io.Copy(multiWriter, resp.Body); err != nil {
Expand Down Expand Up @@ -211,18 +211,18 @@ func FallbackDownloadURL(version, platformName string) string {

// processHeader processes a tar header and determines the target extraction path.
func processHeader(header *tar.Header, targetDir string) (string, bool) {
// Skip the top-level "mise" directory and extract its contents directly
// Skip the top-level "mise" directory and extract its contents directly.
pathParts := strings.Split(header.Name, "/")
if len(pathParts) > 0 && pathParts[0] == "mise" {
if len(pathParts) == 1 {
// This is the top-level "mise" directory itself, skip it
// This is the top-level "mise" directory itself, skip it.
return "", false
}
// Remove the top-level "mise" directory from the path
// Remove the top-level "mise" directory from the path.
header.Name = strings.Join(pathParts[1:], "/")
}

// Clean the path to prevent directory traversal attacks
// Clean the path to prevent directory traversal attacks.
targetPath := filepath.Join(targetDir, header.Name)
if !strings.HasPrefix(targetPath, filepath.Clean(targetDir)) {
return "", false
Expand Down Expand Up @@ -260,14 +260,14 @@ func extractFile(tarReader *tar.Reader, header *tar.Header, targetPath string) e
}

if filepath.Base(targetPath) == "mise" {
// Make mise binary executable
// Make mise binary executable.
err = os.Chmod(targetPath, 0755)
if err != nil {
return fmt.Errorf("make mise binary executable %s: %w", targetPath, err)
}
}
default:
// Skip other file types (symlinks, etc.)
// Skip other file types (symlinks, etc.).
}
return nil
}
40 changes: 29 additions & 11 deletions toolprovider/mise/execenv/execenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,42 @@ const (

// ExecEnv contains everything needed to run mise commands in a specific environment
// that is installed and pre-configured.
type ExecEnv struct {
type ExecEnv interface {
// InstallDir is the directory where mise is installed. This is not necessarily the same as the data directory.
InstallDir string
InstallDir() string
RunMise(args ...string) (string, error)
RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error)
RunMisePlugin(args ...string) (string, error)
}

type MiseExecEnv struct {
installDir string

// Additional env vars that configure mise and are required for its operation.
ExtraEnvs map[string]string
extraEnvs map[string]string
}

func (e *ExecEnv) RunMise(args ...string) (string, error) {
// extraEnvs: additional env vars that configure mise and are required for its operation.
func NewMiseExecEnv(installDir string, extraEnvs map[string]string) MiseExecEnv {
return MiseExecEnv{
installDir: installDir,
extraEnvs: extraEnvs,
}
}

func (e MiseExecEnv) RunMise(args ...string) (string, error) {
return e.RunMiseWithTimeout(0, args...)
}

func (e *ExecEnv) RunMisePlugin(args ...string) (string, error) {
func (e MiseExecEnv) RunMisePlugin(args ...string) (string, error) {
cmdWithArgs := append([]string{"plugin"}, args...)

// Use timeout for all plugin operations as they involve unknown code execution
// Use timeout for all plugin operations as they involve unknown code execution.
return e.RunMiseWithTimeout(DefaultTimeout, cmdWithArgs...)
}

// RunMiseWithTimeout runs mise commands that involve untrusted operations (plugin execution, remote network calls)
// with a timeout to prevent hanging
func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) {
// with a timeout to prevent hanging.
func (e MiseExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) {
var ctx context.Context
if timeout == 0 {
ctx = context.Background()
Expand All @@ -47,10 +61,10 @@ func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (str
defer cancel()
}

executable := filepath.Join(e.InstallDir, "bin", "mise")
executable := filepath.Join(e.installDir, "bin", "mise")
cmd := exec.CommandContext(ctx, executable, args...)
cmd.Env = os.Environ()
for k, v := range e.ExtraEnvs {
for k, v := range e.extraEnvs {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
output, err := cmd.CombinedOutput()
Expand All @@ -63,3 +77,7 @@ func (e *ExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (str

return string(output), nil
}

func (e MiseExecEnv) InstallDir() string {
return e.installDir
}
59 changes: 59 additions & 0 deletions toolprovider/mise/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package mise

import (
"fmt"
"strings"
"time"
)

type fakeExecEnv struct {
// responses maps command strings to their outputs.
responses map[string]string
// errors maps command strings to errors.
errors map[string]error
}

func newFakeExecEnv() *fakeExecEnv {
return &fakeExecEnv{
responses: make(map[string]string),
errors: make(map[string]error),
}
}

func (m *fakeExecEnv) setResponse(cmdKey string, output string) {
m.responses[cmdKey] = output
}

func (m *fakeExecEnv) setError(cmdKey string, err error) {
m.errors[cmdKey] = err
}

func (m *fakeExecEnv) InstallDir() string {
return "/fake/mise/install/dir"
}

func (m *fakeExecEnv) RunMise(args ...string) (string, error) {
return m.runCommand(args...)
}

func (m *fakeExecEnv) RunMisePlugin(args ...string) (string, error) {
return m.runCommand(append([]string{"plugin"}, args...)...)
}

func (m *fakeExecEnv) RunMiseWithTimeout(timeout time.Duration, args ...string) (string, error) {
return m.runCommand(args...)
}

func (m *fakeExecEnv) runCommand(args ...string) (string, error) {
cmdKey := strings.Join(args, " ")

if err, ok := m.errors[cmdKey]; ok {
return "", err
}

if output, ok := m.responses[cmdKey]; ok {
return output, nil
}

return "", fmt.Errorf("no mock response configured for command: %s", cmdKey)
}
Loading