diff --git a/.github/scripts/preview-smoke-scope.mjs b/.github/scripts/preview-smoke-scope.mjs new file mode 100644 index 000000000..e82b45ee9 --- /dev/null +++ b/.github/scripts/preview-smoke-scope.mjs @@ -0,0 +1,33 @@ +const PREVIEW_SMOKE_PATTERN_SOURCES = [ + "^packages/", + "^scripts/", + "^package\\.json$", + "^pnpm-lock\\.yaml$", + "^pnpm-workspace\\.yaml$", + "^turbo\\.json$", + "^tsconfig(?:\\.[^/]+)?\\.json$", + "^vitest(?:\\.[^/]+)?\\.[cm]?[jt]s$", + "^playwright(?:\\.[^/]+)?\\.[cm]?[jt]s$", +]; + +export const PREVIEW_SMOKE_PATTERNS = PREVIEW_SMOKE_PATTERN_SOURCES.map( + (source) => new RegExp(source), +); + +export function getPreviewSmokeMatches(files) { + return files + .filter((file) => !isTestOnlyFile(file)) + .filter((file) => PREVIEW_SMOKE_PATTERNS.some((pattern) => pattern.test(file))) + .sort(); +} + +export function shouldRunPreviewSmoke(files) { + return getPreviewSmokeMatches(files).length > 0; +} + +function isTestOnlyFile(file) { + return ( + file.includes("/__tests__/") || + /\.(?:spec|test)\.[cm]?[jt]sx?$/u.test(file) + ); +} diff --git a/.github/scripts/preview-smoke-scope.test.mjs b/.github/scripts/preview-smoke-scope.test.mjs new file mode 100644 index 000000000..09531c1a3 --- /dev/null +++ b/.github/scripts/preview-smoke-scope.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + getPreviewSmokeMatches, + shouldRunPreviewSmoke, +} from "./preview-smoke-scope.mjs"; + +test("skips preview smoke for workflow-only deployment plumbing", () => { + const files = [ + ".github/workflows/ci.yml", + ".github/workflows/smoke-deploy.yml", + ".github/scripts/preview-smoke-scope.mjs", + ]; + + assert.equal(shouldRunPreviewSmoke(files), false); + assert.deepEqual(getPreviewSmokeMatches(files), []); +}); + +test("runs preview smoke for app, database, and shared package changes", () => { + const files = [ + "packages/web/src/app/tasks/page.tsx", + "packages/db/prisma/schema.prisma", + "packages/data/src/parameters/parameters.ts", + ]; + + assert.equal(shouldRunPreviewSmoke(files), true); + assert.deepEqual(getPreviewSmokeMatches(files), files.sort()); +}); + +test("runs preview smoke for package manager and build configuration changes", () => { + const files = [ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "turbo.json", + "tsconfig.base.json", + "playwright.config.ts", + ]; + + assert.equal(shouldRunPreviewSmoke(files), true); + assert.deepEqual(getPreviewSmokeMatches(files), files.sort()); +}); + +test("ignores test-only files under runtime paths", () => { + const files = [ + "packages/web/e2e/smoke.spec.ts", + "packages/db/src/__tests__/seed.integration.test.ts", + ]; + + assert.equal(shouldRunPreviewSmoke(files), false); + assert.deepEqual(getPreviewSmokeMatches(files), []); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fa14a6ab..53e93588d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run GitHub automation script tests - run: node --test .github/scripts/generate-pr-preview-links.test.mjs .github/scripts/preview-managed-data-filter.test.mjs .github/scripts/preview-masking-workflow-order.test.mjs .github/scripts/audit-sentry-preview.test.mjs + run: node --test .github/scripts/generate-pr-preview-links.test.mjs .github/scripts/preview-managed-data-filter.test.mjs .github/scripts/preview-smoke-scope.test.mjs .github/scripts/preview-masking-workflow-order.test.mjs .github/scripts/audit-sentry-preview.test.mjs - name: Apply database migrations run: pnpm db:deploy @@ -651,21 +651,26 @@ jobs: run: | target_url="https://mikepsinn.github.io/optimitron/pr-${{ github.event.pull_request.number }}/${{ steps.prepare_pages.outputs.short_sha }}/latest.html" echo "target_url=$target_url" >> "$GITHUB_OUTPUT" + echo "available=false" >> "$GITHUB_OUTPUT" echo "Waiting for $target_url" - for attempt in $(seq 1 90); do - status="$(curl -L -sS --connect-timeout 5 --max-time 15 -o /tmp/visual-review-latest.html -w "%{http_code}" "$target_url" || true)" + max_attempts=18 + for attempt in $(seq 1 "$max_attempts"); do + status="$(curl -L -sS --connect-timeout 3 --max-time 4 -o /tmp/visual-review-latest.html -w "%{http_code}" "$target_url" || true)" if [ "$status" = "200" ] && [ -s /tmp/visual-review-latest.html ]; then + echo "available=true" >> "$GITHUB_OUTPUT" echo "Visual review page is live after attempt $attempt." exit 0 fi - echo "Attempt $attempt/90 returned HTTP $status; retrying in 10s." - sleep 10 + echo "Attempt $attempt/$max_attempts returned HTTP $status; retrying in 5s." + if [ "$attempt" -lt "$max_attempts" ]; then + sleep 5 + fi done - echo "::error::Visual review page did not become available: $target_url" - exit 1 + echo "::warning::Visual review page did not become available before the wait limit: $target_url" + echo "The review artifact was uploaded and the gh-pages publish step completed; GitHub Pages propagation should not fail CI." - name: Post Visual review commit status - if: ${{ !cancelled() && steps.wait_visual_review_pages.outcome == 'success' }} + if: ${{ !cancelled() && steps.wait_visual_review_pages.outputs.available == 'true' }} uses: actions/github-script@v8 with: script: | @@ -685,7 +690,7 @@ jobs: }); - name: Create Visual review deployment - if: ${{ !cancelled() && steps.wait_visual_review_pages.outcome == 'success' }} + if: ${{ !cancelled() && steps.wait_visual_review_pages.outputs.available == 'true' }} uses: actions/github-script@v8 with: script: | @@ -720,7 +725,7 @@ jobs: }); - name: Update PR review packet with visual review - if: ${{ !cancelled() && steps.wait_visual_review_pages.outcome == 'success' && steps.pr_preview_url.outputs.result != '' }} + if: ${{ !cancelled() && steps.wait_visual_review_pages.outputs.available == 'true' && steps.pr_preview_url.outputs.result != '' }} uses: actions/github-script@v8 env: PREVIEW_URL: ${{ steps.pr_preview_url.outputs.result }} @@ -862,8 +867,8 @@ jobs: } - name: Check Preview database sync configuration - if: steps.preview_data_changes.outputs.should_sync == 'true' id: preview_secrets + if: steps.preview_data_changes.outputs.should_sync == 'true' env: NEON_API_KEY: ${{ secrets.NEON_API_KEY }} shell: bash @@ -1045,11 +1050,65 @@ jobs: - name: Verify Vercel configuration env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + shell: bash run: | test -n "$VERCEL_TOKEN" || { echo "Missing GitHub secret VERCEL_TOKEN"; exit 1; } test -n "$VERCEL_ORG_ID" || { echo "Missing GitHub variable VERCEL_ORG_ID"; exit 1; } test -n "$VERCEL_PROJECT_ID" || { echo "Missing GitHub variable VERCEL_PROJECT_ID"; exit 1; } + project_json="$(mktemp)" + status="$( + curl --silent --show-error \ + --output "$project_json" \ + --write-out "%{http_code}" \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v9/projects/$VERCEL_PROJECT_ID?teamId=$VERCEL_ORG_ID" + )" + + case "$status" in + 200) ;; + 401) + echo "::error::VERCEL_TOKEN is missing, expired, or invalid for Vercel API access." + exit 1 + ;; + 403) + echo "::error::VERCEL_TOKEN cannot access Vercel team $VERCEL_ORG_ID. Rotate the GitHub Production VERCEL_TOKEN secret or use a token from that team." + exit 1 + ;; + 404) + echo "::error::VERCEL_PROJECT_ID $VERCEL_PROJECT_ID was not found under VERCEL_ORG_ID $VERCEL_ORG_ID. Check the GitHub Production environment variables." + exit 1 + ;; + *) + echo "::error::Vercel project preflight failed with HTTP $status while checking $VERCEL_PROJECT_ID under $VERCEL_ORG_ID." + exit 1 + ;; + esac + + node - "$project_json" <<'NODE' + const fs = require("node:fs"); + const [projectJsonPath] = process.argv.slice(2); + const project = JSON.parse(fs.readFileSync(projectJsonPath, "utf8")); + const name = project.name || project.projectName || "(unknown)"; + const rootDirectory = project.rootDirectory ?? project.settings?.rootDirectory ?? null; + if (rootDirectory !== "packages/web") { + console.log(`::warning::Vercel project ${name} rootDirectory is ${rootDirectory ?? "(unset)"}, expected packages/web.`); + } + fs.mkdirSync(".vercel", { recursive: true }); + fs.writeFileSync( + ".vercel/project.json", + `${JSON.stringify( + { + orgId: process.env.VERCEL_ORG_ID, + projectId: process.env.VERCEL_PROJECT_ID, + }, + null, + 2, + )}\n`, + ); + console.log(`Verified Vercel project ${name} and wrote .vercel/project.json for CI.`); + NODE + - name: Verify production database configuration env: DATABASE_URL: ${{ secrets.DATABASE_URL }} @@ -1059,14 +1118,14 @@ jobs: - name: Pull Vercel production settings env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: pnpm dlx vercel@${VERCEL_CLI_VERSION} pull --yes --environment=production --token "$VERCEL_TOKEN" + run: pnpm dlx vercel@${VERCEL_CLI_VERSION} pull --yes --environment=production --scope "$VERCEL_ORG_ID" --token "$VERCEL_TOKEN" - name: Build Vercel production artifact env: NODE_OPTIONS: --max-old-space-size=6144 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: pnpm dlx vercel@${VERCEL_CLI_VERSION} build --prod --token "$VERCEL_TOKEN" + run: pnpm dlx vercel@${VERCEL_CLI_VERSION} build --prod --scope "$VERCEL_ORG_ID" --token "$VERCEL_TOKEN" - name: Apply production database migrations env: @@ -1087,7 +1146,7 @@ jobs: env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - deployment_url="$(pnpm dlx vercel@${VERCEL_CLI_VERSION} deploy --prebuilt --prod --token "$VERCEL_TOKEN" --yes | tail -n 1 | tr -d '\r')" + deployment_url="$(pnpm dlx vercel@${VERCEL_CLI_VERSION} deploy --prebuilt --prod --scope "$VERCEL_ORG_ID" --token "$VERCEL_TOKEN" --yes | tail -n 1 | tr -d '\r')" case "$deployment_url" in https://*) ;; *) deployment_url="https://$deployment_url" ;; diff --git a/.github/workflows/smoke-deploy.yml b/.github/workflows/smoke-deploy.yml index 5c4516f83..37ba1a5b8 100644 --- a/.github/workflows/smoke-deploy.yml +++ b/.github/workflows/smoke-deploy.yml @@ -4,6 +4,8 @@ on: deployment_status: permissions: + actions: read + checks: read contents: read deployments: read issues: write @@ -28,7 +30,7 @@ jobs: !startsWith(github.event.deployment.environment, 'visual-review') && !startsWith(github.event.deployment_status.environment, 'visual-review') runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 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 @@ -118,8 +120,110 @@ jobs: } 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 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 preview deploy smoke + if: steps.target.outputs.environment == 'Preview' && steps.preview_scope.outputs.should_smoke == 'false' + run: echo "Skipping deployed preview smoke 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 sha = + context.payload.deployment?.sha || + context.payload.deployment_status?.deployment?.sha || + context.sha; + const checkName = "sync-preview-managed-data"; + const timeoutMs = 10 * 60 * 1000; + const intervalMs = 15 * 1000; + const deadline = Date.now() + timeoutMs; + let lastStatus = "not found"; + + while (Date.now() < deadline) { + const { data } = await github.rest.checks.listForRef({ + owner, + repo, + ref: sha, + check_name: checkName, + per_page: 10, + }); + const check = [...data.check_runs].sort((left, right) => + Date.parse(right.started_at || right.created_at || "") - + Date.parse(left.started_at || left.created_at || ""), + )[0]; + + if (check) { + lastStatus = `${check.status}/${check.conclusion || "pending"}`; + core.info(`${checkName}: ${lastStatus}`); + + if (check.status === "completed") { + if (["success", "skipped", "neutral"].includes(check.conclusion)) { + return; + } + core.setFailed(`${checkName} concluded ${check.conclusion}: ${check.html_url}`); + return; + } + } else { + core.info(`${checkName}: not found for ${sha}`); + } + + 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.target.outputs.environment != 'Preview' || steps.preview_scope.outputs.should_smoke != 'false' env: PREVIEW_URL: ${{ steps.target.outputs.environment == 'Preview' && steps.target.outputs.url || '' }} PROD_URL: ${{ steps.target.outputs.environment == 'Production' && steps.target.outputs.url || '' }} @@ -134,7 +238,7 @@ jobs: exit 0 - name: Comment on preview failure - if: always() && steps.target.outputs.environment == 'Preview' && steps.smoke.outputs.exit_code != '0' + 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 @@ -289,7 +393,7 @@ jobs: NODE - name: Fail deploy smoke - if: always() && steps.smoke.outputs.exit_code != '0' + if: always() && steps.smoke.outputs.exit_code != '' && steps.smoke.outputs.exit_code != '0' run: exit "${{ steps.smoke.outputs.exit_code }}" playwright-preview: @@ -313,7 +417,7 @@ jobs: github.event.deployment_status.environment != 'Production' && github.event.deployment_status.environment != 'production' runs-on: ubuntu-latest - timeout-minutes: 12 + timeout-minutes: 25 environment: name: Preview deployment: false @@ -325,33 +429,140 @@ jobs: 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 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 preview database sync + if: steps.preview_scope.outputs.should_smoke != 'false' + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + const sha = + context.payload.deployment?.sha || + context.payload.deployment_status?.deployment?.sha || + context.sha; + const checkName = "sync-preview-managed-data"; + const timeoutMs = 10 * 60 * 1000; + const intervalMs = 15 * 1000; + const deadline = Date.now() + timeoutMs; + let lastStatus = "not found"; + + while (Date.now() < deadline) { + const { data } = await github.rest.checks.listForRef({ + owner, + repo, + ref: sha, + check_name: checkName, + per_page: 10, + }); + const check = [...data.check_runs].sort((left, right) => + Date.parse(right.started_at || right.created_at || "") - + Date.parse(left.started_at || left.created_at || ""), + )[0]; + + if (check) { + lastStatus = `${check.status}/${check.conclusion || "pending"}`; + core.info(`${checkName}: ${lastStatus}`); + + if (check.status === "completed") { + if (["success", "skipped", "neutral"].includes(check.conclusion)) { + return; + } + core.setFailed(`${checkName} concluded ${check.conclusion}: ${check.html_url}`); + return; + } + } else { + core.info(`${checkName}: not found for ${sha}`); + } + + 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' run: | corepack enable corepack prepare pnpm@8.14.0 --activate - name: Setup Node.js + if: steps.preview_scope.outputs.should_smoke != 'false' uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - name: Install dependencies + if: steps.preview_scope.outputs.should_smoke != 'false' run: pnpm install --frozen-lockfile - name: Build web workspace dependencies + if: steps.preview_scope.outputs.should_smoke != 'false' # 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' run: google-chrome --version - name: Install Playwright system deps + if: steps.preview_scope.outputs.should_smoke != 'false' run: pnpm --filter @optimitron/web exec playwright install-deps chromium - name: Run Playwright smoke against preview + if: steps.preview_scope.outputs.should_smoke != 'false' env: BASE_URL: ${{ github.event.deployment_status.environment_url }} VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} @@ -377,7 +588,7 @@ jobs: - name: Audit Sentry preview errors id: sentry_audit - if: always() + if: always() && steps.preview_scope.outputs.should_smoke != 'false' env: PREVIEW_URL: ${{ github.event.deployment_status.environment_url }} # Dedicated read token should have org:read, project:read, event:read. @@ -401,7 +612,7 @@ jobs: exit 0 - name: Comment on Sentry preview errors - if: always() && steps.sentry_audit.outputs.exit_code != '0' + if: always() && steps.preview_scope.outputs.should_smoke != 'false' && steps.sentry_audit.outputs.exit_code != '0' uses: actions/github-script@v8 continue-on-error: true env: @@ -466,7 +677,7 @@ jobs: } - name: Upload Playwright artifacts on failure - if: failure() + if: failure() && steps.preview_scope.outputs.should_smoke != 'false' uses: actions/upload-artifact@v4 with: name: playwright-preview-${{ github.run_id }} @@ -477,5 +688,5 @@ jobs: if-no-files-found: ignore - name: Fail Sentry preview audit - if: always() && steps.sentry_audit.outputs.exit_code != '0' + if: always() && steps.preview_scope.outputs.should_smoke != 'false' && steps.sentry_audit.outputs.exit_code != '0' run: exit "${{ steps.sentry_audit.outputs.exit_code }}"