From ec85a351e0a4bd00d3942005abdafc436ca62c18 Mon Sep 17 00:00:00 2001 From: Benjamin Edwards Date: Fri, 25 Sep 2026 20:22:08 -0400 Subject: [PATCH 1/3] Show server build info on settings page Couldn't tell which version of River UI was running or when it was last upgraded. Add a Build info section to the settings page backed by a new GET /api/version endpoint using runtime/debug.ReadBuildInfo, with -ldflags -X overrides so Docker builds report real metadata. --- .github/workflows/docker-riverproui.yaml | 12 +++ .github/workflows/docker-riverui.yaml | 11 +++ CHANGELOG.md | 4 + Dockerfile | 7 +- Dockerfile.pro | 7 +- handler.go | 1 + handler_api_endpoint.go | 42 +++++++++ handler_api_endpoint_test.go | 25 ++++++ handler_test.go | 1 + internal/buildinfo/buildinfo.go | 71 +++++++++++++++ internal/buildinfo/buildinfo_test.go | 20 +++++ src/components/SettingsPage.test.tsx | 109 ++++++++++++++++++++++- src/components/SettingsPage.tsx | 104 ++++++++++++++++++++- src/hooks/use-version.ts | 11 +++ src/services/version.test.ts | 23 +++++ src/services/version.ts | 38 ++++++++ 16 files changed, 479 insertions(+), 7 deletions(-) create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/buildinfo/buildinfo_test.go create mode 100644 src/hooks/use-version.ts create mode 100644 src/services/version.test.ts create mode 100644 src/services/version.ts diff --git a/.github/workflows/docker-riverproui.yaml b/.github/workflows/docker-riverproui.yaml index e01c94ec..ce35a698 100644 --- a/.github/workflows/docker-riverproui.yaml +++ b/.github/workflows/docker-riverproui.yaml @@ -58,6 +58,14 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} + - name: Build metadata + id: build_metadata + run: | + version=$(git describe --tags --always --match 'riverproui/v*') + echo "version=${version#riverproui/}" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "commit_time=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v5 with: @@ -92,6 +100,10 @@ jobs: with: context: . file: Dockerfile.pro + build-args: | + BUILD_VERSION=${{ steps.build_metadata.outputs.version }} + BUILD_REVISION=${{ steps.build_metadata.outputs.revision }} + BUILD_COMMIT_TIME=${{ steps.build_metadata.outputs.commit_time }} pull: true platforms: ${{ matrix.docker_platform }} provenance: true diff --git a/.github/workflows/docker-riverui.yaml b/.github/workflows/docker-riverui.yaml index de9d5aab..efad56e4 100644 --- a/.github/workflows/docker-riverui.yaml +++ b/.github/workflows/docker-riverui.yaml @@ -44,6 +44,13 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} + - name: Build metadata + id: build_metadata + run: | + echo "version=$(git describe --tags --always --match 'v*')" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "commit_time=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" + - name: Prepare run: | platform=${{ matrix.docker_platform }} @@ -80,6 +87,10 @@ jobs: uses: docker/build-push-action@v7 with: context: . + build-args: | + BUILD_VERSION=${{ steps.build_metadata.outputs.version }} + BUILD_REVISION=${{ steps.build_metadata.outputs.revision }} + BUILD_COMMIT_TIME=${{ steps.build_metadata.outputs.commit_time }} pull: true platforms: ${{ matrix.docker_platform }} labels: ${{ steps.meta.outputs.labels }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c897a5de..8205e962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Show the running server's version, commit, commit date, and Go version on the settings page. [PR #685](https://github.com/riverqueue/riverui/pull/685). + ## [v0.19.0] - 2026-08-25 ### Changed diff --git a/Dockerfile b/Dockerfile index 52a36e05..28390eea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,12 @@ COPY uiendpoints/ uiendpoints/ COPY --from=build-ui /app/dist ./dist -RUN go build -trimpath -ldflags="-w -s -buildid=" -o /bin/riverui ./cmd/riverui +# Pass metadata from the original checkout; this partial copy is not a Git checkout. +ARG BUILD_VERSION +ARG BUILD_REVISION +ARG BUILD_COMMIT_TIME + +RUN go build -trimpath -ldflags="-w -s -buildid= -X riverqueue.com/riverui/internal/buildinfo.version=${BUILD_VERSION} -X riverqueue.com/riverui/internal/buildinfo.revision=${BUILD_REVISION} -X riverqueue.com/riverui/internal/buildinfo.commitTime=${BUILD_COMMIT_TIME}" -o /bin/riverui ./cmd/riverui FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b ENV PATH_PREFIX="/" diff --git a/Dockerfile.pro b/Dockerfile.pro index 2d0d5cab..a4728849 100644 --- a/Dockerfile.pro +++ b/Dockerfile.pro @@ -40,7 +40,12 @@ COPY --from=build-ui /app/dist ./dist # Build the riverproui binary WORKDIR /go/src/riverui/riverproui -RUN go build -trimpath -ldflags="-w -s -buildid=" -o /bin/riverproui ./cmd/riverproui +# Pass metadata from the original checkout; this partial copy is not a Git checkout. +ARG BUILD_VERSION +ARG BUILD_REVISION +ARG BUILD_COMMIT_TIME + +RUN go build -trimpath -ldflags="-w -s -buildid= -X riverqueue.com/riverui/internal/buildinfo.version=${BUILD_VERSION} -X riverqueue.com/riverui/internal/buildinfo.revision=${BUILD_REVISION} -X riverqueue.com/riverui/internal/buildinfo.commitTime=${BUILD_COMMIT_TIME}" -o /bin/riverproui ./cmd/riverproui FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b ENV PATH_PREFIX="/" diff --git a/handler.go b/handler.go index 835a38fb..b2c0d7d0 100644 --- a/handler.go +++ b/handler.go @@ -116,6 +116,7 @@ func (e *endpoints[TTx]) MountEndpoints(archetype *baseservice.Archetype, logger apiendpoint.Mount(mux, newQueueResumeEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newQueueUpdateEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newStateAndCountGetEndpoint(bundle), mountOpts), + apiendpoint.Mount(mux, newVersionGetEndpoint(bundle), mountOpts), } } diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index dce312f1..9bcc193a 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -25,6 +25,7 @@ import ( "github.com/riverqueue/river/rivertype" "riverqueue.com/riverui/internal/apibundle" + "riverqueue.com/riverui/internal/buildinfo" "riverqueue.com/riverui/internal/querycacher" ) @@ -1091,3 +1092,44 @@ func riverQueuesToSerializableQueues(internal []*rivertype.Queue, counts []*rive } return listResponseFrom(queues) } + +// +// versionGetEndpoint +// + +type versionGetEndpoint[TTx any] struct { + apibundle.APIBundle[TTx] + apiendpoint.Endpoint[versionGetRequest, versionGetResponse] +} + +func newVersionGetEndpoint[TTx any](bundle apibundle.APIBundle[TTx]) *versionGetEndpoint[TTx] { + return &versionGetEndpoint[TTx]{APIBundle: bundle} +} + +func (*versionGetEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { + return &apiendpoint.EndpointMeta{ + Pattern: "GET /api/version", + StatusCode: http.StatusOK, + } +} + +type versionGetRequest struct{} + +type versionGetResponse struct { + Version string `json:"version"` + Revision string `json:"revision"` + Time string `json:"time"` + Modified bool `json:"modified"` + GoVersion string `json:"go_version"` +} + +func (*versionGetEndpoint[TTx]) Execute(_ context.Context, _ *versionGetRequest) (*versionGetResponse, error) { + info := buildinfo.Get() + return &versionGetResponse{ + Version: info.Version, + Revision: info.Revision, + Time: info.Time, + Modified: info.Modified, + GoVersion: info.GoVersion, + }, nil +} diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go index df2a133f..609e5ef1 100644 --- a/handler_api_endpoint_test.go +++ b/handler_api_endpoint_test.go @@ -3,6 +3,7 @@ package riverui import ( "context" "encoding/json" + "fmt" "log/slog" "net/http" "net/http/httptest" @@ -26,6 +27,7 @@ import ( "github.com/riverqueue/river/rivertype" "riverqueue.com/riverui/internal/apibundle" + "riverqueue.com/riverui/internal/buildinfo" "riverqueue.com/riverui/internal/riverinternaltest/testfactory" "riverqueue.com/riverui/internal/uicommontest" ) @@ -385,6 +387,29 @@ func TestAPIHandlerFeaturesGet(t *testing.T) { }) } +func TestAPIHandlerVersionGet(t *testing.T) { + t.Parallel() + + // Build metadata does not require a database connection. + endpoint := newVersionGetEndpoint(apibundle.APIBundle[pgx.Tx]{}) + mux := http.NewServeMux() + apiendpoint.Mount(mux, endpoint, testMountOpts(t)) + + response := httptest.NewRecorder() + mux.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/version", nil)) + + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Header().Get("Content-Type"), "application/json") + info := buildinfo.Get() + require.JSONEq(t, fmt.Sprintf(`{ + "version": %q, + "revision": %q, + "time": %q, + "modified": %t, + "go_version": %q + }`, info.Version, info.Revision, info.Time, info.Modified, info.GoVersion), response.Body.String()) +} + func TestAPIHandlerHealthCheckGet(t *testing.T) { t.Parallel() diff --git a/handler_test.go b/handler_test.go index 7a52ad6a..f588ef71 100644 --- a/handler_test.go +++ b/handler_test.go @@ -105,6 +105,7 @@ func TestNewHandlerIntegration(t *testing.T) { }, })) makeAPICall(t, "StateAndCountGet", http.MethodGet, makeURL("/api/states"), nil) + makeAPICall(t, "VersionGet", http.MethodGet, makeURL("/api/version"), nil) // // Static files diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 00000000..b27c413f --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,71 @@ +// Package buildinfo reports build and version information for the running +// binary, sourced from Go's embedded build metadata with optional overrides +// injected at link time: +// +// go build -ldflags "-X riverqueue.com/riverui/internal/buildinfo.version=v1.2.3 ..." +package buildinfo + +import ( + "runtime" + "runtime/debug" +) + +// The variables below may be set at link time with -ldflags -X to override +// the values Go embeds automatically. They take precedence over the +// corresponding build settings when non-empty. +var ( + version string + revision string //nolint:gochecknoglobals // Set by the Go linker with -X. + commitTime string //nolint:gochecknoglobals // Set by the Go linker with -X. +) + +// Info describes the running binary's build. +type Info struct { + Version string + Revision string + Time string + Modified bool + GoVersion string +} + +// Get returns build information for the running binary, preferring link-time +// overrides and falling back to the metadata Go embeds via +// runtime/debug.ReadBuildInfo (module version plus vcs.revision, vcs.time, +// and vcs.modified when built with VCS stamping). Time is the commit time, +// not the time the binary was built. When River UI is embedded, this describes +// the host executable. +func Get() *Info { + info := &Info{GoVersion: runtime.Version()} + + if version != "" { + info.Version = version + } + if revision != "" { + info.Revision = revision + } + if commitTime != "" { + info.Time = commitTime + } + + if buildInfo, ok := debug.ReadBuildInfo(); ok { + if info.Version == "" { + info.Version = buildInfo.Main.Version + } + for _, setting := range buildInfo.Settings { + switch setting.Key { + case "vcs.revision": + if info.Revision == "" { + info.Revision = setting.Value + } + case "vcs.time": + if info.Time == "" { + info.Time = setting.Value + } + case "vcs.modified": + info.Modified = setting.Value == "true" + } + } + } + + return info +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 00000000..0725e0ac --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,20 @@ +package buildinfo + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGet(t *testing.T) { + t.Parallel() + + info := Get() + + require.NotNil(t, info) + require.Equal(t, runtime.Version(), info.GoVersion) + // `go test` builds the test binary from the module, so the module path + // is always embedded; version may be "(devel)" when unbuilt from a tag. + require.NotEmpty(t, info.Version) +} diff --git a/src/components/SettingsPage.test.tsx b/src/components/SettingsPage.test.tsx index 9dd8a57c..0cc733a2 100644 --- a/src/components/SettingsPage.test.tsx +++ b/src/components/SettingsPage.test.tsx @@ -1,3 +1,5 @@ +import { getVersion, type Version } from "@services/version"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createFeatures } from "@test/utils/features"; import { fireEvent, render, screen } from "@testing-library/react"; import { @@ -29,10 +31,47 @@ vi.mock("@contexts/Features.hook", () => ({ useFeatures: mockUseFeatures, })); +vi.mock("@services/version", async (importOriginal) => ({ + ...(await importOriginal()), + getVersion: vi.fn(), +})); + +const mockGetVersion = vi.mocked(getVersion); +const buildInfo: Version = { + goVersion: "go1.26.7", + modified: true, + revision: "abc123def456", + time: "2026-09-25T19:38:00Z", + version: "v0.19.0", +}; + +function renderSettingsPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + return render( + + + , + ); +} + describe("SettingsPage", () => { beforeEach(() => { mockUseFeatures.mockReset(); mockUseSettings.mockReset(); + mockGetVersion.mockReset(); + // Leave the request pending unless a test supplies a response. + mockGetVersion.mockImplementation(() => new Promise(() => {})); + mockUseSettings.mockReturnValue({ + clearShowJobArgs: vi.fn(), + setShowJobArgs: vi.fn(), + settings: {}, + shouldShowJobArgs: true, + }); + mockUseFeatures.mockReturnValue({ + features: createFeatures({ jobListHideArgsByDefault: false }), + }); }); it("renders correctly with default settings", () => { @@ -53,7 +92,7 @@ describe("SettingsPage", () => { }), }); - render(); + renderSettingsPage(); // Title should be visible expect(screen.getByText("Settings")).toBeInTheDocument(); @@ -93,7 +132,7 @@ describe("SettingsPage", () => { }), }); - render(); + renderSettingsPage(); // Reset button should be visible expect(screen.getByTestId("job-args-reset-btn")).toBeInTheDocument(); @@ -128,7 +167,7 @@ describe("SettingsPage", () => { }), }); - render(); + renderSettingsPage(); // Find switch element by data-testid const switchElement = screen.getByTestId("job-args-toggle"); @@ -138,4 +177,68 @@ describe("SettingsPage", () => { fireEvent.click(switchElement); expect(mockSetShowJobArgs).toHaveBeenCalledWith(true); }); + + it("displays build info when loaded", async () => { + mockGetVersion.mockResolvedValue(buildInfo); + + renderSettingsPage(); + + expect(await screen.findByTestId("build-version")).toHaveTextContent( + "v0.19.0", + ); + expect(screen.getByTestId("build-commit")).toHaveTextContent( + "abc123def456 (modified)", + ); + expect(screen.getByText("Commit date")).toBeInTheDocument(); + expect(screen.getByTestId("build-time")).toHaveTextContent( + "2026-09-25T19:38:00Z", + ); + expect(screen.getByTestId("build-go-version")).toHaveTextContent( + "go1.26.7", + ); + }); + + it("shows loading state while build info loads", () => { + renderSettingsPage(); + + expect(screen.getByTestId("build-info-loading")).toBeInTheDocument(); + }); + + it("shows an error and retries a failed request", async () => { + mockGetVersion.mockRejectedValueOnce(new Error("Network error")); + mockGetVersion.mockResolvedValue(buildInfo); + + renderSettingsPage(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load build info.", + ); + expect(screen.queryByTestId("build-info-loading")).not.toBeInTheDocument(); + expect(screen.getByTestId("job-args-toggle")).toBeEnabled(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(await screen.findByTestId("build-version")).toHaveTextContent( + "v0.19.0", + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(mockGetVersion).toHaveBeenCalledTimes(2); + }); + + it("shows unknown metadata without a misleading modified suffix", async () => { + mockGetVersion.mockResolvedValue({ + ...buildInfo, + revision: "", + time: "", + version: "", + }); + + renderSettingsPage(); + + expect(await screen.findByTestId("build-version")).toHaveTextContent( + /^unknown$/, + ); + expect(screen.getByTestId("build-commit")).toHaveTextContent(/^unknown$/); + expect(screen.getByTestId("build-time")).toHaveTextContent(/^unknown$/); + }); }); diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index ec47ed45..1399d5ca 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -3,12 +3,19 @@ import TopNavTitleOnly from "@components/TopNavTitleOnly"; import { useFeatures } from "@contexts/Features.hook"; import { Switch } from "@headlessui/react"; import { useSettings } from "@hooks/use-settings"; +import { useVersion } from "@hooks/use-version"; import clsx from "clsx"; export default function SettingsPage() { const { features } = useFeatures(); const { clearShowJobArgs, setShowJobArgs, settings, shouldShowJobArgs } = useSettings(); + const { + data: buildInfo, + isError: isBuildInfoError, + isLoading: isBuildInfoLoading, + refetch: refetchBuildInfo, + } = useVersion(); // Determine if we're using an override or the default value const isUsingOverride = settings.showJobArgs !== undefined; @@ -22,8 +29,8 @@ export default function SettingsPage() {
-
-
+
+

Display @@ -123,8 +130,101 @@ export default function SettingsPage() {

+ +
+
+

+ Build info +

+

+ Version and build metadata for the running server. When River UI + is embedded, this describes the host application. +

+
+ +
+
+ {isBuildInfoError && !buildInfo ? ( +
+

+ Could not load build info. +

+ +
+ ) : isBuildInfoLoading || !buildInfo ? ( +

+ Loading build info… +

+ ) : ( +
+ + + + +
+ )} +
+
+
); } + +function BuildInfoRow({ + label, + testId, + value, +}: { + label: string; + testId: string; + value: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function displayValue(value: string | undefined): string { + return value === undefined || value === "" ? "unknown" : value; +} diff --git a/src/hooks/use-version.ts b/src/hooks/use-version.ts new file mode 100644 index 00000000..618ed786 --- /dev/null +++ b/src/hooks/use-version.ts @@ -0,0 +1,11 @@ +import { getVersion, versionKey } from "@services/version"; +import { useQuery } from "@tanstack/react-query"; + +export function useVersion() { + // Build info is immutable for the lifetime of the server process. + return useQuery({ + queryFn: getVersion, + queryKey: versionKey(), + staleTime: Infinity, + }); +} diff --git a/src/services/version.test.ts b/src/services/version.test.ts new file mode 100644 index 00000000..affa77f6 --- /dev/null +++ b/src/services/version.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { apiVersionToVersion } from "./version"; + +describe("apiVersionToVersion", () => { + it("converts API version to frontend version", () => { + const apiVersion = { + go_version: "go1.26.7", + modified: false, + revision: "abc123def456", + time: "2026-09-25T19:38:00Z", + version: "(devel)", + } as const; + + expect(apiVersionToVersion(apiVersion)).toEqual({ + goVersion: "go1.26.7", + modified: false, + revision: "abc123def456", + time: "2026-09-25T19:38:00Z", + version: "(devel)", + }); + }); +}); diff --git a/src/services/version.ts b/src/services/version.ts new file mode 100644 index 00000000..c4f405ee --- /dev/null +++ b/src/services/version.ts @@ -0,0 +1,38 @@ +import type { QueryFunction } from "@tanstack/react-query"; + +import { API } from "@utils/api"; + +export type Version = { + goVersion: string; + modified: boolean; + revision: string; + time: string; + version: string; +}; + +export type VersionFromAPI = { + go_version: string; + modified: boolean; + revision: string; + time: string; + version: string; +}; + +export const versionKey = () => ["version"] as const; +export type VersionKey = ReturnType; + +export const apiVersionToVersion = (version: VersionFromAPI): Version => ({ + goVersion: version.go_version, + modified: version.modified, + revision: version.revision, + time: version.time, + version: version.version, +}); + +export const getVersion: QueryFunction = async ({ + signal, +}) => { + return API.get({ path: "/version" }, { signal }).then( + apiVersionToVersion, + ); +}; From bd086970ca7a09cdd4b970cd2e83167a69536bdb Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 27 Sep 2026 16:00:24 -0500 Subject: [PATCH 2/3] use local River UI in Pro builds Pro builds from this checkout currently resolve the previous River UI release, so they omit the new version endpoint and embedded UI assets. Replace that dependency with the local module during development. The separate Pro release can remove the replacement and update the version pin after River UI is released, following the existing release process. --- riverproui/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/riverproui/go.mod b/riverproui/go.mod index afd7dacf..c44a7780 100644 --- a/riverproui/go.mod +++ b/riverproui/go.mod @@ -72,4 +72,4 @@ retract ( // replace riverqueue.com/riverpro/driver/riverpropgxv5 => ../../riverpro/driver/riverpropgxv5 -// replace riverqueue.com/riverui => ../ +replace riverqueue.com/riverui => ../ From 379e2cf713d198490c8a8c8edd2c711b451175d8 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 27 Sep 2026 16:00:32 -0500 Subject: [PATCH 3/3] expire cached build info after five minutes A long-lived browser tab can show the prior server's version after a deployment because the build info query stays fresh indefinitely. Expire that query after five minutes so a later visit or window focus fetches the running server's version while nearby visits reuse the cache. --- src/hooks/use-version.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/use-version.ts b/src/hooks/use-version.ts index 618ed786..80888d4a 100644 --- a/src/hooks/use-version.ts +++ b/src/hooks/use-version.ts @@ -2,10 +2,10 @@ import { getVersion, versionKey } from "@services/version"; import { useQuery } from "@tanstack/react-query"; export function useVersion() { - // Build info is immutable for the lifetime of the server process. + // Refetch after deployments while keeping repeat visits inexpensive. return useQuery({ queryFn: getVersion, queryKey: versionKey(), - staleTime: Infinity, + staleTime: 5 * 60 * 1000, }); }