Skip to content

Fixes 32693: Encrypt and mask ingestion pipeline secrets #86568

Fixes 32693: Encrypt and mask ingestion pipeline secrets

Fixes 32693: Encrypt and mask ingestion pipeline secrets #86568

# 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 direct commit to main
# that keeps `.github/playwright/timing-baseline.json`
# fresh from the latest full-mode run
# (materiality-gated; see the job comment).
#
# 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:
name: >-
${{
(
github.event.action == 'labeled'
|| needs.playwright.outputs.gate_result != 'success'
|| needs.playwright.outputs.gate_should_run == 'true'
)
&& 'playwright-summary'
|| 'playwright-summary (skipped)'
}}
if: ${{ always() && !cancelled() }}
needs: [playwright]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: read
contents: read
# For the budget-breach tracked issue (Signal Playwright budget
# breaches step below).
issues: write
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
# setup-node's built-in cache SAVES as a post-step under the current
# ref, and there is no restore-only mode. On merge_group that ref is
# gh-readonly-queue/*, which dies after the run — but the ~1.2 GB
# node-cache-Linux-x64-yarn-* entry it leaves behind stays charged
# against the repo's 10 GB LRU budget. This job is the only
# setup-node+cache writer on a merge_group trigger in the repo;
# measured 2026-09-09 it held 2.35 GB across dead queue refs while
# main's scope had zero fixture / distribution / ingestion-image
# entries left — all evicted. A cold `yarn --ignore-scripts` here
# costs ~1 min on the summary job; a missed golden fixture costs
# ~17 min on every shard's critical path. Keep the cache on
# pull_request (the PR ref outlives the run and re-pushes hit it);
# disable the write on merge_group. Same rule the reusable applies
# to its own saves. Follow-up: switch to actions/cache/restore
# against populate-playwright-caches.yml's main-scoped
# yarn-pkg-cache-* key so merge_group gets a hit instead of a cold
# install — different key family, so setup-node cannot restore it.
cache: ${{ github.event_name != 'merge_group' && '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: |
# No --enforce: time/flake budget targets are SIGNALS (surfaced by
# the "Signal Playwright budget breaches" step below), never a
# reason to fail the required check on an otherwise-green run.
# Blocking targets (reserved for corrupt-results states; empty
# today) would still exit non-zero under --enforce if reintroduced.
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"
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@v7
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))
# Reporting duration is a BUDGET target: recorded, surfaced by the
# budget-signal step below, but never a reason to fail the check
# (it used to exit 1 here on full-mode runs and eject green PRs).
jq \
--argjson reportSeconds "$report_seconds" \
'.metrics.reportingSeconds = $reportSeconds |
.targets.reportingAtMostTwoMinutes = ($reportSeconds <= 120) |
.budgetTargets.reportingAtMostTwoMinutes = ($reportSeconds <= 120) |
.targetsMet = ([.targets[]] | all) |
.budgetTargetsMet = ([.budgetTargets[]] | all) |
.blockingTargetsMet = ([.blockingTargets[]] | all) |
.convergenceTargetsMet = ([.convergenceTargets[]] | all)' \
"$performance" > "$performance.tmp"
mv "$performance.tmp" "$performance"
if [[ "$report_seconds" -gt 120 ]]; then
echo "::warning::Playwright reporting and report upload took ${report_seconds}s (budget 120s)"
fi
# THE budget signal. Time/flake budgets no longer fail the required
# check (see evaluate_playwright_performance.py — run 32500973433
# ejected a PR with zero test failures); instead every breach is:
# * a ::warning annotation on this run,
# * a section in the job summary,
# * an upsert into the tracked issue "Playwright CI over time budget"
# (merge_group full runs only, comment throttled to one per 20 h)
# so capacity problems page a human through the issue, not by ejecting
# innocent PRs from the merge queue.
- name: Signal Playwright budget breaches
id: budget-signal
if: ${{ always() && needs.playwright.outputs.gate_should_run == 'true' && needs.playwright.outputs.playwright_ci_result != 'skipped' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EXECUTION_MODE: ${{ needs.playwright.outputs.detect_changes_mode }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
ISSUE_TITLE: "Playwright CI over time budget"
run: |
performance="$RUNNER_TEMP/playwright-timing-history/playwright-performance.json"
[[ -f "$performance" ]] || { echo "No performance payload; nothing to signal."; exit 0; }
met=$(jq -r '.budgetTargetsMet // true' "$performance")
if [[ "$met" == "true" ]]; then
echo "All Playwright budget targets met."
exit 0
fi
breached_names=$(jq -r '(.budgetTargets // {}) | to_entries[] | select(.value == false) | .key' "$performance")
detail_lines=$(jq -r '
(.failedBudgetTargetDetails // {}) | to_entries[] | . as $e |
"- **\($e.value.label)**: budget ≤ \($e.value.threshold) \($e.value.unit)" +
(if ($e.value.offendingShards | length) > 0
then " — exceeded on " + ($e.value.offendingShards | map("`\(.shardId)` (\(.value) \($e.value.unit))") | join(", "))
else "" end)' "$performance")
# Budget targets without phase attribution (flaky rate, retry
# share, boot shape) still need a line each.
plain_lines=$(jq -r '
(.failedBudgetTargetDetails // {}) as $d |
(.budgetTargets // {}) | to_entries[]
| select(.value == false and ($d[.key] | not))
| "- \(.key)"' "$performance")
while IFS= read -r name; do
[[ -n "$name" ]] && echo "::warning::Playwright budget breach: $name"
done <<< "$breached_names"
{
echo "## ⚠️ Playwright budget breaches"
echo
[[ -n "$detail_lines" ]] && echo "$detail_lines"
[[ -n "$plain_lines" ]] && echo "$plain_lines"
echo
echo "Budget breaches do not fail this check; investigate via the tracked issue."
} >> "$GITHUB_STEP_SUMMARY"
# Tracked issue: only authoritative (merge_group + full) runs feed
# it — PR-time targeted runs measure a subset and would be noise.
if [[ "${GITHUB_EVENT_NAME}" != "merge_group" || "$EXECUTION_MODE" != "full" ]]; then
exit 0
fi
body=$(printf 'Latest breach: [run %s](%s)\n\n%s\n%s\n\nThe merge-queue check stays green on budget breaches; this issue is the signal. Investigate shard packing (`build_playwright_shards.py`), the timing baseline, or runaway specs. Close when runs stay within budget.\n' \
"${GITHUB_RUN_ID}" "$RUN_URL" "$detail_lines" "$plain_lines")
existing=$(gh issue list --state open --search "in:title \"$ISSUE_TITLE\"" \
--json number,title --jq --arg t "$ISSUE_TITLE" '[.[] | select(.title == $t)][0].number // empty')
if [[ -z "$existing" ]]; then
gh issue create --title "$ISSUE_TITLE" --body "$body"
echo "Opened budget-breach issue."
else
# Throttle: at most one comment per 20 h so a breached week does
# not bury the issue in per-run comments.
last_comment=$(gh issue view "$existing" --json comments \
--jq '.comments[-1].createdAt // ""')
cutoff=$(date -u -d '20 hours ago' +%FT%TZ)
if [[ -z "$last_comment" || "$last_comment" < "$cutoff" ]]; then
gh issue comment "$existing" --body "$body"
echo "Commented on budget-breach issue #$existing."
else
echo "Issue #$existing already updated within 20 h; skipping comment."
fi
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@v7
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@v7
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@v9
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@v7
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 successful full-mode
# merge_group runs. 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).
#
# COMMITS DIRECTLY TO MAIN (no PR). The previous auto-PR design
# deadlocked with the merge queue: the refresh PR had to pass through the
# very queue whose activity kept invalidating it, and concurrent refresh
# runs raced on the bot branch (`cannot lock ref`, run 32354038772). A
# data-only JSON regenerated from a known-good run doesn't need review —
# the guards below are the review. `[skip ci]` keeps the push from
# burning a CI cycle; note any push to main still resets in-flight
# merge-queue entries, which is why the materiality threshold exists.
#
# 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
# * a materiality threshold (--min-materiality-percent) skips commits
# for run-to-run jitter — only structural changes (files gained or
# lost, zero-duration entries recovering) or ≥ 10 % of entries
# drifting/shifting significantly produce a commit
# * fetch → regenerate → push retry loop (3 attempts) absorbs races
# with the queue landing merges mid-refresh
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
steps:
# NOTE: pushing to a protected `main` requires the token identity to
# be on the branch-protection bypass list. RELEASE_BOT_TOKEN is the
# same identity auto-revert-release.yml uses to push release
# branches; GITHUB_TOKEN is the fallback for repos where Actions is
# allowed to bypass.
- name: Checkout
uses: actions/checkout@v7
with:
ref: main
persist-credentials: true
token: ${{ secrets.RELEASE_BOT_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: Refresh baseline and commit to main
env:
HISTORY_PATH: ${{ steps.locate.outputs.history_path }}
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
# Regenerate INSIDE the retry loop: each attempt rebuilds the
# baseline against the freshly-fetched main, so a queue merge (or
# a concurrent refresh run) landing mid-refresh changes the
# regeneration input instead of producing a rejected push — the
# race that killed the bot-branch design (run 32354038772).
for attempt in 1 2 3; do
git fetch origin main
git reset --hard origin/main
set +e
python3 .github/scripts/refresh_timing_baseline.py \
--history "$HISTORY_PATH" \
--current .github/playwright/timing-baseline.json \
--output .github/playwright/timing-baseline.json \
--source-run-id "$RUN_ID" \
--min-materiality-percent 10 \
--summary "$RUNNER_TEMP/baseline-refresh-summary.md"
rc=$?
set -e
if [[ "$rc" -eq 3 ]]; then
echo "Change below materiality threshold; skipping refresh commit."
exit 0
elif [[ "$rc" -ne 0 ]]; then
echo "refresh_timing_baseline.py exited $rc" >&2
exit "$rc"
fi
if git diff --quiet -- .github/playwright/timing-baseline.json; then
echo "Baseline identical to main; nothing to commit."
exit 0
fi
git add .github/playwright/timing-baseline.json
git commit \
-m "chore(playwright): auto-refresh timing baseline from run ${RUN_ID} [skip ci]" \
-m "$(cat "$RUNNER_TEMP/baseline-refresh-summary.md")" \
-m "Source: ${RUN_URL}"
push_rc=0
push_output=$(git push origin HEAD:main 2>&1) || push_rc=$?
echo "$push_output"
if [[ "$push_rc" -eq 0 ]]; then
echo "Baseline refreshed on main (attempt $attempt)."
exit 0
fi
# A ruleset/branch-protection rejection (GH013: "changes must be
# made through the merge queue") is a CONFIGURATION gap, not a
# pipeline failure: the token identity is not on main's bypass
# list yet. Failing the job here turns every full-mode
# merge_group run red and ejects innocent PRs (runs 32750389130,
# 32750390655) — the exact failure mode this pipeline exists to
# prevent. Surface loudly, skip gracefully.
if grep -qE "GH013|rule violations|protected branch" <<< "$push_output"; then
echo "::warning::Baseline refresh skipped: push to main rejected by repository ruleset (GH013). Grant the workflow token (RELEASE_BOT_TOKEN or Actions) bypass on main's ruleset to enable auto-refresh."
{
echo "## ⚠️ Timing-baseline auto-refresh blocked by ruleset"
echo
echo "The direct push to \`main\` was rejected (GH013). The refresh is skipped — downstream planning keeps using the current baseline. Grant the workflow token bypass on the main ruleset to enable it."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "Push rejected (main moved during refresh); retrying." >&2
done
echo "Failed to push refreshed baseline after 3 attempts." >&2
exit 1