Skip to content
Open
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
14 changes: 14 additions & 0 deletions .github/workflows/release-reusable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
with:
ref: ${{ inputs.checkout-ref }}
submodules: 'true'
fetch-depth: 0

- name: login to github
if: inputs.publish
Expand All @@ -75,6 +76,15 @@ jobs:
env:
BUILDX_BUILDER: ${{ steps.buildx.outputs.name }}

- name: Resolve frontend build metadata
id: build_metadata
env:
REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
echo "commit=$(git rev-parse HEAD | cut -c1-12)" >> "$GITHUB_OUTPUT"
echo "branch=$(scripts/ci/resolve-build-branch.sh)" >> "$GITHUB_OUTPUT"
echo "time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"

- name: Build and publish Docker image
uses: docker/build-push-action@v7
with:
Expand All @@ -83,6 +93,10 @@ jobs:
platforms: linux/amd64,linux/arm64
push: ${{ inputs.publish }}
tags: ${{ env.IMAGE_NAME }}:${{ inputs.release-tag }}
build-args: |
VITE_BUILD_COMMIT=${{ steps.build_metadata.outputs.commit }}
VITE_BUILD_BRANCH=${{ steps.build_metadata.outputs.branch }}
VITE_BUILD_TIME=${{ steps.build_metadata.outputs.time }}

release_security:
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ RUN corepack enable pnpm

WORKDIR /app

ARG VITE_BUILD_COMMIT
ARG VITE_BUILD_BRANCH
ARG VITE_BUILD_TIME
ENV VITE_BUILD_COMMIT=$VITE_BUILD_COMMIT
ENV VITE_BUILD_BRANCH=$VITE_BUILD_BRANCH
ENV VITE_BUILD_TIME=$VITE_BUILD_TIME

# Cache dependencies
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ pnpm dev
pnpm dev --mode live
```

### Build metadata

The application footer displays the UI build version and 12-character commit alongside the metadata returned by `GET /system/status`. The System Status page shows version, commit, branch, and build time for both ZenBPM and the UI. Frontend metadata is embedded when Vite starts or builds; the browser never invokes Git.

The UI version comes from `info.version` in `openapi/api.yaml`. By default, Vite reads the commit and branch from Git, shortens the commit to 12 characters, and records the current UTC time. Builds outside a Git checkout can supply the metadata explicitly:

```bash
VITE_BUILD_COMMIT=7af392e12345 \
VITE_BUILD_BRANCH=main \
VITE_BUILD_TIME=2026-08-10T08:00:00Z \
pnpm build
```

Docker accepts the same values as build arguments. The release workflow resolves and supplies all three automatically:

```bash
docker build \
--build-arg VITE_BUILD_COMMIT=7af392e12345 \
--build-arg VITE_BUILD_BRANCH=main \
--build-arg VITE_BUILD_TIME=2026-08-10T08:00:00Z \
-t zenbpm-ui .
```

## Quality Checks

Before submitting a PR, ensure all quality checks pass:
Expand Down
79 changes: 79 additions & 0 deletions e2e/core/build-metadata-footer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';

const frontendMetadata = '1.5.0 (abcdef012345)';
const backendMetadata = 'v1.5.0 (abcdef012345)';

test.describe('Build metadata footer', () => {
test('displays embedded frontend metadata', async ({ page }) => {
await page.goto('/');

const footer = page.getByTestId('build-metadata-footer');
await expect(footer).toContainText(`UI: ${frontendMetadata}`);
await expect(footer).toContainText(`ZenBPM: ${backendMetadata}`);
await expect(footer.locator('p').first()).toHaveClass(/MuiTypography-captionNormal/);
await expect(footer.locator('p').first()).toHaveCSS('text-transform', 'none');
await expect(footer.locator('p').last()).toHaveClass(/MuiTypography-captionNormal/);
await expect(footer.locator('p').last()).toHaveCSS('text-transform', 'none');

const footerText = await footer.textContent();
expect(footerText?.indexOf('ZenBPM')).toBeLessThan(footerText?.indexOf('UI'));
});

test('fetches backend metadata from system status without calling the removed info endpoint', async ({ page }) => {
const requestedSystemEndpoints: string[] = [];
page.on('request', (request) => {
const pathname = new URL(request.url()).pathname;
if (pathname.startsWith('/system/')) {
requestedSystemEndpoints.push(pathname);
}
});

await page.goto('/');

await expect(page.getByTestId('build-metadata-footer')).toContainText(`ZenBPM: ${backendMetadata}`);
expect(requestedSystemEndpoints).toContain('/system/status');
expect(requestedSystemEndpoints).not.toContain('/system/info');
});

test('shows a loading backend state while the status request is pending', async ({ page }) => {
await page.goto('/?systemStatusScenario=loading');

await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: loading');
});

test('shows an unavailable backend state when the status request fails', async ({ page }) => {
await page.goto('/?systemStatusScenario=error');

await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: unavailable');
});

test('hides the status indicator when frontend and backend metadata match', async ({ page }) => {
await page.goto('/');

await expect(page.getByTestId('build-metadata-status')).not.toBeVisible();
});

test('marks differing metadata, including a plus suffix, as mismatching', async ({ page }) => {
await page.goto('/?systemStatusScenario=mismatch');

const status = page.getByTestId('build-metadata-status');
await expect(status).toHaveText('Build metadata mismatch');
await expect(status.locator('.MuiTypography-root')).toHaveClass(/MuiTypography-captionNormal/);
await expect(status.locator('.MuiTypography-root')).toHaveCSS('text-transform', 'none');
await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: v1.5.0+ (abcdef012345)');
});

test('treats a backend release candidate as matching the same UI version', async ({ page }) => {
await page.goto('/?systemStatusScenario=release-candidate');

await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: v1.5.0-rc1 (abcdef012345)');
await expect(page.getByTestId('build-metadata-status')).not.toBeVisible();
});

test('treats differing frontend and backend commits as matching when versions match', async ({ page }) => {
await page.goto('/?systemStatusScenario=commit-difference');

await expect(page.getByTestId('build-metadata-footer')).toContainText('ZenBPM: v1.5.0 (123456789abc)');
await expect(page.getByTestId('build-metadata-status')).not.toBeVisible();
});
});
37 changes: 37 additions & 0 deletions e2e/core/system-status.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';

test.describe('System status', () => {
test('displays backend and frontend build information', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'System Status' }).click();
await expect(page).toHaveURL(/\/system-status$/);

const buildInformation = page.getByTestId('system-build-information');
const backend = buildInformation.getByTestId('backend-build-information');
const frontend = buildInformation.getByTestId('frontend-build-information');

await expect(buildInformation).toContainText('Build Information');

const sectionOrder = await page
.locator('[data-testid="system-cluster-topology"], [data-testid="system-build-information"]')
.evaluateAll((sections) => sections.map((section) => section.getAttribute('data-testid')));
expect(sectionOrder).toEqual(['system-cluster-topology', 'system-build-information']);
await expect(page.getByTestId('system-status-page').locator(':scope > :last-child'))
.toHaveAttribute('data-testid', 'system-build-information');
await expect(frontend).toHaveCSS('border-left-color', 'rgb(240, 240, 240)');
await page.setViewportSize({ width: 600, height: 900 });
await expect(frontend).toHaveCSS('border-top-color', 'rgb(240, 240, 240)');

await expect(backend).toContainText('ZenBPM');
await expect(backend).toContainText(/Version\s*v1\.5\.0/);
await expect(backend).toContainText(/Build Time\s*2026-08-10T07:33:20Z/);
await expect(backend).toContainText(/Branch\s*main/);
await expect(backend).toContainText(/Commit ID\s*abcdef012345/);

await expect(frontend).toContainText('UI');
await expect(frontend).toContainText(/Version\s*1\.5\.0/);
await expect(frontend).toContainText(/Build Time\s*2026-08-10T08:00:00Z/);
await expect(frontend).toContainText(/Branch\s*feat\/system-status/);
await expect(frontend).toContainText(/Commit ID\s*abcdef012345/);
});
});
10 changes: 8 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ export default defineConfig({
},
],
webServer: {
command: 'pnpm dev --mode mocks',
command: 'pnpm dev --mode mocks --port 3000 --strictPort',
env: {
VITE_BUILD_COMMIT: 'abcdef0123456789abcdef0123456789abcdef01',
VITE_BUILD_BRANCH: 'feat/system-status',
VITE_BUILD_TIME: '2026-08-10T08:00:00Z',
VITE_E2E_TEST: 'true',
},
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
timeout: 120 * 1000,
},
});
36 changes: 36 additions & 0 deletions scripts/ci/resolve-build-branch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root=${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}

current_branch=$(git -C "$repo_root" branch --show-current)
if [ -n "$current_branch" ]; then
printf '%s\n' "$current_branch"
exit 0
fi

exact_tag=$(git -C "$repo_root" describe --tags --exact-match HEAD 2>/dev/null || true)
if [ -n "$exact_tag" ]; then
release_branch="release/${exact_tag#v}"
if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/$release_branch" &&
git -C "$repo_root" merge-base --is-ancestor HEAD "refs/remotes/origin/$release_branch"; then
printf '%s\n' "$release_branch"
exit 0
fi
fi

default_branch=${REPOSITORY_DEFAULT_BRANCH:-}
if [ -z "$default_branch" ]; then
origin_head=$(git -C "$repo_root" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
default_branch=${origin_head#origin/}
fi
if [ -n "$default_branch" ] &&
git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/$default_branch" &&
git -C "$repo_root" merge-base --is-ancestor HEAD "refs/remotes/origin/$default_branch"; then
printf '%s\n' "$default_branch"
exit 0
fi

head_commit=$(git -C "$repo_root" rev-parse HEAD)
echo "Unable to determine build branch for commit $head_commit: neither a matching release branch nor the repository default branch contains it" >&2
exit 1
32 changes: 32 additions & 0 deletions src/base/buildMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface BuildMetadata {
git: {
branch: string;
commitId: string;
};
build: {
version: string;
time: string;
};
}

export const frontendBuildMetadata: BuildMetadata = {
git: {
branch: __BUILD_BRANCH__,
commitId: __BUILD_COMMIT__,
},
build: {
version: __BUILD_VERSION__,
time: __BUILD_TIME__,
},
};

const normalizeVersion = (version: string): string =>
version
.replace(/^v(?=\d)/i, '')
.replace(/-rc\d+(?=\+?$)/i, '');

export const isBuildMetadataMatch = (
frontend: BuildMetadata,
backend: BuildMetadata
): boolean =>
normalizeVersion(frontend.build.version) === normalizeVersion(backend.build.version)
12 changes: 12 additions & 0 deletions src/base/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,23 @@
"instances": "Instances"
}
},
"buildMetadata": {
"ui": "UI",
"zenbpm": "ZenBPM",
"loading": "loading",
"unavailable": "unavailable",
"mismatch": "Build metadata mismatch"
},
"systemStatus": {
"title": "System Status",
"description": "Live overview of the ZenBPM engine cluster — nodes, partitions and configuration.",
"link": "View system status",
"lastUpdated": "Updated at {{time}}",
"buildInformation": "Build Information",
"version": "Version",
"buildTime": "Build Time",
"branch": "Branch",
"commitId": "Commit ID",
"topology": "Cluster Topology",
"clusterConfig": "Cluster Configuration",
"nodes": "Nodes",
Expand Down
24 changes: 24 additions & 0 deletions src/base/theme/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import type { CSSProperties } from 'react';
import { createTheme, alpha } from '@mui/material/styles';

declare module '@mui/material/styles' {
interface TypographyVariants {
captionNormal: CSSProperties;
}

interface TypographyVariantsOptions {
captionNormal?: CSSProperties;
}
}

declare module '@mui/material/Typography' {
interface TypographyPropsVariantOverrides {
captionNormal: true;
}
}

// Design 3 - "Clean White" color palette
const colors = {
// Primary green accent (from 4bpm.eu)
Expand Down Expand Up @@ -270,6 +287,13 @@ export const theme = createTheme({
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
captionNormal: {
fontSize: '0.6875rem',
fontWeight: 600,
color: colors.textMuted,
textTransform: 'none',
letterSpacing: '0.5px',
},
button: {
fontWeight: 500,
fontSize: '0.875rem',
Expand Down
28 changes: 28 additions & 0 deletions src/components/BuildMetadataFooter/BuildMetadataFooter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { frontendBuildMetadata } from '@base/buildMetadata';
import { BuildMetadataFooter } from './BuildMetadataFooter';

const meta: Meta<typeof BuildMetadataFooter> = {
title: 'Components/BuildMetadataFooter',
component: BuildMetadataFooter,
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof BuildMetadataFooter>;

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(['systemStatus', 'buildMetadata'], frontendBuildMetadata);

export const MatchingBuilds: Story = {
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
),
],
};
Loading