Skip to content

Performance TTFI Gate #9514

Performance TTFI Gate

Performance TTFI Gate #9514

Workflow file for this run

name: Performance TTFI Gate
on:
pull_request:
branches: [main, dev]
paths:
- 'web/**'
- 'scripts/perf-test.sh'
- '.github/workflows/perf-ttfi.yml'
workflow_dispatch:
schedule:
# Every 2 hours at :15 — same cadence as perf-react-commits and
# perf-bundle-size. On scheduled runs ONLY, a failure files a
# `[perf-regression] ttfi-all-cards` issue via the reusable workflow.
- cron: '15 */2 * * *'
# Least-privilege: read + issues:write. issues:write is required at the
# workflow level because the `notify` job calls the reusable
# `_perf-regression-issue.yml` workflow, and reusable workflows cannot
# receive more permissions than their caller.
permissions:
contents: read
actions: read
issues: write
env:
CI: true
PERF_SIGNAL: ttfi-all-cards
jobs:
all-cards-ttfi:
name: All Cards TTFI (Hard Gate)
runs-on: ubuntu-latest
timeout-minutes: 45
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Build production bundle
run: npm run build
- name: Start preview server
run: |
npx vite preview --port 4174 --host &
echo "Waiting for preview server..."
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:4174 > /dev/null 2>&1; then
echo "Preview server ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Preview server failed to start within 30s"
exit 1
- name: Run all-card TTFI suite
env:
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4174
run: npm run test:e2e:perf:ttfi
- name: Compare against baseline (hard fail)
env:
CI_TOLERANCE_PCT: '15'
run: node e2e/perf/compare-ttfi.mjs
- name: Write perf-result.json (scheduled + manual dispatch)
# Scheduled runs drive the auto-issue notifier in production; manual
# dispatches need it too so we can dry-run the full notify pipeline
# without waiting for a cron tick. PR-time failures already surface on
# the PR via the check status, so PR runs are still excluded.
# `always()` is required so this step still runs after the
# `Compare against baseline (hard fail)` step fails — that's exactly
# when we need the JSON written for the notify job. See #6170.
if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
working-directory: .
env:
LAST_SUCCESSFUL_SHA: ''
run: |
set -euo pipefail
REPORT="web/e2e/test-results/ttfi-report.json"
if [ ! -f "$REPORT" ]; then
echo "::warning::$REPORT missing; skipping perf-result.json"
exit 0
fi
# TTFI report schema: { summary: { worstCardTtfi, budgetMs } }
VALUE=$(node -e "const r=require('./${REPORT}');process.stdout.write(String((r.summary&&r.summary.worstCardTtfi)||0))")
BUDGET=$(node -e "const r=require('./${REPORT}');process.stdout.write(String((r.summary&&r.summary.budgetMs)||0))")
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
cat > web/perf-result.json <<EOF
{
"signal": "ttfi-all-cards",
"displayName": "Worst-case card TTFI",
"value": ${VALUE},
"budget": ${BUDGET},
"unit": "ms",
"context": {
"runId": "${GITHUB_RUN_ID}",
"runUrl": "${RUN_URL}",
"headSha": "${GITHUB_SHA}",
"lastSuccessfulSha": "${LAST_SUCCESSFUL_SHA}"
}
}
EOF
- name: Upload perf result artifact (scheduled + manual dispatch)
if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: perf-result-ttfi-all-cards
path: web/perf-result.json
if-no-files-found: warn
retention-days: 14
- name: Upload TTFI artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ttfi-artifacts
path: |
web/e2e/test-results/ttfi-report.json
web/e2e/test-results/ttfi-summary.md
web/e2e/test-results/ttfi-regression.md
web/perf-report/
retention-days: 14
- name: Publish summary
if: always()
run: |
echo "## All-Card TTFI Gate" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f "e2e/test-results/ttfi-summary.md" ]; then
cat e2e/test-results/ttfi-summary.md >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f "e2e/test-results/ttfi-regression.md" ]; then
cat e2e/test-results/ttfi-regression.md >> $GITHUB_STEP_SUMMARY
fi
notify:
name: Notify on regression (scheduled or manual dispatch)
needs: all-cards-ttfi
# File auto-issues on scheduled runs and manual dispatches (so we can
# dry-run the full notifier pipeline without waiting for a cron tick).
# PR failures still excluded — they already surface as red checks on the PR.
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
# Job-level permissions for the reusable workflow. A reusable workflow can
# never receive MORE permissions than its caller job, so this block is the
# documented way to guarantee `issues: write` reaches
# `_perf-regression-issue.yml` even if the workflow-level default is later
# tightened (e.g. to `read-all`). Without this, the auto-issue creator
# silently no-ops with a permission denied. See #6170.
permissions:
contents: read
issues: write
actions: read
uses: ./.github/workflows/_perf-regression-issue.yml
with:
result-path: web/perf-result.json
signal: ttfi-all-cards