Gate every release cut on a render-level smoke suite

v6.2.0-rc.5 shipped with an empty Proxmox workloads table and a crashing
Thresholds page (#1663) while the assertions that catch exactly that were
failing in CI: spec 64 red in the non-gating probation tier two hours
before the tag, and the gating Core E2E verdict red on the release commit
itself — which the release pipeline never consults. integration_tests is
also skipped entirely for prereleases, so the builds users test shipped
with no integration coverage at all.

Close the hole with a release_smoke job that runs for every cut,
prereleases included, and blocks create_release and the release verdict:
four interaction-free render assertions (Proxmox nodes+workloads, Docker
hosts+containers, Kubernetes clusters+pods, Alert thresholds) against the
mock-mode image built from the verified frontend bundle. The mock fixture
graph now always contains one freshly provisioned zero-used guest
filesystem, so the omitted-zero-numerics wire shape that crashed rc.5
stays exercised on every mock-backed surface. Verified locally: the suite
passes on main and fails on the rc.5 frontend for exactly the two shipped
regressions.
This commit is contained in:
rcourtman 2026-08-01 13:29:40 +01:00
parent 630d151460
commit 9ab38b940e
3 changed files with 223 additions and 2 deletions

View file

@ -671,6 +671,99 @@ jobs:
run: docker compose -f docker-compose.test.yml down -v || true
# Create release after all checks pass
# Release smoke: render-level assertions on the primary surfaces (Proxmox,
# Docker, Kubernetes, Alert thresholds), run for EVERY cut including
# prereleases. integration_tests stays stable-only for depth; this job
# exists because v6.2.0-rc.5 shipped with its primary surfaces broken while
# the only coverage lived in non-gating CI tiers (#1663). A prerelease is
# the build users test — it must never skip the "do the pages render data"
# bar.
release_smoke:
needs:
- prepare
- frontend_checks
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: frontend-modern/dist
name: release-frontend-${{ github.sha }}
- name: Copy frontend to embed location
run: |
rm -rf internal/api/frontend-modern
mkdir -p internal/api/frontend-modern
cp -r frontend-modern/dist internal/api/frontend-modern/
- name: Build Docker image for the smoke environment
# GO_BUILD_TAGS="" drops the release build tag so mock fixtures are
# available; the release-tagged binary itself is covered by
# backend_tests and build_release_candidate.
run: |
docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" .
docker build -t pulse-mock-github:test tests/integration/mock-github-server
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Install Playwright
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
- name: Run release smoke
working-directory: tests/integration
env:
MOCK_CHECKSUM_ERROR: "false"
MOCK_NETWORK_ERROR: "false"
MOCK_RATE_LIMIT: "false"
MOCK_STALE_RELEASE: "false"
run: |
docker compose -f docker-compose.test.yml up -d
echo "Waiting for services to be healthy..."
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-mock-github | grep -q "healthy"; do sleep 2; done'
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-test-server | grep -q "healthy"; do sleep 2; done'
for i in 1 2 3 4 5; do
if curl -f -s http://localhost:7655/api/health > /dev/null 2>&1; then
echo "Pulse server is reachable"
break
elif [ $i -eq 5 ]; then
docker logs pulse-test-server || true
exit 1
fi
sleep 2
done
npx playwright test tests/95-release-smoke.spec.ts \
--project=chromium \
--reporter=list
docker compose -f docker-compose.test.yml down -v
- name: Collect smoke diagnostics
if: failure()
working-directory: tests/integration
run: |
docker logs pulse-test-server || true
docker compose -f docker-compose.test.yml down -v || true
create_release:
needs:
- prepare
@ -680,8 +773,10 @@ jobs:
- docker_build
- helm_smoke
- integration_tests
# Run if integration_tests passed OR was skipped (prereleases)
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
- release_smoke
# Run if integration_tests passed OR was skipped (prereleases). The
# release smoke has no skipped escape: it runs for prereleases too.
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.build_release_candidate.result == 'success' && needs.frontend_checks.result == 'success' && needs.backend_tests.result == 'success' && needs.docker_build.result == 'success' && needs.helm_smoke.result == 'success' && needs.release_smoke.result == 'success' && (needs.integration_tests.result == 'success' || needs.integration_tests.result == 'skipped') }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
@ -1367,6 +1462,7 @@ jobs:
name: Definitive Release Verdict
needs:
- prepare
- release_smoke
- create_release
- publish_docker
- validate_release_assets
@ -1384,6 +1480,7 @@ jobs:
VERSION: ${{ needs.prepare.outputs.version }}
IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }}
CREATE_RESULT: ${{ needs.create_release.result }}
SMOKE_RESULT: ${{ needs.release_smoke.result }}
DOCKER_RESULT: ${{ needs.publish_docker.result }}
VALIDATE_RESULT: ${{ needs.validate_release_assets.result }}
INSTALL_RESULT: ${{ needs.install_sh_smoke.result }}
@ -1403,6 +1500,7 @@ jobs:
fi
}
require_result "release smoke" "$SMOKE_RESULT" success
require_result "release assembly" "$CREATE_RESULT" success
require_result "release asset validation" "$VALIDATE_RESULT" success

View file

@ -402,6 +402,40 @@ func generateVirtualDisks() ([]models.Disk, models.Disk) {
return disks, aggregated
}
// ensureFreshFilesystemFixture guarantees the fixture graph always contains
// at least one running guest with a zero-used filesystem. Freshly
// provisioned mounts serialize with their zero-valued numerics omitted, a
// wire shape consumers must tolerate (#1663), so every mock-backed surface
// keeps exercising it.
func ensureFreshFilesystemFixture(data *models.StateSnapshot) {
const freshBytes = int64(64) * 1024 * 1024 * 1024
fresh := models.Disk{
Total: freshBytes,
Used: 0,
Free: freshBytes,
Usage: 0,
Mountpoint: "/srv/fresh",
Type: "ext4",
Device: "/dev/vdz",
}
for i := range data.VMs {
vm := &data.VMs[i]
if vm.Status != "running" || len(vm.Disks) == 0 {
continue
}
vm.Disks = append(vm.Disks, fresh)
return
}
for i := range data.Containers {
ct := &data.Containers[i]
if ct.Status != "running" || len(ct.Disks) == 0 {
continue
}
ct.Disks = append(ct.Disks, fresh)
return
}
}
// buildFixtureState synthesizes the snapshot-backed portion of the canonical
// fixture graph for demo and test environments.
func buildFixtureState(config MockConfig) models.StateSnapshot {
@ -567,6 +601,8 @@ func buildFixtureState(config MockConfig) models.StateSnapshot {
data.ReplicationJobs = generateReplicationJobs(data.Nodes, data.VMs)
ensureFreshFilesystemFixture(&data)
// Calculate stats
data.Stats.StartTime = time.Now()
data.Stats.Uptime = 0

View file

@ -0,0 +1,87 @@
import { expect, test, type Page } from "@playwright/test";
import { ensureAuthenticated } from "./helpers";
const DESKTOP_VIEWPORT = { width: 1440, height: 900 };
// Release smoke: the minimum bar every cut ships against, prereleases
// included. Each check asserts only that a primary surface renders real
// inventory from the mock dataset — no interaction sequences, no timing
// races — so a failure here always means the surface is broken, never that
// the test is flaky. v6.2.0-rc.5 shipped with an empty Proxmox workloads
// table and a crashing Thresholds page (#1663) while exactly these
// assertions were failing in non-gating tiers; this spec exists so that
// class of regression blocks the release instead.
//
// Keep this file interaction-free and assertion-light. Anything richer
// belongs in the numbered feature specs, not here.
async function expectNoErrorBoundary(page: Page) {
await expect(
page.getByText("This page couldn't load"),
"an error boundary fired on a release-gating surface",
).toHaveCount(0);
}
test.describe("Release smoke", () => {
test.setTimeout(180_000);
test.beforeEach(async ({ page }, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Release smoke gates on the desktop shell",
);
await page.setViewportSize(DESKTOP_VIEWPORT);
await ensureAuthenticated(page);
});
test("Proxmox overview renders nodes and workloads", async ({ page }) => {
await page.goto("/proxmox", { waitUntil: "domcontentloaded" });
// Nodes and workloads travel different data paths (websocket state vs
// /api/resources); rc.5 broke exactly one of them, so assert both.
const nodeRows = page.locator("[data-proxmox-host-row]");
await expect(nodeRows.first()).toBeVisible({ timeout: 60_000 });
const workloadRows = page.locator("tr[data-guest-id]");
await expect(workloadRows.first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText("No Proxmox workloads")).toHaveCount(0);
await expectNoErrorBoundary(page);
});
test("Docker page renders hosts and containers", async ({ page }) => {
await page.goto("/docker", { waitUntil: "domcontentloaded" });
await expect(
page.locator("tr[data-docker-host-row]").first(),
).toBeVisible({ timeout: 60_000 });
await expect(
page.locator("tr[data-docker-container-row]").first(),
).toBeVisible({ timeout: 60_000 });
await expectNoErrorBoundary(page);
});
test("Kubernetes page renders clusters and pods", async ({ page }) => {
await page.goto("/kubernetes", { waitUntil: "domcontentloaded" });
await expect(
page.locator("tr[data-kubernetes-cluster-row]").first(),
).toBeVisible({ timeout: 60_000 });
await expect(
page.locator("tr[data-kubernetes-pod-row]").first(),
).toBeVisible({ timeout: 60_000 });
await expectNoErrorBoundary(page);
});
test("Alert thresholds page renders guest sections", async ({ page }) => {
await page.goto("/alerts/thresholds", { waitUntil: "domcontentloaded" });
// The mock dataset always contains a freshly provisioned zero-used
// guest filesystem (ensureFreshFilesystemFixture), so this render
// covers the omitted-zero-numerics wire shape that crashed rc.5.
await expect(
page.getByText("Virtualization Hosts", { exact: false }).first(),
).toBeVisible({ timeout: 60_000 });
await expectNoErrorBoundary(page);
});
});