-
Notifications
You must be signed in to change notification settings - Fork 2.3k
575 lines (541 loc) · 27.2 KB
/
Copy pathplaywright-postgresql-e2e.yml
File metadata and controls
575 lines (541 loc) · 27.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
# Copyright 2021 Collate
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Dispatcher for the postgresql Playwright E2E PR gate.
#
# The heavy lifting (gate → build → shard) lives in
# `playwright-e2e-reusable.yml`; this file adds two top-level jobs that
# MUST stay in the caller so branch protection can find them by their
# UN-PREFIXED check names:
#
# * `playwright-summary` — the required PR check + PR-comment
# payload uploader (comment forwarding lives
# in playwright-postgresql-pr-comment.yml).
# * `refresh-timing-baseline` — merge_group-only auto-PR that keeps
# `.github/playwright/timing-baseline.json`
# fresh from the latest full-mode run.
#
# Reusable jobs are reported as `<caller-job> / <sub-job>` — GH always
# prefixes — so a required check named `playwright-summary` could never
# be satisfied from inside the reusable. Keeping these two jobs at the
# caller top level lets branch protection match the exact name.
#
# Release-branch dry-runs live in `playwright-postgresql-nightly.yml`;
# any change to shard planning, caching, gate rules, or the shadow-gate
# ledger still belongs in the reusable so both callers stay aligned.
name: Postgresql PR Playwright E2E Tests
on:
merge_group:
workflow_dispatch:
inputs:
full_suite:
description: Run the complete duration-balanced suite
required: true
type: boolean
default: true
protocol:
description: Application protocol used by Playwright
required: true
type: choice
options:
- http
- h2
default: http
coarse_bundle:
description: Build the CI-only coarse Vite bundle
required: true
type: boolean
default: true
# Same-repo PRs run under pull_request — unprivileged, secrets absent
# from the runner. These jobs build PR code, populate caches, and
# exchange artifacts.
pull_request:
types:
- labeled
- opened
- synchronize
- reopened
- ready_for_review
# Fork PRs need cloud-connector secrets (TEST_SNOWFLAKE_*, TEST_BQ_*,
# TEST_REDSHIFT_* …) that pull_request events cannot access on forks.
# pull_request_target runs in the base repo's context so those secrets
# resolve, but requires a maintainer to apply the "safe to test" label
# first (enforced by the gate job in the reusable workflow). Same-repo
# PRs already ran under pull_request above; the reusable short-circuits
# on them via the head-repo check in the gate job.
pull_request_target:
types:
- labeled
- opened
- synchronize
- reopened
- ready_for_review
permissions:
actions: read
contents: read
pull-requests: read
jobs:
playwright:
uses: ./.github/workflows/playwright-e2e-reusable.yml
# secrets: inherit passes the full TEST_* / GITHUB_TOKEN set into the
# reusable without re-listing each one; the reusable only reads what it
# needs.
secrets: inherit
permissions:
actions: read
contents: read
pull-requests: read
checks: read
with:
database: postgresql
profile: postgresql-pr
workflow_filename: playwright-postgresql-e2e.yml
# Defaults inline what workflow_dispatch collects; non-dispatch events
# fall back to the reusable's own defaults (full_suite=true, http,
# coarse_bundle=true).
full_suite: ${{ github.event_name == 'workflow_dispatch' && inputs.full_suite || true }}
protocol: ${{ github.event_name == 'workflow_dispatch' && inputs.protocol || 'http' }}
coarse_bundle: ${{ github.event_name == 'workflow_dispatch' && inputs.coarse_bundle || true }}
playwright-summary:
# Publish the required check name `playwright-summary` ONLY when the
# reusable's gate authorized the run. Every explicit gate skip publishes
# a differently-named check so a redundant sibling event cannot satisfy
# branch protection before the real pipeline finishes. Gate failures
# keep the required name and fail in the guard step below.
#
# Decision tree (mirrors the reusable's gate outputs verbatim):
# gate succeeded with should_run=true → 'playwright-summary'
# gate failed → 'playwright-summary'
# labeled event with non-'safe to test' label → 'playwright-summary (label ignored)'
# gate succeeded with should_run=false → 'playwright-summary (skipped)'
name: >-
${{
(
needs.playwright.outputs.gate_result != 'success'
|| needs.playwright.outputs.gate_should_run == 'true'
)
&& 'playwright-summary'
|| (
github.event.action == 'labeled'
&& github.event.label.name != 'safe to test'
&& 'playwright-summary (label ignored)'
|| 'playwright-summary (skipped)'
)
}}
if: ${{ always() && !cancelled() }}
needs: [playwright]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: read
contents: read
steps:
# SECURITY: this job runs `require('./.github/scripts/render_playwright_summary.cjs')`
# via `actions/github-script` with GITHUB_TOKEN in scope, so whichever tree we
# check out here becomes trusted code executing with token access. For fork PRs
# (pull_request_target) we MUST NOT check out the fork's head — a malicious fork
# could otherwise edit render_playwright_summary.cjs to exfiltrate the token or
# rewrite the check result. Use the base branch's SHA on pull_request_target so
# only reviewed-and-merged versions of these scripts ever run. Everything else
# keeps github.sha (PR merge commit / merge-queue commit / dispatch ref).
- name: Checkout
id: checkout
continue-on-error: true
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
- name: Download blob reports
id: download-blobs
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
uses: actions/download-artifact@v7
continue-on-error: true
with:
pattern: playwright-blob-*
path: ${{ runner.temp }}/playwright-blobs
merge-multiple: true
- name: Download timing metrics
id: download-timings
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
uses: actions/download-artifact@v7
continue-on-error: true
with:
pattern: playwright-timings-*
path: ${{ runner.temp }}/playwright-timings
- name: Download shard plans
id: download-plans
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: playwright-shard-plans
path: ${{ runner.temp }}/playwright-plans
- name: Download all results JSON
id: download-results
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
uses: actions/download-artifact@v7
continue-on-error: true
with:
pattern: playwright-results-json-*
path: results
# Pin the per-artifact subdirectory layout the renderer expects
# (results/playwright-results-json-<shardId>/results.json). When
# only one artifact matches the pattern — typical for spec-only
# single-shard PR runs — some download-artifact configurations
# flatten the contents directly into `path:`, which breaks the
# readdirSync-based shard discovery in render_playwright_summary.cjs
# and reports the shard as "did not upload a usable Playwright
# results artifact". Explicitly locking merge-multiple=false keeps
# the layout consistent across single-shard and multi-shard runs.
# See run 30088248354 for the failure this addresses.
merge-multiple: false
# Diagnostic: log the actual on-disk layout the renderer sees. The
# explicit merge-multiple pin above matches the documented default,
# so if single-shard runs still fail the same way we need this
# trace to see whether download-artifact placed files at
# `results/playwright-results-json-<shardId>/results.json` (expected)
# or somewhere else. Remove once single-shard runs are consistently
# green.
- name: Diagnose downloaded results layout
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
run: |
echo "=== results/ tree ==="
ls -laR results 2>&1 || echo "(results/ does not exist)"
echo
echo "=== expected shardIds (from plan-playwright.matrix) ==="
echo '${{ needs.playwright.outputs.plan_playwright_matrix }}' \
| jq -r '.include[].shardId' \
|| echo "(jq parse failed)"
- name: Setup Node.js
id: setup-node
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
uses: actions/setup-node@v5
with:
node-version-file: openmetadata-ui/src/main/resources/ui/.nvmrc
cache: yarn
cache-dependency-path: openmetadata-ui/src/main/resources/ui/yarn.lock
- name: Install report dependencies
id: install-report-dependencies
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
working-directory: openmetadata-ui/src/main/resources/ui
run: |
corepack enable
timeout --foreground --signal=TERM --kill-after=30s 5m \
yarn --ignore-scripts --frozen-lockfile
- name: Mark report generation start
id: mark-report-start
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
run: echo "PW_REPORT_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV"
- name: Merge HTML report
id: merge-report
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
working-directory: openmetadata-ui/src/main/resources/ui
env:
PLAYWRIGHT_HTML_OUTPUT_DIR: ${{ runner.temp }}/playwright-report
run: |
if ! find "$RUNNER_TEMP/playwright-blobs" -type f -name '*.zip' -print -quit | grep -q .; then
echo "No Playwright blob reports were uploaded" >&2
exit 1
fi
timeout --foreground --signal=TERM --kill-after=30s 3m \
npx playwright merge-reports --reporter=html "$RUNNER_TEMP/playwright-blobs"
- name: Merge Playwright timing history
id: merge-timings
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
env:
EXECUTION_MODE: ${{ needs.playwright.outputs.detect_changes_mode }}
SOURCE_SHA: ${{ github.sha }}
run: |
timeout --foreground --signal=TERM --kill-after=30s 1m \
python3 .github/scripts/merge_playwright_timings.py \
--input-glob "$RUNNER_TEMP/playwright-timings/**/playwright-timings.json" \
--mode "$EXECUTION_MODE" \
--source-sha "$SOURCE_SHA" \
--output "$RUNNER_TEMP/playwright-timing-history/playwright-timing-history.json"
- name: Verify Playwright timing coverage
id: verify-coverage
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
run: |
timeout --foreground --signal=TERM --kill-after=30s 1m \
python3 .github/scripts/verify_playwright_coverage.py \
--plan-glob "$RUNNER_TEMP/playwright-plans/*.json" \
--timing-glob "$RUNNER_TEMP/playwright-timings/**/playwright-timings.json" \
--result-glob "$GITHUB_WORKSPACE/results/playwright-results-json-*/results.json" \
--output "$RUNNER_TEMP/playwright-timing-history/playwright-coverage.json"
- name: Evaluate Playwright performance
id: evaluate-performance
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
env:
EXECUTION_MODE: ${{ needs.playwright.outputs.detect_changes_mode }}
run: |
performance_args=()
if [[ "$EXECUTION_MODE" == "full" ]]; then
performance_args+=(--enforce)
fi
timeout --foreground --signal=TERM --kill-after=30s 2m \
python3 .github/scripts/evaluate_playwright_performance.py \
--timing-glob "$RUNNER_TEMP/playwright-timings/**/playwright-timings.json" \
--request-glob "$RUNNER_TEMP/playwright-timings/**/request-metrics.json" \
--phase-glob "$RUNNER_TEMP/playwright-timings/**/shard-phases.json" \
--mode "$EXECUTION_MODE" \
--output "$RUNNER_TEMP/playwright-timing-history/playwright-performance.json" \
"${performance_args[@]}"
jq . "$RUNNER_TEMP/playwright-timing-history/playwright-performance.json"
- name: Upload merged Playwright report
id: upload-report
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: playwright-report-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/playwright-report
retention-days: 5
if-no-files-found: ignore
- name: Record reporting and report-upload duration
id: record-report-duration
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
env:
EXECUTION_MODE: ${{ needs.playwright.outputs.detect_changes_mode }}
run: |
performance="$RUNNER_TEMP/playwright-timing-history/playwright-performance.json"
[[ -f "$performance" ]] || exit 0
report_seconds=$(($(date +%s) - PW_REPORT_STARTED_AT))
jq \
--argjson reportSeconds "$report_seconds" \
'.metrics.reportingSeconds = $reportSeconds |
.targets.reportingAtMostTwoMinutes = ($reportSeconds <= 120) |
.blockingTargets.reportingAtMostTwoMinutes = ($reportSeconds <= 120) |
.targetsMet = ([.targets[]] | all) |
.blockingTargetsMet = ([.blockingTargets[]] | all) |
.convergenceTargetsMet = ([.convergenceTargets[]] | all)' \
"$performance" > "$performance.tmp"
mv "$performance.tmp" "$performance"
if [[ "$EXECUTION_MODE" == "full" && "$report_seconds" -gt 120 ]]; then
echo "Playwright reporting and report upload exceeded two minutes" >&2
exit 1
fi
- name: Upload timing history
id: upload-timing-history
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: playwright-timing-history-${{ needs.playwright.outputs.detect_changes_mode }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/playwright-timing-history
retention-days: 30
if-no-files-found: warn
- name: Evaluate zero-retry gate in shadow mode
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
env:
EXPECTED_MATRIX: ${{ needs.playwright.outputs.plan_playwright_matrix }}
MATRIX_OUTCOME: ${{ needs.playwright.outputs.playwright_ci_result }}
SOURCE_SHA: ${{ github.sha }}
run: |
python3 .github/scripts/classify_playwright_outcome.py \
--report-glob 'results/playwright-results-json-*/results.json' \
--status-glob 'results/playwright-results-json-*/ci-status.json' \
--matrix-outcome "$MATRIX_OUTCOME" \
--expected-matrix-json "$EXPECTED_MATRIX" \
--profile postgresql-pr \
--source-sha "$SOURCE_SHA" \
--output "$RUNNER_TEMP/playwright-shadow-gate/outcome.json"
- name: Upload Playwright shadow-gate ledger
if: ${{ always() && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: playwright-shadow-gate-postgresql-pr-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/playwright-shadow-gate/outcome.json
if-no-files-found: warn
retention-days: 30
# If gate itself didn't produce a valid decision (crash, cancelled,
# unknown), FAIL loudly rather than silently reporting green below.
# Without this guard, the "should_run != 'true'" branch (or any
# negative match) would treat an unset gate output the same as an
# explicit skip decision — hiding the gate failure and letting a
# required check pass on an invalid pipeline state.
- name: Guard against missing gate decision
if: ${{ needs.playwright.outputs.gate_result != 'success' }}
run: |
echo "::error::gate did not succeed (result=${{ needs.playwright.outputs.gate_result }}, should_run=${{ needs.playwright.outputs.gate_should_run }}). Refusing synthetic green — this playwright-summary must not report success without a valid gate decision."
exit 1
# Short-circuit when gate explicitly decided should_run=false —
# every upstream job is legitimately `skipped` in that case
# (redundant pull_request_target for a same-repo PR, or fork PR
# without safe-to-test), and the renderer below counts each
# skipped upstream as a "CI/reporting failure" and fails the whole
# check. That's what turned run 30090391086 red on PR #30454 despite
# the pipeline correctly opting out. Match on the exact "false"
# string (not != 'true') so unset outputs never fall through here.
- name: Report gate-skipped run as green
if: ${{ needs.playwright.outputs.gate_should_run == 'false' }}
run: |
echo "Gate decided should_run=false for event=${{ github.event_name }}."
echo "This run is intentionally skipped; the authoritative playwright-summary comes from the sibling event's run."
echo "Exiting 0 so this check does not block branch protection."
- name: Render consolidated job summary and gate on results
if: ${{ always() && needs.playwright.outputs.gate_should_run == 'true' }}
uses: actions/github-script@v8
env:
CHECK_CHANGES_RESULT: ${{ needs.playwright.outputs.check_changes_result }}
CACHE_KEYS_RESULT: ${{ needs.playwright.outputs.cache_keys_result }}
BUILD_RESULT: ${{ needs.playwright.outputs.build_result }}
DETECT_CHANGES_RESULT: ${{ needs.playwright.outputs.detect_changes_result }}
PLAN_RESULT: ${{ needs.playwright.outputs.plan_playwright_result }}
FIXTURE_RESTORE_RESULT: ${{ needs.playwright.outputs.restore_playwright_fixture_result }}
FIXTURE_RESULT: ${{ needs.playwright.outputs.prepare_playwright_fixture_result }}
PLAYWRIGHT_RESULT: ${{ needs.playwright.outputs.playwright_ci_result }}
SUMMARY_CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }}
REPORT_DOWNLOAD_BLOBS_OUTCOME: ${{ steps.download-blobs.outcome }}
REPORT_DOWNLOAD_TIMINGS_OUTCOME: ${{ steps.download-timings.outcome }}
REPORT_DOWNLOAD_PLANS_OUTCOME: ${{ steps.download-plans.outcome }}
REPORT_DOWNLOAD_RESULTS_OUTCOME: ${{ steps.download-results.outcome }}
REPORT_SETUP_NODE_OUTCOME: ${{ steps.setup-node.outcome }}
REPORT_INSTALL_OUTCOME: ${{ steps.install-report-dependencies.outcome }}
REPORT_START_OUTCOME: ${{ steps.mark-report-start.outcome }}
REPORT_MERGE_OUTCOME: ${{ steps.merge-report.outcome }}
REPORT_TIMING_MERGE_OUTCOME: ${{ steps.merge-timings.outcome }}
REPORT_COVERAGE_OUTCOME: ${{ steps.verify-coverage.outcome }}
REPORT_PERFORMANCE_OUTCOME: ${{ steps.evaluate-performance.outcome }}
REPORT_UPLOAD_OUTCOME: ${{ steps.upload-report.outcome }}
REPORT_DURATION_OUTCOME: ${{ steps.record-report-duration.outcome }}
REPORT_HISTORY_OUTCOME: ${{ steps.upload-timing-history.outcome }}
E2E_CHANGED: ${{ needs.playwright.outputs.check_changes_e2e }}
DOCKER_COMPOSE_CHANGED: ${{ needs.playwright.outputs.check_changes_docker_compose }}
EXPECTED_MATRIX: ${{ needs.playwright.outputs.plan_playwright_matrix }}
COMMENT_PAYLOAD_PATH: ${{ runner.temp }}/playwright-pr-comment/summary.json
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { renderPlaywrightSummary } = require('./.github/scripts/render_playwright_summary.cjs');
await renderPlaywrightSummary({ github, context, core });
- name: Upload Playwright PR comment payload
if: always()
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: playwright-pr-comment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/playwright-pr-comment/summary.json
retention-days: 5
if-no-files-found: ignore
# Auto-refresh the checked-in timing baseline from every successful
# full-mode merge_group run. Without this,
# `.github/playwright/timing-baseline.json` only gets updated by hand —
# and it drifts fast (see PR #30871 for the 12-day, 23%-dead-title
# snapshot that triggered this workflow addition).
#
# The step opens/updates a single tracked PR against `main`. A human
# still clicks merge — the auto-PR just eliminates the "regenerate the
# file" busywork.
#
# Guards:
# * merge_group event only (skip on PR/dispatch — the ci-status
# signal on merge_group is the safest known-good snapshot)
# * full execution mode only (targeted PR plans do not exercise every
# spec — a targeted refresh would lose coverage)
# * playwright-summary success only (no partial data on gate failures)
# * a drift cap in refresh_timing_baseline.py refuses > 40 % churn as
# a safety valve against an accidentally-broken source run
refresh-timing-baseline:
name: Refresh timing baseline
needs: [playwright, playwright-summary]
if: |
always() &&
github.event_name == 'merge_group' &&
needs.playwright.outputs.detect_changes_mode == 'full' &&
needs.playwright-summary.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: main
persist-credentials: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download timing history artifact
uses: actions/download-artifact@v7
with:
name: playwright-timing-history-full-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/timing-history
- name: Locate merged history file
id: locate
run: |
history=$(find "$RUNNER_TEMP/timing-history" -name 'playwright-timing-history.json' -type f | head -1)
if [[ -z "$history" ]]; then
echo "No playwright-timing-history.json found under $RUNNER_TEMP/timing-history" >&2
find "$RUNNER_TEMP/timing-history" -type f >&2 || true
exit 1
fi
echo "history_path=$history" >> "$GITHUB_OUTPUT"
- name: Build refreshed baseline
run: |
python3 .github/scripts/refresh_timing_baseline.py \
--history "${{ steps.locate.outputs.history_path }}" \
--current .github/playwright/timing-baseline.json \
--output .github/playwright/timing-baseline.json \
--source-run-id "${{ github.run_id }}" \
--summary "$RUNNER_TEMP/baseline-refresh-summary.md"
- name: Skip if baseline unchanged
id: diff
run: |
if git diff --quiet -- .github/playwright/timing-baseline.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Push refresh branch and open/update PR
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
branch="ci/auto-refresh-timing-baseline"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
# Reset the tracked branch to the latest state — a single tracked
# PR gets updated across successive runs rather than accumulating
# one PR per merge_group. Plain `--force` (not `--force-with-lease`)
# because Checkout only fetched `main`, so no remote-tracking ref
# exists for this bot-owned branch and a lease would be rejected
# with "stale info" on the second and every subsequent run.
git checkout -B "$branch"
git add .github/playwright/timing-baseline.json
git commit -m "chore(playwright): auto-refresh timing baseline from run ${RUN_ID}"
git push --force origin "$branch"
summary_body="$(cat "$RUNNER_TEMP/baseline-refresh-summary.md")"
pr_body=$(printf '## Summary\n\nAuto-refreshed from [merge_group run %s](%s).\n\n```\n%s\n```\n\nA human still clicks merge. If this drift looks unreasonable, close the PR — the next successful merge_group run will open a fresh one.\n' "$RUN_ID" "$RUN_URL" "$summary_body")
# `.[0].number` on an empty array yields the literal string "null",
# not an empty string; the `// empty` fallback turns that into a
# real empty string so the -n test below correctly picks the
# `gh pr create` branch on the very first run.
existing=$(gh pr list --head "$branch" --base main --state open --json number --jq '.[0].number // empty' || echo "")
if [[ -n "$existing" ]]; then
gh pr edit "$existing" --body "$pr_body"
echo "Updated existing PR #$existing"
else
gh pr create --base main --head "$branch" \
--title "chore(playwright): auto-refresh timing-baseline.json" \
--body "$pr_body"
fi