From 9ecb97ab0330aa2efa03631178c414d4ab30d71d Mon Sep 17 00:00:00 2001 From: Jamo Date: Mon, 25 May 2026 02:36:34 +0200 Subject: [PATCH] feat(about): version chip in sidebar + About dialog + ghcr publish on tag --- .github/workflows/publish-images.yml | 96 +++++++++++++++++++++ Makefile | 9 ++ backend/Dockerfile | 7 ++ backend/Dockerfile.prod | 7 ++ backend/testjam/main.py | 3 +- backend/testjam/routers/about.py | 32 +++++++ backend/tests/test_about.py | 36 ++++++++ docker-compose-dev.yml | 3 + frontend/src/__tests__/AboutDialog.test.jsx | 69 +++++++++++++++ frontend/src/api/about.js | 5 ++ frontend/src/components/layout/Sidebar.jsx | 72 ++++++++++++---- frontend/src/components/ui/about-dialog.jsx | 88 +++++++++++++++++++ frontend/src/hooks/useAbout.js | 10 +++ frontend/src/i18n/index.js | 17 ++-- frontend/src/i18n/locales/ca/about.json | 11 +++ frontend/src/i18n/locales/ca/nav.json | 1 + frontend/src/i18n/locales/en/about.json | 11 +++ frontend/src/i18n/locales/en/nav.json | 1 + frontend/src/i18n/locales/es/about.json | 11 +++ frontend/src/i18n/locales/es/nav.json | 1 + frontend/src/i18n/locales/eu/about.json | 11 +++ frontend/src/i18n/locales/eu/nav.json | 1 + frontend/src/i18n/locales/gl/about.json | 11 +++ frontend/src/i18n/locales/gl/nav.json | 1 + 24 files changed, 488 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/publish-images.yml create mode 100644 backend/testjam/routers/about.py create mode 100644 backend/tests/test_about.py create mode 100644 frontend/src/__tests__/AboutDialog.test.jsx create mode 100644 frontend/src/api/about.js create mode 100644 frontend/src/components/ui/about-dialog.jsx create mode 100644 frontend/src/hooks/useAbout.js create mode 100644 frontend/src/i18n/locales/ca/about.json create mode 100644 frontend/src/i18n/locales/en/about.json create mode 100644 frontend/src/i18n/locales/es/about.json create mode 100644 frontend/src/i18n/locales/eu/about.json create mode 100644 frontend/src/i18n/locales/gl/about.json diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 0000000..d065eab --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,96 @@ +name: Publish images + +# Builds backend + frontend production images and pushes them to GHCR. +# +# Triggers: +# - Tag push matching `v*` → builds with the tag as version ref and pushes +# both `:` and `:latest`. +# - Manual dispatch → builds with the chosen ref and pushes `:` only +# (no `:latest`), useful for release candidates or smoke-testing the +# workflow without cutting a real tag. + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + ref: + description: "Image tag to publish (e.g. v1.0.0-rc1). No :latest will be moved." + required: true + type: string + +permissions: + contents: read + packages: write + +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Build + push api/web → ghcr.io + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Compute image tags + build metadata + id: meta + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + ref="${{ inputs.ref }}" + move_latest=false + else + ref="${GITHUB_REF_NAME}" + move_latest=true + fi + commit="$(git rev-parse --short HEAD)" + built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + owner_lc="${GITHUB_REPOSITORY_OWNER,,}" + { + echo "ref=${ref}" + echo "commit=${commit}" + echo "built_at=${built_at}" + echo "owner=${owner_lc}" + echo "move_latest=${move_latest}" + } >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push backend (testjam-api) + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile.prod + push: true + build-args: | + GIT_REF=${{ steps.meta.outputs.ref }} + GIT_COMMIT=${{ steps.meta.outputs.commit }} + BUILD_DATE=${{ steps.meta.outputs.built_at }} + tags: | + ghcr.io/${{ steps.meta.outputs.owner }}/testjam-api:${{ steps.meta.outputs.ref }} + ${{ steps.meta.outputs.move_latest == 'true' && format('ghcr.io/{0}/testjam-api:latest', steps.meta.outputs.owner) || '' }} + cache-from: type=gha,scope=testjam-api + cache-to: type=gha,scope=testjam-api,mode=max + + - name: Build and push frontend (testjam-web) + uses: docker/build-push-action@v6 + with: + context: ./frontend + file: ./frontend/Dockerfile.prod + push: true + tags: | + ghcr.io/${{ steps.meta.outputs.owner }}/testjam-web:${{ steps.meta.outputs.ref }} + ${{ steps.meta.outputs.move_latest == 'true' && format('ghcr.io/{0}/testjam-web:latest', steps.meta.outputs.owner) || '' }} + cache-from: type=gha,scope=testjam-web + cache-to: type=gha,scope=testjam-web,mode=max diff --git a/Makefile b/Makefile index d190d3f..25c60dc 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,15 @@ BUILD_STAMP = .docker-build-stamp BUILD_DEPS = backend/Dockerfile backend/pyproject.toml \ frontend/Dockerfile frontend/package.json frontend/package-lock.json +# Build metadata surfaced by GET /api/v1/about (sidebar version chip + About +# dialog). Re-evaluated on every make invocation so a `git checkout` followed +# by `make up` recreates the api container with the new branch/commit env — +# no rebuild needed since source is bind-mounted. +GIT_REF := $(shell git describe --tags --exact-match 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo dev) +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +export GIT_REF GIT_COMMIT BUILD_DATE + .PHONY: help up down reset rebuild admin backup build-client test test-api test-client test-cli-e2e test-orchestrator test-front test-e2e _up-api _up-front help: diff --git a/backend/Dockerfile b/backend/Dockerfile index d7ab989..3e41b42 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,6 +17,13 @@ RUN apt-get update \ WORKDIR /app +ARG GIT_REF=dev +ARG GIT_COMMIT=unknown +ARG BUILD_DATE=unknown +ENV TESTJAM_VERSION_REF=${GIT_REF} \ + TESTJAM_VERSION_COMMIT=${GIT_COMMIT} \ + TESTJAM_BUILD_DATE=${BUILD_DATE} + COPY . . RUN --mount=type=cache,target=/root/.cache/pip \ pip install -e ".[dev]" diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod index 2138664..a9d012f 100644 --- a/backend/Dockerfile.prod +++ b/backend/Dockerfile.prod @@ -19,6 +19,13 @@ RUN apt-get update \ WORKDIR /app +ARG GIT_REF=dev +ARG GIT_COMMIT=unknown +ARG BUILD_DATE=unknown +ENV TESTJAM_VERSION_REF=${GIT_REF} \ + TESTJAM_VERSION_COMMIT=${GIT_COMMIT} \ + TESTJAM_BUILD_DATE=${BUILD_DATE} + COPY . . RUN pip install "." diff --git a/backend/testjam/main.py b/backend/testjam/main.py index ddc7dbc..b15e996 100644 --- a/backend/testjam/main.py +++ b/backend/testjam/main.py @@ -18,7 +18,7 @@ from testjam.database import SessionLocal from testjam.realtime import manager, set_main_loop from testjam.realtime_backplane import build_backplane, set_backplane -from testjam.routers import admin, auth, users, groups, projects, project_groups, dashboard, suites, cases, testplans, executions, versions, environments, bugs, custom_fields, coverage, members, mentions, public_reports, tokens, notifications, notification_preferences, settings as settings_router, webhooks, ws, health, integrations +from testjam.routers import about, admin, auth, users, groups, projects, project_groups, dashboard, suites, cases, testplans, executions, versions, environments, bugs, custom_fields, coverage, members, mentions, public_reports, tokens, notifications, notification_preferences, settings as settings_router, webhooks, ws, health, integrations from testjam.services.log_flusher import configure_from_settings as configure_log_flusher from testjam.services.settings import get_settings as get_app_settings @@ -110,6 +110,7 @@ async def lifespan(_app: FastAPI): app.include_router(notification_preferences.router, prefix=settings.API_V1_PREFIX) app.include_router(settings_router.router, prefix=settings.API_V1_PREFIX) app.include_router(ws.router, prefix=settings.API_V1_PREFIX) +app.include_router(about.router, prefix=settings.API_V1_PREFIX) app.include_router(health.router) os.makedirs(settings.UPLOAD_DIR, exist_ok=True) diff --git a/backend/testjam/routers/about.py b/backend/testjam/routers/about.py new file mode 100644 index 0000000..7cd4559 --- /dev/null +++ b/backend/testjam/routers/about.py @@ -0,0 +1,32 @@ +"""GET /about — build metadata for the running instance. + +Powers the in-app About dialog and version chip. Read-only, unauthenticated: +the same metadata is already exposed through `/health` and there's no secret +material in here (just the package version, git ref, short commit and build +timestamp injected at image build time). +""" +import os +from importlib.metadata import PackageNotFoundError, version + +from fastapi import APIRouter + +UNKNOWN = "unknown" + +router = APIRouter(tags=["About"]) + + +@router.get("/about") +def about() -> dict: + return { + "version": _package_version(), + "ref": os.getenv("TESTJAM_VERSION_REF") or UNKNOWN, + "commit": os.getenv("TESTJAM_VERSION_COMMIT") or UNKNOWN, + "built_at": os.getenv("TESTJAM_BUILD_DATE") or UNKNOWN, + } + + +def _package_version() -> str: + try: + return version("testjam-api") + except PackageNotFoundError: + return UNKNOWN diff --git a/backend/tests/test_about.py b/backend/tests/test_about.py new file mode 100644 index 0000000..f0df0b1 --- /dev/null +++ b/backend/tests/test_about.py @@ -0,0 +1,36 @@ +"""GET /api/v1/about endpoint.""" + + +def test_about_returns_env_injected_metadata(client, monkeypatch): + monkeypatch.setenv("TESTJAM_VERSION_REF", "v1.0.0") + monkeypatch.setenv("TESTJAM_VERSION_COMMIT", "abc1234") + monkeypatch.setenv("TESTJAM_BUILD_DATE", "2026-05-25T12:00:00Z") + + response = client.get("/api/v1/about") + + assert response.status_code == 200 + body = response.json() + assert body["version"] + assert body["ref"] == "v1.0.0" + assert body["commit"] == "abc1234" + assert body["built_at"] == "2026-05-25T12:00:00Z" + + +def test_about_falls_back_to_unknown_when_env_unset(client, monkeypatch): + monkeypatch.delenv("TESTJAM_VERSION_REF", raising=False) + monkeypatch.delenv("TESTJAM_VERSION_COMMIT", raising=False) + monkeypatch.delenv("TESTJAM_BUILD_DATE", raising=False) + + response = client.get("/api/v1/about") + + assert response.status_code == 200 + body = response.json() + assert body["ref"] == "unknown" + assert body["commit"] == "unknown" + assert body["built_at"] == "unknown" + + +def test_about_does_not_require_authentication(client): + response = client.get("/api/v1/about") + + assert response.status_code == 200 diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 95fa1c9..248e0d4 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -38,6 +38,9 @@ services: API_WORKERS: ${API_WORKERS:-1} # Fernet key (urlsafe-b64 of 32 bytes). Dev default; rotate in prod env file. INTEGRATION_ENCRYPTION_KEY: ${INTEGRATION_ENCRYPTION_KEY:-dGVzdGphbS1kZXYta2V5LWZvci1pbnRlZ3JhdGlvbnM=} + TESTJAM_VERSION_REF: ${GIT_REF:-dev} + TESTJAM_VERSION_COMMIT: ${GIT_COMMIT:-unknown} + TESTJAM_BUILD_DATE: ${BUILD_DATE:-unknown} volumes: - ./backend:/app - ./testjam-client:/testjam-client diff --git a/frontend/src/__tests__/AboutDialog.test.jsx b/frontend/src/__tests__/AboutDialog.test.jsx new file mode 100644 index 0000000..0a5cf1d --- /dev/null +++ b/frontend/src/__tests__/AboutDialog.test.jsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { AboutDialog, aboutShortLabel } from "../components/ui/about-dialog" + +vi.mock("../api/about", () => ({ + aboutApi: { + get: vi.fn(), + }, +})) + +import { aboutApi } from "../api/about" + +function renderDialog() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + {}} /> + , + ) +} + +describe("aboutShortLabel", () => { + it("returns the tag when ref is a release tag", () => { + expect(aboutShortLabel({ ref: "v1.0.0", commit: "abc1234", version: "1.0.0" })).toBe("v1.0.0") + }) + + it("returns ref @ commit when ref is a branch", () => { + expect(aboutShortLabel({ ref: "master", commit: "abc1234", version: "0.1.0" })).toBe("master @ abc1234") + }) + + it("falls back to package version when ref and commit are unknown", () => { + expect(aboutShortLabel({ ref: "unknown", commit: "unknown", version: "0.1.0" })).toBe("v0.1.0") + }) + + it("returns null when there is no data", () => { + expect(aboutShortLabel(null)).toBeNull() + }) +}) + +describe("AboutDialog", () => { + it("renders the release tag verbatim when ref looks like a tag", async () => { + aboutApi.get.mockResolvedValue({ + version: "1.0.0", + ref: "v1.0.0", + commit: "abc1234", + built_at: "2026-05-25T12:00:00Z", + }) + + renderDialog() + + expect(await screen.findByText("v1.0.0")).toBeInTheDocument() + expect(screen.getByText("abc1234")).toBeInTheDocument() + }) + + it("renders ref @ commit when ref is a branch", async () => { + aboutApi.get.mockResolvedValue({ + version: "0.1.0", + ref: "master", + commit: "abc1234", + built_at: "unknown", + }) + + renderDialog() + + expect(await screen.findByText("master @ abc1234")).toBeInTheDocument() + }) +}) diff --git a/frontend/src/api/about.js b/frontend/src/api/about.js new file mode 100644 index 0000000..5adc216 --- /dev/null +++ b/frontend/src/api/about.js @@ -0,0 +1,5 @@ +import { api } from './client' + +export const aboutApi = { + get: () => api.get('/about').then(r => r.data), +} diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx index 7fb3dbb..85c727c 100644 --- a/frontend/src/components/layout/Sidebar.jsx +++ b/frontend/src/components/layout/Sidebar.jsx @@ -1,16 +1,18 @@ import { useEffect, useRef, useState } from "react" import { NavLink, Link, useMatch } from "react-router-dom" -import { Bug, FolderKanban, Users, LogOut, UserCircle, FolderOpen, LayoutDashboard, PlayCircle, ClipboardList, ChevronLeft, Server, ListChecks, Shield, ChevronUp, Tag, Search, Settings as SettingsIcon, Sliders, X } from "lucide-react" +import { Bug, FolderKanban, Users, LogOut, UserCircle, FolderOpen, LayoutDashboard, PlayCircle, ClipboardList, ChevronLeft, Server, ListChecks, Shield, ChevronUp, Tag, Search, Settings as SettingsIcon, Sliders, Info, X } from "lucide-react" import { useTranslation } from "react-i18next" import { useLogout } from "../../hooks/useAuth" import { useProject } from "../../hooks/useProjects" import { useExecution } from "../../hooks/useExecutions" import { useCase, useSuite } from "../../hooks/useSuites" +import { useAbout } from "../../hooks/useAbout" import { useQuery } from "@tanstack/react-query" import { plansApi } from "../../api/testplans" import { Logo } from "../ui/logo" import { LocaleSwitcher } from "../ui/locale-switcher" import { ThemeToggle } from "../ui/theme-toggle" +import { AboutDialog, aboutShortLabel } from "../ui/about-dialog" import { NotificationsBell } from "./NotificationsBell" // ── Project-scoped nav items ─────────────────────────────────────────────────── @@ -129,6 +131,7 @@ export function Sidebar({ user, onOpenPalette, mobileOpen = false, onCloseMobile const logout = useLogout() const activeProjectId = useActiveProjectId() const { data: project } = useProject(activeProjectId) + const [aboutOpen, setAboutOpen] = useState(false) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) return ( @@ -138,23 +141,26 @@ export function Sidebar({ user, onOpenPalette, mobileOpen = false, onCloseMobile md:relative md:translate-x-0 ${mobileOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}`} > -
- - - -
- - {onCloseMobile && ( - - )} +
+
+ + + +
+ + {onCloseMobile && ( + + )} +
+ setAboutOpen(true)} />
{onOpenPalette && ( @@ -224,12 +230,35 @@ export function Sidebar({ user, onOpenPalette, mobileOpen = false, onCloseMobile {/* User menu + footer */} - + setAboutOpen(true)} + /> + + ) } -function UserMenu({ user, logout, isAdmin }) { +function VersionTag({ onOpenAbout }) { + const { data } = useAbout() + const label = aboutShortLabel(data) + if (!label) return null + return ( + + ) +} + +function UserMenu({ user, logout, isAdmin, onOpenAbout }) { const { t } = useTranslation("nav") const [open, setOpen] = useState(false) const wrapRef = useRef(null) @@ -299,6 +328,11 @@ function UserMenu({ user, logout, isAdmin }) {
+