diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 0b766bff3..c4730e8c3 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -15,10 +15,16 @@ on: description: 'Image tag override (defaults to short SHA of HEAD)' required: false default: '' + allow_without_staging_smoke: + description: 'EMERGENCY: deploy prod even without a green staging-smoke on this SHA' + type: boolean + required: false + default: false permissions: id-token: write # required to mint the OIDC token for google-github-actions/auth contents: read + statuses: read # read the staging-smoke commit status for the prod-promote gate concurrency: group: deploy-dev @@ -33,8 +39,39 @@ env: NAMESPACE: commonly-dev jobs: + # Prod-promote gate (Sam, 2026-10-23): no prod deploy without a green `staging-smoke` + # commit status on this exact SHA. Deploy Staging runs the Tier-1 UI smoke and posts that + # status; this job reads it and blocks the deploy if it is not success. The + # allow_without_staging_smoke input is the explicit emergency override. + require_staging_smoke: + name: Require green staging-smoke on this SHA + runs-on: ubuntu-latest + steps: + - name: Gate on the staging-smoke commit status + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.sha }} + REPO: ${{ github.repository }} + ALLOW: ${{ inputs.allow_without_staging_smoke }} + run: | + set -uo pipefail + if [ "$ALLOW" = "true" ]; then + echo "::warning title=staging-smoke gate::bypassed via allow_without_staging_smoke=true" + exit 0 + fi + # Latest staging-smoke by created_at, tiebreak id; if a failure and a success share the + # newest created_at (same-second race) prefer failure, so the gate fails closed. + STATE=$(gh api "repos/$REPO/commits/$SHA/statuses" \ + --jq '[.[]|select(.context=="staging-smoke")] | if length==0 then "" else ((max_by(.created_at).created_at) as $m | [.[]|select(.created_at==$m)] | (if any(.[]; .state=="failure") then "failure" else (sort_by(.id)|last|.state) end)) end' 2>/dev/null || echo "") + echo "staging-smoke on $SHA = ${STATE:-}" + if [ "$STATE" != "success" ]; then + echo "::error title=staging-smoke gate::no green staging-smoke on $SHA (got: ${STATE:-none}). Deploy Staging on this SHA first (it runs the Tier-1 smoke and posts the status), or re-dispatch with allow_without_staging_smoke=true for an emergency." + exit 1 + fi + deploy: name: Build + push + helm upgrade + needs: require_staging_smoke runs-on: ubuntu-latest environment: dev timeout-minutes: 30 diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index be270ccb2..719ccdd13 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -187,6 +187,34 @@ jobs: done if [ -n "$FAILED" ]; then echo "Deploy failed for:$FAILED"; exit 1; fi + - name: Set up Node for the Tier-1 smoke + uses: actions/setup-node@v4 + with: + node-version: 20 + + # Tier-1 UI smoke (Playwright): health/ready, seeded non-admin login, /v2 home + a pod + + # /v2/connectors + Bring-your-own render with no console errors, left rail no horizontal + # scroll at 1440 & 390px. It FAILS THE JOB on red, so the staging-smoke commit status step + # below (which requires JOB_STATUS=success) posts failure and the prod promote path is + # blocked. Richer than the stranger reachability probe; it caught the FRONTEND_URL/CORS + # regression on 2026-10-23. Skips the hosted-agent check until staging has an LLM key. + - name: Tier-1 UI smoke (Playwright) + env: + SMOKE_BASE: https://staging.commonly.me + SMOKE_API: https://api-staging.commonly.me + SMOKE_EMAIL: ${{ secrets.STAGING_SMOKE_EMAIL }} + SMOKE_PW: ${{ secrets.STAGING_SMOKE_PW }} + run: | + set -euo pipefail + if [ -z "${SMOKE_EMAIL:-}" ] || [ -z "${SMOKE_PW:-}" ]; then + echo "::error title=Tier-1 smoke::STAGING_SMOKE_EMAIL / STAGING_SMOKE_PW repo secrets are not set" + exit 1 + fi + cd scripts/staging-smoke + npm install --no-audit --no-fund --silent + npx playwright install --with-deps chromium + node staging-smoke.mjs + # The stranger-path smoke drives signup -> welcome -> question -> connect # card against api-staging.commonly.me. Its pass/fail IS the staging-smoke # gate the prod promote path reads, so — unlike the dev journey, which is a diff --git a/scripts/staging-smoke/README.md b/scripts/staging-smoke/README.md new file mode 100644 index 000000000..076c2763c --- /dev/null +++ b/scripts/staging-smoke/README.md @@ -0,0 +1,22 @@ +# Tier-1 staging smoke + +Fast (~1–2 min) post-deploy smoke that **gates prod promotion**: `deploy-staging.yml` runs it +after the staging rollout, and its pass/fail feeds the `staging-smoke` commit status that +`deploy-dev.yml` (prod) requires on the same SHA. + +Checks: `/api/health/ready` 200 · seeded non-admin login · `/v2` home + a pod + `/v2/connectors` ++ Bring-your-own render with no console errors · left rail no horizontal scroll at 1440 & 390px. +The hosted-agent-answers check is skipped until staging has an LLM key (`SMOKE_HOSTED=1` to enable). + +## Run locally +``` +cd scripts/staging-smoke && npm i && npx playwright install chromium +SMOKE_EMAIL=... SMOKE_PW=... npm run smoke +``` + +## Env +- `SMOKE_BASE` (default https://staging.commonly.me), `SMOKE_API` (default https://api-staging.commonly.me) +- `SMOKE_EMAIL` / `SMOKE_PW` — a **non-admin** staging test user (in CI: secrets `STAGING_SMOKE_EMAIL` / `STAGING_SMOKE_PW`; locally a 0600 file, never printed) +- `SMOKE_HOSTED=1` — also check a hosted-agent reply (needs a staging LLM key) + +Exit 0 = all green; non-zero = a blocking check failed → prod promotion must not proceed. diff --git a/scripts/staging-smoke/package.json b/scripts/staging-smoke/package.json new file mode 100644 index 000000000..69a5fb508 --- /dev/null +++ b/scripts/staging-smoke/package.json @@ -0,0 +1,8 @@ +{ + "name": "commonly-staging-smoke", + "private": true, + "type": "module", + "description": "Tier-1 staging smoke — gates prod promotion (see .github/workflows/deploy-staging.yml).", + "scripts": { "smoke": "node staging-smoke.mjs" }, + "dependencies": { "playwright": "1.48.2" } +} diff --git a/scripts/staging-smoke/staging-smoke.mjs b/scripts/staging-smoke/staging-smoke.mjs new file mode 100644 index 000000000..36f2495f2 --- /dev/null +++ b/scripts/staging-smoke/staging-smoke.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Tier-1 staging smoke — fast, gates prod promotion. Run after a deploy-staging rollout. +// Env: SMOKE_BASE (default https://staging.commonly.me), SMOKE_API (default https://api-staging.commonly.me), +// SMOKE_EMAIL + SMOKE_PW (non-admin test creds; from a 0600 file / CI secrets, NEVER printed), +// SMOKE_HOSTED=1 to also check a hosted-agent reply (skip until staging has an LLM key). +// Exit 0 = all green; non-zero = a blocking check failed (caller must block prod promotion). +import { chromium } from 'playwright'; + +const BASE = process.env.SMOKE_BASE || 'https://staging.commonly.me'; +const API = process.env.SMOKE_API || 'https://api-staging.commonly.me'; +const EMAIL = process.env.SMOKE_EMAIL, PW = process.env.SMOKE_PW; +const CHECK_HOSTED = process.env.SMOKE_HOSTED === '1'; +const UA = { 'User-Agent': 'commonly-smoke/0.1' }; +const CONSOLE_ALLOW = [/favicon/i, /manifest\.json/i, /\/api\/pg\/status/i, /ResizeObserver loop/i, /Download the React DevTools/i]; + +const results = []; +const rec = (name, ok, detail='') => { results.push({ name, ok }); console.log(`${ok?'PASS':'FAIL'} ${name}${detail?' — '+detail:''}`); }; +const norm = p => (p || '').replace(/\/+$/,'') || '/'; + +async function api(path, opts={}) { + const r = await fetch(`${API}${path}`, { ...opts, headers: { ...UA, ...(opts.headers||{}) } }); + let body = null; try { body = await r.json(); } catch {} + return { status: r.status, body }; +} + +async function main() { + // 1) health/ready + try { const r = await fetch(`${API}/api/health/ready`, { headers: UA }); rec('health/ready 200', r.status === 200, `status ${r.status}`); } + catch (e) { rec('health/ready 200', false, e.message); } + + // 2) login -> token + let token = null; + try { + const { status, body } = await api('/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ email: EMAIL, password: PW }) }); + token = body?.token || body?.accessToken || null; + rec('non-admin login', status === 200 && !!token, `status ${status}`); + } catch (e) { rec('non-admin login', false, e.message); } + const authH = token ? { Authorization: `Bearer ${token}` } : {}; + + // 3) resolve a real pod id (so /v2/pods/ is a true route, not the path="*" redirect to /v2) + let podId = null; + if (token) { + try { + const { body } = await api('/api/pods', { headers: authH }); + const pods = Array.isArray(body) ? body : (body?.pods || body?.data || []); + if (pods.length) podId = pods[0]._id || pods[0].id; + if (!podId) { + const { body: cb } = await api('/api/pods', { method:'POST', headers:{ ...authH, 'Content-Type':'application/json' }, body: JSON.stringify({ name:'Smoke Check Pod', type:'chat', description:'tier-1 smoke render target' }) }); + const p = cb?.pod || cb; podId = p?._id || p?.id; + } + } catch {} + } + + const browser = await chromium.launch(); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + if (token) await ctx.addInitScript(t => { try { localStorage.setItem('token', t); } catch(e){} }, token); + + // 4) pages must render (http<400), with no non-allowlisted console errors, and WITHOUT redirecting + // away from the requested path (a missing route falls through path="*" to /v2 and would false-pass). + // okLanding: a missing sub-route falls through path="*" to /v2 (or the default pod), so each + // page asserts it LANDED on its own path. Exception: /v2 home itself redirects to the user's + // default pod, so any in-app /v2* landing is fine for home. + const pages = [ + ['/v2 home', '/v2', l => l.startsWith('/v2')], + ['/v2 pod', podId ? `/v2/pods/${podId}` : null, l => l === norm(`/v2/pods/${podId}`)], + ['/v2 connectors', '/v2/connectors', l => l === '/v2/connectors'], + ['bring-your-own', '/v2/agents/byo', l => l === '/v2/agents/byo'], + ]; + for (const [label, path, okLanding] of pages) { + if (!path) { rec(`render ${label}`, false, 'no pod id (GET/POST /api/pods failed)'); continue; } + const page = await ctx.newPage(); + const errs = []; + page.on('console', m => { if (m.type()==='error' && !CONSOLE_ALLOW.some(rx=>rx.test(m.text()))) errs.push(m.text().slice(0,110)); }); + page.on('pageerror', e => errs.push('PAGEERR '+String(e.message).slice(0,110))); + try { + const resp = await page.goto(BASE+path, { waitUntil:'networkidle', timeout: 30000 }); + await page.waitForTimeout(1500); + const landed = norm(new URL(page.url()).pathname); + const redirected = !okLanding(landed); + const httpOk = !resp || resp.status() < 400; + rec(`render ${label}`, httpOk && errs.length===0 && !redirected, + `http ${resp?resp.status():'?'} url=${landed}${redirected?' REDIRECTED (route missing?)':''}${errs.length?'; console: '+errs.slice(0,2).join(' | '):''}`); + } catch (e) { rec(`render ${label}`, false, e.message.slice(0,100)); } + await page.close(); + } + + // 5) no horizontal scroll. At desktop (1440) the left rail is .v2-rail__nav and MUST exist and + // not overflow (TASK-363 — the Activity-badge overflow — lives here, so a missing rail fails). + // At mobile (390) the desktop rail is replaced by a mobile nav, so there we assert the PAGE + // itself does not h-scroll (and the rail, if present, fits). + { + const page = await ctx.newPage(); + try { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(BASE+'/v2', { waitUntil:'networkidle', timeout: 30000 }); + await page.waitForTimeout(1200); + const r = await page.evaluate(() => { + const el = document.querySelector('.v2-rail__nav'); + if (!el) return { found: false }; + return { found: true, over: el.scrollWidth - el.clientWidth }; + }); + rec('rail no h-scroll @1440px', r.found === true && r.over <= 1, r.found ? `over+${r.over}` : 'RAIL NOT FOUND (.v2-rail__nav)'); + } catch (e) { rec('rail no h-scroll @1440px', false, e.message.slice(0,100)); } + await page.close(); + } + { + const page = await ctx.newPage(); + try { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(BASE+'/v2', { waitUntil:'networkidle', timeout: 30000 }); + await page.waitForTimeout(1200); + const r = await page.evaluate(() => { + const doc = document.documentElement; + const el = document.querySelector('.v2-rail__nav'); + return { pageOver: doc.scrollWidth - doc.clientWidth, railOver: el ? (el.scrollWidth - el.clientWidth) : 0 }; + }); + rec('no h-scroll @390px', r.pageOver <= 1 && r.railOver <= 1, `page+${r.pageOver} rail+${r.railOver}`); + } catch (e) { rec('no h-scroll @390px', false, e.message.slice(0,100)); } + await page.close(); + } + + // 6) hosted agent answers (skip until staging has an LLM key) + if (CHECK_HOSTED) rec('hosted agent answers', false, 'TODO once staging LLM key is set'); + else console.log('SKIP hosted agent answers — staging has no LLM key yet (SMOKE_HOSTED!=1)'); + + await browser.close(); + const failed = results.filter(r => !r.ok); + console.log(`\nSMOKE ${failed.length===0?'GREEN':'RED'} — ${results.length-failed.length}/${results.length} passed` + (failed.length?`; failing: ${failed.map(f=>f.name).join(', ')}`:'')); + process.exit(failed.length===0 ? 0 : 1); +} +main().catch(e => { console.error('SMOKE_HARNESS_ERROR', e.message); process.exit(2); });