Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/publish-images.yml
Original file line number Diff line number Diff line change
@@ -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 `:<tag>` and `:latest`.
# - Manual dispatch → builds with the chosen ref and pushes `:<input>` 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
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down
7 changes: 7 additions & 0 deletions backend/Dockerfile.prod
Original file line number Diff line number Diff line change
Expand Up @@ -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 "."

Expand Down
3 changes: 2 additions & 1 deletion backend/testjam/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
32 changes: 32 additions & 0 deletions backend/testjam/routers/about.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions backend/tests/test_about.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/__tests__/AboutDialog.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<AboutDialog open={true} onOpenChange={() => {}} />
</QueryClientProvider>,
)
}

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()
})
})
5 changes: 5 additions & 0 deletions frontend/src/api/about.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { api } from './client'

export const aboutApi = {
get: () => api.get('/about').then(r => r.data),
}
Loading
Loading