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/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 => ../ 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..80888d4a --- /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() { + // Refetch after deployments while keeping repeat visits inexpensive. + return useQuery({ + queryFn: getVersion, + queryKey: versionKey(), + staleTime: 5 * 60 * 1000, + }); +} 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, + ); +};