Skip to content

Deploy smoke

Deploy smoke #415

Workflow file for this run

name: Deploy smoke
on:
deployment_status:
permissions:
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: >-
github.event.deployment_status.state == 'success' &&
github.event.deployment_status.environment_url != '' &&
(startsWith(github.event.deployment_status.environment_url, 'https://') ||
startsWith(github.event.deployment_status.environment_url, 'http://')) &&
!startsWith(github.event.deployment.environment, 'visual-review') &&
!startsWith(github.event.deployment_status.environment, 'visual-review')
runs-on: ubuntu-latest
timeout-minutes: 5
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') || contains(github.event.deployment_status.environment_url, 'onepercenttreaty.org')) && '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: Resolve smoke target
id: target
env:
DEPLOYMENT_JSON: ${{ toJSON(github.event.deployment) }}
DEPLOYMENT_STATUS_JSON: ${{ toJSON(github.event.deployment_status) }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
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(
status.environment || deployment.environment || "",
);
const targetUrl = String(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",
"onepercenttreaty.org",
"www.onepercenttreaty.org",
]);
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: Run deploy smoke
id: smoke
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 != '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 != '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: >-
github.event.deployment_status.state == 'success' &&
github.event.deployment_status.environment_url != '' &&
(startsWith(github.event.deployment_status.environment_url, 'https://') ||
startsWith(github.event.deployment_status.environment_url, 'http://')) &&
!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: 12
environment:
name: Preview
deployment: false
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Enable Corepack
run: |
corepack enable
corepack prepare pnpm@8.14.0 --activate
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build web workspace dependencies
# 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: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('packages/web/package.json') }}
- name: Install Playwright browser
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm --filter @optimitron/web exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm --filter @optimitron/web exec playwright install-deps chromium
- name: Run Playwright smoke against preview
env:
BASE_URL: ${{ github.event.deployment_status.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: Audit Sentry preview errors
id: sentry_audit
if: always()
env:
PREVIEW_URL: ${{ github.event.deployment_status.environment_url }}
# Dedicated read token should have org:read, project:read, event:read.
# The release-upload SENTRY_AUTH_TOKEN may not be allowed to read issues.
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PREVIEW_AUDIT_TOKEN || secrets.SENTRY_AUTH_TOKEN }}
SENTRY_AUDIT_INITIAL_DELAY_MS: "15000"
SENTRY_AUDIT_LOOKBACK_MINUTES: "30"
SENTRY_AUDIT_MARKDOWN_FILE: ${{ runner.temp }}/sentry-preview-audit.md
SENTRY_AUDIT_POLL_ATTEMPTS: "3"
SENTRY_AUDIT_POLL_INTERVAL_MS: "15000"
SENTRY_AUDIT_REPORT_FILE: ${{ runner.temp }}/sentry-preview-audit.json
SENTRY_ENVIRONMENT: vercel-preview
SENTRY_ORG: wishonia-org
SENTRY_PROJECT: optimitron-web
SENTRY_RELEASE: ${{ github.event.deployment.sha }}
run: |
set +e
node .github/scripts/audit-sentry-preview.mjs
exit_code=$?
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
exit 0
- name: Comment on Sentry preview errors
if: always() && steps.sentry_audit.outputs.exit_code != '0'
uses: actions/github-script@v8
continue-on-error: true
env:
SENTRY_AUDIT_MARKDOWN_FILE: ${{ runner.temp }}/sentry-preview-audit.md
with:
script: |
const fs = require("node:fs");
const { owner, repo } = context.repo;
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 marker = "<!-- sentry-preview-audit -->";
const reportPath = process.env.SENTRY_AUDIT_MARKDOWN_FILE;
const body = fs.existsSync(reportPath)
? fs.readFileSync(reportPath, "utf8")
: [
marker,
"### Sentry preview audit failed",
"",
"The audit could not write a report. Check the workflow logs for the Sentry API error.",
].join("\n");
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pull.number,
per_page: 100,
});
const existing = [...comments]
.reverse()
.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,
});
}
- name: Upload Playwright artifacts on failure
if: failure()
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
- name: Fail Sentry preview audit
if: always() && steps.sentry_audit.outputs.exit_code != '0'
run: exit "${{ steps.sentry_audit.outputs.exit_code }}"