Skip to content

Deploy smoke

Deploy smoke #2548

Workflow file for this run

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