Skip to content

Fixed context center flaky tests #59503

Fixed context center flaky tests

Fixed context center flaky tests #59503

# 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.
# This workflow executes end-to-end (e2e) tests using Playwright with PostgreSQL as the database.
# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#apache-maven-with-a-settings-path
name: Postgresql PR Playwright E2E Tests
on:
merge_group:
schedule:
- cron: "30 7 * * *"
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. Merge queue, schedule, and full dispatch runs
# still execute the complete duration-balanced suite.
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 below). Same-repo PRs already ran
# under pull_request above; this block 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
concurrency:
# PR and manual runs for the same branch supersede stale commits. This also
# lets a manually dispatched full-suite run replace the automatic PR subset.
# Include event_name in the group so pull_request and pull_request_target
# runs for the same PR don't cancel each other — one gates false and skips,
# the other does the real work; sharing a group would let the "skip" event
# cancel an in-flight real run.
group: playwright-ci-postgresql-${{ github.event_name }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ !contains(fromJSON('["pull_request","pull_request_target"]'), github.event_name) || github.event.action != 'labeled' || github.event.label.name == 'safe to test' }}
jobs:
# Fork-vs-same-repo PR arbitration. GitHub fires BOTH pull_request and
# pull_request_target on every PR event; without a gate the pipeline
# would either double-run same-repo PRs or run fork PRs with the wrong
# secrets. Routing:
# merge_group / schedule / workflow_dispatch → always run
# drafts → never run
# labeled event with label != "safe to test" → skip (spurious re-fire)
# same-repo PR → pull_request handles it
# fork PR → pull_request_target
# handles it, must carry
# the "safe to test" label
gate:
runs-on: ubuntu-latest
permissions:
checks: read
contents: read
pull-requests: read
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
steps:
# Team Label atomically removes stale fork approval on synchronize and
# re-adds it only for allowlisted authors. Wait for that reconciliation
# instead of relying on another labeled event: GITHUB_TOKEN label writes
# intentionally do not trigger a new workflow run.
- name: Wait for fork label reconciliation
if: |
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository
uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0
with:
ref: ${{ github.event.pull_request.head.sha }}
check-name: Team Label
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
- name: Read reconciled fork labels
id: fork-labels
if: |
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
labels=$(gh api "/repos/${REPO}/pulls/${PR_NUMBER}" \
--jq '[.labels[].name] | @json')
echo "labels=$labels" >> "$GITHUB_OUTPUT"
- name: Compute gate decision
id: gate
env:
EVENT: ${{ github.event_name }}
ACTION: ${{ github.event.action }}
LABEL: ${{ github.event.label.name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
LABELS_JSON: ${{ steps.fork-labels.outputs.labels || toJSON(github.event.pull_request.labels.*.name) }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
run: |
set -euo pipefail
decide() { echo "should_run=$1" >> "$GITHUB_OUTPUT"; exit 0; }
case "$EVENT" in
merge_group|schedule|workflow_dispatch) decide true ;;
esac
if [[ "$IS_DRAFT" == "true" ]]; then decide false; fi
if [[ "$ACTION" == "labeled" && "$LABEL" != "safe to test" ]]; then decide false; fi
if [[ "$HEAD_REPO" == "$BASE_REPO" ]]; then
if [[ "$EVENT" == "pull_request" ]]; then decide true; else decide false; fi
fi
# Fork PR — pull_request_target must fire and the reconciled labels
# for the current head SHA must include "safe to test".
if [[ "$EVENT" == "pull_request_target" ]] && \
echo "$LABELS_JSON" | jq -e '. | index("safe to test")' >/dev/null; then
decide true
fi
decide false
check-changes:
needs: gate
if: ${{ needs.gate.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
outputs:
e2e: ${{ steps.filter.outputs.e2e }}
docker-compose: ${{ steps.filter.outputs.docker-compose }}
steps:
- name: Checkout
uses: actions/checkout@v7
if: ${{ github.event_name == 'merge_group' }}
with:
fetch-depth: 0
filter: blob:none
persist-credentials: false
- uses: dorny/paths-filter@v4
id: filter
if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'merge_group' }}
with:
base: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }}
filters: |
e2e:
- 'openmetadata-service/**'
- 'openmetadata-ui/**'
- 'openmetadata-spec/**'
- 'openmetadata-integration-tests/**'
- 'openmetadata-dist/**'
- 'ingestion/**'
- 'bootstrap/**'
- 'conf/**'
- 'docker/development/**'
- 'docker/run_local_docker.sh'
- 'docker/run_local_docker_common.sh'
- 'openmetadata-clients/**'
- 'openmetadata-airflow-apis/**'
- 'openmetadata-mcp/**'
- 'openmetadata-sdk/**'
- 'openmetadata-shaded-deps/**'
- 'openmetadata-ui-core-components/**'
- 'openmetadata-k8s-operator/**'
- 'common/**'
- 'openspec/**'
- 'pom.xml'
- 'Makefile'
- '.github/actions/setup-openmetadata-test-environment/**'
- '.github/playwright/**'
- '.github/scripts/build_playwright_shards.py'
- '.github/scripts/capture_playwright_server_output.py'
- '.github/scripts/classify_playwright_outcome.py'
- '.github/scripts/create_playwright_fixture.sh'
- '.github/scripts/playwright_cache_fingerprint.py'
- '.github/scripts/playwright_distribution_cache.sh'
- '.github/scripts/rotate_playwright_auth_state.py'
- '.github/scripts/evaluate_playwright_performance.py'
- '.github/scripts/import_playwright_json_timings.py'
- '.github/scripts/merge_playwright_timings.py'
- '.github/scripts/render_playwright_summary.cjs'
- '.github/scripts/select_playwright_tests.py'
- '.github/scripts/start_playwright_fast_environment.sh'
- '.github/scripts/stop_playwright_fast_environment.sh'
- '.github/scripts/summarize_playwright_requests.py'
- '.github/scripts/validate_playwright_fixture.sh'
- '.github/scripts/validate_playwright_ingestion_image.sh'
- '.github/scripts/tests/test_playwright_ci_planning.py'
- '.github/scripts/tests/test_playwright_cache_assets.py'
- '.github/scripts/verify_playwright_coverage.py'
- '.github/workflows/playwright-postgresql-e2e.yml'
docker-compose:
- 'docker/development/docker-compose.yml'
- 'docker/development/docker-compose-postgres.yml'
cache-keys:
needs: check-changes
runs-on: ubuntu-latest
if: |
!github.event.pull_request.draft &&
(!contains(fromJSON('["pull_request","pull_request_target"]'), github.event_name) || github.event.action != 'labeled' || github.event.label.name == 'safe to test')
outputs:
bundle_mode: ${{ steps.fingerprints.outputs.bundle_mode }}
distribution: ${{ steps.fingerprints.outputs.distribution }}
fixture: ${{ steps.fingerprints.outputs.fixture }}
ingestion: ${{ steps.fingerprints.outputs.ingestion }}
toolchain: ${{ steps.fingerprints.outputs.toolchain }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# actions/checkout@v7 refuses to fetch fork code under pull_request_target
# unless the workflow opts in. The gate job already restricts this event to
# "safe to test"-labelled fork PRs, and the build/test steps that consume this
# tree run in docker containers — not directly in the workflow context — so
# the fork code cannot exfiltrate GITHUB_TOKEN or repo secrets. The one
# workflow-runner script consumer (playwright-summary/render_playwright_summary.cjs)
# deliberately checks out base.sha instead, closing the pwn-request path there.
allow-unsafe-pr-checkout: true
persist-credentials: false
- name: Calculate cache fingerprints
id: fingerprints
env:
COARSE_BUNDLE: ${{ github.event_name != 'workflow_dispatch' || inputs.coarse_bundle }}
run: |
bundle_mode=regular
if [[ "$COARSE_BUNDLE" == "true" ]]; then
bundle_mode=coarse
fi
toolchain=$(mvn --version | sed -n '1p' | tr -d '\r')
distribution=$(python3 .github/scripts/playwright_cache_fingerprint.py \
--kind distribution \
--bundle-mode "$bundle_mode" \
--toolchain "$toolchain")
{
echo "bundle_mode=$bundle_mode"
echo "distribution=$distribution"
echo "fixture=$(python3 .github/scripts/playwright_cache_fingerprint.py --kind fixture)"
echo "ingestion=$(python3 .github/scripts/playwright_cache_fingerprint.py --kind ingestion)"
echo "toolchain=$toolchain"
} >> "$GITHUB_OUTPUT"
build:
needs: [check-changes, cache-keys]
runs-on: ubuntu-latest
if: |
!github.event.pull_request.draft &&
(!contains(fromJSON('["pull_request","pull_request_target"]'), github.event_name) || github.event.action != 'labeled' || github.event.label.name == 'safe to test')
env:
DISTRIBUTION_FINGERPRINT: ${{ needs.cache-keys.outputs.distribution }}
BUNDLE_MODE: ${{ needs.cache-keys.outputs.bundle_mode }}
BUILD_TOOLCHAIN: ${{ needs.cache-keys.outputs.toolchain }}
steps:
- name: Wait for the labeler
uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0
if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }}
with:
ref: ${{ github.event.pull_request.head.sha }}
check-name: Team Label
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 90
- name: Verify PR labels
uses: jesusvasquez333/verify-pr-label-action@v1.4.0
if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }}
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
valid-labels: "safe to test"
pull-request-number: "${{ github.event.pull_request.number }}"
disable-reviews: true
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# actions/checkout@v7 refuses to fetch fork code under pull_request_target
# unless the workflow opts in. The gate job already restricts this event to
# "safe to test"-labelled fork PRs, and the build/test steps that consume this
# tree run in docker containers — not directly in the workflow context — so
# the fork code cannot exfiltrate GITHUB_TOKEN or repo secrets. The one
# workflow-runner script consumer (playwright-summary/render_playwright_summary.cjs)
# deliberately checks out base.sha instead, closing the pwn-request path there.
allow-unsafe-pr-checkout: true
persist-credentials: false
- name: Initialize distribution cache path
run: echo "DISTRIBUTION_CACHE_DIR=$RUNNER_TEMP/playwright-distribution-cache" >> "$GITHUB_ENV"
- name: Restore OpenMetadata distribution cache
id: restore-distribution
# Actions cache isolates PR writes to the PR merge ref. PRs may read a
# default-branch entry, but cannot replace the cache used by main/nightly.
uses: actions/cache/restore@v5
with:
path: ${{ env.DISTRIBUTION_CACHE_DIR }}
key: playwright-distribution-v2-${{ runner.os }}-${{ runner.arch }}-${{ needs.cache-keys.outputs.distribution }}
- name: Validate restored distribution
id: distribution-cache
run: |
usable=false
if [[ "${{ steps.restore-distribution.outputs.cache-hit }}" == "true" ]] && \
.github/scripts/playwright_distribution_cache.sh validate \
"$DISTRIBUTION_CACHE_DIR" \
"$DISTRIBUTION_FINGERPRINT" \
"$BUNDLE_MODE" \
"$BUILD_TOOLCHAIN"; then
usable=true
else
rm -rf "$DISTRIBUTION_CACHE_DIR"
fi
echo "usable=$usable" >> "$GITHUB_OUTPUT"
- name: Setup JDK 21
if: ${{ steps.distribution-cache.outputs.usable != 'true' }}
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
- name: Cache Maven Dependencies
if: ${{ steps.distribution-cache.outputs.usable != 'true' }}
uses: actions/cache@v5
with:
path: ~/.m2
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Install antlr cli
if: ${{ steps.distribution-cache.outputs.usable != 'true' }}
run: sudo make install_antlr_cli
- name: Build with Maven
if: ${{ steps.distribution-cache.outputs.usable != 'true' }}
env:
PW_E2E_BUILD: "true"
PW_E2E_BUNDLE: ${{ needs.cache-keys.outputs.bundle_mode == 'coarse' }}
run: mvn -DskipTests clean package -pl openmetadata-dist -am
- name: Package OpenMetadata distribution cache entry
if: ${{ steps.distribution-cache.outputs.usable != 'true' }}
run: |
distribution=$(find openmetadata-dist/target -maxdepth 1 -type f -name 'openmetadata-*.tar.gz' -print -quit)
test -n "$distribution"
.github/scripts/playwright_distribution_cache.sh package \
"$distribution" \
"$DISTRIBUTION_CACHE_DIR" \
"$DISTRIBUTION_FINGERPRINT" \
"$BUNDLE_MODE" \
"$BUILD_TOOLCHAIN"
- name: Save OpenMetadata distribution cache
if: ${{ steps.distribution-cache.outputs.usable != 'true' }}
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ${{ env.DISTRIBUTION_CACHE_DIR }}
key: playwright-distribution-v2-${{ runner.os }}-${{ runner.arch }}-${{ needs.cache-keys.outputs.distribution }}
- name: Validate OpenMetadata distribution
run: |
.github/scripts/playwright_distribution_cache.sh validate \
"$DISTRIBUTION_CACHE_DIR" \
"$DISTRIBUTION_FINGERPRINT" \
"$BUNDLE_MODE" \
"$BUILD_TOOLCHAIN"
- name: Upload OpenMetadata distribution
uses: actions/upload-artifact@v6
with:
name: openmetadata-distribution
overwrite: true
path: |
${{ env.DISTRIBUTION_CACHE_DIR }}/openmetadata-*.tar.gz
${{ env.DISTRIBUTION_CACHE_DIR }}/distribution-manifest.json
compression-level: 0
retention-days: 1
detect-changes:
needs: check-changes
runs-on: ubuntu-latest
if: |
!github.event.pull_request.draft &&
(!contains(fromJSON('["pull_request","pull_request_target"]'), github.event_name) || github.event.action != 'labeled' || github.event.label.name == 'safe to test')
outputs:
mode: ${{ steps.select.outputs.mode }}
selected_count: ${{ steps.select.outputs.selected_count }}
direct_changed_specs: ${{ steps.select.outputs.direct_changed_specs }}
lineage_representative_only: ${{ steps.select.outputs.lineage_representative_only }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
filter: blob:none
# See allow-unsafe-pr-checkout rationale on other checkouts above.
allow-unsafe-pr-checkout: true
persist-credentials: false
- name: Get all changed files
id: all-changes
if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }}
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
- name: Select PR coverage
id: select
env:
ALL_FILES: ${{ steps.all-changes.outputs.all_changed_files }}
run: |
mkdir -p "$RUNNER_TEMP/playwright-selection"
# shellcheck disable=SC2086
printf '%s\n' $ALL_FILES > "$RUNNER_TEMP/playwright-selection/changed-files.txt"
python3 .github/scripts/select_playwright_tests.py \
--event-name "${{ github.event_name }}" \
--changed-files "$RUNNER_TEMP/playwright-selection/changed-files.txt" \
--impact-map .github/playwright/impact-map.json \
--full-suite "${{ inputs.full_suite || false }}" \
--output "$RUNNER_TEMP/playwright-selection/selection.json" \
--github-output "$GITHUB_OUTPUT"
jq '{mode, reason, selected: (.selectors | length)}' \
"$RUNNER_TEMP/playwright-selection/selection.json"
- name: Upload Playwright selection
uses: actions/upload-artifact@v6
with:
name: playwright-selection
path: ${{ runner.temp }}/playwright-selection/selection.json
retention-days: 1
plan-playwright:
needs: [build, detect-changes]
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
shard_count: ${{ steps.plan.outputs.shard_count }}
requires_airflow: ${{ steps.plan.outputs.requires_airflow }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# actions/checkout@v7 refuses to fetch fork code under pull_request_target
# unless the workflow opts in. The gate job already restricts this event to
# "safe to test"-labelled fork PRs, and the build/test steps that consume this
# tree run in docker containers — not directly in the workflow context — so
# the fork code cannot exfiltrate GITHUB_TOKEN or repo secrets. The one
# workflow-runner script consumer (playwright-summary/render_playwright_summary.cjs)
# deliberately checks out base.sha instead, closing the pwn-request path there.
allow-unsafe-pr-checkout: true
persist-credentials: false
- name: Download selection
uses: actions/download-artifact@v7
with:
name: playwright-selection
path: ${{ runner.temp }}/playwright-selection
- name: Setup Node.js
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 dependencies
working-directory: openmetadata-ui/src/main/resources/ui
run: |
corepack enable
yarn --ignore-scripts --frozen-lockfile
- name: Seed Playwright browser cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('openmetadata-ui/src/main/resources/ui/yarn.lock') }}
- name: Install cached Chromium
working-directory: openmetadata-ui/src/main/resources/ui
run: npx playwright install chromium
- name: Download recent full-suite timings
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p "$RUNNER_TEMP/playwright-history"
gh run list \
--workflow playwright-postgresql-e2e.yml \
--status success \
--limit 50 \
--json databaseId,createdAt \
--jq 'sort_by(.createdAt) | reverse | .[].databaseId' | while read -r run_id; do
full_count=$(find "$RUNNER_TEMP/playwright-history" -name 'playwright-timing-history.json' -type f -print0 2>/dev/null | xargs -0 -I{} jq -r 'select(.mode == "full") | .sourceSha' {} | wc -l | tr -d ' ')
[[ "$full_count" -ge 3 ]] && break
run_dir="$RUNNER_TEMP/playwright-history/$run_id"
mkdir -p "$run_dir"
gh run download "$run_id" \
--pattern 'playwright-timing-history-full-*' \
--dir "$run_dir" || true
done
- name: Discover tests
working-directory: openmetadata-ui/src/main/resources/ui
env:
PLAYWRIGHT_IS_OSS: "true"
PW_DEDICATED_INGESTION: "true"
PW_EXECUTION_MODE: ${{ needs.detect-changes.outputs.mode }}
PW_LINEAGE_REPRESENTATIVE_ONLY: ${{ needs.detect-changes.outputs.lineage_representative_only }}
run: |
npx playwright test --list --reporter=json \
--project=chromium \
--project=Basic \
--project=Ingestion \
--project=DataAssetRulesEnabled \
--project=DataAssetRulesDisabled \
--project=SearchRBAC \
--project=DomainIsolation \
--project=Reindex \
--project=GlobalSettings \
--project=SystemCertificationTags \
--project=IntakeForm \
--project=search-nightly \
> "$RUNNER_TEMP/playwright-test-list.json"
- name: Build duration-aware shard plans
id: plan
run: |
history_args=()
while IFS= read -r history_file; do
if jq -e '.mode == "full"' "$history_file" >/dev/null; then
history_args+=(--history "$history_file")
fi
done < <(find "$RUNNER_TEMP/playwright-history" -name 'playwright-timing-history.json' -type f 2>/dev/null | sort)
if [[ ${#history_args[@]} -eq 0 ]]; then
history_args=(--history .github/playwright/timing-baseline.json)
fi
python3 .github/scripts/build_playwright_shards.py \
--test-list "$RUNNER_TEMP/playwright-test-list.json" \
--selection "$RUNNER_TEMP/playwright-selection/selection.json" \
"${history_args[@]}" \
--output-dir "$RUNNER_TEMP/playwright-plans" \
--github-output "$GITHUB_OUTPUT"
jq '{shards: (.include | length), lanes: (.include | group_by(.lane) | map({lane: .[0].lane, count: length}))}' \
"$RUNNER_TEMP/playwright-plans/matrix.json"
- name: Upload shard plans
uses: actions/upload-artifact@v6
with:
name: playwright-shard-plans
path: ${{ runner.temp }}/playwright-plans
retention-days: 1
restore-playwright-fixture:
needs: [cache-keys, plan-playwright]
runs-on: ubuntu-latest
outputs:
fixture_cache_hit: ${{ steps.validate-fixture.outputs.usable }}
ingestion_cache_hit: ${{ steps.validate-ingestion.outputs.usable }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# actions/checkout@v7 refuses to fetch fork code under pull_request_target
# unless the workflow opts in. The gate job already restricts this event to
# "safe to test"-labelled fork PRs, and the build/test steps that consume this
# tree run in docker containers — not directly in the workflow context — so
# the fork code cannot exfiltrate GITHUB_TOKEN or repo secrets. The one
# workflow-runner script consumer (playwright-summary/render_playwright_summary.cjs)
# deliberately checks out base.sha instead, closing the pwn-request path there.
allow-unsafe-pr-checkout: true
persist-credentials: false
- name: Initialize fixture cache paths
run: |
echo "FIXTURE_CACHE_DIR=$RUNNER_TEMP/playwright-fixture-cache" >> "$GITHUB_ENV"
echo "INGESTION_CACHE_DIR=$RUNNER_TEMP/playwright-ingestion-cache" >> "$GITHUB_ENV"
- name: Restore golden fixture cache
id: restore-fixture
# The content hash excludes the commit SHA so compatible main/nightly
# fixtures can be reused across commits. Cache ref scoping keeps PR saves isolated.
uses: actions/cache/restore@v5
with:
path: ${{ env.FIXTURE_CACHE_DIR }}
key: playwright-golden-fixture-v2-${{ runner.os }}-${{ runner.arch }}-${{ needs.cache-keys.outputs.fixture }}
- name: Validate restored golden fixture
id: validate-fixture
env:
EXPECTED_FINGERPRINT: ${{ needs.cache-keys.outputs.fixture }}
run: |
usable=false
fixture="$FIXTURE_CACHE_DIR/playwright-fixture.tar.zst"
if [[ "${{ steps.restore-fixture.outputs.cache-hit }}" == "true" && -s "$fixture" ]] && \
.github/scripts/validate_playwright_fixture.sh "$fixture" "$EXPECTED_FINGERPRINT"; then
usable=true
else
rm -rf "$FIXTURE_CACHE_DIR"
fi
echo "usable=$usable" >> "$GITHUB_OUTPUT"
- name: Restore ingestion image cache
id: restore-ingestion
if: ${{ needs.plan-playwright.outputs.requires_airflow == 'true' }}
uses: actions/cache/restore@v5
with:
path: ${{ env.INGESTION_CACHE_DIR }}
key: playwright-ingestion-image-v2-${{ runner.os }}-${{ runner.arch }}-${{ needs.cache-keys.outputs.ingestion }}
- name: Validate restored ingestion image
id: validate-ingestion
if: ${{ needs.plan-playwright.outputs.requires_airflow == 'true' }}
env:
EXPECTED_FINGERPRINT: ${{ needs.cache-keys.outputs.ingestion }}
run: |
usable=false
image="$INGESTION_CACHE_DIR/playwright-ingestion-image.tar.zst"
if [[ "${{ steps.restore-ingestion.outputs.cache-hit }}" == "true" && -s "$image" ]] && \
.github/scripts/validate_playwright_ingestion_image.sh "$image" "$EXPECTED_FINGERPRINT"; then
usable=true
else
rm -rf "$INGESTION_CACHE_DIR"
fi
echo "usable=$usable" >> "$GITHUB_OUTPUT"
- name: Upload restored seeded fixture
if: ${{ steps.validate-fixture.outputs.usable == 'true' }}
uses: actions/upload-artifact@v6
with:
name: playwright-seeded-fixture
path: ${{ env.FIXTURE_CACHE_DIR }}/playwright-fixture.tar.zst
compression-level: 0
retention-days: 1
- name: Upload restored ingestion image
if: ${{ steps.validate-ingestion.outputs.usable == 'true' }}
uses: actions/upload-artifact@v6
with:
name: playwright-ingestion-image
path: ${{ env.INGESTION_CACHE_DIR }}
compression-level: 0
retention-days: 1
prepare-playwright-fixture:
needs:
[
build,
cache-keys,
detect-changes,
plan-playwright,
restore-playwright-fixture,
]
runs-on: ubuntu-latest
env:
ELASTICSEARCH_CLUSTER_ALIAS: openmetadata
steps:
- name: Reuse cached Playwright assets
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit == 'true' && (needs.plan-playwright.outputs.requires_airflow != 'true' || needs.restore-playwright-fixture.outputs.ingestion_cache_hit == 'true') }}
run: echo "The golden fixture and optional ingestion image were restored while the distribution was prepared."
# Reclaim ~30 GB of runner disk (dotnet, android, haskell, swap) BEFORE
# any docker work runs. `create_playwright_fixture.sh` does a
# `docker image save <ingestion>` after packing the fixture tar, which
# streams multi-GB blobs through /var/lib/docker/tmp/. On a fresh
# ubuntu-latest that partition has ~14 GB free after all the OM images
# (server, ingestion, postgres, opensearch, airflow) and their seeded
# volumes are loaded — not enough headroom for the docker export tmp,
# so the save fails with:
# Error response from daemon: write /var/lib/docker/tmp/...: no space left on device
# (See failure on https://github.com/open-metadata/OpenMetadata/actions/runs/30097915650)
# docker-images:false is deliberate — the OM images are exactly what
# this job needs to snapshot, so we can't prune them here.
- name: Free Disk Space (Ubuntu)
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
swap-storage: true
docker-images: false
- name: Checkout
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# actions/checkout@v7 refuses to fetch fork code under pull_request_target
# unless the workflow opts in. The gate job already restricts this event to
# "safe to test"-labelled fork PRs, and the build/test steps that consume this
# tree run in docker containers — not directly in the workflow context — so
# the fork code cannot exfiltrate GITHUB_TOKEN or repo secrets. The one
# workflow-runner script consumer (playwright-summary/render_playwright_summary.cjs)
# deliberately checks out base.sha instead, closing the pwn-request path there.
allow-unsafe-pr-checkout: true
persist-credentials: false
- name: Initialize generated fixture paths
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
run: |
echo "FIXTURE_CACHE_DIR=$RUNNER_TEMP/playwright-fixture-cache" >> "$GITHUB_ENV"
echo "INGESTION_CACHE_DIR=$RUNNER_TEMP/playwright-ingestion-cache" >> "$GITHUB_ENV"
- name: Download OpenMetadata distribution
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
uses: actions/download-artifact@v7
with:
name: openmetadata-distribution
path: openmetadata-dist/target
- name: Prepare seeded environment
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
uses: ./.github/actions/setup-openmetadata-test-environment
env:
STRICT_DAG_VALIDATION: "true"
VALIDATION_TIMEOUT_SECONDS: "600"
with:
python-version: "3.10"
args: "-d postgresql -s true"
ingestion_dependency: "playwright"
lightweight-ingestion: "true"
- name: Setup Node.js for fixture state
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != '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 fixture-state dependencies
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
working-directory: openmetadata-ui/src/main/resources/ui
run: |
corepack enable
yarn --ignore-scripts --frozen-lockfile
- name: Restore Playwright browser cache
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('openmetadata-ui/src/main/resources/ui/yarn.lock') }}
- name: Install fixture-state browser
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
working-directory: openmetadata-ui/src/main/resources/ui
run: |
npx playwright install-deps chromium
npx playwright install chromium
- name: Seed reusable Playwright auth and entity state
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
working-directory: openmetadata-ui/src/main/resources/ui
env:
PLAYWRIGHT_IS_OSS: "true"
PW_DEDICATED_INGESTION: "true"
run: |
npx playwright test --project=entity-data-setup --reporter=line
test -s playwright/.auth/admin.json
test -s playwright/.auth/admin-api-token.json
test -s playwright/output/entity-response-data.json
- name: Verify the authenticated application bundle
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
working-directory: openmetadata-ui/src/main/resources/ui
env:
PLAYWRIGHT_IS_OSS: "true"
PW_E2E_BUNDLE: ${{ needs.cache-keys.outputs.bundle_mode == 'coarse' }}
PW_PRESEEDED_STATE: "true"
run: npx playwright test --project=bundle-smoke --reporter=line
- name: Upload fixture-state failure diagnostics
if: ${{ failure() && (needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true')) }}
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: playwright-fixture-state-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
path: |
openmetadata-ui/src/main/resources/ui/playwright/output/test-results
openmetadata-ui/src/main/resources/ui/playwright/output/results.json
if-no-files-found: ignore
retention-days: 7
- name: Snapshot PostgreSQL and OpenSearch
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true') }}
env:
CREATE_INGESTION_IMAGE: ${{ needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true' }}
run: |
if ! command -v zstd >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y zstd
fi
mkdir -p "$FIXTURE_CACHE_DIR" "$INGESTION_CACHE_DIR"
fixture_args=("$FIXTURE_CACHE_DIR/playwright-fixture.tar.zst")
if [[ "$CREATE_INGESTION_IMAGE" == "true" ]]; then
fixture_args+=("$INGESTION_CACHE_DIR/playwright-ingestion-image.tar.zst")
fi
./.github/scripts/create_playwright_fixture.sh "${fixture_args[@]}"
- name: Validate generated golden fixture
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' }}
run: |
.github/scripts/validate_playwright_fixture.sh \
"$FIXTURE_CACHE_DIR/playwright-fixture.tar.zst" \
"${{ needs.cache-keys.outputs.fixture }}"
- name: Validate generated ingestion image
if: ${{ needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true' }}
run: |
.github/scripts/validate_playwright_ingestion_image.sh \
"$INGESTION_CACHE_DIR/playwright-ingestion-image.tar.zst" \
"${{ needs.cache-keys.outputs.ingestion }}"
- name: Save golden fixture cache
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' }}
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ${{ env.FIXTURE_CACHE_DIR }}
key: playwright-golden-fixture-v2-${{ runner.os }}-${{ runner.arch }}-${{ needs.cache-keys.outputs.fixture }}
- name: Save ingestion image cache
if: ${{ needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true' }}
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ${{ env.INGESTION_CACHE_DIR }}
key: playwright-ingestion-image-v2-${{ runner.os }}-${{ runner.arch }}-${{ needs.cache-keys.outputs.ingestion }}
- name: Upload seeded fixture
if: ${{ needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' }}
uses: actions/upload-artifact@v6
with:
name: playwright-seeded-fixture
path: ${{ env.FIXTURE_CACHE_DIR }}/playwright-fixture.tar.zst
compression-level: 0
retention-days: 1
- name: Upload ingestion image
if: ${{ needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true' }}
uses: actions/upload-artifact@v6
with:
name: playwright-ingestion-image
path: ${{ env.INGESTION_CACHE_DIR }}
compression-level: 0
retention-days: 1
- name: Clean fixture builder
if: ${{ always() && (needs.restore-playwright-fixture.outputs.fixture_cache_hit != 'true' || (needs.plan-playwright.outputs.requires_airflow == 'true' && needs.restore-playwright-fixture.outputs.ingestion_cache_hit != 'true')) }}
run: |
docker compose -f docker/development/docker-compose-postgres.yml down --remove-orphans || true
sudo rm -rf "$GITHUB_WORKSPACE/docker/development/docker-volume"
playwright-ci-postgresql:
needs:
[build, cache-keys, detect-changes, plan-playwright, prepare-playwright-fixture]
runs-on: ubuntu-latest
timeout-minutes: 30
if: ${{ !cancelled() && needs.build.result == 'success' && needs.detect-changes.result == 'success' && needs.plan-playwright.result == 'success' && needs.prepare-playwright-fixture.result == 'success' }}
environment: test
permissions:
contents: read
env:
# Playwright logs the admin user in many times (performAdminLogin per test, parallel
# workers, retries). The production default of 5 active sessions per user would evict the
# long-lived storageState session the page fixtures rely on and 401 every request. Raise
# the cap for E2E only; docker compose reads this for ${AUTHENTICATION_MAX_ACTIVE_SESSIONS_PER_USER:-5}.
AUTHENTICATION_MAX_ACTIVE_SESSIONS_PER_USER: "10000"
PW_EXECUTION_MODE: ${{ needs.detect-changes.outputs.mode }}
PW_LINEAGE_REPRESENTATIVE_ONLY: ${{ needs.detect-changes.outputs.lineage_representative_only }}
PW_PROTOCOL: ${{ matrix.requiresAirflow && 'http' || (github.event_name == 'workflow_dispatch' && inputs.protocol || 'http') }}
PW_SHARD_ID: ${{ matrix.shardId }}
PW_PRESEEDED_STATE: "true"
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan-playwright.outputs.matrix) }}
steps:
- name: Mark shard start
run: echo "PW_JOB_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV"
- name: Checkout
id: checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# actions/checkout@v7 refuses to fetch fork code under pull_request_target
# unless the workflow opts in. The gate job already restricts this event to
# "safe to test"-labelled fork PRs, and the build/test steps that consume this
# tree run in docker containers — not directly in the workflow context — so
# the fork code cannot exfiltrate GITHUB_TOKEN or repo secrets. The one
# workflow-runner script consumer (playwright-summary/render_playwright_summary.cjs)
# deliberately checks out base.sha instead, closing the pwn-request path there.
allow-unsafe-pr-checkout: true
persist-credentials: false
- name: Download OpenMetadata distribution
id: download-distribution
uses: actions/download-artifact@v7
with:
name: openmetadata-distribution
path: ${{ runner.temp }}/openmetadata-distribution
- name: Download seeded fixture
id: download-fixture
uses: actions/download-artifact@v7
with:
name: playwright-seeded-fixture
path: ${{ runner.temp }}/playwright-fixture
- name: Download ingestion image
id: download-ingestion-image
if: ${{ matrix.requiresAirflow }}
uses: actions/download-artifact@v7
with:
name: playwright-ingestion-image
path: ${{ runner.temp }}/playwright-ingestion-image
- name: Download shard plans
id: download-plans
uses: actions/download-artifact@v7
with:
name: playwright-shard-plans
path: ${{ runner.temp }}/playwright-plans
- name: Resolve fast-environment inputs
id: fast-inputs
env:
PLAN_NAME: ${{ matrix.plan }}
REQUIRES_AIRFLOW: ${{ matrix.requiresAirflow }}
run: |
distribution=$(find "$RUNNER_TEMP/openmetadata-distribution" -maxdepth 2 -type f -name 'openmetadata-*.tar.gz' -print -quit)
fixture=$(find "$RUNNER_TEMP/playwright-fixture" -maxdepth 2 -type f -name 'playwright-fixture.tar.zst' -print -quit)
plan="$RUNNER_TEMP/playwright-plans/$PLAN_NAME"
ingestion_image=""
if [[ "$REQUIRES_AIRFLOW" == "true" ]]; then
ingestion_image=$(find "$RUNNER_TEMP/playwright-ingestion-image" -maxdepth 2 -type f -name 'playwright-ingestion-image.tar.zst' -print -quit)
fi
for input in "$distribution" "$fixture" "$plan"; do
if [[ -z "$input" || ! -f "$input" ]]; then
echo "Missing fast-environment input: $input" >&2
exit 1
fi
done
if [[ "$REQUIRES_AIRFLOW" == "true" && ( -z "$ingestion_image" || ! -f "$ingestion_image" ) ]]; then
echo "Missing ingestion image for the Airflow lane" >&2
exit 1
fi
{
echo "distribution=$(realpath "$distribution")"
echo "fixture=$(realpath "$fixture")"
echo "plan=$(realpath "$plan")"
echo "ingestion_image=${ingestion_image:+$(realpath "$ingestion_image")}"
} >> "$GITHUB_OUTPUT"
- name: Mark environment setup start
run: echo "PW_ENVIRONMENT_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV"
- name: Setup Openmetadata Test Environment
id: setup-environment
uses: ./.github/actions/setup-openmetadata-test-environment
with:
python-version: "3.10"
fast-mode: "true"
fixture-path: ${{ steps.fast-inputs.outputs.fixture }}
distribution-path: ${{ steps.fast-inputs.outputs.distribution }}
ingestion-image-path: ${{ steps.fast-inputs.outputs.ingestion_image }}
install-test-dependencies: "false"
- name: Record environment setup duration
run: |
finished_at=$(date +%s)
echo "PW_ENVIRONMENT_FINISHED_AT=$finished_at" >> "$GITHUB_ENV"
echo "PW_ENVIRONMENT_SECONDS=$((finished_at - PW_ENVIRONMENT_STARTED_AT))" >> "$GITHUB_ENV"
- name: Setup Node.js
id: setup-node
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 dependencies
id: install-dependencies
working-directory: openmetadata-ui/src/main/resources/ui/
run: |
corepack enable
yarn --ignore-scripts --frozen-lockfile
- name: Restore Playwright browser cache
id: restore-browser
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('openmetadata-ui/src/main/resources/ui/yarn.lock') }}
- name: Install Playwright Browsers
id: install-browsers
working-directory: openmetadata-ui/src/main/resources/ui/
run: |
npx playwright install-deps chromium
npx playwright install chromium
- name: Verify the restored authenticated application bundle
id: verify-bundle
working-directory: openmetadata-ui/src/main/resources/ui/
env:
PLAYWRIGHT_IS_OSS: "true"
PW_E2E_BUNDLE: ${{ needs.cache-keys.outputs.bundle_mode == 'coarse' }}
run: npx playwright test --project=bundle-smoke --reporter=line
- name: Run Playwright tests
id: run-tests
working-directory: openmetadata-ui/src/main/resources/ui/
run: |
plan_file="${{ steps.fast-inputs.outputs.plan }}"
mapfile -t projects < <(jq -r '.projects[]' "$plan_file")
mapfile -t files < <(jq -r '.files[]' "$plan_file")
if [[ ${#projects[@]} -eq 0 || ${#files[@]} -eq 0 ]]; then
echo "Shard plan has no projects or files" >&2
jq . "$plan_file" >&2
exit 1
fi
project_args=()
for project in "${projects[@]}"; do
project_args+=("--project=$project")
done
if [[ -f "$PW_SERVER_CAPTURE_PID_FILE" ]]; then
kill -USR2 "$(cat "$PW_SERVER_CAPTURE_PID_FILE")"
fi
jq '{shardId, lane, workers, predictedWorkerMs, predictedExecutionMs, testCount, projects, files: (.files | length)}' "$plan_file"
started_at=$(date +%s)
set +e
timeout --signal=TERM --kill-after=30s 21m \
npx playwright test "${project_args[@]}" "${files[@]}"
test_exit=$?
set -e
finished_at=$(date +%s)
echo "PW_EXECUTION_SECONDS=$((finished_at - started_at))" >> "$GITHUB_ENV"
if [[ -f "$PW_SERVER_CAPTURE_PID_FILE" ]]; then
kill -USR1 "$(cat "$PW_SERVER_CAPTURE_PID_FILE")" || true
fi
exit "$test_exit"
env:
PW_SHARD_ID: ${{ matrix.shardId }}
PW_SHARD_PLAN: ${{ steps.fast-inputs.outputs.plan }}
PW_WORKERS: ${{ matrix.workers }}
PLAYWRIGHT_IS_OSS: true
PLAYWRIGHT_SNOWFLAKE_USERNAME: ${{ secrets.TEST_SNOWFLAKE_USERNAME }}
PLAYWRIGHT_SNOWFLAKE_PASSWORD: ${{ secrets.TEST_SNOWFLAKE_PASSWORD }}
PLAYWRIGHT_SNOWFLAKE_ACCOUNT: ${{ secrets.TEST_SNOWFLAKE_ACCOUNT }}
PLAYWRIGHT_SNOWFLAKE_DATABASE: ${{ secrets.TEST_SNOWFLAKE_DATABASE }}
PLAYWRIGHT_SNOWFLAKE_WAREHOUSE: ${{ secrets.TEST_SNOWFLAKE_WAREHOUSE }}
PLAYWRIGHT_SNOWFLAKE_PASSPHRASE: ${{ secrets.TEST_SNOWFLAKE_PASSPHRASE }}
PLAYWRIGHT_BQ_PRIVATE_KEY: ${{ secrets.TEST_BQ_PRIVATE_KEY }}
PLAYWRIGHT_BQ_PROJECT_ID: ${{ secrets.PLAYWRIGHT_BQ_PROJECT_ID }}
PLAYWRIGHT_BQ_PRIVATE_KEY_ID: ${{ secrets.TEST_BQ_PRIVATE_KEY_ID }}
PLAYWRIGHT_BQ_PROJECT_ID_TAXONOMY: ${{ secrets.TEST_BQ_PROJECT_ID_TAXONOMY }}
PLAYWRIGHT_BQ_CLIENT_EMAIL: ${{ secrets.TEST_BQ_CLIENT_EMAIL }}
PLAYWRIGHT_BQ_CLIENT_ID: ${{ secrets.TEST_BQ_CLIENT_ID }}
PLAYWRIGHT_REDSHIFT_HOST: ${{ secrets.E2E_REDSHIFT_HOST_PORT }}
PLAYWRIGHT_REDSHIFT_USERNAME: ${{ secrets.E2E_REDSHIFT_USERNAME }}
PLAYWRIGHT_REDSHIFT_PASSWORD: ${{ secrets.E2E_REDSHIFT_PASSWORD }}
PLAYWRIGHT_REDSHIFT_DATABASE: ${{ secrets.TEST_REDSHIFT_DATABASE }}
PLAYWRIGHT_METABASE_USERNAME: ${{ secrets.TEST_METABASE_USERNAME }}
PLAYWRIGHT_METABASE_PASSWORD: ${{ secrets.TEST_METABASE_PASSWORD }}
PLAYWRIGHT_METABASE_DB_SERVICE_NAME: ${{ secrets.TEST_METABASE_DB_SERVICE_NAME }}
PLAYWRIGHT_METABASE_HOST_PORT: ${{ secrets.TEST_METABASE_HOST_PORT }}
PLAYWRIGHT_SUPERSET_USERNAME: ${{ secrets.TEST_SUPERSET_USERNAME }}
PLAYWRIGHT_SUPERSET_PASSWORD: ${{ secrets.TEST_SUPERSET_PASSWORD }}
PLAYWRIGHT_SUPERSET_HOST_PORT: ${{ secrets.TEST_SUPERSET_HOST_PORT }}
PLAYWRIGHT_KAFKA_BOOTSTRAP_SERVERS: ${{ secrets.TEST_KAFKA_BOOTSTRAP_SERVERS }}
PLAYWRIGHT_KAFKA_SCHEMA_REGISTRY_URL: ${{ secrets.TEST_KAFKA_SCHEMA_REGISTRY_URL }}
PLAYWRIGHT_GLUE_ACCESS_KEY: ${{ secrets.TEST_GLUE_ACCESS_KEY }}
PLAYWRIGHT_GLUE_SECRET_KEY: ${{ secrets.TEST_GLUE_SECRET_KEY }}
PLAYWRIGHT_GLUE_AWS_REGION: ${{ secrets.TEST_GLUE_AWS_REGION }}
PLAYWRIGHT_GLUE_ENDPOINT: ${{ secrets.TEST_GLUE_ENDPOINT }}
PLAYWRIGHT_GLUE_STORAGE_SERVICE: ${{ secrets.TEST_GLUE_STORAGE_SERVICE }}
PLAYWRIGHT_MYSQL_USERNAME: ${{ secrets.TEST_MYSQL_USERNAME }}
PLAYWRIGHT_MYSQL_PASSWORD: ${{ secrets.TEST_MYSQL_PASSWORD }}
PLAYWRIGHT_MYSQL_HOST_PORT: ${{ secrets.TEST_MYSQL_HOST_PORT }}
PLAYWRIGHT_MYSQL_DATABASE_SCHEMA: ${{ secrets.TEST_MYSQL_DATABASE_SCHEMA }}
PLAYWRIGHT_POSTGRES_USERNAME: ${{ secrets.TEST_POSTGRES_USERNAME }}
PLAYWRIGHT_POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
PLAYWRIGHT_POSTGRES_HOST_PORT: ${{ secrets.TEST_POSTGRES_HOST_PORT }}
PLAYWRIGHT_POSTGRES_DATABASE: ${{ secrets.TEST_POSTGRES_DATABASE }}
PLAYWRIGHT_AIRFLOW_HOST_PORT: ${{ secrets.TEST_AIRFLOW_HOST_PORT }}
PLAYWRIGHT_ML_MODEL_TRACKING_URI: ${{ secrets.TEST_ML_MODEL_TRACKING_URI }}
PLAYWRIGHT_ML_MODEL_REGISTRY_URI: ${{ secrets.TEST_ML_MODEL_REGISTRY_URI }}
PLAYWRIGHT_S3_STORAGE_ACCESS_KEY_ID: ${{ secrets.TEST_S3_STORAGE_ACCESS_KEY_ID }}
PLAYWRIGHT_S3_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.TEST_S3_STORAGE_SECRET_ACCESS_KEY }}
PLAYWRIGHT_S3_STORAGE_END_POINT_URL: ${{ secrets.TEST_S3_STORAGE_END_POINT_URL }}
# Recommended: pass the GitHub token lets this action correctly
# determine the unique run id necessary to re-run the checks
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Record shard phase metrics
if: always()
env:
SHARD_ID: ${{ matrix.shardId }}
LANE: ${{ matrix.lane }}
run: |
output="openmetadata-ui/src/main/resources/ui/playwright/output/shard-phases.json"
mkdir -p "$(dirname "$output")"
now=$(date +%s)
jq -n \
--arg shardId "$SHARD_ID" \
--arg lane "$LANE" \
--argjson environmentSeconds "${PW_ENVIRONMENT_SECONDS:-0}" \
--argjson executionSeconds "${PW_EXECUTION_SECONDS:-0}" \
--argjson elapsedSeconds "$((now - PW_JOB_STARTED_AT))" \
'{
version: 1,
shardId: $shardId,
lane: $lane,
environmentSeconds: $environmentSeconds,
executionSeconds: $executionSeconds,
elapsedBeforeUploadSeconds: $elapsedSeconds
}' > "$output"
- name: Summarize shard requests
if: always()
continue-on-error: true
env:
SHARD_ID: ${{ matrix.shardId }}
run: |
if [[ -f "${PW_SERVER_CAPTURE_PID_FILE:-}" ]]; then
kill -USR1 "$(cat "$PW_SERVER_CAPTURE_PID_FILE")" || true
sleep 1
fi
python3 .github/scripts/summarize_playwright_requests.py \
--log "${PW_SERVER_LOG:-$RUNNER_TEMP/missing-openmetadata-server.log}" \
--aggregate "${PW_REQUEST_METRICS:-$RUNNER_TEMP/missing-request-metrics.json}" \
--output "openmetadata-ui/src/main/resources/ui/playwright/output/request-metrics.json" \
--shard-id "$SHARD_ID"
jq '{shardId, totalRequests, apiRequests, staticRequests, apiBytes, staticBytes, appBoots, uiScenarios, appEntryRequests, staticResourceTypes, topApiEndpoints, topStaticEndpoints}' \
openmetadata-ui/src/main/resources/ui/playwright/output/request-metrics.json
- name: Record shard execution status
if: always()
env:
CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }}
DOWNLOAD_DISTRIBUTION_OUTCOME: ${{ steps.download-distribution.outcome }}
DOWNLOAD_FIXTURE_OUTCOME: ${{ steps.download-fixture.outcome }}
DOWNLOAD_INGESTION_IMAGE_OUTCOME: ${{ steps.download-ingestion-image.outcome }}
DOWNLOAD_PLANS_OUTCOME: ${{ steps.download-plans.outcome }}
FAST_INPUTS_OUTCOME: ${{ steps.fast-inputs.outcome }}
SETUP_ENVIRONMENT_OUTCOME: ${{ steps.setup-environment.outcome }}
SETUP_NODE_OUTCOME: ${{ steps.setup-node.outcome }}
INSTALL_DEPENDENCIES_OUTCOME: ${{ steps.install-dependencies.outcome }}
RESTORE_BROWSER_OUTCOME: ${{ steps.restore-browser.outcome }}
INSTALL_BROWSERS_OUTCOME: ${{ steps.install-browsers.outcome }}
BUNDLE_SMOKE_OUTCOME: ${{ steps.verify-bundle.outcome }}
TEST_OUTCOME: ${{ steps.run-tests.outcome }}
run: |
STATUS_DIR="openmetadata-ui/src/main/resources/ui/playwright/output"
mkdir -p "$STATUS_DIR"
jq -n \
--arg shard "${{ matrix.shardId }}" \
--arg checkout "$CHECKOUT_OUTCOME" \
--arg downloadDistribution "$DOWNLOAD_DISTRIBUTION_OUTCOME" \
--arg downloadFixture "$DOWNLOAD_FIXTURE_OUTCOME" \
--arg downloadIngestionImage "$DOWNLOAD_INGESTION_IMAGE_OUTCOME" \
--arg downloadPlans "$DOWNLOAD_PLANS_OUTCOME" \
--arg fastInputs "$FAST_INPUTS_OUTCOME" \
--arg setupEnvironment "$SETUP_ENVIRONMENT_OUTCOME" \
--arg setupNode "$SETUP_NODE_OUTCOME" \
--arg installDependencies "$INSTALL_DEPENDENCIES_OUTCOME" \
--arg restoreBrowser "$RESTORE_BROWSER_OUTCOME" \
--arg installBrowsers "$INSTALL_BROWSERS_OUTCOME" \
--arg bundleSmoke "$BUNDLE_SMOKE_OUTCOME" \
--arg tests "$TEST_OUTCOME" \
'{
shard: $shard,
steps: {
checkout: $checkout,
downloadDistribution: $downloadDistribution,
downloadFixture: $downloadFixture,
downloadIngestionImage: $downloadIngestionImage,
downloadPlans: $downloadPlans,
fastInputs: $fastInputs,
setupEnvironment: $setupEnvironment,
setupNode: $setupNode,
installDependencies: $installDependencies,
restoreBrowser: $restoreBrowser,
installBrowsers: $installBrowsers,
bundleSmoke: $bundleSmoke,
tests: $tests
}
}' > "$STATUS_DIR/ci-status.json"
- name: Upload Playwright blob report
uses: actions/upload-artifact@v6
if: always()
continue-on-error: true
with:
name: playwright-blob-${{ matrix.shardId }}
overwrite: true
path: openmetadata-ui/src/main/resources/ui/playwright/output/blob-report
compression-level: 0
retention-days: 5
if-no-files-found: ignore
- name: Upload failure-only test results
uses: actions/upload-artifact@v6
if: failure() || cancelled()
continue-on-error: true
with:
name: playwright-test-results-${{ matrix.shardId }}
overwrite: true
path: openmetadata-ui/src/main/resources/ui/playwright/output/test-results
retention-days: 5
if-no-files-found: ignore
- name: Upload timing and request metrics
uses: actions/upload-artifact@v6
if: always()
continue-on-error: true
with:
name: playwright-timings-${{ matrix.shardId }}
overwrite: true
path: |
openmetadata-ui/src/main/resources/ui/playwright/output/playwright-timings.json
openmetadata-ui/src/main/resources/ui/playwright/output/request-metrics.json
openmetadata-ui/src/main/resources/ui/playwright/output/shard-phases.json
retention-days: 30
if-no-files-found: ignore
- name: Upload results JSON for summary
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-results-json-${{ matrix.shardId }}
overwrite: true
path: |
openmetadata-ui/src/main/resources/ui/playwright/output/results.json
openmetadata-ui/src/main/resources/ui/playwright/output/ci-status.json
retention-days: 1
if-no-files-found: ignore
- name: Collect failure diagnostics
if: failure() || cancelled()
continue-on-error: true
run: |
DIAGNOSTICS_DIR="${RUNNER_TEMP}/playwright-ci-diagnostics"
mkdir -p "$DIAGNOSTICS_DIR"
df -h > "$DIAGNOSTICS_DIR/disk-usage.txt" 2>&1 || true
docker system df > "$DIAGNOSTICS_DIR/docker-disk-usage.txt" 2>&1 || true
if [[ -n "${PW_SERVER_LOG:-}" && -f "$PW_SERVER_LOG" ]]; then
tail -n 5000 "$PW_SERVER_LOG" > "$DIAGNOSTICS_DIR/openmetadata-server.log" 2>&1 || true
fi
if [[ -n "${PW_RUNTIME_ROOT:-}" ]]; then
docker compose \
-f docker/development/docker-compose-postgres.yml \
-f docker/development/docker-compose-playwright-fast.yml \
ps --all \
> "$DIAGNOSTICS_DIR/docker-compose-ps.txt" 2>&1 || true
docker compose \
-f docker/development/docker-compose-postgres.yml \
-f docker/development/docker-compose-playwright-fast.yml \
logs --no-color --tail 5000 postgresql opensearch \
> "$DIAGNOSTICS_DIR/docker-compose.log" 2>&1 || true
if [[ -n "${PW_AIRFLOW_CONTAINER:-}" ]]; then
docker logs --tail 5000 "$PW_AIRFLOW_CONTAINER" \
> "$DIAGNOSTICS_DIR/airflow.log" 2>&1 || true
fi
curl -s "http://localhost:9200/_cat/thread_pool/write,refresh,search,search_throttled?v&h=node_name,name,active,queue,rejected,completed" \
> "$DIAGNOSTICS_DIR/opensearch-thread-pool.txt" 2>&1 || true
curl -s "http://localhost:9200/_cat/indices?v&h=index,health,status,docs.count,store.size,refresh.total,refresh.total_time,search.query_total&s=index" \
> "$DIAGNOSTICS_DIR/opensearch-indices.txt" 2>&1 || true
fi
- name: Upload failure diagnostics
uses: actions/upload-artifact@v6
if: failure() || cancelled()
continue-on-error: true
with:
name: playwright-ci-diagnostics-${{ matrix.shardId }}
path: ${{ runner.temp }}/playwright-ci-diagnostics
retention-days: 5
if-no-files-found: ignore
- name: Clean Up
if: always()
continue-on-error: true
run: ./.github/scripts/stop_playwright_fast_environment.sh
playwright-summary:
# Publish the required check name `playwright-summary` ONLY when this
# gate authorizes 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:
# gate succeeds with should_run=true → 'playwright-summary'
# gate fails → 'playwright-summary'
# labeled event with non-'safe to test' label → 'playwright-summary (label ignored)'
# gate succeeds with should_run=false → 'playwright-summary (skipped)'
name: >-
${{
(
needs.gate.result != 'success'
|| needs.gate.outputs.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:
[
gate,
check-changes,
cache-keys,
build,
detect-changes,
plan-playwright,
restore-playwright-fixture,
prepare-playwright-fixture,
playwright-ci-postgresql,
]
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-ci-postgresql.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-ci-postgresql.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-ci-postgresql.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-ci-postgresql.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-ci-postgresql.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.plan-playwright.outputs.matrix }}' \
| jq -r '.include[].shardId' \
|| echo "(jq parse failed)"
- name: Setup Node.js
id: setup-node
if: ${{ always() && needs.playwright-ci-postgresql.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-ci-postgresql.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-ci-postgresql.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-ci-postgresql.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-ci-postgresql.result != 'skipped' }}
continue-on-error: true
env:
EXECUTION_MODE: ${{ needs.detect-changes.outputs.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-ci-postgresql.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-ci-postgresql.result != 'skipped' }}
continue-on-error: true
env:
EXECUTION_MODE: ${{ needs.detect-changes.outputs.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-ci-postgresql.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-ci-postgresql.result != 'skipped' }}
continue-on-error: true
env:
EXECUTION_MODE: ${{ needs.detect-changes.outputs.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-ci-postgresql.result != 'skipped' }}
continue-on-error: true
uses: actions/upload-artifact@v6
with:
name: playwright-timing-history-${{ needs.detect-changes.outputs.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-ci-postgresql.result != 'skipped' }}
continue-on-error: true
env:
EXPECTED_MATRIX: ${{ needs.plan-playwright.outputs.matrix }}
MATRIX_OUTCOME: ${{ needs.playwright-ci-postgresql.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-ci-postgresql.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.
# (Per @greptile-apps P1 review on this PR.)
- name: Guard against missing gate decision
if: ${{ needs.gate.result != 'success' }}
run: |
echo "::error::gate did not succeed (result=${{ needs.gate.result }}, should_run=${{ needs.gate.outputs.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.gate.outputs.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.gate.outputs.should_run == 'true' }}
uses: actions/github-script@v8
env:
CHECK_CHANGES_RESULT: ${{ needs.check-changes.result }}
CACHE_KEYS_RESULT: ${{ needs.cache-keys.result }}
BUILD_RESULT: ${{ needs.build.result }}
DETECT_CHANGES_RESULT: ${{ needs.detect-changes.result }}
PLAN_RESULT: ${{ needs.plan-playwright.result }}
FIXTURE_RESTORE_RESULT: ${{ needs.restore-playwright-fixture.result }}
FIXTURE_RESULT: ${{ needs.prepare-playwright-fixture.result }}
PLAYWRIGHT_RESULT: ${{ needs.playwright-ci-postgresql.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.check-changes.outputs.e2e }}
DOCKER_COMPOSE_CHANGED: ${{ needs.check-changes.outputs.docker-compose }}
EXPECTED_MATRIX: ${{ needs.plan-playwright.outputs.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