From f187655c1a2c9789e8141718db70213cf46848c9 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Sat, 6 Jun 2026 20:44:38 +0200 Subject: [PATCH] Add build version reporting to API and UI footer Exposes the current build version via the `/version` endpoint and displays it in the UI footer. The version is injected at build time via ldflags (falling back to "dev" for local builds), allowing for easier identification of the running release. --- Dockerfile | 12 +++++++++++- frontend/src/App.svelte | 22 ++++++++++++++++++++++ handlers/routes.go | 10 ++++++---- handlers/version.go | 12 ++++++++++++ handlers/version_test.go | 35 +++++++++++++++++++++++++++++++++++ main.go | 10 +++++++++- 6 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 handlers/version.go create mode 100644 handlers/version_test.go diff --git a/Dockerfile b/Dockerfile index edc1ce6..f251cfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libvips-dev pkg-config curl ca-certificates \ && rm -rf /var/lib/apt/lists/* +# VERSION is the release string shown in the UI footer (via /version). It is +# passed in at build time because .git is dockerignored, so the build can't run +# `git describe` itself — compute it on the host and forward it: +# docker build --build-arg VERSION="$(git describe --tags --always)" . +# Falls back to "dev" when not supplied. +ARG VERSION=dev + WORKDIR /app COPY go.mod go.sum ./ RUN go mod download @@ -22,7 +29,10 @@ COPY . . COPY --from=frontend /app/frontend/dist ./frontend/dist # The `vips` tag compiles the govips/libvips integration (headers from # libvips-dev above). Local builds omit it so the toolchain works without libvips. -RUN CGO_ENABLED=1 GOOS=linux go build -tags "vips" -o /app/image-optimizer . +# -X main.version stamps the build version into the binary. +RUN CGO_ENABLED=1 GOOS=linux go build -tags "vips" \ + -ldflags "-X main.version=${VERSION}" \ + -o /app/image-optimizer . # ---- Stage 3: minimal runtime ---- FROM debian:bookworm-slim AS runtime diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index c554a4d..522a57f 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -23,6 +23,25 @@ // transform that would otherwise trap its position:fixed overlay. let cropping = $state(null); + // Build version shown in the footer. Fetched once from /version (injected at + // build time from the git tag); stays '' if the request fails so the footer + // simply omits it. 'dev' is the local-build fallback the server reports. + let appVersion = $state(''); + $effect(() => { + let cancelled = false; + fetch('/version') + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (cancelled || !d?.version) return; + // Show tags as "v0.3.1"; leave non-tag builds (e.g. "dev") as-is. + appVersion = /^\d/.test(d.version) ? `v${d.version}` : d.version; + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }); + let status = $derived(progress.status); let canSubmit = $derived( status === 'idle' && files.length > 0 && selectedPresets.length > 0, @@ -245,6 +264,9 @@ + {#if appVersion} + {appVersion} + {/if} diff --git a/handlers/routes.go b/handlers/routes.go index fdda92d..8039082 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -4,13 +4,15 @@ import "github.com/gofiber/fiber/v3" // RegisterRoutes wires every API route onto the app, sharing one in-memory job // store across upload, progress, and download. maxFileBytes is the per-file -// upload cap. It returns the store so the caller can start its TTL reaper and -// drain in-flight jobs on shutdown. Call this before the SPA catch-all so the -// API paths are not swallowed by the wildcard route. -func RegisterRoutes(app *fiber.App, maxFileBytes int64) *Store { +// upload cap; version is the build version surfaced at /version (and in the UI +// footer). It returns the store so the caller can start its TTL reaper and drain +// in-flight jobs on shutdown. Call this before the SPA catch-all so the API paths +// are not swallowed by the wildcard route. +func RegisterRoutes(app *fiber.App, maxFileBytes int64, version string) *Store { store := NewStore() app.Get("/health", Health) + app.Get("/version", Version(version)) app.Post("/upload", Upload(store, maxFileBytes)) app.Get("/progress/:jobId", Progress(store)) app.Get("/download/:jobId", Download(store)) diff --git a/handlers/version.go b/handlers/version.go new file mode 100644 index 0000000..bbe3631 --- /dev/null +++ b/handlers/version.go @@ -0,0 +1,12 @@ +package handlers + +import "github.com/gofiber/fiber/v3" + +// Version returns a handler that responds 200 with the build version as JSON, so +// the frontend can show which release it is running. The value is injected at +// build time (git tag via -ldflags); it falls back to "dev" for local builds. +func Version(version string) fiber.Handler { + return func(c fiber.Ctx) error { + return c.JSON(fiber.Map{"version": version}) + } +} diff --git a/handlers/version_test.go b/handlers/version_test.go new file mode 100644 index 0000000..e3effdc --- /dev/null +++ b/handlers/version_test.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" +) + +func TestVersionHandler(t *testing.T) { + app := fiber.New() + app.Get("/version", Version("v1.2.3")) + + resp, err := app.Test(httptest.NewRequest("GET", "/version", nil)) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + var got struct { + Version string `json:"version"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal %q: %v", body, err) + } + if got.Version != "v1.2.3" { + t.Errorf("version = %q, want %q", got.Version, "v1.2.3") + } +} diff --git a/main.go b/main.go index 745b0cf..4d4b129 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,14 @@ var frontendDist embed.FS // open connections to close, and how long we wait for in-flight jobs to drain. const shutdownGrace = 30 * time.Second +// version is the build version, surfaced at /version and in the UI footer. It is +// injected at build time from the git tag via: +// +// go build -ldflags "-X main.version=$(git describe --tags)" +// +// (the Dockerfile passes this through a build arg). Local builds keep "dev". +var version = "dev" + func main() { // -healthcheck turns the binary into its own health probe (used by the // Docker HEALTHCHECK so the minimal runtime image needs no curl/wget). @@ -61,7 +69,7 @@ func main() { // API routes (health, upload, progress SSE, download). Registered before the // SPA catch-all so they are not swallowed by the wildcard route. The returned // store owns job lifecycle; start its TTL reaper and drain it on shutdown. - store := handlers.RegisterRoutes(app, cfg.MaxFileBytes) + store := handlers.RegisterRoutes(app, cfg.MaxFileBytes, version) store.StartReaper(ctx, cfg.JobTTL) // Serve the embedded Svelte SPA at the root.