diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 6f126d97a..9bd7c5351 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -79,7 +79,8 @@ Optional environment variables: 7. **Update**: SSHs to the selected demo host and runs the tag-matched root installer from that exact git tag 8. **Host identity check**: Verifies the SSH target reports the governed expected hostname before running installer or deploy steps 9. **Verify**: Checks that the new version is running, mock mode is active, and the public demo HTML serves the same frontend entry asset as the target service -10. **Cleanup**: Removes SSH key from runner +10. **Browser smoke**: Uses the governed Playwright helper to prove the public demo still renders the login shell in a real browser +11. **Cleanup**: Removes SSH key from runner ### Testing @@ -122,6 +123,8 @@ environment without changing the governed release workflow. - Verifies the SSH target reports the governed expected hostname before deploy - Verifies that the public demo shell serves the same frontend entry asset that was built and deployed +- Uses `scripts/run_demo_public_browser_smoke.sh` to prove the public demo + still renders the login shell in Chromium after deploy/update verification ## Helm CI diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml index abfcdc750..e359ddc2a 100644 --- a/.github/workflows/deploy-demo-server.yml +++ b/.github/workflows/deploy-demo-server.yml @@ -316,6 +316,20 @@ jobs: exit 1 } + - name: Verify public browser smoke + run: | + set -euo pipefail + case "$DEMO_PUBLIC_HEALTH_URL" in + */api/health) + export PULSE_PUBLIC_SITE_URL="${DEMO_PUBLIC_HEALTH_URL%/api/health}/" + ;; + *) + echo "::error::DEMO_PUBLIC_HEALTH_URL must end with /api/health for browser verification." + exit 1 + ;; + esac + ./scripts/run_demo_public_browser_smoke.sh + - name: Cleanup SSH material if: always() run: rm -f ~/.ssh/id_ed25519 ~/.ssh/known_hosts diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index f63b193bf..c59dbcf1d 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -385,7 +385,6 @@ jobs: fi - name: Verify frontend parity - if: steps.current.outputs.skip_current != 'true' env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} @@ -426,6 +425,20 @@ jobs: set -euo pipefail curl -fsS "$DEMO_PUBLIC_HEALTH_URL" + - name: Verify public browser smoke + run: | + set -euo pipefail + case "$DEMO_PUBLIC_HEALTH_URL" in + */api/health) + export PULSE_PUBLIC_SITE_URL="${DEMO_PUBLIC_HEALTH_URL%/api/health}/" + ;; + *) + echo "::error::DEMO_PUBLIC_HEALTH_URL must end with /api/health for browser verification." + exit 1 + ;; + esac + ./scripts/run_demo_public_browser_smoke.sh + - name: Cleanup SSH material if: always() run: rm -f ~/.ssh/id_ed25519 ~/.ssh/known_hosts /tmp/pulse-install.sh diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index ba7275230..ea086053a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -58,15 +58,17 @@ server-side update execution surfaces. 36. `scripts/pulse-auto-update.sh` 37. `scripts/release_control/resolve_release_promotion.py` 38. `scripts/release_ldflags.sh` -39. `scripts/trigger-release-dry-run.sh` -40. `scripts/trigger-release.sh` -41. `scripts/toggle-mock.sh` -42. `tests/integration/playwright.config.ts` -43. `tests/integration/QUICK_START.md` -44. `tests/integration/README.md` -45. `tests/integration/scripts/managed-dev-runtime.mjs` -46. `tests/integration/tests/helpers.ts` -47. `tests/integration/tests/runtime-defaults.ts` +39. `scripts/run_demo_public_browser_smoke.sh` +40. `scripts/demo_public_browser_smoke.cjs` +41. `scripts/trigger-release-dry-run.sh` +42. `scripts/trigger-release.sh` +43. `scripts/toggle-mock.sh` +44. `tests/integration/playwright.config.ts` +45. `tests/integration/QUICK_START.md` +46. `tests/integration/README.md` +47. `tests/integration/scripts/managed-dev-runtime.mjs` +48. `tests/integration/tests/helpers.ts` +49. `tests/integration/tests/runtime-defaults.ts` ## Shared Boundaries @@ -81,7 +83,7 @@ server-side update execution surfaces. 2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, or governed promotion metadata resolution through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/resolve_release_promotion.py`, `.dockerignore`, `Dockerfile`, `docs/releases/V6_PRERELEASE_RUNBOOK.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml` 3. Add or change shell installer, Windows installer, container-agent installer, or auto-update script behavior through `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, and `scripts/pulse-auto-update.sh` 4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts` -5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` +5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` 6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, artifact publication lineage enforcement, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `docs/releases/V6_PRERELEASE_RUNBOOK.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` 7. Preserve release-matched installer and Helm operator documentation links through `scripts/install.sh`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-helm-chart.yml`, and the chart metadata itself so deployment guidance and packaged chart metadata do not drift back to branch-tip `main` docs when a release line or promoted tag already exists. 8. Add or change operator-facing hosted tenant runtime canary rollout, batch runtime contract reconciliation, canonical hosted route/public URL generation, or control-plane runtime-registry reconciliation through `cmd/pulse-control-plane/main.go`, `internal/cloudcp/docker/manager.go`, `internal/cloudcp/docker/labels.go`, and `internal/cloudcp/tenant_runtime_rollout.go` @@ -196,6 +198,13 @@ network path. They must establish the canonical Tailscale connectivity step before SSH setup so stable or preview targets may stay on governed private hostnames or Tailscale IPs, rather than silently depending on public SSH reachability from GitHub-hosted runners. +Those same workflows also own customer-visible browser truth for the public +demo shell. Health checks and entry-asset parity are necessary but not +sufficient; after those checks pass, the governed helpers +`scripts/run_demo_public_browser_smoke.sh` and +`scripts/demo_public_browser_smoke.cjs` must exercise the public demo in a real +Chromium session and prove the login shell actually renders instead of failing +open on API-only reachability. Those same governed release workflows also own the operator-facing wording for that promotion metadata. Human-visible workflow inputs, summaries, and error messages must describe the path as a prerelease or preview flow rather than diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 71d176172..47d15c323 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2355,6 +2355,7 @@ "scripts/check-workflow-dispatch-inputs.py", "scripts/clean-mock-alerts.sh", "scripts/com.pulse.hot-dev.plist.template", + "scripts/demo_public_browser_smoke.cjs", "scripts/dev-check.sh", "scripts/dev-launchd-setup.sh", "scripts/dev-launchd-wrapper.sh", @@ -2366,6 +2367,7 @@ "scripts/pulse-auto-update.sh", "scripts/release_control/resolve_release_promotion.py", "scripts/release_ldflags.sh", + "scripts/run_demo_public_browser_smoke.sh", "scripts/toggle-mock.sh", "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh", @@ -2453,6 +2455,21 @@ "scripts/installtests/release_ldflags_test.go" ] }, + { + "id": "demo-public-browser-smoke-runtime", + "label": "demo public browser smoke proof", + "match_prefixes": [], + "match_files": [ + "scripts/demo_public_browser_smoke.cjs", + "scripts/run_demo_public_browser_smoke.sh" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "scripts/installtests/build_release_assets_test.go", + "scripts/release_control/release_promotion_policy_test.py" + ] + }, { "id": "deployment-script-runtime", "label": "deployment script runtime proof", diff --git a/scripts/demo_public_browser_smoke.cjs b/scripts/demo_public_browser_smoke.cjs new file mode 100644 index 000000000..95b8a245a --- /dev/null +++ b/scripts/demo_public_browser_smoke.cjs @@ -0,0 +1,52 @@ +const { chromium } = require('playwright'); + +const siteUrl = String(process.env.PULSE_PUBLIC_SITE_URL || '').trim(); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function normalizeText(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim(); +} + +async function main() { + assert(siteUrl, 'PULSE_PUBLIC_SITE_URL is required'); + + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const response = await page.goto(siteUrl, { + waitUntil: 'networkidle', + timeout: 120000, + }); + assert(response, `No response received for ${siteUrl}`); + assert(response.ok(), `Unexpected status ${response.status()} loading ${siteUrl}`); + + await page.getByLabel('Username').waitFor(); + await page.getByLabel('Password').waitFor(); + await page.getByRole('button', { name: 'Sign in to Pulse' }).waitFor(); + + const title = normalizeText(await page.title()); + assert(title === 'Pulse', `Unexpected page title: ${title}`); + + const bodyText = normalizeText(await page.locator('body').innerText()); + for (const expected of ['Welcome to Pulse', 'Username', 'Password', 'Sign in to Pulse']) { + assert(bodyText.includes(expected), `Public demo body missing ${JSON.stringify(expected)}`); + } + + console.log(`public demo browser smoke passed for ${siteUrl}`); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error(message); + process.exit(1); +}); diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 90b80979e..865eaeb9e 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -127,6 +127,8 @@ func TestDeployDemoWorkflowFailsClosedForPreviewAndVerifiesFrontendParity(t *tes `Preview demo deployments must not target the stable pulse service.`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, `Verify frontend parity`, + `Verify public browser smoke`, + `./scripts/run_demo_public_browser_smoke.sh`, `extract_entry_asset()`, `]*\bsrc=\"(/assets/index-[^\"]*\.js)\"`, `Remote service is serving $REMOTE_ASSET but the build expected $EXPECTED_ASSET.`, @@ -152,6 +154,8 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `authkey: ${{ secrets.TS_AUTHKEY }}`, `Verify target host identity`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, + `Verify public browser smoke`, + `./scripts/run_demo_public_browser_smoke.sh`, } for _, needle := range required { if !strings.Contains(workflow, needle) { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 4989c76fe..52ded7d2a 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -237,6 +237,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Verify target host identity", demo) self.assertIn("Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.", demo) self.assertIn("Verify frontend parity", demo) + self.assertIn("Verify public browser smoke", demo) + self.assertIn("./scripts/run_demo_public_browser_smoke.sh", demo) self.assertIn("extract_entry_asset()", demo) self.assertIn(r']*\bsrc=\"(/assets/index-[^\"]*\.js)\"', demo) self.assertIn("Public demo is serving $PUBLIC_ASSET but the target service is serving $REMOTE_ASSET.", demo) @@ -246,6 +248,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Verify target host identity", preview_deploy) self.assertIn("Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.", preview_deploy) self.assertIn("Verify frontend parity", preview_deploy) + self.assertIn("Verify public browser smoke", preview_deploy) + self.assertIn("./scripts/run_demo_public_browser_smoke.sh", preview_deploy) self.assertIn("extract_entry_asset()", preview_deploy) self.assertIn(r']*\bsrc=\"(/assets/index-[^\"]*\.js)\"', preview_deploy) self.assertIn("Public demo is serving $PUBLIC_ASSET but the build expected $EXPECTED_ASSET.", preview_deploy) diff --git a/scripts/run_demo_public_browser_smoke.sh b/scripts/run_demo_public_browser_smoke.sh new file mode 100755 index 000000000..f1bce527f --- /dev/null +++ b/scripts/run_demo_public_browser_smoke.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v node >/dev/null 2>&1; then + echo "node is required for demo browser smoke" >&2 + exit 1 +fi + +if ! command -v npx >/dev/null 2>&1; then + echo "npx is required for demo browser smoke" >&2 + exit 1 +fi + +if [ -z "${PULSE_PUBLIC_SITE_URL:-}" ]; then + echo "PULSE_PUBLIC_SITE_URL is required for demo browser smoke" >&2 + exit 1 +fi + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-${RUNNER_TEMP:-/tmp}/pw-browsers}" + +mkdir -p "$BROWSERS_PATH" + +export PLAYWRIGHT_BROWSERS_PATH="$BROWSERS_PATH" +npx --yes -p playwright playwright install --with-deps chromium + +playwright_bin="$(npx --yes -p playwright -c 'which playwright')" +playwright_node_path="$(cd "$(dirname "$playwright_bin")/.." && pwd)" + +NODE_PATH="$playwright_node_path${NODE_PATH:+:$NODE_PATH}" \ + node "$SCRIPT_DIR/demo_public_browser_smoke.cjs"