From 1c1f5cf1bbe1c08089c91e87a83b2fad7c7f11b4 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 21 Sep 2026 20:21:43 -0400 Subject: [PATCH] feat(version): bake release tag into binary; About card and /health field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version tag previously existed only in git and the Docker image tag — the running app had no way to report what build it was. Version is now injected at build time via -ldflags into internal/version.Version (defaults to "dev" for local builds), passed by the release workflow as the APP_VERSION Docker build arg from the pushed tag. Surfaced in Admin → Settings → About (extensible card for future rows like disk space) and in the /health JSON response, so deployments can be verified with curl alone. --- .gitea/workflows/release.yml | 3 + Dockerfile | 9 ++- internal/router/frontend.go | 2 + internal/version/version.go | 10 ++++ templates/admin_settings.templ | 21 ++++++- templates/admin_settings_templ.go | 96 +++++++++++++++++++++++++------ 6 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 internal/version/version.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index c11c520..c6e395a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -56,6 +56,9 @@ jobs: context: . file: ./Dockerfile push: true + # Bake the version into the binary (Admin → Settings → About). + build-args: | + APP_VERSION=${{ env.TAG }} # Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag. # Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml; # pin or roll back by setting IMAGE_TAG in .env. diff --git a/Dockerfile b/Dockerfile index 92dc257..027e273 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,10 +37,17 @@ RUN cd templates && templ generate RUN npm run build:css:prod RUN npm run build:ts +# App version baked into the binary (shown in Admin → Settings → About). +# Declared after `COPY . .` so a version bump only invalidates this layer. +# The release workflow passes the git tag, e.g. APP_VERSION=v1.5. +ARG APP_VERSION=dev + # Build Go binary (cached unless Go files or generated code changes) RUN --mount=type=cache,target=/root/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server + CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \ + -ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \ + -o main ./cmd/server # Materialize the Go module cache into an image layer so the test-runner # stage can restore it. GOMODCACHE is /go/pkg/mod; cache mounts elsewhere are diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 1708b73..8e5f1e1 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -17,6 +17,7 @@ import ( "bookhoard/internal/handlers" "bookhoard/internal/services" "bookhoard/internal/utils" + "bookhoard/internal/version" "bookhoard/templates" "github.com/golang-jwt/jwt/v5" @@ -1455,6 +1456,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error { return c.JSON(http.StatusOK, map[string]interface{}{ "status": "healthy", + "version": version.Version, "database": "connected", "scan": map[string]interface{}{ "scan_in_progress": cfg.Worker.HasActiveScans(), diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..dfce3be --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,10 @@ +package version + +// Version is the app version. It defaults to "dev" for local builds and is +// overridden at build time in release images via: +// +// go build -ldflags "-X bookhoard/internal/version.Version=v1.5" +// +// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION +// Docker build arg, which the Dockerfile forwards to this flag. +var Version = "dev" diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index 750da4c..d1c052c 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -1,6 +1,9 @@ package templates -import "fmt" +import ( + "bookhoard/internal/version" + "fmt" +) templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) { @@ -111,6 +114,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan @ScanSettingsSection(scanSettings) @TunableSettingsSection(liveGroups, false) @TunableSettingsSection(restartGroups, true) + @AboutSection() @@ -255,3 +259,18 @@ templ TunableSettingRow(e SettingEntry) { } + +// AboutSection shows build metadata, rendered at the bottom of the System +// Settings page. Add future rows (disk space, uptime, Go version, …) to the +// list below. +templ AboutSection() { +
+
+ @Icon("info", "h-5 w-5 shrink-0") +

About

+
+
+

Version: { version.Version }

+
+
+} diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index 79f2630..9a1d8a5 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -8,7 +8,10 @@ package templates import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "fmt" +import ( + "bookhoard/internal/version" + "fmt" +) func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { @@ -67,7 +70,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 31, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 34, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -93,7 +96,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 45, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 48, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -370,7 +373,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 106, Col: 145} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 109, Col: 145} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -383,7 +386,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 107, Col: 144} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 110, Col: 144} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -396,7 +399,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 108, Col: 152} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 111, Col: 152} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -418,6 +421,10 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = AboutSection().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -472,7 +479,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 154, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 158, Col: 69} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -564,7 +571,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 194, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 198, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -622,7 +629,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 209, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 213, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -640,7 +647,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 211, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 215, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -658,7 +665,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 213, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 217, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -676,7 +683,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 219, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 223, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -689,7 +696,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 223, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 227, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -732,7 +739,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 233, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 237, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { @@ -750,7 +757,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 235, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 239, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) if templ_7745c5c3_Err != nil { @@ -769,7 +776,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 238, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 242, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { @@ -792,7 +799,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 246, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 250, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) if templ_7745c5c3_Err != nil { @@ -818,7 +825,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 255, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 259, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) if templ_7745c5c3_Err != nil { @@ -832,4 +839,57 @@ func TunableSettingRow(e SettingEntry) templ.Component { }) } +// AboutSection shows build metadata, rendered at the bottom of the System +// Settings page. Add future rows (disk space, uptime, Go version, …) to the +// list below. +func AboutSection() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

About

Version: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 273, Col: 134} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate