Skip to content

Commit 1d9ea45

Browse files
mikeland73claude
andauthored
feat(config): add "auto" devbox_version policy to switch versions (#2991)
## Summary Stacked on #2990, which adds `devbox_version`. Merge that first. Adds an `auto` policy that switches to the pinned devbox version: ```json "devbox_version": {"version": "0.18.4", "on_mismatch": "auto"} ``` - **How switching works:** when the running devbox doesn't match, it re-runs the same command through the devbox launcher (`$LAUNCHER_PATH`) with `DEVBOX_USE_VERSION` set to the pinned version. - The existing launcher already downloads, checksum-verifies and runs any release named by `DEVBOX_USE_VERSION`, so the launcher needs no changes. - Download progress goes to stderr, so `shellenv` and direnv output stays clean. - **Exact versions only:** `auto` requires an exact version; a range with `auto` is a config error. `DEVBOX_VERSION_POLICY=auto` with a range fails like `error`. - **A `DEVBOX_USE_VERSION` set by the user wins:** devbox warns instead of switching. - **Loop guard:** `__DEVBOX_AUTO_VERSION` marks a version that `auto` chose. That lets a nested shell switch again for a different project, and makes devbox fail instead of looping if the launcher doesn't switch. - **Without a launcher** (for example Nix or Homebrew installs), `auto` fails with install instructions. - **Inheritance:** `DEVBOX_USE_VERSION` carries into `devbox shell`, so nested devbox commands stay on the pinned version. ## How was it tested? - Unit tests with a mocked `exec` cover: switching, replacing existing env values, switching again for a different project, no launcher, the loop guard, a user-set version winning, and the env override with a range. - `testscripts/version/devbox_version_auto.test.txt` uses a fake launcher script to check that the same command is re-run with `DEVBOX_USE_VERSION` set. It also covers the no-launcher error, a user-set version winning, and rejecting ranges. - Manual end-to-end run with the real launcher (v0.2.2): a binary built as 0.17.1, run in a project pinned to `0.18.4` with `auto`, ran `devbox run` under 0.18.4. Inside the script, `devbox version` printed 0.18.4 and `DEVBOX_USE_VERSION=0.18.4`. - `go test ./internal/...` and `devbox run lint` pass. ## Community Contribution License All community contributions in this pull request are licensed to the project maintainers under the terms of the [Apache 2 License](https://www.apache.org/licenses/LICENSE-2.0). By creating this pull request, I represent that I have the right to license the contributions to the project maintainers under the Apache 2 License as stated in the [Community Contribution License](https://github.com/jetify-com/opensource/blob/main/CONTRIBUTING.md#community-contribution-license). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 87b2e72 commit 1d9ea45

6 files changed

Lines changed: 295 additions & 10 deletions

File tree

‎.schema/devbox.schema.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
},
3434
"on_mismatch": {
3535
"type": "string",
36-
"description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default) and \"error\" fails the command.",
37-
"enum": ["warn", "error"]
36+
"description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default), \"error\" fails the command, and \"auto\" re-runs the command with the required version (requires an exact version).",
37+
"enum": ["warn", "error", "auto"]
3838
}
3939
},
4040
"required": ["version"],

‎internal/devconfig/configfile/devbox_version.go‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@ const (
2525
VersionPolicyWarn VersionPolicy = "warn"
2626
// VersionPolicyError fails the command.
2727
VersionPolicyError VersionPolicy = "error"
28+
// VersionPolicyAuto re-runs the command with the required devbox version
29+
// using the devbox launcher. It requires an exact version.
30+
VersionPolicyAuto VersionPolicy = "auto"
2831
)
2932

3033
// ConfigVersionPolicies are the values allowed for devbox_version.on_mismatch.
3134
var ConfigVersionPolicies = []VersionPolicy{
3235
VersionPolicyWarn,
3336
VersionPolicyError,
37+
VersionPolicyAuto,
3438
}
3539

3640
// DevboxVersion is the devbox_version field of devbox.json. It is either a
@@ -40,7 +44,7 @@ var ConfigVersionPolicies = []VersionPolicy{
4044
//
4145
// or an object with an explicit policy:
4246
//
43-
// "devbox_version": {"version": "0.18.4", "on_mismatch": "error"}
47+
// "devbox_version": {"version": "0.18.4", "on_mismatch": "auto"}
4448
type DevboxVersion struct {
4549
// Version is a semver constraint (e.g. "0.18.4", "^0.18.0",
4650
// ">=0.17.0 <0.19.0") that the running devbox version must satisfy.
@@ -115,10 +119,18 @@ func validateDevboxVersion(cfg *ConfigFile) error {
115119
}
116120
if required.OnMismatch != "" && !slices.Contains(ConfigVersionPolicies, required.OnMismatch) {
117121
return usererr.New(
118-
"Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q and %q.",
119-
required.OnMismatch, VersionPolicyWarn, VersionPolicyError,
122+
"Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q, %q, and %q.",
123+
required.OnMismatch, VersionPolicyWarn, VersionPolicyError, VersionPolicyAuto,
120124
)
121125
}
126+
if required.OnMismatch == VersionPolicyAuto {
127+
if _, ok := required.ExactVersion(); !ok {
128+
return usererr.New(
129+
"devbox_version.on_mismatch %q requires an exact version like \"0.18.4\", but devbox.json has %q.",
130+
VersionPolicyAuto, required.Version,
131+
)
132+
}
133+
}
122134
return nil
123135
}
124136

@@ -130,7 +142,7 @@ func ParseVersionPolicy(s string) (VersionPolicy, error) {
130142
return p, nil
131143
}
132144
return "", errors.Errorf(
133-
"invalid policy %q: valid values are %q, %q, and %q",
134-
s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError,
145+
"invalid policy %q: valid values are %q, %q, %q, and %q",
146+
s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError, VersionPolicyAuto,
135147
)
136148
}

‎internal/devconfig/configfile/devbox_version_test.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ func TestDevboxVersionValidation(t *testing.T) {
5757
`"0.18.x"`,
5858
`"0.17.2 || ^0.18.0"`,
5959
`{"version": "0.18.4", "on_mismatch": "warn"}`,
60+
`{"version": "0.18.4", "on_mismatch": "auto"}`,
61+
`{"version": "v0.18.4", "on_mismatch": "auto"}`,
6062
}
6163
for _, v := range valid {
6264
t.Run(v, func(t *testing.T) {
@@ -73,6 +75,9 @@ func TestDevboxVersionValidation(t *testing.T) {
7375
`{"on_mismatch": "error"}`,
7476
`{"version": "0.18.4", "on_mismatch": "explode"}`,
7577
`{"version": "0.18.4", "on_mismatch": "off"}`,
78+
`{"version": "^0.18.0", "on_mismatch": "auto"}`,
79+
`{"version": "0.18.x", "on_mismatch": "auto"}`,
80+
`{"version": "0.18", "on_mismatch": "auto"}`,
7681
}
7782
for _, v := range invalid {
7883
t.Run(v, func(t *testing.T) {
@@ -106,7 +111,7 @@ func TestDevboxVersionExactVersion(t *testing.T) {
106111
}
107112

108113
func TestParseVersionPolicy(t *testing.T) {
109-
for _, s := range []string{"off", "warn", "error", " WARN "} {
114+
for _, s := range []string{"off", "warn", "error", "auto", " WARN "} {
110115
_, err := ParseVersionPolicy(s)
111116
assert.NoError(t, err, s)
112117
}

‎internal/vercheck/project.go‎

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"log/slog"
1010
"os"
1111
"strings"
12+
"syscall"
1213

1314
"github.com/Masterminds/semver/v3"
1415

@@ -23,9 +24,19 @@ import (
2324
// print the same warning again.
2425
const warnedEnvName = "__DEVBOX_VERSION_MISMATCH_WARNED"
2526

27+
// autoVersionEnvName records the version that the "auto" policy switched to.
28+
// It distinguishes a DEVBOX_USE_VERSION set by the auto policy from one the
29+
// user set, and stops re-exec loops if the launcher doesn't switch versions.
30+
const autoVersionEnvName = "__DEVBOX_AUTO_VERSION"
31+
32+
// execFunc replaces the current process. We use this variable so that we can
33+
// mock it in tests.
34+
var execFunc = syscall.Exec
35+
2636
// CheckProjectVersion enforces the devbox_version field of the devbox.json at
2737
// configPath against the running devbox version. Depending on the policy, a
28-
// mismatch prints a warning or returns an error. The DEVBOX_VERSION_POLICY
38+
// mismatch prints a warning, returns an error, or re-runs the current command
39+
// with the required version (which doesn't return). The DEVBOX_VERSION_POLICY
2940
// environment variable overrides the policy in devbox.json.
3041
func CheckProjectVersion(w io.Writer, configPath string, required *configfile.DevboxVersion) error {
3142
if required == nil || isDevBuild {
@@ -58,16 +69,93 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De
5869
}
5970

6071
msg := mismatchMessage(configPath, required, constraint)
61-
if policy == configfile.VersionPolicyError {
72+
switch policy {
73+
case configfile.VersionPolicyError:
6274
return usererr.New("%s", msg)
75+
case configfile.VersionPolicyAuto:
76+
return switchVersion(w, configPath, required, msg)
77+
default:
78+
return warnOnce(w, configPath, msg)
79+
}
80+
}
81+
82+
// switchVersion re-runs the current command with the exact version required
83+
// by devbox_version. It uses the devbox launcher, which downloads the version
84+
// if needed and runs the version named by DEVBOX_USE_VERSION.
85+
func switchVersion(w io.Writer, configPath string, required *configfile.DevboxVersion, msg string) error {
86+
target, ok := required.ExactVersion()
87+
if !ok {
88+
// Only possible with DEVBOX_VERSION_POLICY=auto, since devbox.json
89+
// validation rejects on_mismatch "auto" with a range.
90+
return usererr.New(
91+
"%s\nDevbox can't switch versions automatically because devbox_version is %q, not an exact version like \"0.18.4\".",
92+
msg, required.Version,
93+
)
6394
}
95+
96+
// Respect a version the user chose explicitly.
97+
autoVersion := os.Getenv(autoVersionEnvName)
98+
if useVersion := os.Getenv(envir.DevboxUseVersion); useVersion != "" && useVersion != autoVersion {
99+
return warnOnce(w, configPath, fmt.Sprintf(
100+
"%s\nNot switching versions automatically because %s=%s is set.",
101+
msg, envir.DevboxUseVersion, useVersion,
102+
))
103+
}
104+
105+
if autoVersion == target {
106+
return usererr.New(
107+
"%s\nDevbox tried to switch to version %s automatically, but the launcher ran version %s instead.",
108+
msg, target, currentDevboxVersion,
109+
)
110+
}
111+
112+
launcher := os.Getenv(envir.LauncherPath)
113+
if launcher == "" {
114+
return usererr.New(
115+
"%s\nDevbox can't switch versions automatically because it wasn't started by the devbox launcher. "+
116+
"Install devbox with `curl -fsSL https://get.jetify.com/devbox | bash` to enable automatic switching.",
117+
msg,
118+
)
119+
}
120+
121+
slog.Debug("switching devbox version for devbox_version", "from", currentDevboxVersion, "to", target, "launcher", launcher)
122+
env := withEnv(os.Environ(), map[string]string{
123+
envir.DevboxUseVersion: target,
124+
autoVersionEnvName: target,
125+
})
126+
args := append([]string{launcher}, os.Args[1:]...)
127+
if err := execFunc(launcher, args, env); err != nil {
128+
return usererr.WithUserMessage(err, "Failed to switch to devbox version %s using the launcher at %s.", target, launcher)
129+
}
130+
return nil
131+
}
132+
133+
// warnOnce prints msg as a warning unless it was already printed for the
134+
// project at configPath by this process or a parent devbox process.
135+
func warnOnce(w io.Writer, configPath, msg string) error {
64136
if os.Getenv(warnedEnvName) == configPath {
65137
return nil
66138
}
67139
ux.Fwarningf(w, "%s\n", msg)
68140
return os.Setenv(warnedEnvName, configPath)
69141
}
70142

143+
// withEnv returns environ with the variables in vars set, replacing any
144+
// existing values.
145+
func withEnv(environ []string, vars map[string]string) []string {
146+
result := make([]string, 0, len(environ)+len(vars))
147+
for _, kv := range environ {
148+
name, _, _ := strings.Cut(kv, "=")
149+
if _, ok := vars[name]; !ok {
150+
result = append(result, kv)
151+
}
152+
}
153+
for name, value := range vars {
154+
result = append(result, name+"="+value)
155+
}
156+
return result
157+
}
158+
71159
func mismatchMessage(configPath string, required *configfile.DevboxVersion, constraint *semver.Constraints) string {
72160
var msg strings.Builder
73161
fmt.Fprintf(

‎internal/vercheck/project_test.go‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package vercheck
66
import (
77
"bytes"
88
"os"
9+
"strings"
910
"testing"
1011

1112
"github.com/stretchr/testify/assert"
@@ -28,6 +29,8 @@ func setupProjectVersionTest(t *testing.T, version string) {
2829
isDevBuild = false
2930

3031
t.Setenv(envir.DevboxVersionPolicy, "")
32+
t.Setenv(envir.DevboxUseVersion, "")
33+
t.Setenv(autoVersionEnvName, "")
3134
t.Setenv(envir.DevboxLatestVersion, "")
3235
t.Setenv(warnedEnvName, "")
3336
t.Cleanup(func() { os.Unsetenv(warnedEnvName) })
@@ -165,3 +168,128 @@ func TestCheckProjectVersionEnvOverride(t *testing.T) {
165168
assert.ErrorContains(t, err, envir.DevboxVersionPolicy)
166169
})
167170
}
171+
172+
// mockExec replaces execFunc and records the call instead of replacing the
173+
// process.
174+
type mockExec struct {
175+
called bool
176+
argv0 string
177+
argv []string
178+
envv []string
179+
}
180+
181+
func (m *mockExec) install(t *testing.T) {
182+
t.Helper()
183+
old := execFunc
184+
t.Cleanup(func() { execFunc = old })
185+
execFunc = func(argv0 string, argv, envv []string) error {
186+
m.called, m.argv0, m.argv, m.envv = true, argv0, argv, envv
187+
return nil
188+
}
189+
}
190+
191+
func (m *mockExec) env(name string) []string {
192+
var values []string
193+
for _, kv := range m.envv {
194+
if k, v, _ := strings.Cut(kv, "="); k == name {
195+
values = append(values, v)
196+
}
197+
}
198+
return values
199+
}
200+
201+
func TestCheckProjectVersionAuto(t *testing.T) {
202+
required := &configfile.DevboxVersion{Version: "v0.18.4", OnMismatch: configfile.VersionPolicyAuto}
203+
204+
t.Run("switches_with_launcher", func(t *testing.T) {
205+
setupProjectVersionTest(t, "0.17.2")
206+
t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox")
207+
t.Setenv(envir.DevboxUseVersion, "")
208+
t.Setenv(autoVersionEnvName, "")
209+
exec := &mockExec{}
210+
exec.install(t)
211+
212+
buf := new(bytes.Buffer)
213+
require.NoError(t, CheckProjectVersion(buf, testConfigPath, required))
214+
require.True(t, exec.called)
215+
assert.Equal(t, "/usr/local/bin/devbox", exec.argv0)
216+
assert.Equal(t, append([]string{"/usr/local/bin/devbox"}, os.Args[1:]...), exec.argv)
217+
// The existing empty values are replaced, not duplicated.
218+
assert.Equal(t, []string{"0.18.4"}, exec.env(envir.DevboxUseVersion))
219+
assert.Equal(t, []string{"0.18.4"}, exec.env(autoVersionEnvName))
220+
assert.Empty(t, buf.String())
221+
})
222+
223+
t.Run("switches_again_for_a_different_project", func(t *testing.T) {
224+
// Inside a shell that auto switched to 0.17.2 for another project.
225+
setupProjectVersionTest(t, "0.17.2")
226+
t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox")
227+
t.Setenv(envir.DevboxUseVersion, "0.17.2")
228+
t.Setenv(autoVersionEnvName, "0.17.2")
229+
exec := &mockExec{}
230+
exec.install(t)
231+
232+
require.NoError(t, CheckProjectVersion(new(bytes.Buffer), testConfigPath, required))
233+
require.True(t, exec.called)
234+
assert.Equal(t, []string{"0.18.4"}, exec.env(envir.DevboxUseVersion))
235+
})
236+
237+
t.Run("no_launcher", func(t *testing.T) {
238+
setupProjectVersionTest(t, "0.17.2")
239+
t.Setenv(envir.LauncherPath, "")
240+
exec := &mockExec{}
241+
exec.install(t)
242+
243+
err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required)
244+
assert.ErrorContains(t, err, "wasn't started by the devbox launcher")
245+
assert.False(t, exec.called)
246+
})
247+
248+
t.Run("launcher_did_not_switch", func(t *testing.T) {
249+
setupProjectVersionTest(t, "0.17.2")
250+
t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox")
251+
t.Setenv(envir.DevboxUseVersion, "0.18.4")
252+
t.Setenv(autoVersionEnvName, "0.18.4")
253+
exec := &mockExec{}
254+
exec.install(t)
255+
256+
err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required)
257+
assert.ErrorContains(t, err, "launcher ran version 0.17.2 instead")
258+
assert.False(t, exec.called)
259+
})
260+
261+
t.Run("user_set_version_wins", func(t *testing.T) {
262+
setupProjectVersionTest(t, "0.17.2")
263+
t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox")
264+
t.Setenv(envir.DevboxUseVersion, "0.17.2")
265+
t.Setenv(autoVersionEnvName, "")
266+
exec := &mockExec{}
267+
exec.install(t)
268+
269+
buf := new(bytes.Buffer)
270+
require.NoError(t, CheckProjectVersion(buf, testConfigPath, required))
271+
assert.False(t, exec.called)
272+
assert.Contains(t, buf.String(), "DEVBOX_USE_VERSION=0.17.2 is set")
273+
})
274+
275+
t.Run("env_override_with_range", func(t *testing.T) {
276+
setupProjectVersionTest(t, "0.17.2")
277+
t.Setenv(envir.DevboxVersionPolicy, "auto")
278+
t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox")
279+
exec := &mockExec{}
280+
exec.install(t)
281+
282+
err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, &configfile.DevboxVersion{Version: "^0.18.0"})
283+
assert.ErrorContains(t, err, "not an exact version")
284+
assert.False(t, exec.called)
285+
})
286+
287+
t.Run("satisfied", func(t *testing.T) {
288+
setupProjectVersionTest(t, "0.18.4")
289+
exec := &mockExec{}
290+
exec.install(t)
291+
292+
require.NoError(t, CheckProjectVersion(new(bytes.Buffer), testConfigPath, required))
293+
assert.False(t, exec.called)
294+
})
295+
}

0 commit comments

Comments
 (0)