feat: expose runtime build version

This commit is contained in:
姚章浩
2026-07-27 12:10:17 +08:00
parent aa0513eb59
commit 2c0ed7cc67
7 changed files with 159 additions and 5 deletions
+12
View File
@@ -15,6 +15,7 @@ Implemented:
- external-message idempotency;
- independent PostgreSQL session/turn storage;
- authenticated internal `POST /v1/qa` ingress endpoint.
- unauthenticated read-only `GET /version` runtime build identity endpoint.
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
@@ -74,6 +75,17 @@ Health check:
curl http://127.0.0.1:8090/healthz
```
Runtime version check:
```bash
curl http://127.0.0.1:8090/version
```
`scripts/deploy-local.sh` builds from its own worktree (or the explicit
`DOBOTAI_SOURCE_ROOT`), injects `version`, `commit_sha`, and `build_time`, and
accepts the deployment only when `/version` reports the exact source commit.
It refuses dirty worktrees so the reported SHA cannot hide uncommitted source.
Internal QA request:
```bash
+15
View File
@@ -25,6 +25,20 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
var (
version = "dev"
commitSHA = "unknown"
buildTime = "unknown"
)
func runtimeBuildInfo() httpapi.BuildInfo {
return httpapi.BuildInfo{
Version: version,
CommitSHA: commitSHA,
BuildTime: buildTime,
}
}
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
@@ -74,6 +88,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
auditOptions.BuildInfo = runtimeBuildInfo()
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
if err != nil {
log.Fatal(err)
+14
View File
@@ -21,6 +21,20 @@ func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDeta
return audit.TurnDetail{}, audit.ErrNotFound
}
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
t.Cleanup(func() {
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
})
version = "2.1.0"
commitSHA = "abc123def456"
buildTime = "2026-07-27T12:00:00Z"
got := runtimeBuildInfo()
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
t.Fatalf("build info=%+v", got)
}
}
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
if err != nil {
+26 -1
View File
@@ -25,9 +25,16 @@ type AuditService interface {
GetTurn(context.Context, string) (audit.TurnDetail, error)
}
type BuildInfo struct {
Version string `json:"version"`
CommitSHA string `json:"commit_sha"`
BuildTime string `json:"build_time"`
}
type Options struct {
AuditService AuditService
AdminToken string
BuildInfo BuildInfo
}
type Server struct {
@@ -35,6 +42,7 @@ type Server struct {
ingressToken string
auditService AuditService
adminToken string
buildInfo BuildInfo
mux *http.ServeMux
}
@@ -58,13 +66,25 @@ func NewWithOptions(service QAService, ingressToken string, opts Options) (*Serv
if hasAdminToken && constantTimeTokenEqual(adminToken, ingressToken) {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN must differ from DOBOTAI_INGRESS_TOKEN")
}
buildInfo := opts.BuildInfo
if buildInfo.Version == "" {
buildInfo.Version = "dev"
}
if buildInfo.CommitSHA == "" {
buildInfo.CommitSHA = "unknown"
}
if buildInfo.BuildTime == "" {
buildInfo.BuildTime = "unknown"
}
s := &Server{
service: service, ingressToken: ingressToken,
auditService: opts.AuditService, adminToken: adminToken,
mux: http.NewServeMux(),
buildInfo: buildInfo,
mux: http.NewServeMux(),
}
s.mux.HandleFunc("GET /healthz", s.health)
s.mux.HandleFunc("GET /version", s.version)
s.mux.HandleFunc("POST /v1/qa", s.authorizeToken(s.ingressToken, s.ask))
if hasAuditService {
s.mux.HandleFunc("GET /v1/admin/audit/turns", s.authorizeToken(s.adminToken, s.listAuditTurns))
@@ -102,6 +122,11 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) version(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, s.buildInfo)
}
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
+40
View File
@@ -25,6 +25,46 @@ func (f *captureQA) Ask(_ context.Context, request core.QARequest) (core.QAResul
return core.QAResult{TurnID: "turn-capture", Answer: "answer"}, nil
}
func TestVersionEndpointReturnsBuildMetadataWithoutAuthentication(t *testing.T) {
server, err := NewWithOptions(fakeQA{}, "ingress-secret", Options{
BuildInfo: BuildInfo{
Version: "2.1.0",
CommitSHA: "abc123def456",
BuildTime: "2026-07-27T12:00:00Z",
},
})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/version", nil)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
if got := result.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control=%q, want no-store", got)
}
want := `{"version":"2.1.0","commit_sha":"abc123def456","build_time":"2026-07-27T12:00:00Z"}`
if got := strings.TrimSpace(result.Body.String()); got != want {
t.Fatalf("body=%s, want %s", got, want)
}
}
func TestVersionEndpointUsesSafeDefaults(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/version", nil)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
want := `{"version":"dev","commit_sha":"unknown","build_time":"unknown"}`
if got := strings.TrimSpace(result.Body.String()); result.Code != http.StatusOK || got != want {
t.Fatalf("status=%d body=%s, want %s", result.Code, got, want)
}
}
func TestQAEndpointRequiresTokenAndReturnsResult(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
+20 -4
View File
@@ -1,18 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
root="/home/zheromiku/dobotai-platform"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="${DOBOTAI_SOURCE_ROOT:-$(cd "$script_dir/.." && pwd)}"
go_bin="${GO_BIN:-/home/zheromiku/.local/share/go1.26.5/bin/go}"
bin_dir="$root/bin"
current="$bin_dir/dobotai"
next="$bin_dir/dobotai.new"
previous="$bin_dir/dobotai.prev"
service="dobotai.service"
version_url="http://127.0.0.1:8090/version"
if [[ -n "$(git -C "$root" status --porcelain)" ]]; then
echo "DobotAI source worktree is dirty; refusing unverifiable build" >&2
exit 1
fi
commit_sha="$(git -C "$root" rev-parse HEAD)"
version="${DOBOTAI_VERSION:-dev}"
build_time="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
ldflags="-X main.version=$version -X main.commitSHA=$commit_sha -X main.buildTime=$build_time"
mkdir -p "$bin_dir"
cd "$root"
"$go_bin" build -trimpath -o "$next" ./cmd/dobotai
"$go_bin" build -trimpath -ldflags "$ldflags" -o "$next" ./cmd/dobotai
chmod 0755 "$next"
if [[ -f "$current" ]]; then
@@ -24,8 +36,12 @@ systemctl --user restart "$service"
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 http://127.0.0.1:8090/healthz >/dev/null; then
echo "DOBOTAI_DEPLOY_OK"
exit 0
deployed_sha="$(curl --noproxy '*' --fail --silent --max-time 2 "$version_url" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("commit_sha", ""))' 2>/dev/null || true)"
if [[ "$deployed_sha" == "$commit_sha" ]]; then
echo "DOBOTAI_DEPLOY_OK"
echo "DOBOTAI_COMMIT_SHA=$commit_sha"
exit 0
fi
fi
sleep 1
done
+32
View File
@@ -0,0 +1,32 @@
package scripts
import (
"os"
"strings"
"testing"
)
func TestDeployLocalBuildsItsSelectedSourceAndVerifiesVersion(t *testing.T) {
data, err := os.ReadFile("deploy-local.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
if strings.Contains(source, `root="/home/zheromiku/dobotai-platform"`) {
t.Fatal("deploy script must not hard-code the production checkout")
}
for _, fragment := range []string{
`BASH_SOURCE[0]`,
`DOBOTAI_SOURCE_ROOT`,
`git -C "$root" status --porcelain`,
`git -C "$root" rev-parse HEAD`,
`main.version`,
`main.commitSHA`,
`main.buildTime`,
`http://127.0.0.1:8090/version`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("deploy script missing %q", fragment)
}
}
}