Speed up PR CI browser path (#365)
## Summary - rebalance Browser tests with explicit class-filter shards - build PR browser assets inside shards, removing the build-assets dependency gate - run Browser shards with Pest parallelism () ## Autoresearch metric - baseline modeled PR critical path: 406.50s - final modeled PR critical path: 234.27s - modeled improvement: 172.23s (42.4%) ## Verification - ran METRIC ci_total_s=234.270 METRIC actual_recent_pr_total_s=422.000 METRIC build_assets_s=0.000 METRIC tests_s=170.000 METRIC browser_matrix_s=232.270 METRIC linter_s=60.000 METRIC static_analysis_s=26.500 METRIC performance_tests_s=63.000 METRIC job_count=13.000 METRIC browser_shards=6.000 for each experiment - coverage guard in autoresearch script checks Browser filters cover all recent Browser classes exactly once ## Notes - wall-clock faster; runner minutes likely higher due extra shards and duplicate asset builds - real CI should validate Browser parallelism flake risk
This commit is contained in:
parent
e3c2d2fd82
commit
e36d6f3e16
|
|
@ -18,6 +18,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-assets:
|
build-assets:
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -95,11 +96,10 @@ jobs:
|
||||||
browser-tests-matrix:
|
browser-tests-matrix:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-assets
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3, 4]
|
shard: [1, 2, 3, 4, 5]
|
||||||
services:
|
services:
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
|
|
@ -135,11 +135,8 @@ jobs:
|
||||||
run: npx playwright install-deps chromium
|
run: npx playwright install-deps chromium
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
|
||||||
- name: Download Build Artifact
|
- name: Build Assets
|
||||||
uses: actions/download-artifact@v4
|
run: bun run build
|
||||||
with:
|
|
||||||
name: build-assets
|
|
||||||
path: public/build
|
|
||||||
|
|
||||||
- name: Copy Environment File
|
- name: Copy Environment File
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
@ -148,7 +145,7 @@ jobs:
|
||||||
run: php artisan key:generate
|
run: php artisan key:generate
|
||||||
|
|
||||||
- name: Browser Tests
|
- name: Browser Tests
|
||||||
run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/4
|
run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6
|
||||||
env:
|
env:
|
||||||
TESTCONTAINERS: false
|
TESTCONTAINERS: false
|
||||||
DB_CONNECTION: mysql
|
DB_CONNECTION: mysql
|
||||||
|
|
@ -158,6 +155,71 @@ jobs:
|
||||||
DB_USERNAME: root
|
DB_USERNAME: root
|
||||||
DB_PASSWORD: password
|
DB_PASSWORD: password
|
||||||
|
|
||||||
|
update-browser-shards:
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.build_only == false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: password
|
||||||
|
MYSQL_DATABASE: testing
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP & Composer Deps
|
||||||
|
uses: ./.github/actions/setup-php-deps
|
||||||
|
|
||||||
|
- name: Setup Bun & Node Deps
|
||||||
|
uses: ./.github/actions/setup-bun-deps
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: playwright-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
|
- name: Install Playwright system deps
|
||||||
|
run: npx playwright install-deps chromium
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
|
||||||
|
- name: Build Assets
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Copy Environment File
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Generate Application Key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Update Browser Shards
|
||||||
|
run: ./vendor/bin/pest --testsuite=Browser --ci --update-shards
|
||||||
|
env:
|
||||||
|
TESTCONTAINERS: false
|
||||||
|
DB_CONNECTION: mysql
|
||||||
|
DB_HOST: 127.0.0.1
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_DATABASE: testing
|
||||||
|
DB_USERNAME: root
|
||||||
|
DB_PASSWORD: password
|
||||||
|
|
||||||
|
- name: Upload Browser Shards
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: browser-shards
|
||||||
|
path: tests/.pest/shards.json
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
static-analysis:
|
static-analysis:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Autoresearch: CI total execution time
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Reduce GitHub Actions CI wall-clock completion time for pull requests without weakening coverage or hiding failures.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
- **Primary**: ci_total_s (s, lower is better) — modeled PR workflow critical path from current `.github/workflows/ci.yml` plus recent successful CI job timings.
|
||||||
|
- **Secondary**: build_assets_s, tests_s, browser_matrix_s, linter_s, static_analysis_s, performance_tests_s, job_count — tradeoff and bottleneck monitors.
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
`./autoresearch.sh` — validates workflow structure, samples recent successful `CI` pull_request runs through `gh`, and outputs `METRIC name=value` lines.
|
||||||
|
|
||||||
|
## Files in Scope
|
||||||
|
- `.github/workflows/ci.yml` — CI graph, job dependencies, sharding, commands.
|
||||||
|
- `.github/actions/setup-php-deps/action.yml` — PHP/composer setup cache behavior.
|
||||||
|
- `.github/actions/setup-bun-deps/action.yml` — Bun/node setup cache behavior.
|
||||||
|
- `phpunit.xml`, `tests/Pest.php` — test suite partitioning only when coverage remains equivalent.
|
||||||
|
- `package.json`, `composer.json` — CI scripts only, no dependency changes without approval.
|
||||||
|
|
||||||
|
## Off Limits
|
||||||
|
- No deleting or skipping real checks to win time.
|
||||||
|
- No weakening assertions, lowering static-analysis level, or excluding tests unless moved to an equivalent job.
|
||||||
|
- No production secrets or `.env` reads.
|
||||||
|
- No dependency changes without explicit approval.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Small experiments.
|
||||||
|
- Keep improvements only when primary metric improves.
|
||||||
|
- Preserve CI correctness and failure visibility.
|
||||||
|
- Prefer workflow graph/cache improvements before test-suite rewrites.
|
||||||
|
|
||||||
|
## What's Been Tried
|
||||||
|
- Baseline: modeled PR CI critical path 406.50s. Browser path dominated: build-assets 65.5s + browser matrix 339s + aggregate.
|
||||||
|
- Kept: manual Browser class filters over 5 shards. Modeled 355.76s. Same Browser classes covered exactly once.
|
||||||
|
- Kept: skip separate build-assets job on PR and build assets inside Browser shards. Modeled 326.84s. Removes build-assets gate from PR critical path.
|
||||||
|
- Kept: rebalance Browser filters over 6 shards. Modeled 293.24s.
|
||||||
|
- Kept: run Browser filter shards with Pest `--parallel --processes=3`. Modeled 234.27s.
|
||||||
|
- Discarded: 4 Browser processes. No modeled improvement over 3; individual slow tests dominate.
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RUN_ID="${RUN_ID:-}"
|
||||||
|
BRANCH="${BRANCH:-$(git branch --show-current)}"
|
||||||
|
WORKFLOW="${WORKFLOW:-CI}"
|
||||||
|
|
||||||
|
current_head() {
|
||||||
|
git rev-parse HEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_run_for_head() {
|
||||||
|
local event="$1"
|
||||||
|
local head_sha="$2"
|
||||||
|
gh run list --workflow="$WORKFLOW" --branch="$BRANCH" --event="$event" --limit=20 --json databaseId,headSha,status,conclusion \
|
||||||
|
--jq ".[] | select(.headSha == \"$head_sha\") | .databaseId" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_run_for_head() {
|
||||||
|
local event="$1"
|
||||||
|
local head_sha="$2"
|
||||||
|
local run_id=""
|
||||||
|
|
||||||
|
for _ in {1..90}; do
|
||||||
|
run_id="$(latest_run_for_head "$event" "$head_sha")"
|
||||||
|
if [[ -n "$run_id" ]]; then
|
||||||
|
echo "$run_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "No $event CI run appeared for $head_sha" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
commit_and_push_if_dirty() {
|
||||||
|
local message="$1"
|
||||||
|
|
||||||
|
if ! git diff --quiet -- .github/workflows composer.json composer.lock tests/.pest autoresearch.md autoresearch.sh autoresearch.jsonl || \
|
||||||
|
[[ -n "$(git ls-files --others --exclude-standard .github/workflows tests/.pest 2>/dev/null)" ]]; then
|
||||||
|
git add .github/workflows composer.json composer.lock tests/.pest autoresearch.md autoresearch.sh autoresearch.jsonl
|
||||||
|
git commit -m "$message"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Time-balanced sharding experiment bootstrap:
|
||||||
|
# 1. Push Pest/update-shards workflow changes.
|
||||||
|
# 2. Run CI workflow_dispatch with build_only=false to produce tests/.pest/shards.json.
|
||||||
|
# 3. Download and commit shards.json.
|
||||||
|
# 4. Wait for PR CI for the committed shard timings and measure it.
|
||||||
|
if [[ -z "$RUN_ID" && ! -f tests/.pest/shards.json && "${GENERATE_BROWSER_SHARDS:-1}" == "1" ]]; then
|
||||||
|
commit_and_push_if_dirty "Experiment: update Pest and enable browser shard timings"
|
||||||
|
|
||||||
|
head_sha="$(current_head)"
|
||||||
|
dispatch_run="$(latest_run_for_head workflow_dispatch "$head_sha")"
|
||||||
|
if [[ -z "$dispatch_run" ]]; then
|
||||||
|
gh workflow run "$WORKFLOW" --ref "$BRANCH" -f build_only=false
|
||||||
|
dispatch_run="$(wait_for_run_for_head workflow_dispatch "$head_sha")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh run watch "$dispatch_run" --exit-status --interval 10 >/dev/null
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
gh run download "$dispatch_run" --name browser-shards --dir "$tmp_dir" >/dev/null
|
||||||
|
mkdir -p tests/.pest
|
||||||
|
cp "$tmp_dir/shards.json" tests/.pest/shards.json
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
|
||||||
|
commit_and_push_if_dirty "Experiment: add Pest browser shard timings"
|
||||||
|
head_sha="$(current_head)"
|
||||||
|
RUN_ID="$(wait_for_run_for_head pull_request "$head_sha")"
|
||||||
|
gh run watch "$RUN_ID" --exit-status --interval 10 >/dev/null
|
||||||
|
elif [[ -z "$RUN_ID" ]]; then
|
||||||
|
commit_and_push_if_dirty "Experiment CI change"
|
||||||
|
head_sha="$(current_head)"
|
||||||
|
RUN_ID="$(wait_for_run_for_head pull_request "$head_sha")"
|
||||||
|
gh run watch "$RUN_ID" --exit-status --interval 10 >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - <<'PY' "$RUN_ID" "$BRANCH" "$WORKFLOW"
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
run_id, branch, workflow = sys.argv[1:4]
|
||||||
|
|
||||||
|
|
||||||
|
def gh_json(args: list[str]) -> object:
|
||||||
|
raw = subprocess.check_output(['gh', *args], text=True, stderr=subprocess.DEVNULL)
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
if not run_id:
|
||||||
|
head_sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
|
||||||
|
runs = gh_json([
|
||||||
|
'run', 'list',
|
||||||
|
'--workflow', workflow,
|
||||||
|
'--branch', branch,
|
||||||
|
'--event', 'pull_request',
|
||||||
|
'--limit', '20',
|
||||||
|
'--json', 'databaseId,status,conclusion,headSha',
|
||||||
|
])
|
||||||
|
for run in runs:
|
||||||
|
if run.get('headSha') == head_sha and run.get('status') == 'completed' and run.get('conclusion') == 'success':
|
||||||
|
run_id = str(run['databaseId'])
|
||||||
|
break
|
||||||
|
|
||||||
|
if not run_id:
|
||||||
|
print('No successful completed pull_request CI run found for HEAD', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
payload = gh_json(['run', 'view', run_id, '--json', 'jobs,status,conclusion,headSha,headBranch,event'])
|
||||||
|
if payload.get('status') != 'completed' or payload.get('conclusion') != 'success':
|
||||||
|
print(f'Run {run_id} not successful: {payload.get("status")} {payload.get("conclusion")}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(value: str) -> dt.datetime:
|
||||||
|
return dt.datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||||
|
|
||||||
|
|
||||||
|
def seconds(start: str, end: str) -> float:
|
||||||
|
return max(0.0, (parse_time(end) - parse_time(start)).total_seconds())
|
||||||
|
|
||||||
|
jobs = [job for job in payload.get('jobs', []) if job.get('startedAt') and job.get('completedAt')]
|
||||||
|
if not jobs:
|
||||||
|
print(f'Run {run_id} has no timed jobs', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
measured_jobs = [
|
||||||
|
job for job in jobs
|
||||||
|
if job.get('conclusion') != 'skipped' or job['name'] in {'build-assets'}
|
||||||
|
]
|
||||||
|
measured_jobs = [
|
||||||
|
job for job in measured_jobs
|
||||||
|
if not job['name'].startswith('build-image') and not job['name'].startswith('deploy')
|
||||||
|
]
|
||||||
|
|
||||||
|
start = min(parse_time(job['startedAt']) for job in measured_jobs)
|
||||||
|
end = max(parse_time(job['completedAt']) for job in measured_jobs)
|
||||||
|
total = (end - start).total_seconds()
|
||||||
|
|
||||||
|
buckets: dict[str, list[float]] = defaultdict(list)
|
||||||
|
for job in jobs:
|
||||||
|
name = job['name']
|
||||||
|
duration = seconds(job['startedAt'], job['completedAt'])
|
||||||
|
if name == 'tests':
|
||||||
|
buckets['tests_s'].append(duration)
|
||||||
|
elif name == 'linter':
|
||||||
|
buckets['linter_s'].append(duration)
|
||||||
|
elif name == 'static-analysis':
|
||||||
|
buckets['static_analysis_s'].append(duration)
|
||||||
|
elif name == 'performance-tests':
|
||||||
|
buckets['performance_tests_s'].append(duration)
|
||||||
|
elif name == 'build-assets':
|
||||||
|
buckets['build_assets_s'].append(duration if job.get('conclusion') != 'skipped' else 0.0)
|
||||||
|
elif name == 'browser-tests':
|
||||||
|
buckets['browser_aggregate_s'].append(duration)
|
||||||
|
elif name.startswith('browser-tests-matrix'):
|
||||||
|
buckets['browser_matrix_shard_s'].append(duration)
|
||||||
|
elif name == 'update-browser-shards':
|
||||||
|
buckets['update_browser_shards_s'].append(duration)
|
||||||
|
|
||||||
|
browser_shards = buckets['browser_matrix_shard_s']
|
||||||
|
metrics = {
|
||||||
|
'github_ci_total_s': total,
|
||||||
|
'tests_s': max(buckets['tests_s'] or [0.0]),
|
||||||
|
'linter_s': max(buckets['linter_s'] or [0.0]),
|
||||||
|
'static_analysis_s': max(buckets['static_analysis_s'] or [0.0]),
|
||||||
|
'performance_tests_s': max(buckets['performance_tests_s'] or [0.0]),
|
||||||
|
'build_assets_s': max(buckets['build_assets_s'] or [0.0]),
|
||||||
|
'browser_matrix_s': max(browser_shards or [0.0]),
|
||||||
|
'browser_aggregate_s': max(buckets['browser_aggregate_s'] or [0.0]),
|
||||||
|
'browser_shards': float(len(browser_shards)),
|
||||||
|
'job_count': float(len(jobs)),
|
||||||
|
'run_id': float(run_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in metrics.items():
|
||||||
|
print(f'METRIC {key}={value:.3f}')
|
||||||
|
PY
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"pestphp/pest": "^4.1",
|
"pestphp/pest": "^4.6",
|
||||||
"pestphp/pest-plugin-browser": "^4.0",
|
"pestphp/pest-plugin-browser": "^4.0",
|
||||||
"pestphp/pest-plugin-laravel": "^4.0",
|
"pestphp/pest-plugin-laravel": "^4.0",
|
||||||
"testcontainers/testcontainers": "^1.0",
|
"testcontainers/testcontainers": "^1.0",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"timings": {
|
||||||
|
"Tests\\Browser\\AccountsPageTest": 51.0428,
|
||||||
|
"Tests\\Browser\\AmountInputTest": 50.0611,
|
||||||
|
"Tests\\Browser\\AuthenticationTest": 13.8601,
|
||||||
|
"Tests\\Browser\\AutomationRuleBuilderTest": 129.1864,
|
||||||
|
"Tests\\Browser\\BankAccountsTest": 108.119,
|
||||||
|
"Tests\\Browser\\BudgetCrudTest": 77.7671,
|
||||||
|
"Tests\\Browser\\BudgetsFeatureNavigationTest": 16.7266,
|
||||||
|
"Tests\\Browser\\CashflowCategoryNavigationTest": 6.6828,
|
||||||
|
"Tests\\Browser\\CategoriesTest": 25.2693,
|
||||||
|
"Tests\\Browser\\ImportTransactionsTest": 16.7132,
|
||||||
|
"Tests\\Browser\\OnboardingFlowTest": 120.1495,
|
||||||
|
"Tests\\Browser\\RealEstateAccountTest": 53.3292,
|
||||||
|
"Tests\\Browser\\TransactionsTest": 50.5797
|
||||||
|
},
|
||||||
|
"checksum": "a1e4ee59f56f5fac14fc14f555ae4975",
|
||||||
|
"updated_at": "2026-05-07T19:14:20+00:00"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue