Deploy smoke #2548
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy smoke | |
| on: | |
| deployment_status: | |
| permissions: | |
| actions: read | |
| checks: read | |
| contents: read | |
| deployments: read | |
| issues: write | |
| pull-requests: write | |
| # Operator setup for protected Vercel deployments: | |
| # 1. Open GitHub Settings -> Environments -> Preview -> Environment secrets. | |
| # 2. Add VERCEL_AUTOMATION_BYPASS_SECRET with the Vercel project secret from | |
| # Deployment Protection -> Protection Bypass for Automation. | |
| # 3. Add the same secret to Production if you want production smoke to hit the | |
| # exact protected *.vercel.app deployment URL. Without it, production smoke | |
| # uses the public production aliases so host-specific outages stay visible. | |
| jobs: | |
| smoke: | |
| name: Smoke deployed URL | |
| if: >- | |
| !startsWith(github.event.deployment.environment, 'visual-review') && | |
| !startsWith(github.event.deployment_status.environment, 'visual-review') && | |
| (github.event.deployment.creator.login == 'vercel[bot]' || | |
| github.event.deployment_status.creator.login == 'vercel[bot]' || | |
| github.event.deployment.environment == 'Production' || | |
| github.event.deployment.environment == 'production' || | |
| github.event.deployment_status.environment == 'Production' || | |
| github.event.deployment_status.environment == 'production' || | |
| contains(github.event.deployment_status.environment_url, 'warondisease.org') || | |
| contains(github.event.deployment_status.environment_url, 'optimitron.com')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| environment: | |
| name: ${{ (github.event.deployment.environment == 'Production' || github.event.deployment.environment == 'production' || github.event.deployment_status.environment == 'Production' || github.event.deployment_status.environment == 'production' || contains(github.event.deployment_status.environment_url, 'warondisease.org') || contains(github.event.deployment_status.environment_url, 'optimitron.com')) && 'Production' || 'Preview' }} | |
| deployment: false | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 | |
| - name: Wait for successful deployment URL | |
| id: deployment_status | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const eventDeployment = context.payload.deployment || {}; | |
| const initialStatus = context.payload.deployment_status || {}; | |
| const eventDeploymentId = | |
| eventDeployment.id || | |
| initialStatus.deployment_id || | |
| initialStatus.deployment?.id; | |
| const eventDeploymentCreator = String( | |
| initialStatus.creator?.login || | |
| eventDeployment.creator?.login || | |
| initialStatus.deployment?.creator?.login || | |
| "", | |
| ); | |
| const deploymentSha = String( | |
| eventDeployment.sha || | |
| initialStatus.deployment?.sha || | |
| context.sha || | |
| "", | |
| ); | |
| const timeoutMs = 20 * 60 * 1000; | |
| const intervalMs = 10 * 1000; | |
| const deadline = Date.now() + timeoutMs; | |
| const terminalFailures = new Set(["failure", "error"]); | |
| let lastStatus = "not found"; | |
| let consecutiveInactive = 0; | |
| function isHttpUrl(value) { | |
| return /^https?:\/\//u.test(String(value || "").trim()); | |
| } | |
| function isProductionUrl(value) { | |
| try { | |
| const hostname = new URL(String(value || "")).hostname.toLowerCase(); | |
| return [ | |
| "warondisease.org", | |
| "optimitron.com", | |
| ].some( | |
| (domain) => hostname === domain || hostname.endsWith(`.${domain}`), | |
| ); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function isProductionDeployment(deployment, status) { | |
| const environment = String( | |
| status.environment || deployment.environment || "", | |
| ); | |
| return ( | |
| /^production$/i.test(environment) || | |
| isProductionUrl(status.environment_url) | |
| ); | |
| } | |
| async function resolvePreviewUrlFromVercelComment() { | |
| const { data: pulls } = | |
| await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner, | |
| repo, | |
| commit_sha: deploymentSha, | |
| }); | |
| const pull = | |
| pulls.find((candidate) => candidate.state === "open") || pulls[0]; | |
| if (!pull?.number) return null; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner, | |
| repo, | |
| issue_number: pull.number, | |
| per_page: 100, | |
| }); | |
| for (const comment of [...comments].reverse()) { | |
| if (comment.user?.login !== "vercel[bot]") continue; | |
| const previewMatch = String(comment.body || "").match( | |
| /\[(?:Visit )?Preview\]\((https:\/\/[^)\s]+\.vercel\.app[^)]*)\)/u, | |
| ); | |
| if (previewMatch?.[1]) return previewMatch[1]; | |
| } | |
| return null; | |
| } | |
| async function resolveVercelStatusPreviewUrl() { | |
| const { data } = await github.rest.repos.getCombinedStatusForRef({ | |
| owner, | |
| repo, | |
| ref: deploymentSha, | |
| }); | |
| const vercelStatus = data.statuses.find( | |
| (status) => status.context === "Vercel", | |
| ); | |
| if (!vercelStatus || vercelStatus.state !== "success") return null; | |
| const targetUrl = String(vercelStatus.target_url || "").trim(); | |
| if (targetUrl) { | |
| try { | |
| const hostname = new URL(targetUrl).hostname.toLowerCase(); | |
| if (hostname.endsWith(".vercel.app")) return targetUrl; | |
| } catch {} | |
| } | |
| return resolvePreviewUrlFromVercelComment(); | |
| } | |
| async function resolveVercelDeployment() { | |
| if ( | |
| eventDeploymentId && | |
| (eventDeploymentCreator === "vercel[bot]" || | |
| isProductionDeployment(eventDeployment, initialStatus)) | |
| ) { | |
| return { ...eventDeployment, id: eventDeploymentId }; | |
| } | |
| const { data } = await github.rest.repos.listDeployments({ | |
| owner, | |
| repo, | |
| sha: deploymentSha, | |
| per_page: 50, | |
| }); | |
| return ( | |
| data.find((deployment) => deployment.creator?.login === "vercel[bot]") || | |
| null | |
| ); | |
| } | |
| async function latestDeploymentStatus(deployment) { | |
| const { data } = await github.rest.repos.listDeploymentStatuses({ | |
| owner, | |
| repo, | |
| deployment_id: deployment.id, | |
| per_page: 10, | |
| }); | |
| return data[0] || initialStatus; | |
| } | |
| while (Date.now() < deadline) { | |
| const deployment = await resolveVercelDeployment(); | |
| if (!deployment?.id) { | |
| const previewUrl = await resolveVercelStatusPreviewUrl(); | |
| if (previewUrl) { | |
| core.info(`Recovered Vercel preview URL from status/comment: ${previewUrl}`); | |
| core.setOutput("environment", "Preview"); | |
| core.setOutput("environment_url", previewUrl); | |
| core.setOutput("state", "success"); | |
| return; | |
| } | |
| lastStatus = `no smokeable deployment found for ${deploymentSha}`; | |
| core.info(`Deployment status: ${lastStatus}`); | |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); | |
| continue; | |
| } | |
| const status = await latestDeploymentStatus(deployment); | |
| const state = String(status.state || "").toLowerCase(); | |
| const environmentUrl = String(status.environment_url || "").trim(); | |
| const environment = String( | |
| status.environment || deployment.environment || eventDeployment.environment || "", | |
| ); | |
| lastStatus = `${state || "unknown"} ${environmentUrl || "(no URL yet)"}`; | |
| core.info(`Deployment status: ${lastStatus}`); | |
| if (state === "success") { | |
| if (isHttpUrl(environmentUrl)) { | |
| core.setOutput("environment", environment); | |
| core.setOutput("environment_url", environmentUrl); | |
| core.setOutput("state", state); | |
| return; | |
| } | |
| const previewUrl = await resolvePreviewUrlFromVercelComment(); | |
| if (previewUrl) { | |
| core.info(`Recovered Vercel preview URL from PR comment: ${previewUrl}`); | |
| core.setOutput("environment", environment || "Preview"); | |
| core.setOutput("environment_url", previewUrl); | |
| core.setOutput("state", state); | |
| return; | |
| } | |
| } | |
| if (state === "inactive") { | |
| const previewUrl = await resolveVercelStatusPreviewUrl(); | |
| if (previewUrl) { | |
| core.info(`Recovered active Vercel preview URL after inactive deployment event: ${previewUrl}`); | |
| core.setOutput("environment", environment || "Preview"); | |
| core.setOutput("environment_url", previewUrl); | |
| core.setOutput("state", "success"); | |
| return; | |
| } | |
| // An inactive deployment never becomes active again. With no | |
| // recoverable URL this event is a Vercel ignored-build or a | |
| // superseded deployment — nothing to smoke; skip instead of | |
| // burning the timeout and failing the check. | |
| consecutiveInactive += 1; | |
| if (consecutiveInactive >= 3) { | |
| core.notice( | |
| `Deployment is inactive with no recoverable URL (likely an ignored build); skipping smoke for this event.`, | |
| ); | |
| core.setOutput("environment", environment || "Preview"); | |
| core.setOutput("environment_url", ""); | |
| core.setOutput("state", "skipped"); | |
| return; | |
| } | |
| } else { | |
| consecutiveInactive = 0; | |
| } | |
| if (terminalFailures.has(state)) { | |
| core.setFailed(`Deployment reached ${lastStatus} before smoke could run.`); | |
| return; | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); | |
| } | |
| core.setFailed(`Deployment did not reach success with an environment URL before smoke timeout; last status: ${lastStatus}.`); | |
| - name: Resolve smoke target | |
| id: target | |
| if: steps.deployment_status.outputs.state != 'skipped' | |
| env: | |
| DEPLOYMENT_JSON: ${{ toJSON(github.event.deployment) }} | |
| DEPLOYMENT_STATUS_JSON: ${{ toJSON(github.event.deployment_status) }} | |
| RESOLVED_DEPLOYMENT_ENVIRONMENT: ${{ steps.deployment_status.outputs.environment }} | |
| RESOLVED_DEPLOYMENT_URL: ${{ steps.deployment_status.outputs.environment_url }} | |
| VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} | |
| # onepercenttreaty.org used to be listed here and is gone on purpose: | |
| # we do not own it. It was registered by someone else on 2026-04-30, | |
| # points at Cloudflare nameservers with an empty zone, and has never | |
| # resolved -- so all nine of its routes returned "no response" and | |
| # kept production smoke permanently red. Do not add it back. If we | |
| # ever acquire it, the redirect (not the app) is what should be | |
| # asserted, since smoke follows redirects and would otherwise just | |
| # re-test warondisease.org. | |
| PUBLIC_PRODUCTION_SMOKE_URLS: ${{ vars.PUBLIC_PRODUCTION_SMOKE_URLS || 'https://warondisease.org,https://optimitron.com' }} | |
| run: | | |
| node <<'NODE' | |
| const fs = require("node:fs"); | |
| const deployment = JSON.parse(process.env.DEPLOYMENT_JSON || "{}"); | |
| const status = JSON.parse(process.env.DEPLOYMENT_STATUS_JSON || "{}"); | |
| const environmentName = String( | |
| process.env.RESOLVED_DEPLOYMENT_ENVIRONMENT || | |
| status.environment || | |
| deployment.environment || | |
| "", | |
| ); | |
| const targetUrl = String( | |
| process.env.RESOLVED_DEPLOYMENT_URL || status.environment_url || "", | |
| ).trim(); | |
| if (!targetUrl) { | |
| throw new Error("deployment_status did not include environment_url."); | |
| } | |
| const parsedUrl = new URL(targetUrl); | |
| const bypassSecret = String( | |
| process.env.VERCEL_AUTOMATION_BYPASS_SECRET || "", | |
| ).trim(); | |
| const productionHosts = new Set([ | |
| "warondisease.org", | |
| "www.warondisease.org", | |
| "optimitron.com", | |
| "www.optimitron.com", | |
| ]); | |
| const isProduction = | |
| /^production$/i.test(environmentName) || | |
| productionHosts.has(parsedUrl.hostname.toLowerCase()); | |
| const environment = isProduction ? "Production" : "Preview"; | |
| const isProtectedVercelDeploymentUrl = | |
| environment === "Production" && | |
| parsedUrl.hostname.endsWith(".vercel.app") && | |
| !bypassSecret; | |
| const publicProductionSmokeUrls = parseUrlList( | |
| process.env.PUBLIC_PRODUCTION_SMOKE_URLS, | |
| ); | |
| const smokeUrls = isProtectedVercelDeploymentUrl | |
| ? publicProductionSmokeUrls | |
| : [targetUrl]; | |
| if (isProtectedVercelDeploymentUrl && smokeUrls.length === 0) { | |
| throw new Error( | |
| "PUBLIC_PRODUCTION_SMOKE_URLS must include at least one public production URL when Production deployment protection is enabled without a bypass secret.", | |
| ); | |
| } | |
| if (isProtectedVercelDeploymentUrl) { | |
| console.log( | |
| "Production deployment URL is Vercel-protected and no Production bypass secret is configured; smoking the public production domains instead.", | |
| ); | |
| } | |
| fs.appendFileSync( | |
| process.env.GITHUB_OUTPUT, | |
| `environment=${environment}\nurl=${smokeUrls[0]}\nurls=${smokeUrls.join(",")}\nsource_url=${targetUrl}\n`, | |
| ); | |
| console.log( | |
| `Resolved ${environment} smoke target(s): ${smokeUrls.join(", ")}`, | |
| ); | |
| function parseUrlList(value) { | |
| return String(value || "") | |
| .split(/[,\s]+/u) | |
| .map((url) => url.trim()) | |
| .filter(Boolean); | |
| } | |
| NODE | |
| - name: Resolve preview smoke scope | |
| id: preview_scope | |
| if: steps.target.outputs.environment == 'Preview' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const { pathToFileURL } = require("node:url"); | |
| const scope = await import( | |
| pathToFileURL(`${process.env.GITHUB_WORKSPACE}/.github/scripts/preview-smoke-scope.mjs`).href | |
| ); | |
| const { owner, repo } = context.repo; | |
| const sha = | |
| context.payload.deployment?.sha || | |
| context.payload.deployment_status?.deployment?.sha || | |
| context.sha; | |
| const pulls = await github.paginate( | |
| github.rest.repos.listPullRequestsAssociatedWithCommit, | |
| { | |
| owner, | |
| repo, | |
| commit_sha: sha, | |
| per_page: 100, | |
| }, | |
| ); | |
| const pull = pulls.find((pr) => pr.state === "open") || pulls[0]; | |
| if (!pull) { | |
| core.info(`No pull request found for deployment commit ${sha}; running preview smoke.`); | |
| core.setOutput("should_smoke", "true"); | |
| return; | |
| } | |
| const { data: currentPull } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: pull.number, | |
| }); | |
| if (currentPull.state === "open" && currentPull.head?.sha && currentPull.head.sha !== sha) { | |
| core.info(`Skipping stale deployment ${sha}; PR #${pull.number} head is ${currentPull.head.sha}.`); | |
| core.setOutput("should_smoke", "false"); | |
| core.setOutput("matched_files", ""); | |
| return; | |
| } | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner, | |
| repo, | |
| pull_number: pull.number, | |
| per_page: 100, | |
| }); | |
| const filenames = files.map((file) => file.filename); | |
| const matches = scope.getPreviewSmokeMatches(filenames); | |
| const shouldSmoke = matches.length > 0; | |
| core.setOutput("should_smoke", shouldSmoke ? "true" : "false"); | |
| core.setOutput("matched_files", matches.join("\n")); | |
| if (shouldSmoke) { | |
| core.info(`Running preview smoke for PR #${pull.number}: ${matches.join(", ")}`); | |
| } else { | |
| core.info(`Limiting preview smoke for PR #${pull.number}: no app/runtime inputs changed.`); | |
| } | |
| - name: Note limited preview smoke scope | |
| if: steps.target.outputs.environment == 'Preview' && steps.preview_scope.outputs.should_smoke == 'false' | |
| run: echo "Running lightweight deployed preview smoke only because this PR only changed workflow/deploy plumbing." | |
| - name: Wait for preview database sync | |
| if: steps.target.outputs.environment == 'Preview' && steps.preview_scope.outputs.should_smoke != 'false' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const deploymentSha = | |
| context.payload.deployment?.sha || | |
| context.payload.deployment_status?.deployment?.sha || | |
| context.sha; | |
| const checkName = "sync-preview-managed-data"; | |
| const upstreamCheckName = "web-validate"; | |
| const timeoutMs = 60 * 60 * 1000; | |
| const intervalMs = 15 * 1000; | |
| const deadline = Date.now() + timeoutMs; | |
| let lastStatus = "not found"; | |
| const pulls = await github.paginate( | |
| github.rest.repos.listPullRequestsAssociatedWithCommit, | |
| { | |
| owner, | |
| repo, | |
| commit_sha: deploymentSha, | |
| per_page: 100, | |
| }, | |
| ); | |
| const pull = pulls.find((candidate) => candidate.state === "open") || pulls[0]; | |
| const checkRefs = new Set([deploymentSha]); | |
| if (pull?.number) { | |
| const { data: currentPull } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: pull.number, | |
| }); | |
| if ( | |
| currentPull.head?.sha === deploymentSha && | |
| currentPull.merge_commit_sha | |
| ) { | |
| checkRefs.add(currentPull.merge_commit_sha); | |
| } | |
| } | |
| core.info(`Waiting for preview checks on ${[...checkRefs].join(", ")}.`); | |
| async function latestCheck(name) { | |
| const runs = []; | |
| for (const ref of checkRefs) { | |
| const { data } = await github.rest.checks.listForRef({ | |
| owner, | |
| repo, | |
| ref, | |
| check_name: name, | |
| per_page: 10, | |
| }); | |
| runs.push(...data.check_runs); | |
| } | |
| return runs.sort((left, right) => | |
| Date.parse(right.started_at || right.created_at || "") - | |
| Date.parse(left.started_at || left.created_at || ""), | |
| )[0]; | |
| } | |
| while (Date.now() < deadline) { | |
| const check = await latestCheck(checkName); | |
| if (check) { | |
| lastStatus = `${check.status}/${check.conclusion || "pending"}`; | |
| core.info(`${checkName}: ${lastStatus}`); | |
| if (check.status === "completed") { | |
| if (check.conclusion === "success") { | |
| return; | |
| } | |
| core.setFailed(`${checkName} concluded ${check.conclusion}: ${check.html_url}`); | |
| return; | |
| } | |
| } else { | |
| const upstreamCheck = await latestCheck(upstreamCheckName); | |
| if (upstreamCheck) { | |
| const upstreamStatus = `${upstreamCheck.status}/${upstreamCheck.conclusion || "pending"}`; | |
| lastStatus = `${checkName}: not found; ${upstreamCheckName}: ${upstreamStatus}`; | |
| core.info(lastStatus); | |
| if (upstreamCheck.status === "completed" && upstreamCheck.conclusion !== "success") { | |
| core.setFailed(`${upstreamCheckName} concluded ${upstreamCheck.conclusion}: ${upstreamCheck.html_url}`); | |
| return; | |
| } | |
| } else { | |
| lastStatus = `${checkName}: not found; ${upstreamCheckName}: not found`; | |
| core.info(`${lastStatus} for ${[...checkRefs].join(", ")}`); | |
| } | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); | |
| } | |
| core.setFailed(`${checkName} did not complete before preview smoke timeout; last status: ${lastStatus}.`); | |
| - name: Run deploy smoke | |
| id: smoke | |
| if: steps.deployment_status.outputs.state != 'skipped' | |
| env: | |
| PREVIEW_URL: ${{ steps.target.outputs.environment == 'Preview' && steps.target.outputs.url || '' }} | |
| PROD_URL: ${{ steps.target.outputs.environment == 'Production' && steps.target.outputs.url || '' }} | |
| PROD_URLS: ${{ steps.target.outputs.environment == 'Production' && steps.target.outputs.urls || '' }} | |
| VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} | |
| SMOKE_DEPLOY_RESULT_FILE: ${{ runner.temp }}/smoke-deploy-result.json | |
| run: | | |
| set +e | |
| node packages/web/scripts/smoke-deploy.mjs | |
| exit_code=$? | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Comment on preview failure | |
| if: always() && steps.target.outputs.environment == 'Preview' && steps.smoke.outputs.exit_code != '' && steps.smoke.outputs.exit_code != '0' | |
| uses: actions/github-script@v8 | |
| env: | |
| SMOKE_DEPLOY_RESULT_FILE: ${{ runner.temp }}/smoke-deploy-result.json | |
| with: | |
| script: | | |
| const fs = require("node:fs"); | |
| const { owner, repo } = context.repo; | |
| const result = JSON.parse( | |
| fs.readFileSync(process.env.SMOKE_DEPLOY_RESULT_FILE, "utf8"), | |
| ); | |
| const sha = | |
| context.payload.deployment?.sha || | |
| context.payload.deployment?.ref || | |
| context.sha; | |
| const pulls = await github.paginate( | |
| github.rest.repos.listPullRequestsAssociatedWithCommit, | |
| { | |
| owner, | |
| repo, | |
| commit_sha: sha, | |
| per_page: 100, | |
| }, | |
| ); | |
| const pull = pulls.find((pr) => pr.state === "open") || pulls[0]; | |
| if (!pull) { | |
| core.warning(`No pull request found for deployment commit ${sha}.`); | |
| return; | |
| } | |
| const failures = result.failures || []; | |
| const rows = failures.map((failure) => { | |
| const status = failure.status ? String(failure.status) : "no response"; | |
| const marker = failure.matchedErrorMarker || "none"; | |
| const detail = truncate(failure.error || "failed", 180); | |
| return `| \`${failure.path}\` | ${escapeCell(status)} | ${escapeCell(marker)} | ${escapeCell(detail)} |`; | |
| }); | |
| const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; | |
| const marker = "<!-- deploy-smoke-preview-failure -->"; | |
| const body = [ | |
| marker, | |
| "### Preview deploy smoke failed", | |
| "", | |
| `Target: ${result.targetUrl}`, | |
| `Run: ${runUrl}`, | |
| "", | |
| "| Route | Status | Error marker | Detail |", | |
| "| --- | --- | --- | --- |", | |
| ...rows, | |
| "", | |
| "The smoke request uses the Vercel automation bypass header and checks HTTP 200, owned error markers, and expected h1 text.", | |
| ].join("\n"); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: pull.number, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find( | |
| (comment) => comment.body && comment.body.includes(marker), | |
| ); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: pull.number, | |
| body, | |
| }); | |
| } | |
| function truncate(value, maxLength) { | |
| const text = String(value).replace(/\s+/g, " ").trim(); | |
| return text.length > maxLength | |
| ? `${text.slice(0, maxLength - 1)}...` | |
| : text; | |
| } | |
| function escapeCell(value) { | |
| return String(value).replace(/\|/g, "\\|"); | |
| } | |
| - name: Post production failure to Slack | |
| if: always() && steps.target.outputs.environment == 'Production' && steps.smoke.outputs.exit_code != '0' | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| SMOKE_DEPLOY_RESULT_FILE: ${{ runner.temp }}/smoke-deploy-result.json | |
| run: | | |
| node <<'NODE' | |
| const fs = require("node:fs"); | |
| main().catch((error) => { | |
| console.error(error); | |
| process.exit(1); | |
| }); | |
| async function main() { | |
| const webhookUrl = process.env.SLACK_WEBHOOK_URL; | |
| if (!webhookUrl) { | |
| console.warn("SLACK_WEBHOOK_URL is missing; production smoke failure was not posted to Slack."); | |
| return; | |
| } | |
| const result = JSON.parse( | |
| fs.readFileSync(process.env.SMOKE_DEPLOY_RESULT_FILE, "utf8"), | |
| ); | |
| const failures = result.failures || []; | |
| const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; | |
| const failureLines = failures | |
| .map((failure) => { | |
| const status = failure.status ? `HTTP ${failure.status}` : "no response"; | |
| const marker = failure.matchedErrorMarker | |
| ? ` marker: ${failure.matchedErrorMarker}` | |
| : ""; | |
| return `- ${failure.path}: ${status}${marker} - ${truncate( | |
| failure.error || "failed", | |
| 160, | |
| )}`; | |
| }) | |
| .join("\n"); | |
| const text = [ | |
| "Production deploy smoke failed", | |
| `Target: ${result.targetUrl}`, | |
| `Run: ${runUrl}`, | |
| failureLines, | |
| ] | |
| .filter(Boolean) | |
| .join("\n"); | |
| const response = await fetch(webhookUrl, { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ text }), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Slack webhook returned HTTP ${response.status}.`); | |
| } | |
| } | |
| function truncate(value, maxLength) { | |
| const textValue = String(value).replace(/\s+/g, " ").trim(); | |
| return textValue.length > maxLength | |
| ? `${textValue.slice(0, maxLength - 1)}...` | |
| : textValue; | |
| } | |
| NODE | |
| - name: Fail deploy smoke | |
| if: always() && steps.smoke.outputs.exit_code != '' && steps.smoke.outputs.exit_code != '0' | |
| run: exit "${{ steps.smoke.outputs.exit_code }}" | |
| playwright-preview: | |
| # Run the smoke Playwright suite against the live preview deploy so we | |
| # exercise the masked-prod-fork Neon branch under real Vercel routing. | |
| # The web-validate Playwright run uses a synthetic CI Postgres, so a | |
| # regression that only triggers on prod-shape data (volume, FK density, | |
| # masking artifacts) would slip past web-validate. This job catches it | |
| # without extending the critical path — it runs in parallel with the | |
| # existing smoke job on the same deployment_status event. | |
| name: Playwright preview smoke | |
| if: >- | |
| !startsWith(github.event.deployment.environment, 'visual-review') && | |
| !startsWith(github.event.deployment_status.environment, 'visual-review') && | |
| github.event.deployment.environment != 'Production' && | |
| github.event.deployment.environment != 'production' && | |
| github.event.deployment_status.environment != 'Production' && | |
| github.event.deployment_status.environment != 'production' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 105 | |
| environment: | |
| name: Preview | |
| deployment: false | |
| env: | |
| PLAYWRIGHT_BROWSER_CHANNEL: chrome | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| - name: Resolve preview smoke scope | |
| id: preview_scope | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const { pathToFileURL } = require("node:url"); | |
| const scope = await import( | |
| pathToFileURL(`${process.env.GITHUB_WORKSPACE}/.github/scripts/preview-smoke-scope.mjs`).href | |
| ); | |
| const { owner, repo } = context.repo; | |
| const sha = | |
| context.payload.deployment?.sha || | |
| context.payload.deployment_status?.deployment?.sha || | |
| context.sha; | |
| const pulls = await github.paginate( | |
| github.rest.repos.listPullRequestsAssociatedWithCommit, | |
| { | |
| owner, | |
| repo, | |
| commit_sha: sha, | |
| per_page: 100, | |
| }, | |
| ); | |
| const pull = pulls.find((pr) => pr.state === "open") || pulls[0]; | |
| if (!pull) { | |
| core.info(`No pull request found for deployment commit ${sha}; running preview smoke.`); | |
| core.setOutput("should_smoke", "true"); | |
| return; | |
| } | |
| const { data: currentPull } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: pull.number, | |
| }); | |
| if (currentPull.state === "open" && currentPull.head?.sha && currentPull.head.sha !== sha) { | |
| core.info(`Skipping stale deployment ${sha}; PR #${pull.number} head is ${currentPull.head.sha}.`); | |
| core.setOutput("should_smoke", "false"); | |
| core.setOutput("matched_files", ""); | |
| return; | |
| } | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner, | |
| repo, | |
| pull_number: pull.number, | |
| per_page: 100, | |
| }); | |
| const filenames = files.map((file) => file.filename); | |
| const matches = scope.getPreviewSmokeMatches(filenames); | |
| const shouldSmoke = matches.length > 0; | |
| core.setOutput("should_smoke", shouldSmoke ? "true" : "false"); | |
| core.setOutput("matched_files", matches.join("\n")); | |
| if (shouldSmoke) { | |
| core.info(`Running preview smoke for PR #${pull.number}: ${matches.join(", ")}`); | |
| } else { | |
| core.info(`Skipping preview smoke for PR #${pull.number}: no app/runtime inputs changed.`); | |
| } | |
| - name: Skip Playwright preview smoke | |
| if: steps.preview_scope.outputs.should_smoke == 'false' | |
| run: echo "Skipping Playwright preview smoke because this PR only changed workflow/deploy plumbing." | |
| - name: Wait for successful deployment URL | |
| id: deployment_status | |
| if: steps.preview_scope.outputs.should_smoke != 'false' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const eventDeployment = context.payload.deployment || {}; | |
| const initialStatus = context.payload.deployment_status || {}; | |
| const eventDeploymentId = | |
| eventDeployment.id || | |
| initialStatus.deployment_id || | |
| initialStatus.deployment?.id; | |
| const eventDeploymentCreator = String( | |
| initialStatus.creator?.login || | |
| eventDeployment.creator?.login || | |
| initialStatus.deployment?.creator?.login || | |
| "", | |
| ); | |
| const deploymentSha = String( | |
| eventDeployment.sha || | |
| initialStatus.deployment?.sha || | |
| context.sha || | |
| "", | |
| ); | |
| const timeoutMs = 20 * 60 * 1000; | |
| const intervalMs = 10 * 1000; | |
| const deadline = Date.now() + timeoutMs; | |
| const terminalFailures = new Set(["failure", "error"]); | |
| let lastStatus = "not found"; | |
| let consecutiveInactive = 0; | |
| function isHttpUrl(value) { | |
| return /^https?:\/\//u.test(String(value || "").trim()); | |
| } | |
| async function resolvePreviewUrlFromVercelComment() { | |
| const { data: pulls } = | |
| await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner, | |
| repo, | |
| commit_sha: deploymentSha, | |
| }); | |
| const pull = | |
| pulls.find((candidate) => candidate.state === "open") || pulls[0]; | |
| if (!pull?.number) return null; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner, | |
| repo, | |
| issue_number: pull.number, | |
| per_page: 100, | |
| }); | |
| for (const comment of [...comments].reverse()) { | |
| if (comment.user?.login !== "vercel[bot]") continue; | |
| const previewMatch = String(comment.body || "").match( | |
| /\[(?:Visit )?Preview\]\((https:\/\/[^)\s]+\.vercel\.app[^)]*)\)/u, | |
| ); | |
| if (previewMatch?.[1]) return previewMatch[1]; | |
| } | |
| return null; | |
| } | |
| async function resolveVercelStatusPreviewUrl() { | |
| const { data } = await github.rest.repos.getCombinedStatusForRef({ | |
| owner, | |
| repo, | |
| ref: deploymentSha, | |
| }); | |
| const vercelStatus = data.statuses.find( | |
| (status) => status.context === "Vercel", | |
| ); | |
| if (!vercelStatus || vercelStatus.state !== "success") return null; | |
| const targetUrl = String(vercelStatus.target_url || "").trim(); | |
| if (targetUrl) { | |
| try { | |
| const hostname = new URL(targetUrl).hostname.toLowerCase(); | |
| if (hostname.endsWith(".vercel.app")) return targetUrl; | |
| } catch {} | |
| } | |
| return resolvePreviewUrlFromVercelComment(); | |
| } | |
| async function resolveVercelDeployment() { | |
| if (eventDeploymentId && eventDeploymentCreator === "vercel[bot]") { | |
| return eventDeployment; | |
| } | |
| const { data } = await github.rest.repos.listDeployments({ | |
| owner, | |
| repo, | |
| sha: deploymentSha, | |
| per_page: 50, | |
| }); | |
| return ( | |
| data.find((deployment) => deployment.creator?.login === "vercel[bot]") || | |
| null | |
| ); | |
| } | |
| async function latestDeploymentStatus(deployment) { | |
| const { data } = await github.rest.repos.listDeploymentStatuses({ | |
| owner, | |
| repo, | |
| deployment_id: deployment.id, | |
| per_page: 10, | |
| }); | |
| return data[0] || initialStatus; | |
| } | |
| while (Date.now() < deadline) { | |
| const deployment = await resolveVercelDeployment(); | |
| if (!deployment?.id) { | |
| const previewUrl = await resolveVercelStatusPreviewUrl(); | |
| if (previewUrl) { | |
| core.info(`Recovered Vercel preview URL from status/comment: ${previewUrl}`); | |
| core.setOutput("environment_url", previewUrl); | |
| core.setOutput("state", "success"); | |
| return; | |
| } | |
| lastStatus = `no Vercel deployment found for ${deploymentSha}`; | |
| core.info(`Deployment status: ${lastStatus}`); | |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); | |
| continue; | |
| } | |
| const status = await latestDeploymentStatus(deployment); | |
| const state = String(status.state || "").toLowerCase(); | |
| const environmentUrl = String(status.environment_url || "").trim(); | |
| lastStatus = `${state || "unknown"} ${environmentUrl || "(no URL yet)"}`; | |
| core.info(`Deployment status: ${lastStatus}`); | |
| if (state === "success") { | |
| if (isHttpUrl(environmentUrl)) { | |
| core.setOutput("environment_url", environmentUrl); | |
| core.setOutput("state", state); | |
| return; | |
| } | |
| const previewUrl = await resolvePreviewUrlFromVercelComment(); | |
| if (previewUrl) { | |
| core.info(`Recovered Vercel preview URL from PR comment: ${previewUrl}`); | |
| core.setOutput("environment_url", previewUrl); | |
| core.setOutput("state", state); | |
| return; | |
| } | |
| } | |
| if (state === "inactive") { | |
| const previewUrl = await resolveVercelStatusPreviewUrl(); | |
| if (previewUrl) { | |
| core.info(`Recovered active Vercel preview URL after inactive deployment event: ${previewUrl}`); | |
| core.setOutput("environment_url", previewUrl); | |
| core.setOutput("state", "success"); | |
| return; | |
| } | |
| // An inactive deployment never becomes active again. With no | |
| // recoverable preview URL this event is a Vercel ignored-build | |
| // (e.g. a commit with no build-scope changes) or a superseded | |
| // deployment — there is nothing to smoke; skip instead of | |
| // burning the 20-minute timeout and failing the check. | |
| consecutiveInactive += 1; | |
| if (consecutiveInactive >= 3) { | |
| core.notice( | |
| `Deployment is inactive with no recoverable preview URL (likely an ignored build); skipping preview smoke for this event.`, | |
| ); | |
| core.setOutput("environment_url", ""); | |
| core.setOutput("state", "skipped"); | |
| return; | |
| } | |
| } else { | |
| consecutiveInactive = 0; | |
| } | |
| if (terminalFailures.has(state)) { | |
| core.setFailed(`Deployment reached ${lastStatus} before preview smoke could run.`); | |
| return; | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); | |
| } | |
| core.setFailed(`Deployment did not reach success with an environment URL before preview smoke timeout; last status: ${lastStatus}.`); | |
| - name: Wait for preview database sync | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const deploymentSha = | |
| context.payload.deployment?.sha || | |
| context.payload.deployment_status?.deployment?.sha || | |
| context.sha; | |
| const checkName = "sync-preview-managed-data"; | |
| const upstreamCheckName = "web-validate"; | |
| const timeoutMs = 60 * 60 * 1000; | |
| const intervalMs = 15 * 1000; | |
| const deadline = Date.now() + timeoutMs; | |
| let lastStatus = "not found"; | |
| const pulls = await github.paginate( | |
| github.rest.repos.listPullRequestsAssociatedWithCommit, | |
| { | |
| owner, | |
| repo, | |
| commit_sha: deploymentSha, | |
| per_page: 100, | |
| }, | |
| ); | |
| const pull = pulls.find((candidate) => candidate.state === "open") || pulls[0]; | |
| const checkRefs = new Set([deploymentSha]); | |
| if (pull?.number) { | |
| const { data: currentPull } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: pull.number, | |
| }); | |
| if ( | |
| currentPull.head?.sha === deploymentSha && | |
| currentPull.merge_commit_sha | |
| ) { | |
| checkRefs.add(currentPull.merge_commit_sha); | |
| } | |
| } | |
| core.info(`Waiting for preview checks on ${[...checkRefs].join(", ")}.`); | |
| async function latestCheck(name) { | |
| const runs = []; | |
| for (const ref of checkRefs) { | |
| const { data } = await github.rest.checks.listForRef({ | |
| owner, | |
| repo, | |
| ref, | |
| check_name: name, | |
| per_page: 10, | |
| }); | |
| runs.push(...data.check_runs); | |
| } | |
| return runs.sort((left, right) => | |
| Date.parse(right.started_at || right.created_at || "") - | |
| Date.parse(left.started_at || left.created_at || ""), | |
| )[0]; | |
| } | |
| while (Date.now() < deadline) { | |
| const check = await latestCheck(checkName); | |
| if (check) { | |
| lastStatus = `${check.status}/${check.conclusion || "pending"}`; | |
| core.info(`${checkName}: ${lastStatus}`); | |
| if (check.status === "completed") { | |
| if (check.conclusion === "success") { | |
| return; | |
| } | |
| core.setFailed(`${checkName} concluded ${check.conclusion}: ${check.html_url}`); | |
| return; | |
| } | |
| } else { | |
| const upstreamCheck = await latestCheck(upstreamCheckName); | |
| if (upstreamCheck) { | |
| const upstreamStatus = `${upstreamCheck.status}/${upstreamCheck.conclusion || "pending"}`; | |
| lastStatus = `${checkName}: not found; ${upstreamCheckName}: ${upstreamStatus}`; | |
| core.info(lastStatus); | |
| if (upstreamCheck.status === "completed" && upstreamCheck.conclusion !== "success") { | |
| core.setFailed(`${upstreamCheckName} concluded ${upstreamCheck.conclusion}: ${upstreamCheck.html_url}`); | |
| return; | |
| } | |
| } else { | |
| lastStatus = `${checkName}: not found; ${upstreamCheckName}: not found`; | |
| core.info(`${lastStatus} for ${[...checkRefs].join(", ")}`); | |
| } | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); | |
| } | |
| core.setFailed(`${checkName} did not complete before preview smoke timeout; last status: ${lastStatus}.`); | |
| - name: Enable Corepack | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| run: | | |
| corepack enable | |
| corepack prepare pnpm@8.14.0 --activate | |
| - name: Setup Node.js | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - name: Install dependencies | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| run: pnpm install --frozen-lockfile | |
| - name: Build web workspace dependencies | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| # Without this, Playwright fails at import time on @optimitron/db, | |
| # @optimitron/data, etc. (transitive imports from e2e/utils/*). | |
| # Same step as web-validate (ci.yml line 152). | |
| run: pnpm --filter @optimitron/web run build:workspace-deps | |
| - name: Verify system Chrome | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| run: google-chrome --version | |
| - name: Install Playwright system deps | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| run: pnpm --filter @optimitron/web exec playwright install-deps chromium | |
| - name: Run Playwright smoke against preview | |
| if: steps.preview_scope.outputs.should_smoke != 'false' && steps.deployment_status.outputs.state != 'skipped' | |
| env: | |
| BASE_URL: ${{ steps.deployment_status.outputs.environment_url }} | |
| VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} | |
| PLAYWRIGHT_SMOKE_SCOPE: critical | |
| # SKIP_SERVER=1 disables the local `next start` that Playwright | |
| # would otherwise try to launch (playwright.config.ts L125). We | |
| # are pointing at a deployed URL; no local server needed. | |
| SKIP_SERVER: "1" | |
| # The smoke suite uses request.get with the configured baseURL + | |
| # extraHTTPHeaders from playwright.config.ts (which picks up | |
| # VERCEL_AUTOMATION_BYPASS_SECRET when set). | |
| run: | | |
| if [ -z "$VERCEL_AUTOMATION_BYPASS_SECRET" ]; then | |
| echo "::error::VERCEL_AUTOMATION_BYPASS_SECRET is not set in the GitHub Preview environment. The secret is fetchable via Vercel API:" | |
| echo "::error:: curl -H 'Authorization: Bearer \$VERCEL_TOKEN' 'https://api.vercel.com/v9/projects/\$VERCEL_PROJECT_ID?teamId=\$VERCEL_ORG_ID' | jq -r '.protectionBypass | keys[0]'" | |
| echo "::error::Then: gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --env Preview --body <value>" | |
| exit 1 | |
| fi | |
| # --project=default runs the headless project. Without this, | |
| # Playwright runs all projects including demo-recording, which | |
| # has headless: false and crashes in CI (no display). | |
| pnpm --filter @optimitron/web exec playwright test e2e/smoke.spec.ts --project=default --reporter=list | |
| - name: Upload Playwright artifacts on failure | |
| if: failure() && steps.preview_scope.outputs.should_smoke != 'false' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-preview-${{ github.run_id }} | |
| path: | | |
| packages/web/playwright-report/ | |
| packages/web/test-results/ | |
| retention-days: 7 | |
| if-no-files-found: ignore |