version is now a var that -ldflags "-X main.version=..." can set, which is how the release archives will say which tag they were built from. A go install at a tag falls back to the module version in the build info; a build from a checkout still says what defaultVersion says, because the pseudo-version Go would stamp there reads worse than the source's own. The --version line keeps its shape, "codey v0.6.0". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 3 changed files with 121 additions and 4 deletions.
| @@ -35,10 +35,11 @@ import ( | ||
| 35 | 35 | "github.com/johannSo/codey/internal/websearch" |
| 36 | 36 | ) |
| 37 | 37 | |
| 38 | - // version is what the TUI's welcome block reports. It is a constant rather | |
| 39 | - // than build-stamped: Codey is built from this tree, and a stamp that | |
| 40 | - // can disagree with the source is worse than no stamp. | |
| 41 | - const version = "v0.6.0" | |
| 38 | + // version is what the TUI's welcome block and --version report. A release | |
| 39 | + // build stamps it at link time with -ldflags "-X main.version=..."; left | |
| 40 | + // empty, it is filled in from the module version or the default in | |
| 41 | + // version.go, which is also where to bump it. | |
| 42 | + var version string | |
| 42 | 43 | |
| 43 | 44 | // effectiveWebMode is the web-search mode this process actually ended up with. |
| 44 | 45 | // Asking for one and getting it are different things: -no-web says no, and a |
| @@ -0,0 +1,68 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "regexp" | |
| 5 | + "runtime/debug" | |
| 6 | + "strings" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // defaultVersion is the version of this source tree, and what a build that | |
| 10 | + // carries no better answer reports: a go build or go run from a checkout. It | |
| 11 | + // is bumped with each release, in the same commit the tag is cut from, so a | |
| 12 | + // build from that commit says the same thing however it was made. | |
| 13 | + const defaultVersion = "v0.6.0" | |
| 14 | + | |
| 15 | + func init() { | |
| 16 | + version = resolveVersion(version, debug.ReadBuildInfo) | |
| 17 | + } | |
| 18 | + | |
| 19 | + // resolveVersion picks the version to report, trying the sources from the | |
| 20 | + // most to the least deliberate: | |
| 21 | + // | |
| 22 | + // 1. stamped, which is whatever -ldflags "-X main.version=..." put there. | |
| 23 | + // goreleaser does this for every release archive, from the tag being | |
| 24 | + // built, so it is the one answer that cannot be stale. | |
| 25 | + // 2. The main module's version in the build info. go install | |
| 26 | + // github.com/johannSo/codey/cmd/codey@v0.7.0 records v0.7.0 there, and | |
| 27 | + // no flag is involved, so without this such an install would report | |
| 28 | + // whatever defaultVersion said at that commit — right only if someone | |
| 29 | + // remembered to bump it. | |
| 30 | + // 3. defaultVersion. | |
| 31 | + // | |
| 32 | + // The build info is passed in rather than read here so the order can be | |
| 33 | + // tested; a test binary's own build info says nothing useful. | |
| 34 | + func resolveVersion(stamped string, buildInfo func() (*debug.BuildInfo, bool)) string { | |
| 35 | + if v := strings.TrimSpace(stamped); v != "" { | |
| 36 | + // goreleaser's {{.Version}} is the tag without its v. The v goes | |
| 37 | + // back on so a release reads the way every other build does: | |
| 38 | + // "codey v0.7.0", not sometimes "codey 0.7.0". | |
| 39 | + if v[0] >= '0' && v[0] <= '9' { | |
| 40 | + v = "v" + v | |
| 41 | + } | |
| 42 | + return v | |
| 43 | + } | |
| 44 | + if info, ok := buildInfo(); ok && info != nil && isRelease(info.Main.Version) { | |
| 45 | + return info.Main.Version | |
| 46 | + } | |
| 47 | + return defaultVersion | |
| 48 | + } | |
| 49 | + | |
| 50 | + // pseudoVersion matches the tail Go gives a module version that is not a tag: | |
| 51 | + // a fourteen-digit UTC timestamp and a twelve-character commit hash, as in | |
| 52 | + // v0.0.0-20261002101500-219352e3b1c4. | |
| 53 | + var pseudoVersion = regexp.MustCompile(`[-.]\d{14}-[0-9a-f]{12}$`) | |
| 54 | + | |
| 55 | + // isRelease reports whether a build-info module version names a tag. Since Go | |
| 56 | + // 1.24 a plain go build in a git checkout stamps one too, derived from the | |
| 57 | + // nearest tag and the commit, and with no tag in reach that is | |
| 58 | + // v0.0.0-<time>-<hash>: true, but a worse answer than defaultVersion for | |
| 59 | + // someone building the tree they have checked out. So only a tag counts. | |
| 60 | + // "(devel)" is what a build with no version control information gets, and a | |
| 61 | + // "+dirty" suffix means the tree had uncommitted changes, which is not the | |
| 62 | + // tagged source either. | |
| 63 | + func isRelease(v string) bool { | |
| 64 | + if v == "" || v == "(devel)" || strings.Contains(v, "+") { | |
| 65 | + return false | |
| 66 | + } | |
| 67 | + return !pseudoVersion.MatchString(v) | |
| 68 | + } | |
| @@ -0,0 +1,48 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "runtime/debug" | |
| 5 | + "testing" | |
| 6 | + ) | |
| 7 | + | |
| 8 | + func TestResolveVersionTriesStampThenModuleThenDefault(t *testing.T) { | |
| 9 | + info := func(v string) func() (*debug.BuildInfo, bool) { | |
| 10 | + return func() (*debug.BuildInfo, bool) { | |
| 11 | + return &debug.BuildInfo{Main: debug.Module{Path: "github.com/johannSo/codey", Version: v}}, true | |
| 12 | + } | |
| 13 | + } | |
| 14 | + none := func() (*debug.BuildInfo, bool) { return nil, false } | |
| 15 | + | |
| 16 | + cases := []struct { | |
| 17 | + name string | |
| 18 | + stamped string | |
| 19 | + info func() (*debug.BuildInfo, bool) | |
| 20 | + want string | |
| 21 | + }{ | |
| 22 | + {"goreleaser stamp, which has no v", "0.7.0", info("v0.7.0"), "v0.7.0"}, | |
| 23 | + {"a stamp wins over the module", "v0.8.0", info("v0.7.0"), "v0.8.0"}, | |
| 24 | + {"snapshot stamp", "0.7.1-SNAPSHOT-abc1234", none, "v0.7.1-SNAPSHOT-abc1234"}, | |
| 25 | + {"go install at a tag", "", info("v0.7.0"), "v0.7.0"}, | |
| 26 | + {"go install at a pre-release tag", "", info("v0.7.0-rc.1"), "v0.7.0-rc.1"}, | |
| 27 | + {"go build with no vcs info", "", info("(devel)"), defaultVersion}, | |
| 28 | + {"go build with no tag in reach", "", info("v0.0.0-20261002101500-219352e3b1c4"), defaultVersion}, | |
| 29 | + {"go build past a tag", "", info("v0.7.1-0.20261002101500-219352e3b1c4"), defaultVersion}, | |
| 30 | + {"go build with local changes", "", info("v0.7.0+dirty"), defaultVersion}, | |
| 31 | + {"no build info at all", "", none, defaultVersion}, | |
| 32 | + } | |
| 33 | + for _, c := range cases { | |
| 34 | + if got := resolveVersion(c.stamped, c.info); got != c.want { | |
| 35 | + t.Errorf("%s: resolveVersion(%q) = %q, want %q", c.name, c.stamped, got, c.want) | |
| 36 | + } | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + // Every answer resolveVersion can give still has to satisfy the client's | |
| 41 | + // pattern, or a release would read to T3 Code as a broken install. | |
| 42 | + func TestEveryVersionShapeMatchesWhatTheClientLooksFor(t *testing.T) { | |
| 43 | + for _, v := range []string{defaultVersion, "v0.7.0", "v0.7.0-rc.1", "v0.7.1-SNAPSHOT-abc1234"} { | |
| 44 | + if !t3VersionPattern.MatchString("codey " + v) { | |
| 45 | + t.Errorf("%q does not match %s", v, t3VersionPattern) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + } | |