Skip to content
Open
77 changes: 77 additions & 0 deletions .github/workflows/read-perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Read perf

on:
pull_request:
paths:
- 'src/**'
- 'examples/jsm/**'
- 'package.json'
- 'utils/build/**'
- 'test/e2e/perf-**'

# Read-only: safely operates on untrusted PR code.
# The matching report-perf.yml runs with pull-requests:write and posts the comment.
permissions:
contents: read

jobs:
read-perf:
name: Perf regression
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Git checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0 # needed for `git worktree add <baseline-ref>`
- name: Install Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: 'npm'
- name: Install Vulkan drivers and xvfb
run: |
sudo apt-get update
sudo apt-get install -y mesa-vulkan-drivers xvfb
- name: Install dependencies
run: npm ci

- name: === Perf regression ===
id: perf
# Exit 2 = regression detected (we still want to post the comment).
# Exit 1 = crash. continue-on-error lets both cases proceed to the
# artifact upload step so the comment still lands on regressions;
# the overall workflow status still reflects the failure, so GitHub
# shows a red check on regressions or crashes.
continue-on-error: true
run: |
xvfb-run -a node test/e2e/perf-regression-orchestrator.js \
webgpu_backdrop_water \
--baseline=${{ github.event.pull_request.base.sha }}

- name: Attach PR number to summary
# Always run. If the orchestrator crashed we won't have a summary —
# skip gracefully; the crash is already visible in the perf step log.
if: always()
run: |
SUMMARY=test/e2e/perf-regression-webgpu_backdrop_water.summary.json
if [ ! -f "$SUMMARY" ]; then
echo "::warning::No summary JSON found — orchestrator likely crashed. See the 'Perf regression' step log."
exit 0
fi
PR=${{ github.event.pull_request.number }}
node -e "
const fs=require('fs');
const p='$SUMMARY';
const s=JSON.parse(fs.readFileSync(p,'utf8'));
s.pr=$PR;
fs.writeFileSync(p, JSON.stringify(s));
"

- name: Upload artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: perf-summary
path: test/e2e/perf-regression-*.summary.json
if-no-files-found: ignore
92 changes: 92 additions & 0 deletions .github/workflows/report-perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Report perf

on:
workflow_run:
workflows: ["Read perf"]
types:
- completed

# This workflow needs "pull-requests: write" to comment on the PR. It does NOT
# check out PR code — it only reads the artifact produced by Read perf.
# Reference: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
permissions:
pull-requests: write

jobs:
report-perf:
name: Comment on PR
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
# Using actions/download-artifact doesn't work across workflow_run
# https://github.com/actions/download-artifact/issues/60
- name: Download artifact
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
id: download
with:
result-encoding: string
script: |
const fs = require('fs/promises');
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const match = artifacts.data.artifacts.find( a => a.name === 'perf-summary' );
if (!match) throw new Error('perf-summary artifact not found');
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: match.id,
archive_format: 'zip',
});
await fs.writeFile('perf-summary.zip', Buffer.from(download.data));
await exec.exec('unzip -o perf-summary.zip -d perf-summary');
const files = await fs.readdir('perf-summary');
const jsonFile = files.find( f => f.endsWith( '.summary.json' ) );
if (!jsonFile) throw new Error('summary.json not found in artifact');
const json = await fs.readFile('perf-summary/' + jsonFile, 'utf8');
return JSON.stringify({ jsonFile, json });

# Need the formatter script from the base branch. We explicitly check out
# the base ref (never the PR head) so malicious PRs can't substitute
# their own formatter.
- name: Git checkout (base ref)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.workflow_run.pull_requests[0].base.ref || 'dev' }}

- name: Format comment
id: format
run: |
mkdir -p .perf
node -e "
const fs=require('fs');
const { jsonFile, json } = ${{ steps.download.outputs.result }};
fs.writeFileSync('.perf/summary.json', json);
console.log('PR=' + JSON.parse(json).pr);
" | tee -a $GITHUB_OUTPUT
node test/e2e/perf-format-comment.js .perf/summary.json > .perf/comment.md
# Expose body via an env var for the create-or-update-comment step.
{
echo 'BODY<<EOF_PERF'
cat .perf/comment.md
echo 'EOF_PERF'
} >> $GITHUB_ENV

- name: Find existing comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
id: find-comment
with:
issue-number: ${{ steps.format.outputs.PR }}
comment-author: 'github-actions[bot]'
body-includes: 'Perf regression'

- name: Comment on PR
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
with:
issue-number: ${{ steps.format.outputs.PR }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: ${{ env.BODY }}
Loading
Loading