Align release header audit with publish contract

This commit is contained in:
rcourtman
2026-04-11 18:25:53 +01:00
parent a48fb63cb3
commit 47b6d0fb1c
19 changed files with 203 additions and 27 deletions
+3
View File
@@ -168,6 +168,9 @@ jobs:
- name: Lint frontend
run: npm --prefix frontend-modern run lint
- name: Audit header composition
run: npm --prefix frontend-modern run lint:headers
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
@@ -241,6 +241,15 @@ ledger reads until that policy resolves and suppress them while commercial
surfaces are hidden. Public demo mode therefore renders a redacted
presentation-policy state instead of creating a fake entitlement, probing
hidden commercial endpoints, or showing monitored-system usage pressure.
That same commercial/public browser boundary also owns pricing-handoff
framing. `frontend-modern/src/pages/PricingHandoff.tsx` may keep the
operator-visible handoff on `/pricing`, but it must render the shared
`PageHeader` shell while `frontend-modern/src/utils/pricingHandoff.ts`
continues to own destination resolution, self-hosted Pulse Account handoff,
and public-pricing fallback truth. The route must not fork a raw top-level
heading, duplicate destination logic in the page shell, or let commercial
handoff framing drift away from the same shared browser chrome used by the
rest of the product.
The governed browser proof for that posture lives in
`tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts` and is
expected to stay runnable through
@@ -186,6 +186,11 @@ state. When `.github/workflows/create-release.yml` runs in `draft_only` mode,
it must pass the real draft state into `.github/workflows/validate-release-assets.yml`
so validation blocks or annotates the draft release as a draft, rather than
misclassifying the run as post-publish revalidation.
That same frontend-release boundary also owns shared header-composition proof.
`.github/workflows/release-dry-run.yml` and `.github/workflows/create-release.yml`
must both run the same `lint:headers` audit so a branch that would be rejected
by the real publish workflow cannot pass the governed dry run only because the
rehearsal skipped that header-composition gate.
That same governed demo-deployment boundary now owns target separation between
the public stable demo and the opt-in v6 preview demo. `.github/workflows/create-release.yml`,
`.github/workflows/update-demo-server.yml`, and `.github/workflows/deploy-demo-server.yml`
@@ -373,6 +373,17 @@ connections` visible as the API-backed alternative for Proxmox and
such as `truenas_checked` instead of letting feature-local fixtures or
fallback objects collapse API-backed TrueNAS systems back into generic
agent-host presentation.
That same shared route-shell boundary also owns header-composition audit.
`frontend-modern/scripts/header-audit.mjs`,
`.github/workflows/release-dry-run.yml`, and
`.github/workflows/create-release.yml` must prove the same shared
top-level page-header contract before publication. The audit may follow
local imports when a route shell composes `PageHeader` through a nested
surface, and settings coverage must stay limited to top-level registry
panels rather than every helper `*Panel.tsx` file. Route shells such as
`frontend-modern/src/features/operations/OperationsPageSurface.tsx` must
therefore keep the shared `PageHeader` above owned subtabs instead of
drifting back to page-local `<h1>` framing.
23. Keep the authenticated app root aligned with that same first-session path.
That same shared-primitive ownership now includes contextual row focus.
`frontend-modern/src/components/shared/contextualFocus.ts` is the canonical
@@ -236,6 +236,14 @@ regression protection.
must not hydrate `all-resources` or recovery rollups behind a hidden node
summary; selector-owned data hooks must be explicitly visibility-gated so
`/workloads` only pays for workload-owned transports.
34. Keep dashboard page-header framing additive on the compact hot path.
`frontend-modern/src/pages/Dashboard.tsx` may render the shared
`PageHeader` for route-level shell consistency, but that header must stay
pure presentation on top of the existing compact overview, trends,
actions, and recovery/storage widget hydration. It must not introduce a
second dashboard data load, widen suspense ownership, or force dashboard
summaries back through full-resource fetch paths just to satisfy page
chrome.
## Forbidden Paths
@@ -292,6 +292,13 @@ querying, and the operator-facing storage health presentation layer.
`/api/storage-charts` payload once per additional dashboard resource page
or invent a dashboard-only storage summary transport path outside the
canonical cache owners.
38. Keep storage and recovery route framing additive and owner-neutral.
`frontend-modern/src/components/Storage/Storage.tsx` and storage/recovery-
adjacent dashboard composition may use the shared `PageHeader` shell for
top-level route framing, but that header must stay additive on top of the
canonical storage page model, recovery presenters, and shared summary
caches. Header chrome must not become a second owner for storage filters,
recovery posture, commercial purchase state, or transport selection.
## Forbidden Paths
@@ -337,6 +337,13 @@ assembly branch.
compact `/api/charts/storage-summary` contract instead of rebuilding
page-local per-resource storage history fetches, storage-type aliases, or
full storage-page `/api/storage-charts` fetches.
19. Keep infrastructure page-header framing presentation-only on the page
shell. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
may render the shared `PageHeader`, but canonical source/status/search
state, summary scope, and row selection must remain on
`frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
and the unified-resource selectors it composes. The header must not become
a second state owner, scope banner, or provider-local filter surface.
## Current State
+90 -16
View File
@@ -11,8 +11,7 @@ const REQUIRED_PAGE_HEADERS = new Map([
['src/pages/Infrastructure.tsx', 'PageHeader'],
['src/pages/Operations.tsx', 'PageHeader'],
['src/pages/NotFound.tsx', 'PageHeader'],
['src/pages/Pricing.tsx', 'PageHeader'],
['src/pages/MigrationGuide.tsx', 'PageHeader'],
['src/pages/PricingHandoff.tsx', 'PageHeader'],
['src/components/Settings/Settings.tsx', 'PageHeader'],
]);
@@ -41,6 +40,75 @@ function hasPrimitive(content, primitive) {
return new RegExp(`<${primitive}\\b`).test(content);
}
function resolveImport(specifier, fromFile) {
let basePath = null;
if (specifier.startsWith('@/')) {
basePath = path.join(ROOT, 'src', specifier.slice(2));
} else if (specifier.startsWith('.')) {
basePath = path.resolve(path.dirname(path.join(ROOT, fromFile)), specifier);
}
if (!basePath) {
return null;
}
const candidates = [
basePath,
`${basePath}.tsx`,
`${basePath}.ts`,
path.join(basePath, 'index.tsx'),
path.join(basePath, 'index.ts'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return path.relative(ROOT, candidate);
}
}
return null;
}
function getImportedLocalFiles(relPath) {
const content = readFileSafe(relPath);
if (!content) {
return [];
}
const imports = new Set();
const importPattern = /from\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g;
let match;
while ((match = importPattern.exec(content)) !== null) {
const specifier = match[1] ?? match[2];
const resolved = resolveImport(specifier, relPath);
if (resolved) {
imports.add(resolved);
}
}
return Array.from(imports);
}
function collectPrimitiveUsage(relPath, visited = new Set()) {
if (visited.has(relPath)) {
return new Set();
}
visited.add(relPath);
const content = readFileSafe(relPath);
const primitives = new Set(
HEADER_PRIMITIVES.filter((primitive) => hasPrimitive(content, primitive)),
);
for (const importedFile of getImportedLocalFiles(relPath)) {
for (const primitive of collectPrimitiveUsage(importedFile, visited)) {
primitives.add(primitive);
}
}
return primitives;
}
function listTopLevelPages() {
const dir = path.join(ROOT, 'src/pages');
return fs
@@ -50,13 +118,20 @@ function listTopLevelPages() {
.sort();
}
function listSettingsPanels() {
const dir = path.join(ROOT, 'src/components/Settings');
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('Panel.tsx'))
.map((entry) => `src/components/Settings/${entry.name}`)
.sort();
function listTopLevelSettingsPanels() {
const registryFile = 'src/components/Settings/settingsPanelRegistryLoaders.ts';
const content = readFileSafe(registryFile);
const panels = new Set();
const importPattern = /import\('\.\/([^']+)'\)/g;
let match;
while ((match = importPattern.exec(content)) !== null) {
const moduleName = match[1];
if (!moduleName.endsWith('Panel')) {
continue;
}
panels.add(`src/components/Settings/${moduleName}.tsx`);
}
return Array.from(panels).sort();
}
const failures = [];
@@ -67,7 +142,8 @@ for (const [file, requiredPrimitive] of REQUIRED_PAGE_HEADERS.entries()) {
failures.push(`${file}: missing file`);
continue;
}
if (!hasPrimitive(content, requiredPrimitive)) {
const primitives = collectPrimitiveUsage(file);
if (!primitives.has(requiredPrimitive)) {
failures.push(`${file}: must use <${requiredPrimitive}>`);
}
}
@@ -86,7 +162,7 @@ for (const [file, requiredPrimitive] of REQUIRED_OPERATIONS_WRAPPERS.entries())
const pageInventory = [];
for (const pageFile of listTopLevelPages()) {
const content = readFileSafe(pageFile);
const primitives = HEADER_PRIMITIVES.filter((primitive) => hasPrimitive(content, primitive));
const primitives = Array.from(collectPrimitiveUsage(pageFile));
const hasRawH1 = /<h1\b/.test(content);
pageInventory.push({
@@ -108,13 +184,11 @@ for (const pageFile of listTopLevelPages()) {
}
}
for (const panelFile of listSettingsPanels()) {
if (panelFile === 'src/components/Settings/OperationsPanel.tsx') continue;
for (const panelFile of listTopLevelSettingsPanels()) {
if (SETTINGS_PANEL_SHIMS.has(panelFile)) continue;
const content = readFileSafe(panelFile);
const hasSharedWrapper =
hasPrimitive(content, 'SettingsPanel') || hasPrimitive(content, 'OperationsPanel');
const primitives = collectPrimitiveUsage(panelFile);
const hasSharedWrapper = primitives.has('SettingsPanel') || primitives.has('OperationsPanel');
if (!hasSharedWrapper) {
failures.push(`${panelFile}: must use <SettingsPanel> or <OperationsPanel>`);
@@ -4,6 +4,7 @@ import StorageContentCard from '@/components/Storage/StorageContentCard';
import StoragePageBanners from '@/components/Storage/StoragePageBanners';
import StoragePageControls from '@/components/Storage/StoragePageControls';
import StoragePageSummary from '@/components/Storage/StoragePageSummary';
import { PageHeader } from '@/components/shared/PageHeader';
import { StickySummarySection } from '@/components/shared/StickySummarySection';
import { isStorageRecordCeph } from './storagePageState';
import { useStoragePageModel } from './useStoragePageModel';
@@ -73,6 +74,11 @@ const Storage: Component = () => {
class="space-y-4"
data-testid="storage-page"
>
<PageHeader
title="Storage"
description="Track capacity, cluster health, and storage alerts across local and distributed systems."
/>
<StickySummarySection desktopOnly={false}>
<StoragePageSummary
filteredRecordCount={() => filteredRecords().length}
@@ -4,6 +4,7 @@ import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastr
import { EmptyState } from '@/components/shared/EmptyState';
import { Card } from '@/components/shared/Card';
import { FilterSegmentedControl, LabeledFilterSelect } from '@/components/shared/FilterToolbar';
import { PageHeader } from '@/components/shared/PageHeader';
import { PageControls } from '@/components/shared/PageControls';
import { SearchInput } from '@/components/shared/SearchInput';
import { StickySummarySection } from '@/components/shared/StickySummarySection';
@@ -86,6 +87,11 @@ export function InfrastructurePageSurface() {
data-testid="infrastructure-page"
class="space-y-4"
>
<PageHeader
title="Infrastructure"
description="Inspect discovered systems, cluster health, and resource status across the monitored estate."
/>
<Show
when={!loading() || initialLoadComplete()}
fallback={
@@ -11,6 +11,9 @@ describe('InfrastructurePageSurface guardrails', () => {
it('keeps the feature shell separate from route-sync and page-model ownership', () => {
expect(infrastructurePageSurfaceSource).toContain('useInfrastructurePageState');
expect(infrastructurePageSurfaceSource).toContain('useNavigate');
expect(infrastructurePageSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';");
expect(infrastructurePageSurfaceSource).toContain('<PageHeader');
expect(infrastructurePageSurfaceSource).toContain('title="Infrastructure"');
expect(infrastructurePageSurfaceSource).not.toContain('useLocation(');
expect(infrastructurePageSurfaceSource).not.toContain('buildInfrastructurePath(');
@@ -6,6 +6,7 @@ import TerminalIcon from 'lucide-solid/icons/terminal';
import { DiagnosticsPanel } from '@/components/Settings/DiagnosticsPanel';
import { ReportingPanel } from '@/components/Settings/ReportingPanel';
import { SystemLogsPanel } from '@/components/Settings/SystemLogsPanel';
import { PageHeader } from '@/components/shared/PageHeader';
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
import { DASHBOARD_PATH } from '@/routing/resourceLinks';
import { presentationPolicyIsDemoMode } from '@/stores/sessionPresentationPolicy';
@@ -62,6 +63,11 @@ export function OperationsPageSurface() {
return (
<Show when={!hiddenInDemoMode()}>
<div class="space-y-6">
<PageHeader
title="Operations"
description="Run diagnostics, review generated reports, and inspect system logs without leaving the app."
/>
<div class="mb-6">
<Subtabs
value={activeTab()}
+6
View File
@@ -34,6 +34,7 @@ import { RecentAlertsPanel } from '@/components/Alerts/RecentAlertsPanel';
import { RelayOnboardingCard } from '@/components/Dashboard/RelayOnboardingCard';
import { DashboardRecoveryStatusPanel } from '@/components/Recovery/DashboardRecoveryStatusPanel';
import { DashboardStoragePanel } from '@/components/Storage/DashboardStoragePanel';
import { PageHeader } from '@/components/shared/PageHeader';
import type { DashboardWidgetDef, DashboardWidgetId } from '@/features/dashboardOverview/dashboardWidgets';
export default function Dashboard() {
const navigate = useNavigate();
@@ -169,6 +170,11 @@ export default function Dashboard() {
return (
<main data-testid="dashboard-page" class="space-y-6">
<PageHeader
title="Dashboard"
description="Monitor fleet health, recent alerts, storage pressure, and recovery readiness from one surface."
/>
{/* Connection warning banner — shown above all content, NOT a full-page takeover */}
<Show when={hasConnectionError() && initialLoadComplete()}>
<div
+17 -11
View File
@@ -1,5 +1,6 @@
import { Navigate, useLocation } from '@solidjs/router';
import { Show, createMemo, onMount } from 'solid-js';
import { PageHeader } from '@/components/shared/PageHeader';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getPricingRouteDestination,
@@ -36,17 +37,22 @@ export default function PricingHandoff() {
fallback={<Navigate href={destination()} />}
>
<div class="flex min-h-[50vh] items-center justify-center">
<div class="space-y-2 text-center">
<h1 class="text-lg font-semibold text-base-content">
Redirecting to {handoffLabel()}
</h1>
<p class="text-sm text-muted">
If the handoff does not start automatically,{' '}
<a href={destination()} class="text-blue-600 hover:underline dark:text-blue-400">
{handoffLinkLabel()}
</a>
.
</p>
<div class="max-w-xl space-y-2 text-center">
<PageHeader
title={`Redirecting to ${handoffLabel()}`}
description={
<>
If the handoff does not start automatically,{' '}
<a href={destination()} class="text-blue-600 hover:underline dark:text-blue-400">
{handoffLinkLabel()}
</a>
.
</>
}
class="items-center text-center"
titleClass="text-lg"
descriptionClass="text-sm"
/>
</div>
</div>
</Show>
@@ -146,6 +146,9 @@ describe('Dashboard page module contract', () => {
it('routes dashboard overview panels through the dashboard overview feature owner', () => {
expect(dashboardPageSource).toContain("from '@/features/dashboardOverview'");
expect(dashboardPageSource).toContain("from '@/components/Dashboard/RelayOnboardingCard'");
expect(dashboardPageSource).toContain("from '@/components/shared/PageHeader'");
expect(dashboardPageSource).toContain('<PageHeader');
expect(dashboardPageSource).toContain('title="Dashboard"');
expect(dashboardPageSource).toContain('<RelayOnboardingCard />');
expect(dashboardPageSource).toContain(
'ActionRequiredPanel,\n DashboardCustomizer,\n KPIStrip,\n ProblemResourcesTable,\n TrendCharts,',
@@ -178,6 +181,7 @@ describe('Dashboard page module contract', () => {
it('routes the empty dashboard state to infrastructure install', () => {
render(() => <DashboardPage />);
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'No resources yet' })).toBeInTheDocument();
expect(screen.queryByTestId('relay-onboarding-card')).toBeNull();
expect(
@@ -15,6 +15,9 @@ describe('operations page route shell', () => {
expect(operationsPageRouteSource).not.toContain('useNavigate');
expect(operationsPageRouteSource).not.toContain('createSignal');
expect(operationsPageSurfaceSource).toContain('@/components/shared/Subtabs');
expect(operationsPageSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';");
expect(operationsPageSurfaceSource).toContain('<PageHeader');
expect(operationsPageSurfaceSource).toContain('title="Operations"');
expect(operationsPageSurfaceSource).toContain('getOperationsTabFromPath');
expect(operationsPageSurfaceSource).toContain('buildOperationsPath');
expect(operationsPageSurfaceSource).toContain('operationsSurfaceHiddenInDemoMode');
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import PricingHandoff from '@/pages/PricingHandoff';
import pricingHandoffSource from '@/pages/PricingHandoff.tsx?raw';
import { getSelfHostedPurchaseStartUrl } from '@/utils/pricingHandoff';
const trackPaywallViewedMock = vi.fn();
@@ -59,6 +60,12 @@ describe('PricingHandoff', () => {
);
});
it('keeps the pricing handoff on the shared page-header shell', () => {
expect(pricingHandoffSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';");
expect(pricingHandoffSource).toContain('<PageHeader');
expect(pricingHandoffSource).not.toContain('<h1');
});
it('keeps monitored-system pricing handoffs inside the product', async () => {
window.history.replaceState({}, '', '/pricing?feature=max_monitored_systems');
@@ -14,6 +14,9 @@ describe('storage page route shell', () => {
);
expect(storagePageRouteSource).toContain('<StorageSurface />');
expect(storagePageRouteSource).not.toContain('useStoragePageModel');
expect(storageSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';");
expect(storageSurfaceSource).toContain('<PageHeader');
expect(storageSurfaceSource).toContain('title="Storage"');
expect(storageSurfaceSource).toContain('useStoragePageModel');
});
});
@@ -211,6 +211,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn('--ref "$CURRENT_BRANCH"', helper)
self.assertIn("Release automation executes the selected remote ref", helper)
self.assertNotIn("Continue anyway?", helper)
self.assertIn("Audit header composition", content)
self.assertIn("run: npm --prefix frontend-modern run lint:headers", content)
self.assertIn("pushed governed release-branch copy of `.github/workflows/release-dry-run.yml`", policy)
self.assertIn("GitHub executes the selected remote ref", normalize_ws(policy))
checklist = read("docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md")