Independent client/engine versioning with compatibility check

Split release.sh into release-client.sh and release-engine.sh for
independent release cadences. Client checks engine version on first
API call and hard-fails if engine is below MinEngineVersion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 15:59:16 +00:00
parent b04823e67b
commit 528a09ca90
14 changed files with 729 additions and 93 deletions
+1
View File
@@ -0,0 +1 @@
2.0.0
+2 -1
View File
@@ -1,5 +1,6 @@
VERSION ?= $(shell cat VERSION 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-s -w -X github.com/kb-search/kb/cmd.Version=$(VERSION)"
MIN_ENGINE_VERSION ?= $(shell cat MIN_ENGINE_VERSION 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-s -w -X github.com/kb-search/kb/cmd.Version=$(VERSION) -X github.com/kb-search/kb/cmd.MinEngineVersion=$(MIN_ENGINE_VERSION)"
PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
+5
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/config"
"github.com/spf13/cobra"
)
@@ -11,6 +12,9 @@ import (
// Version is set at build time via -ldflags.
var Version = "dev"
// MinEngineVersion is set at build time via -ldflags.
var MinEngineVersion = "dev"
var (
flagEngine string
flagFormat string
@@ -31,6 +35,7 @@ var rootCmd = &cobra.Command{
}
func init() {
api.SetVersionInfo(Version, MinEngineVersion)
rootCmd.Version = Version
rootCmd.PersistentFlags().StringVar(&flagEngine, "engine", "", "engine API URL")
rootCmd.PersistentFlags().StringVar(&flagFormat, "format", "", "output format (human|json)")
+86 -3
View File
@@ -7,6 +7,9 @@ import (
"io"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"github.com/kb-search/kb/internal/config"
)
@@ -18,11 +21,25 @@ type FileUpload struct {
Reader io.Reader
}
// Package-level version info, set once by cmd.init via SetVersionInfo.
var (
clientVersion string
minEngineVersion string
)
// SetVersionInfo configures the client and minimum engine version for compatibility checking.
// Called once from cmd package initialization.
func SetVersionInfo(cv, minEV string) {
clientVersion = cv
minEngineVersion = minEV
}
// Client is an HTTP client for the kb-search engine API.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
baseURL string
apiKey string
httpClient *http.Client
versionChecked bool
}
// NewClient creates a Client from the current configuration.
@@ -48,6 +65,7 @@ func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request,
}
func (c *Client) do(req *http.Request) (*http.Response, error) {
c.checkEngineVersion()
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Cannot reach engine at %s: %v", c.baseURL, err)
@@ -55,6 +73,71 @@ func (c *Client) do(req *http.Request) (*http.Response, error) {
return resp, nil
}
func (c *Client) checkEngineVersion() {
if c.versionChecked {
return
}
c.versionChecked = true
minVer := minEngineVersion
if minVer == "" || minVer == "dev" {
return
}
statusReq, err := c.newRequest(http.MethodGet, "/api/v1/status", nil)
if err != nil {
return
}
resp, err := c.httpClient.Do(statusReq)
if err != nil {
return // unreachable — let the actual request surface the error
}
defer resp.Body.Close()
var status struct {
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return
}
if !semverAtLeast(status.Version, minVer) {
fmt.Fprintf(os.Stderr, "Error: kb client v%s requires engine v%s+ (connected engine is v%s)\nUpdate your engine image to engine-v%s or later.\n",
clientVersion, minVer, status.Version, minVer)
os.Exit(1)
}
}
// semverAtLeast returns true if version >= minimum, comparing major.minor.patch.
func semverAtLeast(version, minimum string) bool {
parse := func(s string) (int, int, int) {
s = strings.TrimPrefix(s, "v")
parts := strings.SplitN(s, ".", 3)
var major, minor, patch int
if len(parts) >= 1 {
major, _ = strconv.Atoi(parts[0])
}
if len(parts) >= 2 {
minor, _ = strconv.Atoi(parts[1])
}
if len(parts) >= 3 {
patch, _ = strconv.Atoi(parts[2])
}
return major, minor, patch
}
vMaj, vMin, vPat := parse(version)
mMaj, mMin, mPat := parse(minimum)
if vMaj != mMaj {
return vMaj > mMaj
}
if vMin != mMin {
return vMin > mMin
}
return vPat >= mPat
}
// Get performs a GET request to the given path.
func (c *Client) Get(path string) (*http.Response, error) {
req, err := c.newRequest(http.MethodGet, path, nil)
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSemverAtLeast(t *testing.T) {
tests := []struct {
version string
minimum string
expected bool
}{
{"2.1.0", "2.0.0", true},
{"2.0.0", "2.0.0", true},
{"2.0.5", "2.0.0", true},
{"2.1.5", "2.1.0", true},
{"2.0.9", "2.1.0", false},
{"1.9.9", "2.0.0", false},
{"3.0.0", "2.9.9", true},
{"2.0.0", "2.0.1", false},
}
for _, tt := range tests {
t.Run(tt.version+">="+tt.minimum, func(t *testing.T) {
got := semverAtLeast(tt.version, tt.minimum)
if got != tt.expected {
t.Errorf("semverAtLeast(%q, %q) = %v, want %v", tt.version, tt.minimum, got, tt.expected)
}
})
}
}
func TestCheckEngineVersion_Compatible(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"version": "2.1.0"})
}))
defer srv.Close()
clientVersion = "2.2.0"
minEngineVersion = "2.1.0"
defer func() { clientVersion = ""; minEngineVersion = "" }()
c := &Client{
baseURL: srv.URL,
httpClient: &http.Client{},
}
// Should not panic or exit
c.checkEngineVersion()
if !c.versionChecked {
t.Error("versionChecked should be true after check")
}
}
func TestCheckEngineVersion_SkipsWhenDev(t *testing.T) {
clientVersion = "dev"
minEngineVersion = "dev"
defer func() { clientVersion = ""; minEngineVersion = "" }()
c := &Client{
baseURL: "http://localhost:99999",
httpClient: &http.Client{},
}
// Should not attempt connection
c.checkEngineVersion()
if !c.versionChecked {
t.Error("versionChecked should be true after skipping")
}
}
func TestCheckEngineVersion_SkipsWhenEmpty(t *testing.T) {
clientVersion = "1.0.0"
minEngineVersion = ""
defer func() { clientVersion = ""; minEngineVersion = "" }()
c := &Client{
baseURL: "http://localhost:99999",
httpClient: &http.Client{},
}
c.checkEngineVersion()
if !c.versionChecked {
t.Error("versionChecked should be true after skipping")
}
}
func TestCheckEngineVersion_SkipsWhenUnreachable(t *testing.T) {
clientVersion = "2.0.0"
minEngineVersion = "2.0.0"
defer func() { clientVersion = ""; minEngineVersion = "" }()
c := &Client{
baseURL: "http://localhost:99999",
httpClient: &http.Client{},
}
// Should not panic — just skip
c.checkEngineVersion()
if !c.versionChecked {
t.Error("versionChecked should be true even when unreachable")
}
}
func TestCheckEngineVersion_CachedAfterFirstCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
json.NewEncoder(w).Encode(map[string]string{"version": "2.1.0"})
}))
defer srv.Close()
clientVersion = "2.1.0"
minEngineVersion = "2.0.0"
defer func() { clientVersion = ""; minEngineVersion = "" }()
c := &Client{
baseURL: srv.URL,
httpClient: &http.Client{},
}
c.checkEngineVersion()
c.checkEngineVersion()
c.checkEngineVersion()
if callCount != 1 {
t.Errorf("expected 1 status call, got %d", callCount)
}
}