From 40595ea57820250a19fef4b1619d1ec9549ae9a9 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 6 Aug 2026 14:00:40 +0200 Subject: [PATCH 1/5] Report the next release version for dev builds A local build reported 0.0.0-dev+, which semver sorts below every release even though the build is made from main and is therefore newer than the latest release. Report -dev+ instead, read from .nextchanges/version, matching what goreleaser produces for snapshots. Dev builds were detected by string-matching the 0.0.0 prefix in six places, all of which would silently stop recognizing them. Replace those with build.Info.IsDevelopment, which keys off the -dev prerelease. --- .nextchanges/cli/dev-build-version.md | 1 + .nextchanges/nextversion.go | 14 +++++++ .../permission_level_migration/output.txt | 2 +- .../resources.v1.json | 2 +- acceptance/bundle/telemetry/deploy/script | 2 +- .../cmd/bundle/dms-read-only/output.txt | 4 +- acceptance/cmd/bundle/dms-read-only/test.toml | 4 +- bundle/config/mutator/verify_cli_version.go | 5 ++- bundle/deploy/terraform/init_test.go | 4 +- internal/build/info.go | 26 +++++++++++- internal/build/info_test.go | 42 +++++++++++++++++++ libs/aitools/installer/installer.go | 2 +- libs/aitools/installer/update.go | 2 +- libs/clicompat/clicompat.go | 20 +++++---- libs/jsonschema/schema.go | 9 +++- libs/versioncheck/versioncheck.go | 4 +- tools/validate_nextchanges.py | 8 +++- 17 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 .nextchanges/cli/dev-build-version.md create mode 100644 .nextchanges/nextversion.go diff --git a/.nextchanges/cli/dev-build-version.md b/.nextchanges/cli/dev-build-version.md new file mode 100644 index 00000000000..ede9046cb4a --- /dev/null +++ b/.nextchanges/cli/dev-build-version.md @@ -0,0 +1 @@ +A locally built CLI (`go build`, without release flags) now reports the next release version with a `-dev` prerelease, e.g. `1.12.0-dev+abcdef123456`, instead of `0.0.0-dev+abcdef123456`. The old string sorted below every published release even though a local build is newer than the latest release; the new one sorts above the latest release and below the release it will become, matching what goreleaser already produces for snapshot builds. diff --git a/.nextchanges/nextversion.go b/.nextchanges/nextversion.go new file mode 100644 index 00000000000..6cd3eba6bb9 --- /dev/null +++ b/.nextchanges/nextversion.go @@ -0,0 +1,14 @@ +// Package nextchanges exposes the next release version to the build. +// +// It lives in this directory because go:embed cannot reach a parent directory: +// embedding the version file from internal/build would require a second copy of +// the value, which could then drift from this one. +package nextchanges + +import _ "embed" + +// Version is the next release version, e.g. "1.12.0\n". Callers must trim it. +// The release tooling bumps the embedded file after each release; see README.md. +// +//go:embed version +var Version string diff --git a/acceptance/bundle/state/permission_level_migration/output.txt b/acceptance/bundle/state/permission_level_migration/output.txt index d6d2e3f71ca..2d6157efa8f 100644 --- a/acceptance/bundle/state/permission_level_migration/output.txt +++ b/acceptance/bundle/state/permission_level_migration/output.txt @@ -13,7 +13,7 @@ Deployment complete! >>> print_state.py { "state_version": 2, - "cli_version": "[CLI_VERSION]", + "cli_version": "0.0.0-test", "lineage": "test-lineage", "serial": 2, "state": { diff --git a/acceptance/bundle/state/permission_level_migration/resources.v1.json b/acceptance/bundle/state/permission_level_migration/resources.v1.json index 78766b3be93..99f0842d3e8 100644 --- a/acceptance/bundle/state/permission_level_migration/resources.v1.json +++ b/acceptance/bundle/state/permission_level_migration/resources.v1.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0-test", "lineage": "test-lineage", "serial": 1, "state": { diff --git a/acceptance/bundle/telemetry/deploy/script b/acceptance/bundle/telemetry/deploy/script index 93ac2780676..cd2cf5fed0f 100644 --- a/acceptance/bundle/telemetry/deploy/script +++ b/acceptance/bundle/telemetry/deploy/script @@ -13,7 +13,7 @@ trace cat telemetry.json | jq ' .entry.databricks_cli_log.bundle_deploy_event.ex # omit it from the engine-agnostic out.telemetry.txt below. Sizes are deterministic # for a fixed config and asserted exactly. state_file_size_bytes is dropped because # it is os.Stat of resources.json, whose header embeds the CLI version string -# (0.0.0-dev+ on linux/macos vs 0.0.0-dev on windows). +# (-dev+ on linux/macos vs -dev on windows). cat telemetry.json | jq '.entry.databricks_cli_log.bundle_deploy_event.resources_metadata | if . then del(.state_file_size_bytes) else . end' > out.resources_metadata.$DATABRICKS_BUNDLE_ENGINE.txt # The dry-run migration to the direct engine runs only after a terraform deploy, diff --git a/acceptance/cmd/bundle/dms-read-only/output.txt b/acceptance/cmd/bundle/dms-read-only/output.txt index 5bb069a5c98..2dfd130777e 100644 --- a/acceptance/cmd/bundle/dms-read-only/output.txt +++ b/acceptance/cmd/bundle/dms-read-only/output.txt @@ -32,7 +32,7 @@ >>> [CLI] bundle-deployments list-versions deployments/abc [ { - "cli_version": "[CLI_VERSION]", + "cli_version": "0.0.0-test", "name": "deployments/abc/versions/v1", "status": "VERSION_STATUS_COMPLETED", "version_type": "" @@ -41,7 +41,7 @@ >>> [CLI] bundle-deployments get-version deployments/abc/versions/v1 { - "cli_version": "[CLI_VERSION]", + "cli_version": "0.0.0-test", "git_info": { "branch": "main", "commit": "[COMMIT_SHA]", diff --git a/acceptance/cmd/bundle/dms-read-only/test.toml b/acceptance/cmd/bundle/dms-read-only/test.toml index aa916970fa8..143c0e62923 100644 --- a/acceptance/cmd/bundle/dms-read-only/test.toml +++ b/acceptance/cmd/bundle/dms-read-only/test.toml @@ -65,7 +65,7 @@ Response.Body = ''' "versions": [ { "name": "deployments/abc/versions/v1", - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0-test", "status": "VERSION_STATUS_COMPLETED" } ] @@ -77,7 +77,7 @@ Pattern = "GET /api/2.0/bundle/deployments/abc/versions/v1" Response.Body = ''' { "name": "deployments/abc/versions/v1", - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0-test", "status": "VERSION_STATUS_COMPLETED", "git_info": { "branch": "main", diff --git a/bundle/config/mutator/verify_cli_version.go b/bundle/config/mutator/verify_cli_version.go index 873e4f78065..a6d705a6fbb 100644 --- a/bundle/config/mutator/verify_cli_version.go +++ b/bundle/config/mutator/verify_cli_version.go @@ -39,7 +39,10 @@ func (v *verifyCliVersion) Apply(ctx context.Context, b *bundle.Bundle) diag.Dia } if !c.Check(version) { - if version.Prerelease() == "dev" && version.Major() == 0 { + // Development builds are built from main and may already carry the change + // the constraint requires, so the constraint is a warning rather than an + // error. Masterminds reports the prerelease without its leading dash. + if version.Prerelease() == "dev" { return diag.Warningf("Ignoring Databricks CLI version constraint for development build. Required: %s, current: %s", constraint, currentVersion) } diff --git a/bundle/deploy/terraform/init_test.go b/bundle/deploy/terraform/init_test.go index 7ca9c484404..da06e2dbed4 100644 --- a/bundle/deploy/terraform/init_test.go +++ b/bundle/deploy/terraform/init_test.go @@ -14,6 +14,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/internal/tf/schema" + "github.com/databricks/cli/internal/build" "github.com/databricks/cli/internal/testutil" "github.com/databricks/cli/libs/env" "github.com/hashicorp/go-version" @@ -237,8 +238,9 @@ func TestSetUserAgentExtra_Python(t *testing.T) { env := make(map[string]string, 0) err := setUserAgentExtraEnvVar(env, b) require.NoError(t, err) + // The CLI version is the test binary's own, which is the not-injected default. assert.Equal(t, map[string]string{ - "DATABRICKS_USER_AGENT_EXTRA": "cli/0.0.0-dev databricks-pydabs/0.7.0", + "DATABRICKS_USER_AGENT_EXTRA": "cli/" + build.DefaultSemver + " databricks-pydabs/0.7.0", }, env) } diff --git a/internal/build/info.go b/internal/build/info.go index 54c3f286832..6a144583460 100644 --- a/internal/build/info.go +++ b/internal/build/info.go @@ -8,6 +8,7 @@ import ( "sync" "time" + nextchanges "github.com/databricks/cli/.nextchanges" "golang.org/x/mod/semver" ) @@ -42,7 +43,30 @@ func (i Info) GetSanitizedVersion() string { return version } -const DefaultSemver = "0.0.0-dev" +// devPrerelease marks a build that was not produced from a release tag. +const devPrerelease = "-dev" + +// DefaultSemver is the version reported when buildVersion was not injected, +// i.e. a plain "go build" rather than a goreleaser build. It is the next release +// version with a -dev prerelease, so it sorts above the latest release and below +// the release it will become: +// +// Compare(v1.11.0, v1.12.0-dev+sha) = -1 +// Compare(v1.12.0, v1.12.0-dev+sha) = +1 +// +// A bare "0.0.0-dev" would instead sort below every published release, even +// though a local build is built from main and is therefore newer than the +// latest release. This matches what goreleaser produces for snapshot builds +// (see snapshot.version_template in .goreleaser.yaml). +var DefaultSemver = strings.TrimSpace(nextchanges.Version) + devPrerelease + +// IsDevelopment reports whether this binary was built from a development or +// snapshot build rather than a release tag. It keys off the -dev prerelease +// rather than a specific version number, so it keeps working as the next +// release version changes. +func (i Info) IsDevelopment() bool { + return i.IsSnapshot || semver.Prerelease("v"+i.Version) == devPrerelease +} // getDefaultBuildVersion uses build information stored by Go itself // to synthesize a build version if one wasn't set. diff --git a/internal/build/info_test.go b/internal/build/info_test.go index 7b33a114e86..57aa6109408 100644 --- a/internal/build/info_test.go +++ b/internal/build/info_test.go @@ -1,15 +1,57 @@ package build import ( + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" ) func TestGetDetails(t *testing.T) { GetInfo() } +func TestIsDevelopment(t *testing.T) { + tests := []struct { + name string + info Info + want bool + }{ + {"dev build with commit metadata", Info{Version: "1.12.0-dev+abc123"}, true}, + {"dev build without metadata", Info{Version: "1.12.0-dev"}, true}, + {"released version", Info{Version: "1.12.0"}, false}, + // A release candidate is not a dev build: it is built from a tag, so the + // version constraints and update checks that dev builds bypass still apply. + {"release candidate", Info{Version: "1.12.0-rc.1"}, false}, + // goreleaser marks snapshots explicitly, independent of the version string. + {"snapshot of a release version", Info{Version: "1.12.0", IsSnapshot: true}, true}, + {"malformed version", Info{Version: "not-a-version"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.info.IsDevelopment()) + }) + } +} + +// TestDefaultSemverSortsAboveLastRelease pins the invariant this version scheme +// exists for: a local build reports a version that sorts ABOVE the most recent +// release (it is built from main, so it is newer) and BELOW the release it will +// become. A bare "0.0.0-dev" sorted below every release instead. +func TestDefaultSemverSortsAboveLastRelease(t *testing.T) { + v := "v" + DefaultSemver + require.True(t, semver.IsValid(v), "DefaultSemver %q must be valid semver", DefaultSemver) + require.Equal(t, devPrerelease, semver.Prerelease(v)) + + // The release this dev build will become, e.g. v1.12.0 for 1.12.0-dev. + next := strings.TrimSuffix(v, devPrerelease) + assert.Positive(t, semver.Compare(next, v), "the upcoming release must sort above the dev build") + assert.True(t, Info{Version: DefaultSemver}.IsDevelopment()) +} + func TestGetSanitizedVersion(t *testing.T) { tests := []struct { name string diff --git a/libs/aitools/installer/installer.go b/libs/aitools/installer/installer.go index 93521292735..10167d0ff3a 100644 --- a/libs/aitools/installer/installer.go +++ b/libs/aitools/installer/installer.go @@ -463,7 +463,7 @@ func incompatibleAgentNames(targetAgents []*agents.Agent) []string { func resolveSkills(ctx context.Context, skills map[string]SkillMeta, opts InstallOptions) (map[string]SkillMeta, error) { isSpecific := len(opts.SpecificSkills) > 0 cliVersion := build.GetInfo().Version - isDev := strings.HasPrefix(cliVersion, build.DefaultSemver) + isDev := build.GetInfo().IsDevelopment() // Start with all skills or only the requested ones. var candidates map[string]SkillMeta diff --git a/libs/aitools/installer/update.go b/libs/aitools/installer/update.go index 36a7ef9c279..baa3d6a9863 100644 --- a/libs/aitools/installer/update.go +++ b/libs/aitools/installer/update.go @@ -114,7 +114,7 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent result := &UpdateResult{} cliVersion := build.GetInfo().Version - isDev := strings.HasPrefix(cliVersion, build.DefaultSemver) + isDev := build.GetInfo().IsDevelopment() // Sort skill names for deterministic output. names := slices.Sorted(maps.Keys(skillSet)) diff --git a/libs/clicompat/clicompat.go b/libs/clicompat/clicompat.go index e0daada0bca..6576b00c524 100644 --- a/libs/clicompat/clicompat.go +++ b/libs/clicompat/clicompat.go @@ -47,10 +47,11 @@ const ( // localManifestFile is the filename for the locally cached manifest. localManifestFile = "compat-manifest.json" - // devVersionPrefix identifies dev builds whose semver (0.0.0) is lower than - // all real CLI versions. These are treated as bleeding-edge and resolve to - // the highest versioned entry. - devVersionPrefix = "0.0.0-dev" + // devPrerelease identifies dev builds, which are treated as bleeding-edge and + // resolve to the highest versioned entry. A dev build's semver sits between + // the last release and the release it will become, so it would otherwise + // resolve to the entry for the previous release. + devPrerelease = "-dev" maxFetchAttempts = 3 fetchRetryBackoff = 300 * time.Millisecond @@ -208,7 +209,7 @@ func IsNotFoundError(err error) bool { // and all versions above it, up to (but not including) the next entry. // // Resolution order: -// 1. Dev builds (version starts with "0.0.0-dev") use the highest versioned entry. +// 1. Dev builds (a -dev prerelease) use the highest versioned entry. // 2. Exact match on CLI version. // 3. Nearest lower version (semver-sorted). This also handles CLI versions // newer than all entries, returning the highest known entry. @@ -224,10 +225,11 @@ func Resolve(m Manifest, cliVersion string) (Entry, error) { return Entry{}, errors.New("compatibility manifest has no versioned entries") } - // Dev builds (0.0.0-dev*) have semver lower than all real CLI versions, - // so they would incorrectly resolve to the lowest entry. Use the highest - // versioned entry instead, since dev builds represent the bleeding edge. - if strings.HasPrefix(cliVersion, devVersionPrefix) { + // Dev builds are built from main and may carry changes that no released + // version has, so resolving them by semver would pick the entry for the + // previous release. Use the highest versioned entry instead, since dev + // builds represent the bleeding edge. + if semver.Prerelease("v"+cliVersion) == devPrerelease { return m[versions[0]], nil } diff --git a/libs/jsonschema/schema.go b/libs/jsonschema/schema.go index 99afa2f677d..c82a49c64d1 100644 --- a/libs/jsonschema/schema.go +++ b/libs/jsonschema/schema.go @@ -159,6 +159,10 @@ const ( IntegerType Type = "integer" ) +// devPrerelease is the prerelease identifier of a development build; see +// build.Info.IsDevelopment. +const devPrerelease = "-dev" + // Validate property types are all valid JSON schema types. func (s *Schema) validateSchemaPropertyTypes() error { for _, v := range s.Properties { @@ -270,8 +274,9 @@ func (s *Schema) validateSchemaMinimumCliVersion(currentVersion string) func() e return nil } - // Ignore this validation rule for local builds. - if semver.Compare("v"+build.DefaultSemver, currentVersion) == 0 { + // Ignore this validation rule for development builds, which are built from + // main and may already carry the change the schema requires. + if semver.Prerelease(currentVersion) == devPrerelease { return nil } diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go index 07dfa0525a5..2d0100c0cd2 100644 --- a/libs/versioncheck/versioncheck.go +++ b/libs/versioncheck/versioncheck.go @@ -112,9 +112,9 @@ func Check(ctx context.Context) *Result { // isDevelopmentBuild reports whether the binary was not built from a tagged // release. Snapshot builds (goreleaser --snapshot) and local `go build` -// binaries (version 0.0.0-dev+) fall into this category. +// binaries (version -dev+) fall into this category. func isDevelopmentBuild(info build.Info) bool { - return info.IsSnapshot || strings.HasPrefix(info.Version, "0.0.0") + return info.IsDevelopment() } // isNewer reports whether latest is a higher semver than current. Both are diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index 996cb425e76..a6755ea5d25 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -32,6 +32,12 @@ # mistaken for a fragment. README = "README.md" +# nextversion.go embeds the version file above so the build can report the next +# release version. It lives here because go:embed cannot reach a parent +# directory, and keeping it here avoids a second copy of the version that could +# drift. The release renderer only reads *.md fragments, so it ignores this. +NEXTVERSION_GO = "nextversion.go" + def load_sections(root): """Return the section slugs from .codegen.json, in changelog order. @@ -63,7 +69,7 @@ def find_problems(changelog_dir, sections): # Root-level: only the version file and root documentation belong here. This prevents # someone accidentally putting a .md into .nextchanges thinking it would be picked up. if len(rel.parts) == 1: - if name != VERSION_FILE and name != README: + if name not in (VERSION_FILE, README, NEXTVERSION_GO): problems.append((path, "unexpected file at .nextchanges root")) continue From a36687a00287df35441852d9e4d4451e2f98c8ac Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sun, 9 Aug 2026 12:08:31 +0200 Subject: [PATCH 2/5] Trim the embedded version and share the dev-build predicate go:embed is byte-exact and the version file must end with a newline, so keep the raw contents unexported and export the trimmed value. Callers no longer have to remember to trim. Replace the three copies of the "-dev" prerelease constant with one exported predicate, and pin the version ordering with a test that asserts every pair in an ascending list. --- .nextchanges/nextversion.go | 15 +++++-- internal/build/info.go | 20 ++++++--- internal/build/info_test.go | 71 +++++++++++++++++++++++++++++-- libs/clicompat/clicompat.go | 8 +--- libs/jsonschema/schema.go | 6 +-- libs/versioncheck/notice.go | 2 +- libs/versioncheck/versioncheck.go | 9 +--- 7 files changed, 97 insertions(+), 34 deletions(-) diff --git a/.nextchanges/nextversion.go b/.nextchanges/nextversion.go index 6cd3eba6bb9..d939c9b42f2 100644 --- a/.nextchanges/nextversion.go +++ b/.nextchanges/nextversion.go @@ -5,10 +5,17 @@ // the value, which could then drift from this one. package nextchanges -import _ "embed" +import ( + _ "embed" + "strings" +) -// Version is the next release version, e.g. "1.12.0\n". Callers must trim it. -// The release tooling bumps the embedded file after each release; see README.md. +// versionFile is the raw contents of the version file. It has a trailing newline +// (the whitespace linter requires one), so it is not exported directly. // //go:embed version -var Version string +var versionFile string + +// Version is the next release version, e.g. "1.12.0". The release tooling bumps +// the embedded file after each release; see README.md. +var Version = strings.TrimSpace(versionFile) diff --git a/internal/build/info.go b/internal/build/info.go index 6a144583460..e9f10264646 100644 --- a/internal/build/info.go +++ b/internal/build/info.go @@ -58,14 +58,24 @@ const devPrerelease = "-dev" // though a local build is built from main and is therefore newer than the // latest release. This matches what goreleaser produces for snapshot builds // (see snapshot.version_template in .goreleaser.yaml). -var DefaultSemver = strings.TrimSpace(nextchanges.Version) + devPrerelease +var DefaultSemver = nextchanges.Version + devPrerelease + +// IsDevelopmentVersion reports whether a version string is a development build's. +// It keys off the -dev prerelease rather than a specific version number, so it +// keeps working as the next release version changes. The version may be given +// with or without a leading "v". +// +// Prefer Info.IsDevelopment when you have the running build's Info; this is for +// callers holding only a version string (e.g. one read from a file or a +// parameter), so the definition of "development build" lives in one place. +func IsDevelopmentVersion(version string) bool { + return semver.Prerelease("v"+strings.TrimPrefix(version, "v")) == devPrerelease +} // IsDevelopment reports whether this binary was built from a development or -// snapshot build rather than a release tag. It keys off the -dev prerelease -// rather than a specific version number, so it keeps working as the next -// release version changes. +// snapshot build rather than a release tag. func (i Info) IsDevelopment() bool { - return i.IsSnapshot || semver.Prerelease("v"+i.Version) == devPrerelease + return i.IsSnapshot || IsDevelopmentVersion(i.Version) } // getDefaultBuildVersion uses build information stored by Go itself diff --git a/internal/build/info_test.go b/internal/build/info_test.go index 57aa6109408..b62fa3f4788 100644 --- a/internal/build/info_test.go +++ b/internal/build/info_test.go @@ -37,10 +37,73 @@ func TestIsDevelopment(t *testing.T) { } } -// TestDefaultSemverSortsAboveLastRelease pins the invariant this version scheme -// exists for: a local build reports a version that sorts ABOVE the most recent -// release (it is built from main, so it is newer) and BELOW the release it will -// become. A bare "0.0.0-dev" sorted below every release instead. +func TestIsDevelopmentVersion(t *testing.T) { + tests := []struct { + version string + want bool + }{ + {"1.12.0-dev+abc123", true}, + {"1.12.0-dev", true}, + // Accepted with or without a leading "v", since callers hold versions in + // both forms (the schema's minimum version is v-prefixed). + {"v1.12.0-dev", true}, + {"1.12.0", false}, + {"v1.12.0", false}, + {"1.12.0-rc.1", false}, + {"not-a-version", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + assert.Equal(t, tt.want, IsDevelopmentVersion(tt.version)) + }) + } +} + +// TestVersionOrdering pins the full ordering model this version scheme exists +// for. A dev build is built from main, so it must sort ABOVE the release it +// followed and BELOW the release it will become. The old "0.0.0-dev" scheme +// violated this: it sorted below every release, including bare "0.0.0". +// +// The list is in strictly ascending order; the test asserts every pair, so it +// covers both the neighbouring steps and the transitive relationships. +func TestVersionOrdering(t *testing.T) { + ascending := []string{ + "0.0.0-dev", + "0.0.0", + "1.11.0-dev", + "1.11.0", + // Build metadata is not part of precedence, so this is EQUAL to the + // bare 1.12.0-dev below and must sit between 1.11.0 and 1.12.0. + "1.12.0-dev+abc123", + "1.12.0-rc.1", + "1.12.0", + "1.12.1-dev", + "1.12.1", + "2.0.0-dev", + "2.0.0", + } + + // Build metadata is ignored for precedence, so this pair compares equal. + require.Zero(t, semver.Compare("v1.12.0-dev+abc123", "v1.12.0-dev")) + + for i, lower := range ascending { + require.True(t, semver.IsValid("v"+lower), "%q must be valid semver", lower) + assert.Zero(t, semver.Compare("v"+lower, "v"+lower), "%q must equal itself", lower) + + for _, higher := range ascending[i+1:] { + // 1.12.0-dev+abc123 and 1.12.0-rc.1 are adjacent in the list but + // -dev sorts below -rc alphabetically, so they are still ordered. + assert.Negative(t, semver.Compare("v"+lower, "v"+higher), "%q must sort below %q", lower, higher) + assert.Positive(t, semver.Compare("v"+higher, "v"+lower), "%q must sort above %q", higher, lower) + } + } +} + +// TestDefaultSemverSortsAboveLastRelease applies the ordering above to the +// version an actual local build reports, which TestVersionOrdering cannot do +// because DefaultSemver tracks .nextchanges/version. func TestDefaultSemverSortsAboveLastRelease(t *testing.T) { v := "v" + DefaultSemver require.True(t, semver.IsValid(v), "DefaultSemver %q must be valid semver", DefaultSemver) diff --git a/libs/clicompat/clicompat.go b/libs/clicompat/clicompat.go index 6576b00c524..49034a5ed0e 100644 --- a/libs/clicompat/clicompat.go +++ b/libs/clicompat/clicompat.go @@ -47,12 +47,6 @@ const ( // localManifestFile is the filename for the locally cached manifest. localManifestFile = "compat-manifest.json" - // devPrerelease identifies dev builds, which are treated as bleeding-edge and - // resolve to the highest versioned entry. A dev build's semver sits between - // the last release and the release it will become, so it would otherwise - // resolve to the entry for the previous release. - devPrerelease = "-dev" - maxFetchAttempts = 3 fetchRetryBackoff = 300 * time.Millisecond ) @@ -229,7 +223,7 @@ func Resolve(m Manifest, cliVersion string) (Entry, error) { // version has, so resolving them by semver would pick the entry for the // previous release. Use the highest versioned entry instead, since dev // builds represent the bleeding edge. - if semver.Prerelease("v"+cliVersion) == devPrerelease { + if build.IsDevelopmentVersion(cliVersion) { return m[versions[0]], nil } diff --git a/libs/jsonschema/schema.go b/libs/jsonschema/schema.go index c82a49c64d1..302a49366e2 100644 --- a/libs/jsonschema/schema.go +++ b/libs/jsonschema/schema.go @@ -159,10 +159,6 @@ const ( IntegerType Type = "integer" ) -// devPrerelease is the prerelease identifier of a development build; see -// build.Info.IsDevelopment. -const devPrerelease = "-dev" - // Validate property types are all valid JSON schema types. func (s *Schema) validateSchemaPropertyTypes() error { for _, v := range s.Properties { @@ -276,7 +272,7 @@ func (s *Schema) validateSchemaMinimumCliVersion(currentVersion string) func() e // Ignore this validation rule for development builds, which are built from // main and may already carry the change the schema requires. - if semver.Prerelease(currentVersion) == devPrerelease { + if build.IsDevelopmentVersion(currentVersion) { return nil } diff --git a/libs/versioncheck/notice.go b/libs/versioncheck/notice.go index 39c985e82c8..dc3a9d5c864 100644 --- a/libs/versioncheck/notice.go +++ b/libs/versioncheck/notice.go @@ -256,7 +256,7 @@ func gatherConditions(ctx context.Context, cmd *cobra.Command) notifyConditions nonInteractive := !cmdio.HasIO(ctx) || cmdio.GetInteractiveMode(ctx) == cmdio.InteractiveModeNone onRuntime := dbr.HasDetection(ctx) && dbr.RunsOnRuntime(ctx) return notifyConditions{ - developmentBuild: isDevelopmentBuild(build.GetInfo()), + developmentBuild: build.GetInfo().IsDevelopment(), cacheDisabled: cacheDisabled, optedOut: optedOut, onRuntime: onRuntime, diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go index 2d0100c0cd2..0a78cf35192 100644 --- a/libs/versioncheck/versioncheck.go +++ b/libs/versioncheck/versioncheck.go @@ -79,7 +79,7 @@ type Result struct { // compare against, so they short-circuit without a network call. func Check(ctx context.Context) *Result { info := build.GetInfo() - if isDevelopmentBuild(info) { + if info.IsDevelopment() { return &Result{ CurrentVersion: info.Version, DevelopmentBuild: true, @@ -110,13 +110,6 @@ func Check(ctx context.Context) *Result { } } -// isDevelopmentBuild reports whether the binary was not built from a tagged -// release. Snapshot builds (goreleaser --snapshot) and local `go build` -// binaries (version -dev+) fall into this category. -func isDevelopmentBuild(info build.Info) bool { - return info.IsDevelopment() -} - // isNewer reports whether latest is a higher semver than current. Both are // bare versions without a leading "v". func isNewer(current, latest string) bool { From 66dfc90ca644efc2e0e32071f1383d17a34ac4d0 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sun, 9 Aug 2026 14:42:09 +0200 Subject: [PATCH 3/5] Handle a prerelease next version in dev builds For completeness with next_release_version() in internal/genkit/tagging.py, which bumps a prerelease when .nextchanges/version carries one. This repo has never published a prerelease, so this is not a live bug. Appending "-dev" to "1.13.0-rc.2" yields the prerelease "rc.2-dev", a single identifier that merely ends in "dev", so IsDevelopmentVersion would report false and every dev-build exemption would silently switch off. --- internal/build/info.go | 43 +++++++++++++++++++++++------- internal/build/info_test.go | 52 ++++++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/internal/build/info.go b/internal/build/info.go index e9f10264646..5553d59a455 100644 --- a/internal/build/info.go +++ b/internal/build/info.go @@ -43,13 +43,14 @@ func (i Info) GetSanitizedVersion() string { return version } -// devPrerelease marks a build that was not produced from a release tag. -const devPrerelease = "-dev" +// devIdentifier is the prerelease identifier marking a build that was not +// produced from a release tag. +const devIdentifier = "dev" // DefaultSemver is the version reported when buildVersion was not injected, // i.e. a plain "go build" rather than a goreleaser build. It is the next release -// version with a -dev prerelease, so it sorts above the latest release and below -// the release it will become: +// version with a dev prerelease identifier, so it sorts above the latest release +// and below the release it will become: // // Compare(v1.11.0, v1.12.0-dev+sha) = -1 // Compare(v1.12.0, v1.12.0-dev+sha) = +1 @@ -58,18 +59,42 @@ const devPrerelease = "-dev" // though a local build is built from main and is therefore newer than the // latest release. This matches what goreleaser produces for snapshot builds // (see snapshot.version_template in .goreleaser.yaml). -var DefaultSemver = nextchanges.Version + devPrerelease +var DefaultSemver = devVersion(nextchanges.Version) + +// devVersion returns the development-build version for an upcoming release, +// normally a plain version with a dev prerelease ("1.12.0" -> "1.12.0-dev"). +// +// The prerelease branch is for completeness with next_release_version() in +// internal/genkit/tagging.py, which bumps the prerelease instead of the minor +// when .nextchanges/version already carries one; it is not because this repo +// publishes prereleases (it never has). Appending "-dev" to "1.13.0-rc.2" would +// produce the prerelease "rc.2-dev", a single identifier that merely ends in +// "dev", so IsDevelopmentVersion would report false and every dev-build +// exemption would silently switch off. A dot-separated identifier +// ("1.13.0-rc.2.dev") keeps detection working and still sorts after the rc and +// before the final release. +func devVersion(next string) string { + if semver.Prerelease("v"+next) != "" { + return next + "." + devIdentifier + } + return next + "-" + devIdentifier +} // IsDevelopmentVersion reports whether a version string is a development build's. -// It keys off the -dev prerelease rather than a specific version number, so it -// keeps working as the next release version changes. The version may be given -// with or without a leading "v". +// It keys off the trailing dev prerelease identifier rather than a specific +// version number, so it keeps working as the next release version changes. The +// version may be given with or without a leading "v". // // Prefer Info.IsDevelopment when you have the running build's Info; this is for // callers holding only a version string (e.g. one read from a file or a // parameter), so the definition of "development build" lives in one place. func IsDevelopmentVersion(version string) bool { - return semver.Prerelease("v"+strings.TrimPrefix(version, "v")) == devPrerelease + prerelease := strings.TrimPrefix(semver.Prerelease("v"+strings.TrimPrefix(version, "v")), "-") + if prerelease == "" { + return false + } + identifiers := strings.Split(prerelease, ".") + return identifiers[len(identifiers)-1] == devIdentifier } // IsDevelopment reports whether this binary was built from a development or diff --git a/internal/build/info_test.go b/internal/build/info_test.go index b62fa3f4788..87eedbf7d04 100644 --- a/internal/build/info_test.go +++ b/internal/build/info_test.go @@ -47,9 +47,14 @@ func TestIsDevelopmentVersion(t *testing.T) { // Accepted with or without a leading "v", since callers hold versions in // both forms (the schema's minimum version is v-prefixed). {"v1.12.0-dev", true}, + // A dev build on a prerelease release track; see devVersion. + {"1.13.0-rc.2.dev", true}, {"1.12.0", false}, {"v1.12.0", false}, {"1.12.0-rc.1", false}, + // "rc.2-dev" is a single identifier that merely ends in "dev", not a dev + // marker. This is what naively appending "-dev" to a prerelease produced. + {"1.13.0-rc.2-dev", false}, {"not-a-version", false}, {"", false}, } @@ -78,6 +83,10 @@ func TestVersionOrdering(t *testing.T) { // bare 1.12.0-dev below and must sit between 1.11.0 and 1.12.0. "1.12.0-dev+abc123", "1.12.0-rc.1", + // A dev build off a prerelease sorts after the rc it follows and before + // the final release; see devVersion for why this shape exists. + "1.12.0-rc.1.dev", + "1.12.0-rc.2", "1.12.0", "1.12.1-dev", "1.12.1", @@ -101,18 +110,55 @@ func TestVersionOrdering(t *testing.T) { } } +// TestDevVersion covers both shapes .nextchanges/version can take. The +// prerelease case is for completeness with next_release_version() in +// internal/genkit/tagging.py, which bumps a prerelease when the file carries +// one; this repo has never published a prerelease. +func TestDevVersion(t *testing.T) { + tests := []struct { + next string + want string + // finalRelease is the release the dev build must sort below: the version + // itself on a stable track, or the release the prerelease leads up to. + finalRelease string + }{ + {"1.12.0", "1.12.0-dev", "1.12.0"}, + {"2.0.0", "2.0.0-dev", "2.0.0"}, + // Appending "-dev" here would yield the prerelease "rc.2-dev", which is + // not a dev marker, so IsDevelopmentVersion would report false and every + // dev-build exemption would silently switch off. + {"1.13.0-rc.2", "1.13.0-rc.2.dev", "1.13.0"}, + } + + for _, tt := range tests { + t.Run(tt.next, func(t *testing.T) { + got := devVersion(tt.next) + assert.Equal(t, tt.want, got) + require.True(t, semver.IsValid("v"+got), "%q must be valid semver", got) + assert.True(t, IsDevelopmentVersion(got), "%q must be recognized as a dev build", got) + // The dev build sits below the release it will become, and above the + // prerelease it already contains, if any. + assert.Negative(t, semver.Compare("v"+got, "v"+tt.finalRelease), "%q must sort below %q", got, tt.finalRelease) + if tt.next != tt.finalRelease { + assert.Positive(t, semver.Compare("v"+got, "v"+tt.next), "%q must sort above %q", got, tt.next) + } + }) + } +} + // TestDefaultSemverSortsAboveLastRelease applies the ordering above to the // version an actual local build reports, which TestVersionOrdering cannot do // because DefaultSemver tracks .nextchanges/version. func TestDefaultSemverSortsAboveLastRelease(t *testing.T) { v := "v" + DefaultSemver require.True(t, semver.IsValid(v), "DefaultSemver %q must be valid semver", DefaultSemver) - require.Equal(t, devPrerelease, semver.Prerelease(v)) + require.True(t, IsDevelopmentVersion(DefaultSemver)) + assert.True(t, Info{Version: DefaultSemver}.IsDevelopment()) // The release this dev build will become, e.g. v1.12.0 for 1.12.0-dev. - next := strings.TrimSuffix(v, devPrerelease) + next := "v" + strings.TrimSuffix(strings.TrimSuffix(DefaultSemver, "-"+devIdentifier), "."+devIdentifier) + require.NotEqual(t, v, next, "DefaultSemver must end with the dev identifier") assert.Positive(t, semver.Compare(next, v), "the upcoming release must sort above the dev build") - assert.True(t, Info{Version: DefaultSemver}.IsDevelopment()) } func TestGetSanitizedVersion(t *testing.T) { From 2befeee2614f75e5239c8b41c44332b4dadc1fd0 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 5 Aug 2026 11:27:01 +0200 Subject: [PATCH 4/5] Warn when state was written by a newer CLI version resources.json records the CLI version that last wrote it. Warn on plan/deploy when that version is newer than the running build. --- .../state-newer-cli-version-warning.md | 1 + .../wal/corrupted-wal-entry/resources.json | 2 +- .../wal/future-serial-wal/resources.json | 2 +- .../wal/lineage-mismatch/resources.json | 2 +- .../deploy/wal/stale-wal/resources.json | 2 +- .../deploy/wal/wal-with-delete/resources.json | 2 +- .../state/newer_cli_version/databricks.yml | 7 ++++ .../state/newer_cli_version/out.test.toml | 3 ++ .../bundle/state/newer_cli_version/output.txt | 15 +++++++ .../newer_cli_version/resources.newer.json | 7 ++++ .../bundle/state/newer_cli_version/script | 11 +++++ .../bundle/state/newer_cli_version/test.toml | 4 ++ bundle/direct/dstate/state.go | 12 ++++++ cmd/bundle/utils/process.go | 24 +++++++++++ cmd/bundle/utils/process_test.go | 42 +++++++++++++++++++ 15 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 .nextchanges/bundles/state-newer-cli-version-warning.md create mode 100644 acceptance/bundle/state/newer_cli_version/databricks.yml create mode 100644 acceptance/bundle/state/newer_cli_version/out.test.toml create mode 100644 acceptance/bundle/state/newer_cli_version/output.txt create mode 100644 acceptance/bundle/state/newer_cli_version/resources.newer.json create mode 100644 acceptance/bundle/state/newer_cli_version/script create mode 100644 acceptance/bundle/state/newer_cli_version/test.toml create mode 100644 cmd/bundle/utils/process_test.go diff --git a/.nextchanges/bundles/state-newer-cli-version-warning.md b/.nextchanges/bundles/state-newer-cli-version-warning.md new file mode 100644 index 00000000000..4fc1066bb5e --- /dev/null +++ b/.nextchanges/bundles/state-newer-cli-version-warning.md @@ -0,0 +1 @@ +Warn when the deployment state was last written by a newer CLI version than the one running. diff --git a/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json b/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json index f9f4e54d1ed..7b7e0edafb1 100644 --- a/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json +++ b/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0", + "cli_version": "0.0.0-dev", "lineage": "test-lineage-123", "serial": 5, "state": {} diff --git a/acceptance/bundle/deploy/wal/future-serial-wal/resources.json b/acceptance/bundle/deploy/wal/future-serial-wal/resources.json index f2f06b34bf4..8734a9d6f43 100644 --- a/acceptance/bundle/deploy/wal/future-serial-wal/resources.json +++ b/acceptance/bundle/deploy/wal/future-serial-wal/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0", + "cli_version": "0.0.0-dev", "lineage": "test-lineage-123", "serial": 2, "state": { diff --git a/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json b/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json index 444a9ea888d..60699dcc115 100644 --- a/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json +++ b/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0", + "cli_version": "0.0.0-dev", "lineage": "state-lineage-aaa", "serial": 1, "state": { diff --git a/acceptance/bundle/deploy/wal/stale-wal/resources.json b/acceptance/bundle/deploy/wal/stale-wal/resources.json index 6fd38b67ae8..3dc274b82ae 100644 --- a/acceptance/bundle/deploy/wal/stale-wal/resources.json +++ b/acceptance/bundle/deploy/wal/stale-wal/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0", + "cli_version": "0.0.0-dev", "lineage": "stale-test-lineage", "serial": 2, "state": { diff --git a/acceptance/bundle/deploy/wal/wal-with-delete/resources.json b/acceptance/bundle/deploy/wal/wal-with-delete/resources.json index 04263ec36f9..96dabd5169a 100644 --- a/acceptance/bundle/deploy/wal/wal-with-delete/resources.json +++ b/acceptance/bundle/deploy/wal/wal-with-delete/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0", + "cli_version": "0.0.0-dev", "lineage": "delete-test-lineage", "serial": 1, "state": { diff --git a/acceptance/bundle/state/newer_cli_version/databricks.yml b/acceptance/bundle/state/newer_cli_version/databricks.yml new file mode 100644 index 00000000000..5134dbcc12c --- /dev/null +++ b/acceptance/bundle/state/newer_cli_version/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: test-bundle + +resources: + jobs: + my_job: + name: "my job" diff --git a/acceptance/bundle/state/newer_cli_version/out.test.toml b/acceptance/bundle/state/newer_cli_version/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/state/newer_cli_version/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/state/newer_cli_version/output.txt b/acceptance/bundle/state/newer_cli_version/output.txt new file mode 100644 index 00000000000..cfe256b2c2c --- /dev/null +++ b/acceptance/bundle/state/newer_cli_version/output.txt @@ -0,0 +1,15 @@ + +=== State written by a newer CLI: warn, but proceed + +>>> [CLI] bundle plan +Warn: State was last deployed with CLI version 99.0.0 but current version is [CLI_VERSION] +create jobs.my_job + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +=== State written by this CLI: no warning + +>>> [CLI] bundle plan +create jobs.my_job + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged diff --git a/acceptance/bundle/state/newer_cli_version/resources.newer.json b/acceptance/bundle/state/newer_cli_version/resources.newer.json new file mode 100644 index 00000000000..2105aa89d83 --- /dev/null +++ b/acceptance/bundle/state/newer_cli_version/resources.newer.json @@ -0,0 +1,7 @@ +{ + "state_version": 2, + "cli_version": "99.0.0", + "lineage": "test-lineage", + "serial": 1, + "state": {} +} diff --git a/acceptance/bundle/state/newer_cli_version/script b/acceptance/bundle/state/newer_cli_version/script new file mode 100644 index 00000000000..172dc126cb3 --- /dev/null +++ b/acceptance/bundle/state/newer_cli_version/script @@ -0,0 +1,11 @@ +title "State written by a newer CLI: warn, but proceed\n" +mkdir -p .databricks/bundle/default +cp resources.newer.json .databricks/bundle/default/resources.json +trace $CLI bundle plan + +title "State written by this CLI: no warning\n" +# The running version is only known at test time, so derive the state's +# cli_version from the binary itself rather than committing it as a fixture. +version=$($CLI version | sed 's/^Databricks CLI v//') +jq --arg v "$version" '.cli_version = $v' resources.newer.json > .databricks/bundle/default/resources.json +trace $CLI bundle plan diff --git a/acceptance/bundle/state/newer_cli_version/test.toml b/acceptance/bundle/state/newer_cli_version/test.toml new file mode 100644 index 00000000000..c1e21fbbd8b --- /dev/null +++ b/acceptance/bundle/state/newer_cli_version/test.toml @@ -0,0 +1,4 @@ +Ignore = [".databricks"] + +# The warning is emitted when reading the direct engine's resources.json. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 7e92aa7c1d2..0b67dbe92cc 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -184,6 +184,18 @@ func (db *DeploymentState) GetResourceID(key string) string { return db.stateIDs[key] } +// StateCLIVersion returns the CLI version that last wrote the state, or an empty +// string if the state does not record one (a fresh state that this CLI has not +// written yet). It is the version stored in the on-disk header, not the running +// build's version. +func (db *DeploymentState) StateCLIVersion() string { + db.AssertOpenedForReadOrWrite() + db.mu.Lock() + defer db.mu.Unlock() + + return db.Data.CLIVersion +} + // GetOrInitLineage returns the deployment lineage, generating and storing a new // one if the state does not have one yet. It is the single place the lineage is // initialized, shared so the direct deployment engine (when it writes state, via diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index e4f232605ce..9af72971863 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -26,6 +26,7 @@ import ( "github.com/databricks/cli/libs/sync" "github.com/databricks/cli/libs/telemetry/protos" "github.com/spf13/cobra" + "golang.org/x/mod/semver" ) type ProcessOptions struct { @@ -215,6 +216,16 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle logdiag.LogError(ctx, err) return b, stateDesc, root.ErrAlreadyPrinted } + + // Warn when the state was last written by a newer CLI than the one + // running now. The state schema version is a hard gate (dstate.Open + // rejects a too-new state_version), but a state can be written by a + // newer CLI that shares this schema; that is allowed, and this only + // hints that a downgrade may be unintended. + currentVersion := build.GetInfo().Version + if stateVersion := b.DeploymentBundle.StateDB.StateCLIVersion(); isNewerVersion(stateVersion, currentVersion) { + log.Warnf(ctx, "State was last deployed with CLI version %s but current version is %s", stateVersion, currentVersion) + } } // These are not safe in plan/deploy because they insert empty config settings for deleted resources. @@ -396,6 +407,19 @@ func ResolveEngineSetting(ctx context.Context, b *bundle.Bundle) (engine.EngineS return engine.EngineSetting{}, nil } +// isNewerVersion reports whether the state's recorded CLI version is strictly +// newer than the running build. Both are bare versions without a leading "v". +// An empty stateVersion (state not written by any CLI yet) or an unparseable +// version returns false, so we never warn on missing or malformed data. +func isNewerVersion(stateVersion, currentVersion string) bool { + sv := "v" + stateVersion + cv := "v" + currentVersion + if !semver.IsValid(sv) || !semver.IsValid(cv) { + return false + } + return semver.Compare(sv, cv) > 0 +} + func rejectDefinitions(ctx context.Context, b *bundle.Bundle) { if b.Config.Definitions != nil { v := dyn.GetValue(b.Config.Value(), "definitions") diff --git a/cmd/bundle/utils/process_test.go b/cmd/bundle/utils/process_test.go new file mode 100644 index 00000000000..bcd8ddf7059 --- /dev/null +++ b/cmd/bundle/utils/process_test.go @@ -0,0 +1,42 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + name string + state string + current string + want bool + }{ + {"state newer major", "1.0.0", "0.300.0", true}, + {"state newer minor", "0.301.0", "0.300.0", true}, + {"state newer patch", "0.300.1", "0.300.0", true}, + {"same version", "0.300.0", "0.300.0", false}, + {"state older", "0.299.0", "0.300.0", false}, + // A released CLI reading a state written by a dev build must not warn: + // 0.0.0-dev sorts below every release. + {"dev state, released current", "0.0.0-dev+abc123", "0.300.0", false}, + // A dev build reading a state written by a real release does warn, since + // the release genuinely is newer. + {"released state, dev current", "0.300.0", "0.0.0-dev+abc123", true}, + // A prerelease sorts below its own release per semver. + {"prerelease below release", "0.300.0-rc1", "0.300.0", false}, + {"release above prerelease", "0.300.0", "0.300.0-rc1", true}, + // Missing or malformed data must never produce a warning. + {"empty state version", "", "0.300.0", false}, + {"empty current version", "0.300.0", "", false}, + {"malformed state version", "not-a-version", "0.300.0", false}, + {"malformed current version", "0.300.0", "not-a-version", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isNewerVersion(tt.state, tt.current)) + }) + } +} From 0205b8073a1846f40aedc06bd686c6446ca66dac Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 6 Aug 2026 14:25:30 +0200 Subject: [PATCH 5/5] Drop WAL fixture churn now that dev versions sort correctly The fixtures recorded cli_version 0.0.0 as a placeholder; with dev builds reporting a real version this no longer looks newer than the running CLI, so they need no change. Update the version comparison tests to use real dev versions instead of the old 0.0.0-dev scheme. --- .../deploy/wal/corrupted-wal-entry/resources.json | 2 +- .../deploy/wal/future-serial-wal/resources.json | 2 +- .../deploy/wal/lineage-mismatch/resources.json | 2 +- .../bundle/deploy/wal/stale-wal/resources.json | 2 +- .../deploy/wal/wal-with-delete/resources.json | 2 +- cmd/bundle/utils/process_test.go | 14 ++++++++------ 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json b/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json index 7b7e0edafb1..f9f4e54d1ed 100644 --- a/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json +++ b/acceptance/bundle/deploy/wal/corrupted-wal-entry/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0", "lineage": "test-lineage-123", "serial": 5, "state": {} diff --git a/acceptance/bundle/deploy/wal/future-serial-wal/resources.json b/acceptance/bundle/deploy/wal/future-serial-wal/resources.json index 8734a9d6f43..f2f06b34bf4 100644 --- a/acceptance/bundle/deploy/wal/future-serial-wal/resources.json +++ b/acceptance/bundle/deploy/wal/future-serial-wal/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0", "lineage": "test-lineage-123", "serial": 2, "state": { diff --git a/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json b/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json index 60699dcc115..444a9ea888d 100644 --- a/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json +++ b/acceptance/bundle/deploy/wal/lineage-mismatch/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0", "lineage": "state-lineage-aaa", "serial": 1, "state": { diff --git a/acceptance/bundle/deploy/wal/stale-wal/resources.json b/acceptance/bundle/deploy/wal/stale-wal/resources.json index 3dc274b82ae..6fd38b67ae8 100644 --- a/acceptance/bundle/deploy/wal/stale-wal/resources.json +++ b/acceptance/bundle/deploy/wal/stale-wal/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0", "lineage": "stale-test-lineage", "serial": 2, "state": { diff --git a/acceptance/bundle/deploy/wal/wal-with-delete/resources.json b/acceptance/bundle/deploy/wal/wal-with-delete/resources.json index 96dabd5169a..04263ec36f9 100644 --- a/acceptance/bundle/deploy/wal/wal-with-delete/resources.json +++ b/acceptance/bundle/deploy/wal/wal-with-delete/resources.json @@ -1,6 +1,6 @@ { "state_version": 1, - "cli_version": "0.0.0-dev", + "cli_version": "0.0.0", "lineage": "delete-test-lineage", "serial": 1, "state": { diff --git a/cmd/bundle/utils/process_test.go b/cmd/bundle/utils/process_test.go index bcd8ddf7059..c276806e5c1 100644 --- a/cmd/bundle/utils/process_test.go +++ b/cmd/bundle/utils/process_test.go @@ -18,12 +18,14 @@ func TestIsNewerVersion(t *testing.T) { {"state newer patch", "0.300.1", "0.300.0", true}, {"same version", "0.300.0", "0.300.0", false}, {"state older", "0.299.0", "0.300.0", false}, - // A released CLI reading a state written by a dev build must not warn: - // 0.0.0-dev sorts below every release. - {"dev state, released current", "0.0.0-dev+abc123", "0.300.0", false}, - // A dev build reading a state written by a real release does warn, since - // the release genuinely is newer. - {"released state, dev current", "0.300.0", "0.0.0-dev+abc123", true}, + // A dev build is built from main, so its version is the next release with a + // -dev prerelease: newer than the last release, older than the release it + // will become. Deploying with a dev build after a state written by the last + // release is the normal case for a CLI developer and must not warn. + {"state from last release, dev current", "0.300.0", "0.301.0-dev+abc123", false}, + // A released CLI reading a state written by a dev build of the same upcoming + // release does warn: that build may have written fields this CLI lacks. + {"dev state, released current", "0.301.0-dev+abc123", "0.300.0", true}, // A prerelease sorts below its own release per semver. {"prerelease below release", "0.300.0-rc1", "0.300.0", false}, {"release above prerelease", "0.300.0", "0.300.0-rc1", true},