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
@@ -0,0 +1,93 @@
## Context
Currently the project uses a single version number shared between client and engine, managed by `release.sh`. Both `client/VERSION` and `engine/VERSION` are always bumped to the same value. A single git tag `vX.Y.Z` is created, and a single Gitea release bundles Go client binaries and Docker engine image references. This means any change to either component forces a full release of both.
The client is a Go binary distributed as platform-specific downloads. The engine is a Python FastAPI server distributed as Docker images. They communicate over HTTP via `/api/v1/` endpoints. The engine already exposes its version via `GET /api/v1/status``{"version": "X.Y.Z", ...}`.
## Goals / Non-Goals
**Goals:**
- Allow client and engine to have independent version numbers and release cadences
- Provide a runtime compatibility check so users get a clear error when their client is too new for their engine
- Split release tooling so each component can be released without touching the other
**Non-Goals:**
- API versioning beyond the existing `/api/v1/` path prefix
- Backward-compatible negotiation or feature detection (client either works or fails)
- Automatic upgrades or update notifications
- Version checking in the other direction (engine requiring minimum client)
## Decisions
### 1. Tag naming: `client-vX.Y.Z` and `engine-vX.Y.Z`
Prefix-style tags clearly identify which component a release belongs to and sort well in git tag listings.
**Why over path-style (`client/vX.Y.Z`):** Slashes in git tags can cause issues with some tooling and are less conventional. Prefix-style is simpler and widely used in monorepos.
**Why over separate repos:** The project is small and tightly coupled at the API level. A monorepo with prefixed tags keeps everything together while allowing independent releases.
### 2. Two release scripts: `release-client.sh` and `release-engine.sh`
Each script handles its own component end-to-end: version bump, build, tag, release, push.
**Why over a single script with flags:** Two simple scripts are easier to understand and maintain than one script with component-selection logic. Each script is ~100 lines instead of one ~200-line script with branching. The shared logic (version helpers, pre-flight checks) is minimal and acceptable to duplicate.
**Shared structure for both scripts:**
1. Pre-flight checks (on main branch, tag doesn't exist)
2. Version bump (reads/writes component's VERSION file only)
3. Build artifacts (Go binaries or Docker images)
4. Commit version bump, create prefixed tag, push
5. Create Gitea release with assets
6. (Engine only) Push Docker images
### 3. `MinEngineVersion` as a build-time constant in the Go client
The client embeds a `MinEngineVersion` string constant alongside the existing `Version` constant. It is set via `-ldflags` at build time, sourced from a `client/MIN_ENGINE_VERSION` file.
**Why a separate file over embedding in `VERSION`:** The two values have different lifecycles. `VERSION` changes every release; `MIN_ENGINE_VERSION` changes only when the client starts using a new engine feature. A separate file makes the intent clear.
**Why ldflags over hardcoding in Go source:** Consistent with how `Version` is already injected. The value lives in a plain text file that's easy to bump manually.
### 4. Compatibility check on every API call via the `Client` struct
The `api.Client` checks engine compatibility on its first HTTP call by hitting `GET /api/v1/status` and comparing the `version` field against `MinEngineVersion`. The result is cached on the `Client` instance — subsequent calls skip the check.
**Flow:**
1. First call to any `Client` method (Get/Post/Delete/Put)
2. Before the actual request, call `GET /api/v1/status`
3. Parse `version` from response
4. Compare against `MinEngineVersion` using semver major.minor.patch comparison
5. If engine version < min: print error to stderr, `os.Exit(1)`
6. If check passes: set `versionChecked = true`, proceed with original request
7. If status endpoint unreachable: proceed with original request (connectivity error will surface on the actual call)
**Why hard fail, no skip flag:** This is a personal tool. If the client needs a newer engine, the user needs to update. A skip flag adds complexity for a scenario where the outcome (broken behavior) is worse than the error.
**Why check on first API call, not at startup:** The `PersistentPreRunE` in cobra runs before every command, but some future commands might not need the engine (e.g. `kb version`, `kb help`). Checking in the `Client` ensures we only check when actually contacting the engine.
**Why proceed when status endpoint is unreachable:** If we can't reach `/status`, the actual API call will also fail with a connection error. No point in double-failing. The compatibility check is for version mismatch, not connectivity.
### 5. Compose files: use `build:` context, not pinned image tags
The compose files currently use `build:` directives, not pre-built image references. Users who build locally don't need pinned tags — they're building from source. Users pulling pre-built images will reference the image tag directly in their own compose file or `docker run` command.
**Decision:** Leave compose files as-is. Release notes for engine releases will include the exact `docker pull` command with the versioned tag.
### 6. Semver comparison: major.minor.patch, no pre-release
Compare versions as three integers. No support for pre-release suffixes (`-rc1`, `-beta`) — the project doesn't use them. If `MinEngineVersion` is `2.1.0` and engine reports `2.1.5`, the check passes. If engine reports `2.0.9`, it fails.
## Risks / Trade-offs
- **Extra HTTP round-trip on first command** — One additional `GET /api/v1/status` call per client invocation. Negligible for a local-network tool.
→ Mitigation: Cached after first check within the Client instance.
- **Developer must remember to bump `MIN_ENGINE_VERSION`** — When adding client code that depends on a new engine endpoint/field, the developer must manually update the file.
→ Mitigation: This is a conscious decision point. The file's existence serves as a reminder. Could add a CI check later if needed.
- **Breaking change to git tag format** — Existing `v2.0.x` tags won't match the new `client-v*` / `engine-v*` convention. Old tags remain in history.
→ Mitigation: No migration needed. Old tags stay as historical artifacts. New convention starts from the first independent release.
- **Two Gitea releases per coordinated release** — When both components change, two releases are created instead of one.
→ Mitigation: Acceptable trade-off. Each release is self-contained with its own assets and notes.