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.