ci: resource profiler

This commit is contained in:
ethernet 2026-07-29 04:31:58 -04:00
parent 67c61c3d29
commit ead990a048
11 changed files with 889 additions and 42 deletions

88
.github/actions/profile/action.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: Profile a command (CPU/RAM/Disk)
description: >-
Run a shell command while sampling CPU, RAM, and disk IO every second.
Produces a resource-profile.json artifact per job so the CI timing
report can show per-job resource usage and identify bottlenecks.
inputs:
command:
description: Shell command to run (and profile).
required: true
label:
description: Label for this profile (e.g. "tests slice 1/8").
required: true
working-directory:
description: Directory to run in.
default: '.'
runs:
using: composite
steps:
- name: Start resource profiler
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
# Start profiler in background. It writes to resource-profile.json
# on SIGTERM (or when the command finishes and we signal it).
python3 scripts/ci/resource_profile.py \
--output resource-profile.json \
--label "$PROFILE_LABEL" &
echo $! > "$RUNNER_TEMP/profiler.pid"
env:
PROFILE_LABEL: ${{ inputs.label }}
- name: Run command
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
_CMD: ${{ inputs.command }}
run: |
bash -c "$_CMD"
- name: Stop profiler and collect results
id: stop-profiler
if: always()
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
if [ -f "$RUNNER_TEMP/profiler.pid" ]; then
PID=$(cat "$RUNNER_TEMP/profiler.pid")
if kill -0 "$PID" 2>/dev/null; then
kill -TERM "$PID"
# Give it a moment to write the JSON
for i in 1 2 3 4 5; do
if kill -0 "$PID" 2>/dev/null; then
sleep 0.2
else
break
fi
done
kill -KILL "$PID" 2>/dev/null || true
fi
fi
# hashFiles() only matches inside the workspace, so surface file
# existence as a step output instead for the upload condition.
if [ -s resource-profile.json ]; then
echo "profile_written=true" >> "$GITHUB_OUTPUT"
else
echo "profile_written=false" >> "$GITHUB_OUTPUT"
fi
- name: Sanitize resource profile label
id: sanitize
if: always()
shell: bash
env:
PROFILE_LABEL: ${{ inputs.label }}
run: |
SAFE=$(printf '%s' "$PROFILE_LABEL" | sed -E 's/[^a-zA-Z0-9]+/-/g')
echo "safe_label=$SAFE" >> "$GITHUB_OUTPUT"
- name: Upload resource profile
if: always() && steps.stop-profiler.outputs.profile_written == 'true'
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: resource-profile-${{ steps.sanitize.outputs.safe_label }}
path: ${{ inputs.working-directory }}/resource-profile.json
retention-days: 14

View file

@ -333,6 +333,18 @@ jobs:
restore-keys: |
ci-timings-baseline-
- name: Download resource profiles
# Advisory — if no profiles were uploaded (e.g. sub-workflows
# didn't run), this step finds nothing and the report still works.
# NOTE: no merge-multiple — every artifact contains a file named
# resource-profile.json, so merging would clobber all but one.
# Per-artifact subdirs are exactly what load_resource_profiles walks.
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: resource-profile-*
path: resource-profiles
- name: Collect timings and generate report
env:
GITHUB_TOKEN: ${{ github.token }}
@ -341,7 +353,8 @@ jobs:
--baseline ci-timings-baseline.json \
--output ci-timings-report.html \
--json-out ci-timings.json \
--summary-out ci-timings-summary.md
--summary-out ci-timings-summary.md \
--profiles-dir resource-profiles
- name: Upload HTML report
# Advisory report — artifact-service blips must not fail the job.
@ -361,6 +374,7 @@ jobs:
python3 scripts/ci/timings_report.py \
--from-json ci-timings.json \
--baseline ci-timings-baseline.json \
--profiles-dir resource-profiles \
--review-status-out review-status.json \
--review-status-only

View file

@ -107,16 +107,10 @@ jobs:
command: uv sync --locked --python 3.11 --extra dev
- name: Run docker integration tests
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
# Match the policy in tests.yml :: test job — no accidental
# real-API calls from inside the harness.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
uses: ./.github/actions/profile
with:
label: docker-tests
command: scripts/run_tests.sh tests/docker/ --file-timeout 600
# ---------------------------------------------------------------------------
# Rebuild and push each architecture only after the unprivileged build/test

View file

@ -45,5 +45,8 @@ jobs:
working-directory: website
- name: Build Docusaurus
run: npm run build
working-directory: website
uses: ./.github/actions/profile
with:
label: docs-site-build
working-directory: website
command: npm run build

View file

@ -87,17 +87,20 @@ jobs:
# `npm run test:e2e` builds dist/ as a pretest hook so the renderer
# is always fresh — no separate build step needed.
- name: Run Playwright E2E tests
working-directory: apps/desktop
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
uses: ./.github/actions/profile
with:
label: e2e-desktop
working-directory: apps/desktop
command: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
CI: "true"
# Ensure no real API keys leak into the test env.

View file

@ -66,4 +66,7 @@ jobs:
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix ${{ matrix.package }} ${{ matrix.script }}
- uses: ./.github/actions/profile
with:
label: js-${{ matrix.package }}-${{ matrix.script }}
command: npm run --prefix ${{ matrix.package }} ${{ matrix.script }}

View file

@ -148,8 +148,10 @@ jobs:
- name: ruff check .
# No --exit-zero, no || true. Exit code propagates to the job,
# which propagates to the required-check gate.
run: |
ruff check .
uses: ./.github/actions/profile
with:
label: ruff-blocking
command: ruff check .
windows-footguns:
# Static guardrails on Windows-unsafe Python primitives — os.kill(pid, 0),

View file

@ -114,11 +114,14 @@ jobs:
#
# File list is pre-computed by the generate job (--generate-slices)
# which runs LPT distribution once and passes the file list to each
# matrix job via --files. Previously each job re-discovered files and
# re-ran LPT independently — redundant N times.
run: |
source .venv/bin/activate
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
# matrix job via --files. Previously each job re-discovered files
# and re-ran LPT independently — redundant N times.
uses: ./.github/actions/profile
with:
label: tests-slice-${{ matrix.slice.index }}
command: |
source .venv/bin/activate
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
@ -226,9 +229,12 @@ jobs:
run: uv cache prune --ci
- name: Run e2e tests
run: |
source .venv/bin/activate
python -m pytest tests/e2e/ -v --tb=short
uses: ./.github/actions/profile
with:
label: tests-e2e
command: |
source .venv/bin/activate
python -m pytest tests/e2e/ -v --tb=short
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""