From 12f145028bf1b7589394a461a44909184b283810 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 10:05:46 -0700 Subject: [PATCH 1/4] feat(config): add devbox_version to require a devbox version Add a top-level `devbox_version` field to devbox.json that declares which devbox versions a project supports (#1371). It accepts a semver constraint string, or an object with an explicit mismatch policy: "devbox_version": "^0.18.0" "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} When the running devbox doesn't satisfy the constraint, "warn" (the default) prints a warning once per project per process tree and "error" fails the command. DEVBOX_VERSION_POLICY=off|warn|error overrides the policy. The check runs in devbox.Open right after the config loads, before the lockfile is read, so it covers every command that opens a project (including `devbox global`). Development builds skip it. Constraints use npm-style syntax via Masterminds/semver, which moves from an indirect to a direct dependency. Co-Authored-By: Claude Opus 5.5 (1M context) --- .schema/devbox.schema.json | 25 ++++ go.mod | 2 +- internal/devbox/devbox.go | 9 ++ .../devconfig/configfile/devbox_version.go | 136 +++++++++++++++++ .../configfile/devbox_version_test.go | 115 +++++++++++++++ internal/devconfig/configfile/file.go | 5 + internal/envir/env.go | 7 +- internal/vercheck/project.go | 89 ++++++++++++ internal/vercheck/project_test.go | 137 ++++++++++++++++++ testscripts/version/devbox_version.test.txt | 64 ++++++++ 10 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 internal/devconfig/configfile/devbox_version.go create mode 100644 internal/devconfig/configfile/devbox_version_test.go create mode 100644 internal/vercheck/project.go create mode 100644 internal/vercheck/project_test.go create mode 100644 testscripts/version/devbox_version.test.txt diff --git a/.schema/devbox.schema.json b/.schema/devbox.schema.json index eafd6a9f76e..8f931832694 100644 --- a/.schema/devbox.schema.json +++ b/.schema/devbox.schema.json @@ -17,6 +17,31 @@ "description": "A description of the Devbox development environment.", "type": "string" }, + "devbox_version": { + "description": "The devbox version (or semver constraint) this project requires, such as \"0.18.4\" or \"^0.18.0\". By default, running a different version prints a warning.", + "oneOf": [ + { + "type": "string", + "description": "A devbox version or semver constraint. A mismatch prints a warning." + }, + { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "A devbox version or semver constraint, such as \"0.18.4\" or \"^0.18.0\"." + }, + "on_mismatch": { + "type": "string", + "description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default) and \"error\" fails the command.", + "enum": ["warn", "error"] + } + }, + "required": ["version"], + "additionalProperties": false + } + ] + }, "packages": { "description": "Collection of packages to install", "oneOf": [ diff --git a/go.mod b/go.mod index 7eada5aab4f..77dd5242af2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.1 require ( al.essio.dev/pkg/shellescape v1.6.0 github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/Masterminds/semver/v3 v3.5.0 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/briandowns/spinner v1.23.2 github.com/denisbrodbeck/machineid v1.0.1 @@ -57,7 +58,6 @@ require ( github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect github.com/Djarvur/go-err113 v0.1.1 // indirect github.com/InVisionApp/go-logger v1.0.1 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect github.com/STARRY-S/zip v0.2.3 // indirect diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index 50d49424123..b3db43f1e4b 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -45,6 +45,7 @@ import ( "go.jetify.com/devbox/internal/shellgen" "go.jetify.com/devbox/internal/telemetry" "go.jetify.com/devbox/internal/ux" + "go.jetify.com/devbox/internal/vercheck" "go.jetify.com/devbox/nix/flake" ) @@ -109,6 +110,14 @@ func Open(opts *devopt.Opts) (*Devbox, error) { return nil, usererr.WithUserMessage(err, "Error loading devbox.json.") } + var stderr io.Writer = os.Stderr + if opts.Stderr != nil { + stderr = opts.Stderr + } + if err := vercheck.CheckProjectVersion(stderr, cfg.Root.AbsRootPath, cfg.Root.DevboxVersion); err != nil { + return nil, err + } + environment, err := validateEnvironment(opts.Environment) if err != nil { return nil, err diff --git a/internal/devconfig/configfile/devbox_version.go b/internal/devconfig/configfile/devbox_version.go new file mode 100644 index 00000000000..9550789142d --- /dev/null +++ b/internal/devconfig/configfile/devbox_version.go @@ -0,0 +1,136 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package configfile + +import ( + "encoding/json" + "slices" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + "go.jetify.com/devbox/internal/boxcli/usererr" +) + +// VersionPolicy controls what devbox does when the running devbox version +// doesn't satisfy a project's devbox_version constraint. +type VersionPolicy string + +const ( + // VersionPolicyOff disables the check. It can only be set with the + // DEVBOX_VERSION_POLICY environment variable, not in devbox.json. + VersionPolicyOff VersionPolicy = "off" + // VersionPolicyWarn prints a warning and continues. + VersionPolicyWarn VersionPolicy = "warn" + // VersionPolicyError fails the command. + VersionPolicyError VersionPolicy = "error" +) + +// ConfigVersionPolicies are the values allowed for devbox_version.on_mismatch. +var ConfigVersionPolicies = []VersionPolicy{ + VersionPolicyWarn, + VersionPolicyError, +} + +// DevboxVersion is the devbox_version field of devbox.json. It is either a +// version constraint string, which uses the default "warn" policy: +// +// "devbox_version": "^0.18.0" +// +// or an object with an explicit policy: +// +// "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} +type DevboxVersion struct { + // Version is a semver constraint (e.g. "0.18.4", "^0.18.0", + // ">=0.17.0 <0.19.0") that the running devbox version must satisfy. + Version string `json:"version"` + + // OnMismatch is the policy to apply when the running devbox version + // doesn't satisfy Version. Defaults to "warn". + OnMismatch VersionPolicy `json:"on_mismatch,omitempty"` + + // isShorthand records whether the field was written as a plain string so + // that marshaling preserves the original form. + isShorthand bool +} + +// Policy returns the configured on_mismatch policy, or the default if unset. +func (d *DevboxVersion) Policy() VersionPolicy { + if d.OnMismatch == "" { + return VersionPolicyWarn + } + return d.OnMismatch +} + +// Constraint parses Version as a semver constraint. +func (d *DevboxVersion) Constraint() (*semver.Constraints, error) { + c, err := semver.NewConstraint(d.Version) + if err != nil { + return nil, usererr.New( + "Invalid devbox_version %q in devbox.json: %v. Use a version like \"0.18.4\" or a constraint like \"^0.18.0\".", + d.Version, err, + ) + } + return c, nil +} + +// ExactVersion returns Version as an exact version (without a leading "v") +// and true if Version is a single exact version rather than a range. +func (d *DevboxVersion) ExactVersion() (string, bool) { + v := strings.TrimPrefix(strings.TrimSpace(d.Version), "v") + if _, err := semver.StrictNewVersion(v); err != nil { + return "", false + } + return v, true +} + +func (d *DevboxVersion) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + d.isShorthand = true + return json.Unmarshal(data, &d.Version) + } + type devboxVersion DevboxVersion + return json.Unmarshal(data, (*devboxVersion)(d)) +} + +func (d DevboxVersion) MarshalJSON() ([]byte, error) { + if d.isShorthand { + return json.Marshal(d.Version) + } + type devboxVersion DevboxVersion + return json.Marshal(devboxVersion(d)) +} + +func validateDevboxVersion(cfg *ConfigFile) error { + required := cfg.DevboxVersion + if required == nil { + return nil + } + if strings.TrimSpace(required.Version) == "" { + return usererr.New("devbox_version in devbox.json must specify a version") + } + if _, err := required.Constraint(); err != nil { + return err + } + if required.OnMismatch != "" && !slices.Contains(ConfigVersionPolicies, required.OnMismatch) { + return usererr.New( + "Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q and %q.", + required.OnMismatch, VersionPolicyWarn, VersionPolicyError, + ) + } + return nil +} + +// ParseVersionPolicy parses a policy from the DEVBOX_VERSION_POLICY +// environment variable. Unlike devbox.json, it also accepts "off". +func ParseVersionPolicy(s string) (VersionPolicy, error) { + p := VersionPolicy(strings.ToLower(strings.TrimSpace(s))) + if p == VersionPolicyOff || slices.Contains(ConfigVersionPolicies, p) { + return p, nil + } + return "", errors.Errorf( + "invalid policy %q: valid values are %q, %q, and %q", + s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError, + ) +} diff --git a/internal/devconfig/configfile/devbox_version_test.go b/internal/devconfig/configfile/devbox_version_test.go new file mode 100644 index 00000000000..eae187c8c5c --- /dev/null +++ b/internal/devconfig/configfile/devbox_version_test.go @@ -0,0 +1,115 @@ +package configfile + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDevboxVersionShorthand(t *testing.T) { + cfg, err := LoadBytes([]byte(`{"devbox_version": "^0.18.0"}`)) + require.NoError(t, err) + require.NotNil(t, cfg.DevboxVersion) + assert.Equal(t, "^0.18.0", cfg.DevboxVersion.Version) + assert.Equal(t, VersionPolicyWarn, cfg.DevboxVersion.Policy()) + + b, err := json.Marshal(cfg.DevboxVersion) + require.NoError(t, err) + assert.JSONEq(t, `"^0.18.0"`, string(b)) +} + +func TestDevboxVersionObject(t *testing.T) { + cfg, err := LoadBytes([]byte(`{ + "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} + }`)) + require.NoError(t, err) + require.NotNil(t, cfg.DevboxVersion) + assert.Equal(t, "0.18.4", cfg.DevboxVersion.Version) + assert.Equal(t, VersionPolicyError, cfg.DevboxVersion.Policy()) + + b, err := json.Marshal(cfg.DevboxVersion) + require.NoError(t, err) + assert.JSONEq(t, `{"version": "0.18.4", "on_mismatch": "error"}`, string(b)) +} + +func TestDevboxVersionObjectDefaultsToWarn(t *testing.T) { + cfg, err := LoadBytes([]byte(`{"devbox_version": {"version": "0.18.4"}}`)) + require.NoError(t, err) + assert.Equal(t, VersionPolicyWarn, cfg.DevboxVersion.Policy()) +} + +func TestDevboxVersionUnset(t *testing.T) { + cfg, err := LoadBytes([]byte(`{"packages": []}`)) + require.NoError(t, err) + assert.Nil(t, cfg.DevboxVersion) +} + +func TestDevboxVersionValidation(t *testing.T) { + valid := []string{ + `"0.18.4"`, + `"v0.18.4"`, + `"^0.18.0"`, + `"~0.18.1"`, + `">=0.17.0 <0.19.0"`, + `">=0.17.0, <0.19.0"`, + `"0.18.x"`, + `"0.17.2 || ^0.18.0"`, + `{"version": "0.18.4", "on_mismatch": "warn"}`, + } + for _, v := range valid { + t.Run(v, func(t *testing.T) { + _, err := LoadBytes([]byte(`{"devbox_version": ` + v + `}`)) + assert.NoError(t, err) + }) + } + + invalid := []string{ + `""`, + `"latest"`, + `">=> 1"`, + `{}`, + `{"on_mismatch": "error"}`, + `{"version": "0.18.4", "on_mismatch": "explode"}`, + `{"version": "0.18.4", "on_mismatch": "off"}`, + } + for _, v := range invalid { + t.Run(v, func(t *testing.T) { + _, err := LoadBytes([]byte(`{"devbox_version": ` + v + `}`)) + assert.Error(t, err) + }) + } +} + +func TestDevboxVersionExactVersion(t *testing.T) { + tests := map[string]struct { + want string + ok bool + }{ + "0.18.4": {"0.18.4", true}, + "v0.18.4": {"0.18.4", true}, + " 0.18.4 ": {"0.18.4", true}, + "0.18.4-rc1": {"0.18.4-rc1", true}, + "0.18": {"", false}, + "^0.18.0": {"", false}, + "=0.18.4": {"", false}, + ">=0.17.0 <0.19.0": {"", false}, + } + for version, tt := range tests { + t.Run(version, func(t *testing.T) { + got, ok := (&DevboxVersion{Version: version}).ExactVersion() + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseVersionPolicy(t *testing.T) { + for _, s := range []string{"off", "warn", "error", " WARN "} { + _, err := ParseVersionPolicy(s) + assert.NoError(t, err, s) + } + _, err := ParseVersionPolicy("explode") + assert.Error(t, err) +} diff --git a/internal/devconfig/configfile/file.go b/internal/devconfig/configfile/file.go index a5e81927feb..98a5c62afa6 100644 --- a/internal/devconfig/configfile/file.go +++ b/internal/devconfig/configfile/file.go @@ -43,6 +43,10 @@ type ConfigFile struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` + // DevboxVersion constrains which devbox versions can be used with this + // project and what happens when the running version doesn't match. + DevboxVersion *DevboxVersion `json:"devbox_version,omitempty"` + // PackagesMutator is the slice of Nix packages that devbox makes available in // its environment. Deliberately do not omitempty. PackagesMutator PackagesMutator `json:"packages"` @@ -188,6 +192,7 @@ func validateConfig(cfg *ConfigFile) error { ValidateNixpkg, validateScripts, validateAliases, + validateDevboxVersion, } for _, fn := range fns { diff --git a/internal/envir/env.go b/internal/envir/env.go index 403daa3b12f..b8c0b5e221b 100644 --- a/internal/envir/env.go +++ b/internal/envir/env.go @@ -18,7 +18,12 @@ const ( DevboxSearchHost = "DEVBOX_SEARCH_HOST" DevboxShellEnabled = "DEVBOX_SHELL_ENABLED" DevboxShellStartTime = "DEVBOX_SHELL_START_TIME" - DevboxVM = "DEVBOX_VM" + // DevboxUseVersion tells the launcher which devbox version to run. + DevboxUseVersion = "DEVBOX_USE_VERSION" + // DevboxVersionPolicy overrides the devbox_version.on_mismatch policy in + // devbox.json. Valid values are "off", "warn", and "error". + DevboxVersionPolicy = "DEVBOX_VERSION_POLICY" + DevboxVM = "DEVBOX_VM" LauncherVersion = "LAUNCHER_VERSION" LauncherPath = "LAUNCHER_PATH" diff --git a/internal/vercheck/project.go b/internal/vercheck/project.go new file mode 100644 index 00000000000..52c78958c33 --- /dev/null +++ b/internal/vercheck/project.go @@ -0,0 +1,89 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package vercheck + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" + + "github.com/Masterminds/semver/v3" + + "go.jetify.com/devbox/internal/boxcli/usererr" + "go.jetify.com/devbox/internal/devconfig/configfile" + "go.jetify.com/devbox/internal/envir" + "go.jetify.com/devbox/internal/ux" +) + +// warnedEnvName records the config path of the last project whose +// devbox_version mismatch was reported, so that nested devbox commands don't +// print the same warning again. +const warnedEnvName = "__DEVBOX_VERSION_MISMATCH_WARNED" + +// CheckProjectVersion enforces the devbox_version field of the devbox.json at +// configPath against the running devbox version. Depending on the policy, a +// mismatch prints a warning or returns an error. The DEVBOX_VERSION_POLICY +// environment variable overrides the policy in devbox.json. +func CheckProjectVersion(w io.Writer, configPath string, required *configfile.DevboxVersion) error { + if required == nil || isDevBuild { + return nil + } + + policy := required.Policy() + if env := os.Getenv(envir.DevboxVersionPolicy); env != "" { + p, err := configfile.ParseVersionPolicy(env) + if err != nil { + return usererr.New("Invalid %s: %v", envir.DevboxVersionPolicy, err) + } + policy = p + } + if policy == configfile.VersionPolicyOff { + return nil + } + + constraint, err := required.Constraint() + if err != nil { + return err + } + current, err := semver.NewVersion(currentDevboxVersion) + if err != nil { + slog.Debug("skipping devbox_version check: can't parse running version", "version", currentDevboxVersion, "err", err) + return nil + } + if constraint.Check(current) { + return nil + } + + msg := mismatchMessage(configPath, required) + if policy == configfile.VersionPolicyError { + return usererr.New("%s", msg) + } + if os.Getenv(warnedEnvName) == configPath { + return nil + } + ux.Fwarningf(w, "%s\n", msg) + return os.Setenv(warnedEnvName, configPath) +} + +func mismatchMessage(configPath string, required *configfile.DevboxVersion) string { + var msg strings.Builder + fmt.Fprintf( + &msg, + "This project requires devbox %s (devbox_version in %s), but you are running %s.\n", + required.Version, configPath, currentDevboxVersion, + ) + if exact, ok := required.ExactVersion(); ok { + fmt.Fprintf(&msg, "Set %s=%s to run the required version.", envir.DevboxUseVersion, exact) + } else { + fmt.Fprintf( + &msg, + "Run `devbox version update`, or set %s to a version that satisfies %q.", + envir.DevboxUseVersion, required.Version, + ) + } + fmt.Fprintf(&msg, " To skip this check, set %s=off.", envir.DevboxVersionPolicy) + return msg.String() +} diff --git a/internal/vercheck/project_test.go b/internal/vercheck/project_test.go new file mode 100644 index 00000000000..20d27361669 --- /dev/null +++ b/internal/vercheck/project_test.go @@ -0,0 +1,137 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package vercheck + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.jetify.com/devbox/internal/boxcli/usererr" + "go.jetify.com/devbox/internal/devconfig/configfile" + "go.jetify.com/devbox/internal/envir" +) + +const testConfigPath = "/project/devbox.json" + +// setupProjectVersionTest mocks the running devbox version and clears any +// environment that affects CheckProjectVersion. +func setupProjectVersionTest(t *testing.T, version string) { + t.Helper() + oldVersion, oldIsDev := currentDevboxVersion, isDevBuild + t.Cleanup(func() { currentDevboxVersion, isDevBuild = oldVersion, oldIsDev }) + currentDevboxVersion = version + isDevBuild = false + + t.Setenv(envir.DevboxVersionPolicy, "") + t.Setenv(warnedEnvName, "") + t.Cleanup(func() { os.Unsetenv(warnedEnvName) }) +} + +func TestCheckProjectVersionSatisfied(t *testing.T) { + setupProjectVersionTest(t, "0.18.4") + + for _, version := range []string{"0.18.4", "v0.18.4", "^0.18.0", "~0.18.1", ">=0.17.0 <0.19.0", "0.18.x"} { + t.Run(version, func(t *testing.T) { + buf := new(bytes.Buffer) + err := CheckProjectVersion(buf, testConfigPath, &configfile.DevboxVersion{ + Version: version, + OnMismatch: configfile.VersionPolicyError, + }) + require.NoError(t, err) + assert.Empty(t, buf.String()) + }) + } +} + +func TestCheckProjectVersionNoConstraint(t *testing.T) { + setupProjectVersionTest(t, "0.18.4") + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, nil)) + assert.Empty(t, buf.String()) +} + +func TestCheckProjectVersionSkipsDevBuild(t *testing.T) { + setupProjectVersionTest(t, "0.0.0-dev") + isDevBuild = true + + buf := new(bytes.Buffer) + err := CheckProjectVersion(buf, testConfigPath, &configfile.DevboxVersion{ + Version: "0.18.4", + OnMismatch: configfile.VersionPolicyError, + }) + require.NoError(t, err) + assert.Empty(t, buf.String()) +} + +func TestCheckProjectVersionWarn(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + required := &configfile.DevboxVersion{Version: "^0.18.0"} + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Contains(t, buf.String(), "requires devbox ^0.18.0") + assert.Contains(t, buf.String(), "you are running 0.17.2") + assert.Contains(t, buf.String(), "devbox version update") + + // A nested devbox command for the same project doesn't warn again. + buf.Reset() + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Empty(t, buf.String()) + + // A different project still warns. + require.NoError(t, CheckProjectVersion(buf, "/other/devbox.json", required)) + assert.Contains(t, buf.String(), "requires devbox ^0.18.0") +} + +func TestCheckProjectVersionError(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + + buf := new(bytes.Buffer) + err := CheckProjectVersion(buf, testConfigPath, &configfile.DevboxVersion{ + Version: "0.18.4", + OnMismatch: configfile.VersionPolicyError, + }) + require.Error(t, err) + userErr, ok := usererr.Extract(err) + require.True(t, ok, "expected a user error") + assert.Contains(t, userErr.Error(), "requires devbox 0.18.4") + assert.Contains(t, userErr.Error(), "DEVBOX_USE_VERSION=0.18.4") + assert.Empty(t, buf.String()) +} + +func TestCheckProjectVersionEnvOverride(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + required := &configfile.DevboxVersion{Version: "0.18.4", OnMismatch: configfile.VersionPolicyError} + + t.Run("off", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "off") + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Empty(t, buf.String()) + }) + + t.Run("warn", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "warn") + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.Contains(t, buf.String(), "requires devbox 0.18.4") + }) + + t.Run("error", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "error") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, &configfile.DevboxVersion{Version: "0.18.4"}) + assert.Error(t, err) + }) + + t.Run("invalid", func(t *testing.T) { + t.Setenv(envir.DevboxVersionPolicy, "explode") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, envir.DevboxVersionPolicy) + }) +} diff --git a/testscripts/version/devbox_version.test.txt b/testscripts/version/devbox_version.test.txt new file mode 100644 index 00000000000..c294540ed78 --- /dev/null +++ b/testscripts/version/devbox_version.test.txt @@ -0,0 +1,64 @@ +# Verify that devbox enforces the devbox_version field in devbox.json. +# +# Development builds skip the check, so DEVBOX_PROD forces a production build. +# Its version is still 0.0.0-dev, which doesn't satisfy >=0.1.0. +# `devbox generate readme` is used because it loads the project config without +# needing Nix. + +env DEVBOX_PROD=1 + +# The default policy warns and continues. +cp warn.json devbox.json +exec devbox generate readme +stderr 'Warning: This project requires devbox >=0.1.0' +stderr 'you are running 0.0.0-dev' +exists README.md +rm README.md + +# The error policy fails the command. +cp error.json devbox.json +! exec devbox generate readme +stderr 'This project requires devbox 0.1.0' +stderr 'DEVBOX_USE_VERSION=0.1.0' +! exists README.md + +# DEVBOX_VERSION_POLICY overrides the policy in devbox.json. +env DEVBOX_VERSION_POLICY=off +exec devbox generate readme +! stderr 'requires devbox' +exists README.md +env DEVBOX_VERSION_POLICY= + +# A satisfied constraint is silent. +cp satisfied.json devbox.json +exec devbox generate readme +! stderr 'requires devbox' + +# An invalid constraint is a config error. +cp invalid.json devbox.json +! exec devbox generate readme +stderr 'Invalid devbox_version "latest"' + +-- warn.json -- +{ + "devbox_version": ">=0.1.0", + "packages": [] +} + +-- error.json -- +{ + "devbox_version": {"version": "0.1.0", "on_mismatch": "error"}, + "packages": [] +} + +-- satisfied.json -- +{ + "devbox_version": {"version": "0.0.0-dev", "on_mismatch": "error"}, + "packages": [] +} + +-- invalid.json -- +{ + "devbox_version": "latest", + "packages": [] +} From 83e8fc2f109375871126fb4112191900c760abc9 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 10:25:57 -0700 Subject: [PATCH 2/4] fix(vercheck): only suggest `devbox version update` when it would help For range constraints, the mismatch message always suggested `devbox version update`, but that only moves to the latest release. If the running version is already past the constraint's upper bound, or the latest release doesn't satisfy it, updating can't fix the mismatch. Now we only suggest it when the latest version (DEVBOX_LATEST_VERSION) satisfies the constraint, and otherwise point at DEVBOX_USE_VERSION alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/vercheck/project.go | 17 ++++++++++++++--- internal/vercheck/project_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/internal/vercheck/project.go b/internal/vercheck/project.go index 52c78958c33..3a559655e1f 100644 --- a/internal/vercheck/project.go +++ b/internal/vercheck/project.go @@ -57,7 +57,7 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De return nil } - msg := mismatchMessage(configPath, required) + msg := mismatchMessage(configPath, required, constraint) if policy == configfile.VersionPolicyError { return usererr.New("%s", msg) } @@ -68,7 +68,7 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De return os.Setenv(warnedEnvName, configPath) } -func mismatchMessage(configPath string, required *configfile.DevboxVersion) string { +func mismatchMessage(configPath string, required *configfile.DevboxVersion, constraint *semver.Constraints) string { var msg strings.Builder fmt.Fprintf( &msg, @@ -77,13 +77,24 @@ func mismatchMessage(configPath string, required *configfile.DevboxVersion) stri ) if exact, ok := required.ExactVersion(); ok { fmt.Fprintf(&msg, "Set %s=%s to run the required version.", envir.DevboxUseVersion, exact) - } else { + } else if latestSatisfies(constraint) { fmt.Fprintf( &msg, "Run `devbox version update`, or set %s to a version that satisfies %q.", envir.DevboxUseVersion, required.Version, ) + } else { + fmt.Fprintf(&msg, "Set %s to a version that satisfies %q.", envir.DevboxUseVersion, required.Version) } fmt.Fprintf(&msg, " To skip this check, set %s=off.", envir.DevboxVersionPolicy) return msg.String() } + +// latestSatisfies reports whether the latest devbox release satisfies +// constraint, meaning `devbox version update` would fix a mismatch. It's false +// when the latest version is unknown or when the constraint excludes it (for +// example, an upper bound that the running version is already past). +func latestSatisfies(constraint *semver.Constraints) bool { + latest, err := semver.NewVersion(latestVersion()) + return err == nil && constraint.Check(latest) +} diff --git a/internal/vercheck/project_test.go b/internal/vercheck/project_test.go index 20d27361669..0965c462ae2 100644 --- a/internal/vercheck/project_test.go +++ b/internal/vercheck/project_test.go @@ -28,6 +28,7 @@ func setupProjectVersionTest(t *testing.T, version string) { isDevBuild = false t.Setenv(envir.DevboxVersionPolicy, "") + t.Setenv(envir.DevboxLatestVersion, "") t.Setenv(warnedEnvName, "") t.Cleanup(func() { os.Unsetenv(warnedEnvName) }) } @@ -71,6 +72,7 @@ func TestCheckProjectVersionSkipsDevBuild(t *testing.T) { func TestCheckProjectVersionWarn(t *testing.T) { setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.DevboxLatestVersion, "0.18.4") required := &configfile.DevboxVersion{Version: "^0.18.0"} buf := new(bytes.Buffer) @@ -89,6 +91,34 @@ func TestCheckProjectVersionWarn(t *testing.T) { assert.Contains(t, buf.String(), "requires devbox ^0.18.0") } +func TestCheckProjectVersionUpdateSuggestion(t *testing.T) { + required := &configfile.DevboxVersion{Version: ">=0.17.0 <0.19.0", OnMismatch: configfile.VersionPolicyError} + + t.Run("latest_satisfies", func(t *testing.T) { + setupProjectVersionTest(t, "0.16.0") + t.Setenv(envir.DevboxLatestVersion, "0.18.4") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, "devbox version update") + }) + + // Updating can't go backwards, so don't suggest it when the running + // version is already past the constraint's upper bound. + t.Run("too_new", func(t *testing.T) { + setupProjectVersionTest(t, "0.20.0") + t.Setenv(envir.DevboxLatestVersion, "0.20.0") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + require.ErrorContains(t, err, "DEVBOX_USE_VERSION") + assert.NotContains(t, err.Error(), "devbox version update") + }) + + t.Run("latest_unknown", func(t *testing.T) { + setupProjectVersionTest(t, "0.16.0") + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + require.ErrorContains(t, err, "DEVBOX_USE_VERSION") + assert.NotContains(t, err.Error(), "devbox version update") + }) +} + func TestCheckProjectVersionError(t *testing.T) { setupProjectVersionTest(t, "0.17.2") From 4331a49674239eb08284442695567610053ac08d Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 11:23:38 -0700 Subject: [PATCH 3/4] fix(boxcli): don't check devbox_version when opening a project incidentally Two places opened the current directory's project on every devbox invocation, so the devbox_version check fired for commands that don't use the project (e.g. `devbox version` printed the mismatch warning), and a swallowed error let `devbox run --list` ignore the "error" policy: - `devbox run` computed ValidArgs eagerly while building the command tree. It's now a lazy ValidArgsFunction that only runs during shell completion, which also lets cobra parse --config for us. listScripts now returns its error, so `run --list` and bare `run` report it. - The telemetry middleware opens the project after every command to record package names. Add devopt.Opts.SkipVersionCheck for these incidental opens (telemetry and completion), and cover `devbox version` and `run --list` in the testscript. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/boxcli/midcobra/telemetry.go | 7 ++- internal/boxcli/run.go | 65 ++++++++++++--------- internal/devbox/devbox.go | 6 +- internal/devbox/devopt/devboxopts.go | 5 ++ testscripts/version/devbox_version.test.txt | 11 ++++ 5 files changed, 63 insertions(+), 31 deletions(-) diff --git a/internal/boxcli/midcobra/telemetry.go b/internal/boxcli/midcobra/telemetry.go index 44c7f8aefd9..5069ad10d8b 100644 --- a/internal/boxcli/midcobra/telemetry.go +++ b/internal/boxcli/midcobra/telemetry.go @@ -94,9 +94,10 @@ func getPackagesAndCommitHash(c *cobra.Command) ([]string, string) { } box, err := devbox.Open(&devopt.Opts{ - Dir: path, - Stderr: os.Stderr, - IgnoreWarnings: true, + Dir: path, + Stderr: os.Stderr, + IgnoreWarnings: true, + SkipVersionCheck: true, }) if err != nil { return []string{}, "" diff --git a/internal/boxcli/run.go b/internal/boxcli/run.go index b4045cac557..c521fed6f85 100644 --- a/internal/boxcli/run.go +++ b/internal/boxcli/run.go @@ -6,7 +6,6 @@ package boxcli import ( "fmt" "log/slog" - "os" "slices" "sort" "strings" @@ -83,58 +82,72 @@ func runCmd(defaults runFlagDefaults) *cobra.Command { "run command in all projects in the working directory, recursively. If command is not found in any project, it will be skipped.", ) - command.ValidArgs = listScripts(command, flags) + // Compute completions lazily so that running a command doesn't open the + // project just to build the command tree. + command.ValidArgsFunction = func( + cmd *cobra.Command, args []string, toComplete string, + ) ([]string, cobra.ShellCompDirective) { + return completeScripts(cmd, args, toComplete, flags) + } return command } -func listScripts(cmd *cobra.Command, flags runCmdFlags) []string { - path := flags.config.path - - // Special code path for shell completion. - // Landau: I'm not entirely sure why: - // * Flags need to be parsed again - // * cmd.Flag("config") contains the correct value, but flags.config.path is empty - // Give my low confidence, I'm making this a very narrow code path. - if path == "" && slices.Contains(os.Args, "__complete") { - _ = cmd.ParseFlags(os.Args) - if flag := cmd.Flag("config"); flag != nil && flag.Value != nil { - path = flag.Value.String() - } +// completeScripts completes the first argument of `devbox run` with the +// project's script names. +func completeScripts( + cmd *cobra.Command, args []string, toComplete string, flags runCmdFlags, +) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveDefault + } + scripts, err := listScripts(cmd, flags, true /*skipVersionCheck*/) + if err != nil { + slog.Error("failed to open devbox", "err", err) + } + if len(scripts) == 0 { + return nil, cobra.ShellCompDirectiveDefault } + return lo.Filter(scripts, func(s string, _ int) bool { + return strings.HasPrefix(s, toComplete) + }), cobra.ShellCompDirectiveNoFileComp +} +func listScripts(cmd *cobra.Command, flags runCmdFlags, skipVersionCheck bool) ([]string, error) { devboxOpts := &devopt.Opts{ - Dir: path, - Environment: flags.config.environment, - Stderr: cmd.ErrOrStderr(), - IgnoreWarnings: true, + Dir: flags.config.path, + Environment: flags.config.environment, + Stderr: cmd.ErrOrStderr(), + IgnoreWarnings: true, + SkipVersionCheck: skipVersionCheck, } if flags.allProjects { boxes, err := multi.Open(devboxOpts) if err != nil { - slog.Error("failed to open devbox", "err", err) - return nil + return nil, err } scripts := []string{} for _, box := range boxes { scripts = append(scripts, box.ListScripts()...) } sort.Strings(scripts) - return lo.Uniq(scripts) + return lo.Uniq(scripts), nil } box, err := devbox.Open(devboxOpts) if err != nil { - slog.Error("failed to open devbox", "err", err) - return nil + return nil, err } - return box.ListScripts() + return box.ListScripts(), nil } func runScriptCmd(cmd *cobra.Command, args []string, flags runCmdFlags) error { ctx := cmd.Context() if len(args) == 0 || flags.listScripts { - scripts := listScripts(cmd, flags) + scripts, err := listScripts(cmd, flags, false /*skipVersionCheck*/) + if err != nil { + return err + } if len(scripts) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "no scripts defined in devbox.json") return nil diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index b3db43f1e4b..e5bc7472293 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -114,8 +114,10 @@ func Open(opts *devopt.Opts) (*Devbox, error) { if opts.Stderr != nil { stderr = opts.Stderr } - if err := vercheck.CheckProjectVersion(stderr, cfg.Root.AbsRootPath, cfg.Root.DevboxVersion); err != nil { - return nil, err + if !opts.SkipVersionCheck { + if err := vercheck.CheckProjectVersion(stderr, cfg.Root.AbsRootPath, cfg.Root.DevboxVersion); err != nil { + return nil, err + } } environment, err := validateEnvironment(opts.Environment) diff --git a/internal/devbox/devopt/devboxopts.go b/internal/devbox/devopt/devboxopts.go index 6b66692d221..788b0b27d52 100644 --- a/internal/devbox/devopt/devboxopts.go +++ b/internal/devbox/devopt/devboxopts.go @@ -15,6 +15,11 @@ type Opts struct { IgnoreWarnings bool CustomProcessComposeFile string Stderr io.Writer + + // SkipVersionCheck skips enforcing devbox_version. Set it when opening a + // project incidentally (e.g. for telemetry or shell completion) rather + // than to run the user's command. + SkipVersionCheck bool } type ProcessComposeOpts struct { diff --git a/testscripts/version/devbox_version.test.txt b/testscripts/version/devbox_version.test.txt index c294540ed78..2df1123e0b3 100644 --- a/testscripts/version/devbox_version.test.txt +++ b/testscripts/version/devbox_version.test.txt @@ -15,6 +15,10 @@ stderr 'you are running 0.0.0-dev' exists README.md rm README.md +# Commands that don't use the project don't check its devbox_version. +exec devbox version +! stderr 'requires devbox' + # The error policy fails the command. cp error.json devbox.json ! exec devbox generate readme @@ -22,6 +26,13 @@ stderr 'This project requires devbox 0.1.0' stderr 'DEVBOX_USE_VERSION=0.1.0' ! exists README.md +# Listing scripts reports the error instead of ignoring it. +! exec devbox run --list +stderr 'This project requires devbox 0.1.0' + +exec devbox version +! stderr 'requires devbox' + # DEVBOX_VERSION_POLICY overrides the policy in devbox.json. env DEVBOX_VERSION_POLICY=off exec devbox generate readme From 9f508640ba4b982bc5ea51d7f32995d8b140aca9 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 28 Sep 2026 13:10:00 -0700 Subject: [PATCH 4/4] fix(examples): wait for postgres readiness instead of fixed sleep in lepp/lapp stacks The lepp-stack run_test flaked in CI with "the database system is starting up" because it assumed postgres was ready after a fixed 2s sleep. Poll with pg_isready (up to 30s) instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/stacks/lapp-stack/devbox.json | 2 +- examples/stacks/lepp-stack/devbox.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/stacks/lapp-stack/devbox.json b/examples/stacks/lapp-stack/devbox.json index edca4b8d747..201aabfd0c1 100644 --- a/examples/stacks/lapp-stack/devbox.json +++ b/examples/stacks/lapp-stack/devbox.json @@ -22,7 +22,7 @@ "mkdir -p /tmp/devbox/lapp", "initdb", "devbox services up -b", - "echo 'sleep 5 second for the postgres server to initialize.' && sleep 5", + "echo 'Waiting for the postgres server to accept connections...' && for i in $(seq 1 30); do pg_isready -q && break; sleep 1; done && pg_isready", "cat .devbox/compose.log", "dropdb --if-exists devbox_lapp", "createdb devbox_lapp", diff --git a/examples/stacks/lepp-stack/devbox.json b/examples/stacks/lepp-stack/devbox.json index f98cb0a5e70..bd0d74d1b05 100644 --- a/examples/stacks/lepp-stack/devbox.json +++ b/examples/stacks/lepp-stack/devbox.json @@ -25,7 +25,7 @@ "rm -rf .devbox/virtenv/postgresql/data", "initdb", "devbox services up -b", - "echo 'sleep 2 seconds for the postgres server to initialize.' && sleep 2", + "echo 'Waiting for the postgres server to accept connections...' && for i in $(seq 1 30); do pg_isready -q && break; sleep 1; done && pg_isready", "dropdb --if-exists devbox_lepp", "createdb devbox_lepp", "psql devbox_lepp < setup_postgres_db.sql",