From 40595ea57820250a19fef4b1619d1ec9549ae9a9 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 6 Aug 2026 14:00:40 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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) {