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.