Skip to content

Commit cb90fbc

Browse files
committed
Add server version endpoint and bs version command
- GET /api/v1/version (unauthenticated) reports the server version - bs version command shows both client and server versions as JSON - Version is injected into server.Config from serve.go at startup - Makefile derives version from git describe for local builds - Document [skip ci] in releasing.md; clarify xattr note in README
1 parent 67d6b7f commit cb90fbc

11 files changed

Lines changed: 160 additions & 4 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
BINARY_NAME := bs
22
BUILD_DIR := build
33
SRC := ./cmd/bs
4-
VERSION ?= dev
4+
VERSION ?= $(patsubst v%,%,$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev"))
55
LDFLAGS := -X 'github.com/yourorg/beads_server/internal/cli.version=$(VERSION)'
66

77
.PHONY: all build test clean linux windows darwin-arm64 darwin-amd64

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ xattr -d com.apple.quarantine bs # clear Gatekeeper quarantine flag
3838
./bs --version
3939
```
4040

41-
> The `xattr` step is required on macOS because the binary is not code-signed. Gatekeeper will block it otherwise.
41+
> The `xattr` step clears the Gatekeeper quarantine flag. It is needed if you downloaded via a browser; curl downloads typically don't set it, in which case the command does nothing.
4242
4343
### Build from source
4444

docs/api-reference.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Base URL: `http://localhost:9999/api/v1` (configurable via `BS_PORT`)
44

5-
All endpoints except health require the header:
5+
All endpoints except health and version require the header:
66

77
```
88
Authorization: Bearer <token>
@@ -28,6 +28,22 @@ No authentication required.
2828

2929
---
3030

31+
## Version
32+
33+
```
34+
GET /api/v1/version
35+
```
36+
37+
No authentication required.
38+
39+
**Response** `200`:
40+
41+
```json
42+
{"version": "0.9.0"}
43+
```
44+
45+
---
46+
3147
## Create Bead
3248

3349
```

docs/releasing.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,13 @@ Use [semantic versioning](https://semver.org): `vMAJOR.MINOR.PATCH`
3232
## Non-release commits
3333

3434
Ordinary commits and PRs to `main` run CI (tests only) but do **not** produce a release. Only tagged commits trigger a release build.
35+
36+
## Skipping CI entirely
37+
38+
Add `[skip ci]` anywhere in the commit message to suppress all workflow runs for that push:
39+
40+
```bash
41+
git commit -m "Fix typo in README [skip ci]"
42+
```
43+
44+
Useful for trivial changes where you've already tested locally. Note: this only suppresses `push`-triggered runs — it does not affect PR workflow runs.

internal/cli/commands_query.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,54 @@
11
package cli
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"net/url"
67
"strconv"
78

89
"github.com/spf13/cobra"
910
)
1011

12+
func newVersionCmd() *cobra.Command {
13+
return &cobra.Command{
14+
Use: "version",
15+
Short: "Show client and server versions",
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
c, err := NewClientFromEnv()
18+
if err != nil {
19+
return err
20+
}
21+
22+
data, err := c.Do("GET", "/api/v1/version", nil)
23+
if err != nil {
24+
return err
25+
}
26+
27+
var serverResp struct {
28+
Version string `json:"version"`
29+
}
30+
if err := json.Unmarshal(data, &serverResp); err != nil {
31+
return fmt.Errorf("parsing server response: %w", err)
32+
}
33+
34+
combined := struct {
35+
Client string `json:"client"`
36+
Server string `json:"server"`
37+
}{Client: version, Server: serverResp.Version}
38+
b, err := json.Marshal(combined)
39+
if err != nil {
40+
return err
41+
}
42+
out, err := prettyJSON(b)
43+
if err != nil {
44+
return err
45+
}
46+
fmt.Fprintln(cmd.OutOrStdout(), out)
47+
return nil
48+
},
49+
}
50+
}
51+
1152
func newListCmd() *cobra.Command {
1253
var all bool
1354
var ready bool

internal/cli/commands_query_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@ import (
99
"github.com/yourorg/beads_server/internal/store"
1010
)
1111

12+
func TestVersionCmd(t *testing.T) {
13+
ts := startTestServerWithVersion(t, "9.8.7")
14+
setClientEnv(t, ts.URL)
15+
16+
out := runCmd(t, "version")
17+
18+
var result map[string]string
19+
if err := json.Unmarshal([]byte(out), &result); err != nil {
20+
t.Fatalf("parse output: %v\noutput: %s", err, out)
21+
}
22+
if result["server"] != "9.8.7" {
23+
t.Errorf("server = %q, want 9.8.7", result["server"])
24+
}
25+
if result["client"] != version {
26+
t.Errorf("client = %q, want %q", result["client"], version)
27+
}
28+
}
29+
1230
func TestList_Default(t *testing.T) {
1331
ts := startTestServer(t)
1432
setClientEnv(t, ts.URL)

internal/cli/commands_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ func startTestServer(t *testing.T) *httptest.Server {
3636
return ts
3737
}
3838

39+
// startTestServerWithVersion creates a test HTTP server reporting the given version string.
40+
func startTestServerWithVersion(t *testing.T, v string) *httptest.Server {
41+
t.Helper()
42+
dir := t.TempDir()
43+
s, err := store.Load(filepath.Join(dir, "beads.json"))
44+
if err != nil {
45+
t.Fatalf("store.Load: %v", err)
46+
}
47+
p := server.NewSingleStoreProvider(testToken, s)
48+
srv, err := server.New(server.Config{Port: 0, LogOutput: io.Discard, Version: v}, p)
49+
if err != nil {
50+
t.Fatalf("server.New: %v", err)
51+
}
52+
ts := httptest.NewServer(srv.Router)
53+
t.Cleanup(ts.Close)
54+
return ts
55+
}
56+
3957
// setClientEnv sets BS_URL and BS_TOKEN env vars for a test, restoring them after.
4058
func setClientEnv(t *testing.T, url string) {
4159
t.Helper()

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func NewRootCmd() *cobra.Command {
2626
root.AddCommand(serveCmd)
2727

2828
for _, cmd := range []*cobra.Command{
29+
newVersionCmd(),
2930
newWhoamiCmd(),
3031
newAddCmd(),
3132
newShowCmd(),

internal/cli/serve.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ func newServeCmd() *cobra.Command {
9191
}
9292

9393
cfg := server.Config{
94-
Port: port,
94+
Port: port,
95+
Version: version,
9596
}
9697

9798
srv, err := server.New(cfg, provider)

internal/server/server.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Config struct {
2626
Port int
2727
DataFile string
2828
LogOutput io.Writer // destination for request logs; nil defaults to os.Stdout
29+
Version string // reported by GET /api/v1/version
2930
}
3031

3132
// Server is the HTTP server for the beads API.
@@ -62,6 +63,7 @@ func New(cfg Config, p StoreProvider) (*Server, error) {
6263
srv.Router.Get("/", srv.handleDashboard)
6364
srv.Router.Get("/bead/{project}/{id}", srv.handleBeadDetail)
6465
srv.Router.Get("/api/v1/health", srv.handleHealth)
66+
srv.Router.Get("/api/v1/version", srv.handleVersion)
6567

6668
// All other API routes require auth
6769
srv.Router.Group(func(r chi.Router) {
@@ -126,6 +128,12 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
126128
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
127129
}
128130

131+
// handleVersion returns the server version.
132+
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
133+
w.Header().Set("Content-Type", "application/json")
134+
json.NewEncoder(w).Encode(map[string]string{"version": s.config.Version})
135+
}
136+
129137
// requestLogger logs one line per request: method, path, status, and duration.
130138
func (s *Server) requestLogger(next http.Handler) http.Handler {
131139
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)