web: footer shows the running build (branch and CI build number)

"foundations · build 6", on master only "build 6"; the full sha is the tooltip.
CI passes BRANCH and BUILD_NUMBER (github.run_number) as build args next to
REVISION; the server reads BUILD_* and serves them at /api/build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
homelab-updaterandClaude Opus 5 committed 2026-09-19 13:14:48 +02:00
1 parent de5ee92057
commit da40e50630
14 files changed
+130 -7

No files matched your search

+2 -1
View File
@@ -38,6 +38,7 @@ jobs:
env:
REGISTRY_USER: ${{ github.repository_owner }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
BUILD_NUMBER: ${{ github.run_number }}
run: |
set -euo pipefail
image="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/homelab-upgrade"
@@ -46,7 +47,7 @@ jobs:
[ "${GITHUB_REF_NAME}" = master ] && refs+=("${image}:latest")
printf '%s' "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
tag_args=(); for ref in "${refs[@]}"; do tag_args+=(-t "${ref}"); done
docker build --build-arg "REVISION=${GITHUB_SHA}" "${tag_args[@]}" .
docker build --build-arg "REVISION=${GITHUB_SHA}" --build-arg "BRANCH=${GITHUB_REF_NAME}" --build-arg "BUILD_NUMBER=${BUILD_NUMBER}" "${tag_args[@]}" .
# Push only these refs: the daemon is the VM's, so `--all-tags` would also push the local :rollback tag from update.sh.
for ref in "${refs[@]}"; do docker push "${ref}"; done
docker logout "${REGISTRY}"
+4
View File
@@ -16,7 +16,11 @@ RUN npm run build:web
FROM ${NODE_IMAGE} AS runtime
ARG REVISION=unknown
# Shown in the app's footer: the branch and the CI build number (empty in a local build).
ARG BRANCH=
ARG BUILD_NUMBER=
LABEL org.opencontainers.image.title="homelab-upgrade" org.opencontainers.image.revision="${REVISION}"
ENV BUILD_BRANCH=${BRANCH} BUILD_NUMBER=${BUILD_NUMBER} BUILD_REVISION=${REVISION}
ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080 DB_PATH=/data/queue.sqlite WEB_DIR=/app/dist/web
WORKDIR /app
COPY package.json package-lock.json ./
+8
View File
@@ -46,6 +46,14 @@ export const AppOverviewSchema = z.object({
last: StepSchema.nullable(),
});
/** Which build the server runs: branch, CI build number and commit. Null where unknown (a local run). */
export const BuildResponse = z.object({
branch: z.string().nullable(),
number: z.number().int().positive().nullable(),
revision: z.string().nullable(),
});
export type BuildInfo = z.infer<typeof BuildResponse>;
export const RunsResponse = z.array(RunSummarySchema);
export const RunDetailResponse = z.object({ run: RunSummarySchema, steps: z.array(StepSchema) });
export const StepsResponse = z.array(StepSchema);
+13 -1
View File
@@ -14,7 +14,7 @@ import { RecordingNotifier } from '../testing/fakes.ts';
import { createTestDatabase } from '../testing/test-db.ts';
import { createChangeHub } from './change-hub.ts';
import type { ChangeHub } from './change-hub.ts';
import { AppsResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from './contract.ts';
import { AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from './contract.ts';
import { buildServer } from './server.ts';
import type { ServerDeps } from './server.ts';
@@ -50,6 +50,18 @@ async function json<T extends z.ZodType>(app: FastifyInstance, schema: T, reques
const resolve = (id: number | string, body: unknown) => ({ method: 'POST' as const, url: `/api/steps/${String(id)}/resolve`, payload: body as object });
const decision = (action: string, extra: object = {}) => ({ action, resolution: 'ok by owner', ...extra });
describe('build', () => {
it('reports the build the server runs (branch, CI build number, revision)', async () => {
const { app } = await fixture({ build: { branch: 'foundations', number: 6, revision: 'de5ee92' } });
expect(await json(app, BuildResponse, '/api/build')).toEqual({ branch: 'foundations', number: 6, revision: 'de5ee92' });
});
it('without build facts every field is null', async () => {
const { app } = await fixture();
expect(await json(app, BuildResponse, '/api/build')).toEqual({ branch: null, number: null, revision: null });
});
});
describe('runs', () => {
it('starts empty', async () => {
const { app } = await fixture();
+7
View File
@@ -11,6 +11,7 @@ import { StepStatus } from '../domain/steps.ts';
import { watchChanges } from './change-hub.ts';
import type { ChangeHub } from './change-hub.ts';
import { EVENTS_PATH, ResolutionRequest } from './contract.ts';
import type { BuildInfo } from './contract.ts';
export interface ServerDeps {
readonly store: StepStore;
@@ -22,8 +23,12 @@ export interface ServerDeps {
/** How often to look for changes made by the driver process; 0 disables (tests). */
readonly pollChangesMs?: number;
readonly logger?: boolean;
/** Shown in the footer; unknown by default. */
readonly build?: BuildInfo;
}
const UNKNOWN_BUILD: BuildInfo = { branch: null, number: null, revision: null };
const DEFAULT_POLL_MS = 1000;
const DEFAULT_RUNS = 50;
const MAX_LIMIT = 500;
@@ -87,6 +92,8 @@ export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
app.get('/api/health', (_request, reply) => reply.send({ ok: true }));
app.get('/api/build', (_request, reply) => reply.send(deps.build ?? UNKNOWN_BUILD));
app.get('/api/runs', (request, reply) => {
const query = RunsQuery.safeParse(request.query);
if (!query.success) return fail(reply, 400, 'invalid_request', issues(query.error));
+8 -2
View File
@@ -11,12 +11,13 @@ describe('loadConfig', () => {
driverIdleMs: 60_000,
demo: false,
appsFile: 'config/apps.json',
build: { branch: null, number: null, revision: null },
});
});
it('reads overrides from the environment', () => {
expect(
loadConfig({ DB_PATH: '/data/q.sqlite', HOST: '0.0.0.0', PORT: '9000', WEB_DIR: '/app/web', DRIVER_IDLE_MS: '1500', DEMO: '1', APPS_FILE: '/etc/apps.json' }),
loadConfig({ DB_PATH: '/data/q.sqlite', HOST: '0.0.0.0', PORT: '9000', WEB_DIR: '/app/web', DRIVER_IDLE_MS: '1500', DEMO: '1', APPS_FILE: '/etc/apps.json', BUILD_BRANCH: 'foundations', BUILD_NUMBER: '6', BUILD_REVISION: 'de5ee92' }),
).toEqual({
dbPath: '/data/q.sqlite',
host: '0.0.0.0',
@@ -25,14 +26,19 @@ describe('loadConfig', () => {
driverIdleMs: 1500,
demo: true,
appsFile: '/etc/apps.json',
build: { branch: 'foundations', number: 6, revision: 'de5ee92' },
});
});
it.each([{ PORT: '0' }, { PORT: 'abc' }, { PORT: '70000' }, { DRIVER_IDLE_MS: '5' }, { DEMO: 'yes' }])('rejects %j with a readable message', (env) => {
it.each([{ PORT: '0' }, { PORT: 'abc' }, { PORT: '70000' }, { DRIVER_IDLE_MS: '5' }, { DEMO: 'yes' }, { BUILD_NUMBER: 'six' }, { BUILD_NUMBER: '0' }])('rejects %j with a readable message', (env) => {
expect(() => loadConfig(env)).toThrow(/invalid configuration/);
});
});
it('treats empty build variables (a local docker build without build args) as unknown', () => {
expect(loadConfig({ BUILD_BRANCH: '', BUILD_NUMBER: '', BUILD_REVISION: '' }).build).toEqual({ branch: null, number: null, revision: null });
});
describe('secretValues', () => {
it('collects values of secret-looking variables so they can be masked literally', () => {
const env = { HA_TOKEN: 'tok-123456', DB_PASSWORD: 'pw-abcdef', PATH: '/usr/bin', API_KEY: '', NOTES: 'hello world', CLIENT_SECRET: 's3cr3t!' };
+8
View File
@@ -10,8 +10,14 @@ const Env = z.object({
DEMO: z.enum(['0', '1']).default('0'),
/** The apps to load into app_config when it is empty (real mode). */
APPS_FILE: z.string().min(1).default('config/apps.json'),
/** Build facts baked into the image by CI (Dockerfile build args); empty or missing = unknown. */
BUILD_BRANCH: z.string().optional(),
BUILD_NUMBER: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(),
BUILD_REVISION: z.string().optional(),
});
const orNull = <T>(value: T | '' | undefined): T | null => (value === undefined || value === '' ? null : value);
export interface Config {
readonly dbPath: string;
readonly host: string;
@@ -20,6 +26,7 @@ export interface Config {
readonly driverIdleMs: number;
readonly demo: boolean;
readonly appsFile: string;
readonly build: { readonly branch: string | null; readonly number: number | null; readonly revision: string | null };
}
export function loadConfig(env: Readonly<Record<string, string | undefined>>): Config {
@@ -37,6 +44,7 @@ export function loadConfig(env: Readonly<Record<string, string | undefined>>): C
driverIdleMs: values.DRIVER_IDLE_MS,
demo: values.DEMO === '1',
appsFile: values.APPS_FILE,
build: { branch: orNull(values.BUILD_BRANCH), number: orNull(values.BUILD_NUMBER), revision: orNull(values.BUILD_REVISION) },
};
}
+1
View File
@@ -26,6 +26,7 @@ const app = await buildServer({
clock: systemClock,
hub: createChangeHub(),
staticDir: config.webDir,
build: config.build,
logger: true,
});
+2 -1
View File
@@ -1,5 +1,5 @@
import type { z } from 'zod';
import { AppsResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from '../http/contract.ts';
import { AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from '../http/contract.ts';
import type { ResolutionRequest } from '../http/contract.ts';
export class ApiError extends Error {
@@ -29,6 +29,7 @@ export const api = {
runs: () => call(RunsResponse, '/api/runs?limit=50'),
run: (roundId: string) => call(RunDetailResponse, `/api/runs/${encodeURIComponent(roundId)}`),
apps: () => call(AppsResponse, '/api/apps'),
build: () => call(BuildResponse, '/api/build'),
appSteps: (app: string) => call(StepsResponse, `/api/steps?app=${encodeURIComponent(app)}&limit=100`),
queue: () => call(StepsResponse, '/api/queue'),
startRun: () => call(StartRunResponse, '/api/runs', { type: 'manual' }),
+12 -1
View File
@@ -49,7 +49,7 @@ beforeEach(async () => {
seedDemoConfig(appConfig);
const clock = new FakeClock();
hub = createChangeHub();
server = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0 });
server = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } });
const driver = createDriver({ store, clock, notifier: new RecordingNotifier(), handlers: createDemoHandlers({ appConfig, world: createDemoWorld() }) });
startManualRun(store, clock);
await driver.drain(new AbortController().signal);
@@ -111,6 +111,17 @@ async function choose(select: HTMLSelectElement, value: string) {
await nextTask();
}
describe('footer', () => {
it('shows which build is running, with the full revision on hover', async () => {
const footer = await eventually(() => {
const found = container.querySelector('footer');
expect(found?.textContent).toBe('foundations · build 6');
return found!;
});
expect(footer.querySelector('[title]')?.getAttribute('title')).toBe('de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901');
});
});
describe('decision queue', () => {
it('shows what is waiting for the owner, with reason and detail, and a count in the navigation', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
+26
View File
@@ -5,6 +5,8 @@ import { ErrorNote } from './components.tsx';
import { LiveContext, useResource } from './hooks.ts';
import { useLive } from './live.ts';
import type { LiveStatus } from './live.ts';
import { formatBuild } from './format.ts';
import type { BuildInfo } from '../http/contract.ts';
import { hrefFor, parseRoute } from './route.ts';
import type { Route } from './route.ts';
import { AppDetailView, AppsView } from './views/apps.tsx';
@@ -100,6 +102,29 @@ function View({ route }: { route: Route }): ComponentChildren {
}
}
/** Which build is running. Loaded once: the server's build does not change while the page is open. */
function Footer() {
const [build, setBuild] = useState<BuildInfo | null>(null);
useEffect(() => {
let current = true;
api.build().then(
(data) => {
if (current) setBuild(data);
},
() => undefined, // the footer is a nicety: no build facts, no footer
);
return () => {
current = false;
};
}, []);
if (build === null) return null;
return (
<footer>
<span title={build.revision ?? undefined}>{formatBuild(build)}</span>
</footer>
);
}
export function App() {
const route = useRoute();
const { revision, status, refresh } = useLive();
@@ -109,6 +134,7 @@ export function App() {
<main>
<View route={route} />
</main>
<Footer />
</LiveContext.Provider>
);
}
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatCost, formatDuration, formatInstant, humanizeCode } from './format.ts';
import { formatBuild, formatCost, formatDuration, formatInstant, humanizeCode } from './format.ts';
describe('formatInstant', () => {
it('shows date and time in the given zone', () => {
@@ -46,3 +46,25 @@ describe('humanizeCode', () => {
expect(humanizeCode('ok')).toBe('ok');
});
});
describe('formatBuild', () => {
const REVISION = 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901';
it('master shows only the build number', () => {
expect(formatBuild({ branch: 'master', number: 12, revision: REVISION })).toBe('build 12');
});
it('another branch shows its name and the build number', () => {
expect(formatBuild({ branch: 'foundations', number: 6, revision: REVISION })).toBe('foundations · build 6');
});
it('without a build number it falls back to the short revision', () => {
expect(formatBuild({ branch: 'foundations', number: null, revision: REVISION })).toBe('foundations · de5ee92');
expect(formatBuild({ branch: 'master', number: null, revision: REVISION })).toBe('de5ee92');
});
it('a local run without build facts says so', () => {
expect(formatBuild({ branch: null, number: null, revision: null })).toBe('dev build');
expect(formatBuild({ branch: 'foundations', number: null, revision: null })).toBe('foundations · dev build');
});
});
+8
View File
@@ -1,3 +1,5 @@
import type { BuildInfo } from '../http/contract.ts';
const DASH = '—';
/** `2026-09-18 23:43:12` in the browser's time zone (or `timeZone` when given, for tests). */
@@ -30,3 +32,9 @@ export function formatCost(usd: number): string {
export function humanizeCode(code: string): string {
return code.replaceAll('_', ' ');
}
/** The footer's build label: `build 12` on master, `foundations · build 6` elsewhere; the short commit when there is no number. */
export function formatBuild(build: BuildInfo): string {
const id = build.number !== null ? `build ${String(build.number)}` : build.revision !== null ? build.revision.slice(0, 7) : 'dev build';
return build.branch === null || build.branch === 'master' ? id : `${build.branch} · ${id}`;
}
+8
View File
@@ -64,6 +64,14 @@ main {
padding: 16px;
}
footer {
max-width: 1100px;
margin: 0 auto;
padding: 8px 16px 24px;
color: var(--muted);
font-size: 0.8rem;
}
h1 {
margin: 8px 0 4px;
font-size: 1.4rem;