diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f5bd3fda5..4bead5286 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -9,6 +9,10 @@ on: description: 'Version number (e.g., 4.30.0)' required: true type: string + expected_source_sha: + description: 'Exact 40-character commit SHA admitted for this release' + required: true + type: string release_notes: description: 'Release notes (markdown)' required: true @@ -108,6 +112,22 @@ jobs: visual_capture_count: ${{ steps.visual_plan.outputs.capture_count }} visual_comparison_tag: ${{ steps.visual_plan.outputs.comparison_tag }} steps: + - name: Verify admitted source commit + env: + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + run: | + set -euo pipefail + if [[ ! "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::expected_source_sha must be an exact 40-character commit SHA" + exit 1 + fi + if [[ "${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}" || \ + "${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}" ]]; then + echo "::error::Release dispatch expected ${EXPECTED_SOURCE_SHA}, but GitHub resolved source ${GITHUB_SHA} and workflow ${GITHUB_WORKFLOW_SHA}." + exit 1 + fi + echo "[OK] Release dispatch is bound to ${EXPECTED_SOURCE_SHA}" + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index b2612d18a..f3889eaa7 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -2454,13 +2454,17 @@ func TestRun_DockerRetry(t *testing.T) { origDocker := newDockerAgent defer func() { newDockerAgent = origDocker }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + retrySucceeded := make(chan struct{}) + // First call fails, second succeeds - calls := 0 + var calls atomic.Int32 newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { - calls++ - if calls == 1 { + if calls.Add(1) == 1 { return nil, errors.New("not available yet") } + close(retrySucceeded) return &mockRunnableCloser{mockRunnable: mockRunnable{started: make(chan struct{})}}, nil } @@ -2469,8 +2473,6 @@ func TestRun_DockerRetry(t *testing.T) { retryInitialDelay = 1 * time.Millisecond defer func() { retryInitialDelay = origInitial }() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() @@ -2479,17 +2481,26 @@ func TestRun_DockerRetry(t *testing.T) { errCh <- run(ctx, []string{"-token", "T", "-url", server.URL, "-enable-docker=true", "-enable-host=false"}, func(s string) string { return "" }) }() + select { + case <-retrySucceeded: + cancel() + case err := <-errCh: + t.Fatalf("run returned before Docker retry succeeded: %v", err) + case <-ctx.Done(): + t.Fatalf("Docker retry did not succeed: %v", ctx.Err()) + } + select { case err := <-errCh: if err != nil { t.Errorf("expected nil error, got %v", err) } case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for run") + t.Fatal("timeout waiting for run to stop") } - if calls < 2 { - t.Errorf("expected at least 2 calls to newDockerAgent, got %d", calls) + if got := calls.Load(); got != 2 { + t.Errorf("newDockerAgent calls = %d, want 2", got) } } @@ -2547,13 +2558,17 @@ func TestRun_KubeRetry(t *testing.T) { origKube := newKubeAgent defer func() { newKubeAgent = origKube }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + retrySucceeded := make(chan struct{}) + // First call fails, second succeeds - calls := 0 + var calls atomic.Int32 newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { - calls++ - if calls == 1 { + if calls.Add(1) == 1 { return nil, errors.New("not available yet") } + close(retrySucceeded) return &mockRunnable{started: make(chan struct{})}, nil } @@ -2562,8 +2577,6 @@ func TestRun_KubeRetry(t *testing.T) { retryInitialDelay = 1 * time.Millisecond defer func() { retryInitialDelay = origInitial }() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() @@ -2573,17 +2586,26 @@ func TestRun_KubeRetry(t *testing.T) { errCh <- run(ctx, []string{"-token", "T", "-url", server.URL, "-enable-kubernetes=true", "-enable-host=false", "-enable-docker=false"}, func(s string) string { return "" }) }() + select { + case <-retrySucceeded: + cancel() + case err := <-errCh: + t.Fatalf("run returned before Kubernetes retry succeeded: %v", err) + case <-ctx.Done(): + t.Fatalf("Kubernetes retry did not succeed: %v", ctx.Err()) + } + select { case err := <-errCh: if err != nil { t.Errorf("expected nil error, got %v", err) } case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for run") + t.Fatal("timeout waiting for run to stop") } - if calls < 2 { - t.Errorf("expected at least 2 calls to newKubeAgent, got %d", calls) + if got := calls.Load(); got != 2 { + t.Errorf("newKubeAgent calls = %d, want 2", got) } } diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 91e7124ad..9d11c65fb 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2138,6 +2138,13 @@ artifact-selection behaviour. trigger, promotion resolver, rendered release body, current upgrade guide, or current release packet that routes systemd/LXC rollback through the Unified Agent installer, and must retain explicit Docker image guidance. +17. Bind every publishing release dispatch to the exact commit admitted by the + caller. `.github/workflows/create-release.yml` must require a full + 40-character `expected_source_sha` and, before checkout, reject the run + unless both `GITHUB_SHA` and `GITHUB_WORKFLOW_SHA` equal that commit. + `scripts/trigger-release.sh` and `scripts/trigger-stable-patch.sh` must send + the exact remote candidate SHA they already verified; branch ancestry or a + later branch tip is not equivalent release admission. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8988419bd..a7ded1465 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -41,6 +41,10 @@ overlay's accessible heading. A reusable panel may suppress its standalone title when the owning overlay supplies the canonical title, while preserving that title in inline and desktop contexts; the overlay remains responsible for one visible heading, its accessible label, dismissal, and focus return. +The shared `Dialog` component requires exactly one accessible-name strategy at +its component boundary: consumers provide either `ariaLabelledBy` for a visible +heading or `ariaLabel` when no visible label is available. Unnamed dialogs and +consumers that provide both strategies must fail the frontend type boundary. The alert schedule's initial-delivery selector composes `SettingsPanel` and `FormSelect`, uses the shared alert-configuration presentation vocabulary, and exposes the same email, webhook, Apprise, and all-destination labels used by diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 0ab28a8fd..2d7ac6f24 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,42 +1,19 @@ { "version": 1, - "base_sha": "2f8a4ec629b75c7ecefd38a9abc4b1511bc9a891", - "verified_at": "2026-09-01T19:54:19Z", + "base_sha": "9fba43ffed507f092f876acaf23bef013f0f6fab", + "verified_at": "2026-09-02T03:39:02Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/App.tsx", - "frontend-modern/src/AppLayout.tsx", - "frontend-modern/src/components/shared/MobileNavBar.tsx", - "frontend-modern/src/components/shared/mobileNavBarModel.ts", - "frontend-modern/src/features/home/HomePageSurface.tsx", - "frontend-modern/src/features/home/homePageModel.ts", - "frontend-modern/src/i18n/messages.de.ts", - "frontend-modern/src/i18n/messages.es.ts", - "frontend-modern/src/i18n/messages.ts", - "frontend-modern/src/routing/navigation.ts", - "frontend-modern/src/routing/resourceLinks.ts", - "frontend-modern/src/routing/routePreload.ts", - "frontend-modern/src/utils/assistantPageContext.ts" - ], + "changed_paths": ["frontend-modern/src/components/shared/Dialog.tsx"], "content_sha256": { - "frontend-modern/src/App.tsx": "d5473f7838148edeaaf5564f01c166d97b6086232dcba70f6296b5c58e563a4b", - "frontend-modern/src/AppLayout.tsx": "db685903fef2a1509edd812acb3904f7ff3ffab0bbd1af2bff183c1b3ae8e373", - "frontend-modern/src/components/shared/MobileNavBar.tsx": "27358f3e77267cc111ce97b32d715b0069e2d1370ddd2b3ecc4f2d34a3e6dac5", - "frontend-modern/src/components/shared/mobileNavBarModel.ts": "ab9e69d379579d02e33a2abd1224d8c13e6543dd6aa0ad40cc1f65697a289d04", - "frontend-modern/src/features/home/HomePageSurface.tsx": "deleted", - "frontend-modern/src/features/home/homePageModel.ts": "deleted", - "frontend-modern/src/i18n/messages.de.ts": "602246d3d4ce11a1a8a914027d950a30ba1f2f25850c385836013853957c2d0c", - "frontend-modern/src/i18n/messages.es.ts": "ed5a749603efad29293cd6964215f6edc21a4ce14bca99a48429ddd120770263", - "frontend-modern/src/i18n/messages.ts": "43a757e00eaa7879c400895c9c59a930e31513dbf03e0ae6d60f7e72a6ad9962", - "frontend-modern/src/routing/navigation.ts": "8ae1ad012e60ef345ffb3d18bb66f5d8af056758109655a8ec362ebf4c7b9556", - "frontend-modern/src/routing/resourceLinks.ts": "dee9a426de785e23390ba49c9067f55c8f923cecfce1247ff18f9b1004e0cc90", - "frontend-modern/src/routing/routePreload.ts": "ee79d423db0afcf8d76d1d39da59a13cb2c98e908e516a016cbb425982b25873", - "frontend-modern/src/utils/assistantPageContext.ts": "bfca19b4e183777ee1535073d48cce92ce31980f4105bab337f33a28538a6a35" + "frontend-modern/src/components/shared/Dialog.tsx": "185f09e185a9e5ccddf73906a81552ea0cc8b07390fb5ab587fbe1b952697735" }, "routes": [ - "/", - "/home", - "/proxmox/overview" + "/settings/infrastructure", + "/actions", + "/alerts/overview", + "/settings/system-general", + "/patrol", + "/" ], "viewports": [ { @@ -44,21 +21,25 @@ "height": 720 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 + }, + { + "width": 393, + "height": 851 } ], "states": [ - "signed-in desktop shell with primary tabs Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines and no Home tab", - "/home renders the Page Not Found surface with 'No route matched /home' and a Go to workspace button", - "signed-in narrow shell on /proxmox/overview with the bottom navigation bar showing Proxmox, Alerts, Patrol, Actions, More and no Home entry", - "More navigation sheet open at narrow width listing Settings only", - "no horizontal page overflow at 375px; no console errors at either width" + "Add infrastructure dialog open at desktop and narrow widths with reduced motion, a visible labelled heading, accessible description, focused close control, contained panel geometry, and no horizontal document overflow", + "Add infrastructure dialog dismissed at desktop and narrow widths with the underlying Infrastructure surface restored", + "authenticated Actions, Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion and no automatically detectable WCAG A/AA violations", + "logged-out welcome surface with reduced motion and no automatically detectable WCAG A/AA violations" ], "interactions": [ - "navigated to / and /home at desktop width and read the rendered nav and main headings", - "pressed Go to workspace on the /home not-found page and landed on /proxmox/overview", - "resized to 375x812, loaded /proxmox/overview, opened the More navigation sheet, closed it with Escape", - "confirmed the default landing route and nav order are unchanged from the parent revision apart from the removed Home entry" + "opened Add infrastructure from its named trigger at desktop and narrow widths and verified the dialog accessible name and description", + "inspected final desktop and 390x844 screenshots for placement, clipping, stacking, scrolling, focus treatment, and responsive layout", + "verified the dialog bounds stay inside both viewports and the document has no horizontal overflow", + "dismissed the dialog with Escape at desktop and narrow widths and verified focus returned to Add infrastructure", + "scanned representative authenticated and logged-out surfaces for WCAG A/AA violations and unexpected reduced-motion effects" ] } diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 2fae9e46d..3d4ea0def 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -258,6 +258,7 @@ export function AppLayout(props: AppLayoutProps) { const [skipLinkFocused, setSkipLinkFocused] = createSignal(false); const [primaryRouteMemoryVersion, setPrimaryRouteMemoryVersion] = createSignal(0); let headerEl: HTMLDivElement | undefined; + let mainContentEl: HTMLElement | undefined; let assistantLauncherEl: HTMLButtonElement | undefined; let restoreAssistantLauncherFocus = false; let headerHideTimeout: ReturnType | undefined; @@ -730,6 +731,7 @@ export function AppLayout(props: AppLayoutProps) { jump past the chrome straight into the page content. */} mainContentEl?.focus()} onFocus={() => setSkipLinkFocused(true)} onBlur={() => setSkipLinkFocused(false)} class={ @@ -814,7 +816,7 @@ export function AppLayout(props: AppLayoutProps) { - + Preview @@ -980,7 +982,9 @@ export function AppLayout(props: AppLayoutProps) {
diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 821a963de..d097bb98a 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -174,7 +174,16 @@ describe('AppLayout navigation icons', () => { expect(container.querySelector('.pulse-shell')).toHaveClass('pb-safe-or-14'); expect(container.querySelector('.pulse-shell')).not.toHaveClass('pb-safe-or-16'); expect(container.querySelector('.header')).toHaveClass('mb-1', 'sm:mb-3'); - expect(container.querySelector('main')).toHaveClass('mb-1', 'sm:mb-2'); + const main = container.querySelector('main'); + expect(main).toHaveClass('mb-1', 'sm:mb-2'); + expect(main).toHaveAttribute('id', 'main'); + expect(main).toHaveAttribute('tabindex', '-1'); + const skipLink = screen.getByRole('link', { name: 'Skip to main content' }); + expect(skipLink).toHaveAttribute('href', '#main'); + fireEvent.click(skipLink); + expect(main).toHaveFocus(); + expect(screen.getByText('Preview')).toHaveClass('bg-orange-700', 'text-white'); + expect(screen.getByText('Preview')).not.toHaveClass('bg-orange-500'); expect(container.querySelector('footer')).toHaveClass('pulse-footer', 'px-2', 'sm:px-4'); const desktopNav = screen.getByRole('navigation', { name: 'Primary navigation' }); diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index 0f3b9ad5f..d6f00707e 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -223,6 +223,7 @@ export const GeneralSettingsPanel: Component = (props layoutStore.toggle()} />
@@ -276,6 +277,7 @@ export const GeneralSettingsPanel: Component = (props props.handleTelemetryEnabledChange(!props.telemetryEnabled())} /> diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx index d37365766..3cd87ddc7 100644 --- a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx @@ -86,6 +86,14 @@ describe('GeneralSettingsPanel localization', () => { ).toBeInTheDocument(); expect(screen.getByText('Usage data and privacy')).toBeInTheDocument(); expect(screen.getByText('Outbound usage telemetry')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Full-width mode' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(screen.getByRole('button', { name: 'Outbound usage telemetry' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); expect(screen.getByRole('button', { name: 'Preview payload' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Reset ID' })).toBeInTheDocument(); expect(screen.getByText('Monitoring cadence')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/shared/Dialog.tsx b/frontend-modern/src/components/shared/Dialog.tsx index f00014d07..a6bd2989f 100644 --- a/frontend-modern/src/components/shared/Dialog.tsx +++ b/frontend-modern/src/components/shared/Dialog.tsx @@ -9,19 +9,29 @@ import { } from './dialogModel'; import { useDialogState } from './useDialogState'; -interface DialogProps { +interface DialogBaseProps { isOpen: boolean; onClose: () => void; children: JSX.Element; panelClass?: string; layout?: DialogLayout; closeOnBackdrop?: boolean; - ariaLabel?: string; - ariaLabelledBy?: string; ariaDescribedBy?: string; returnFocus?: () => HTMLElement | null | undefined; } +type DialogProps = DialogBaseProps & + ( + | { + ariaLabel: string; + ariaLabelledBy?: never; + } + | { + ariaLabel?: never; + ariaLabelledBy: string; + } + ); + export const Dialog: Component = (props) => { const state = useDialogState(props); diff --git a/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx b/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx index 1d86f3a64..3f172a1d8 100644 --- a/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { createSignal, Show } from 'solid-js'; +import type { ComponentProps, JSX } from 'solid-js'; import { Dialog } from '@/components/shared/Dialog'; import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState'; import dialogSource from '@/components/shared/Dialog.tsx?raw'; @@ -53,10 +54,30 @@ describe('Dialog', () => { expect(dialogModelSource).toContain('FOCUSABLE_SELECTOR'); }); + it('requires exactly one accessible-name strategy at the component boundary', () => { + type UnnamedDialogProps = { + isOpen: boolean; + onClose: () => void; + children: JSX.Element; + }; + type DialogComponentProps = ComponentProps; + + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf< + UnnamedDialogProps & { ariaLabel: string } + >().toMatchTypeOf(); + expectTypeOf< + UnnamedDialogProps & { ariaLabelledBy: string } + >().toMatchTypeOf(); + expectTypeOf< + UnnamedDialogProps & { ariaLabel: string; ariaLabelledBy: string } + >().not.toMatchTypeOf(); + }); + it('renders as a modal dialog and closes on backdrop click', () => { const onClose = vi.fn(); render(() => ( - +
@@ -76,7 +97,7 @@ describe('Dialog', () => { it('closes on Escape and locks body scroll while open', () => { const onClose = vi.fn(); const { unmount } = render(() => ( - +
Body
)); @@ -96,7 +117,7 @@ describe('Dialog', () => { expect(dialogStackHasBlockingDialog()).toBe(false); const { unmount } = render(() => ( - undefined}> + undefined} ariaLabel="Test dialog">
Body
)); @@ -112,7 +133,7 @@ describe('Dialog', () => { document.body.appendChild(background); const { unmount } = render(() => ( - undefined}> + undefined} ariaLabel="Test dialog"> )); @@ -133,7 +154,7 @@ describe('Dialog', () => { document.body.appendChild(background); const { unmount } = render(() => ( - undefined}> + undefined} ariaLabel="Test dialog"> )); @@ -178,7 +199,7 @@ describe('Dialog', () => { it('makes body-level surfaces added while a dialog is open inert', async () => { render(() => ( - undefined}> + undefined} ariaLabel="Test dialog"> )); @@ -194,7 +215,7 @@ describe('Dialog', () => { it('keeps keyboard focus trapped in the dialog', async () => { const onClose = vi.fn(); render(() => ( - +
@@ -294,7 +315,7 @@ describe('Dialog', () => { it('honors an explicitly requested initial focus target', async () => { render(() => ( - undefined}> + undefined} ariaLabel="Test dialog">