187 lines
6.4 KiB
Bash
Executable File
187 lines
6.4 KiB
Bash
Executable File
#!/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
|