fix(web): validate the onboarding runtime before Continue is pressed #20108
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: ci | |
| on: | |
| pull_request: | |
| merge_group: | |
| workflow_dispatch: | |
| inputs: | |
| ci_mode: | |
| description: Validation strength for this manual run | |
| type: choice | |
| options: | |
| - full | |
| - hot | |
| default: full | |
| permissions: | |
| actions: read | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event.pull_request.number || github.ref }} | |
| # Prefer current-head signal over preserving superseded logs: PR authors often | |
| # push fixups while this workflow is still running, and stale runs can report | |
| # failures for commits reviewers no longer need to evaluate. Release workflows | |
| # use cancel-in-progress: false where preserving build evidence matters more. | |
| # Manual comparison runs need isolated groups so queued/stuck experiments do | |
| # not block the next measurement on this branch. | |
| cancel-in-progress: true | |
| jobs: | |
| runners: | |
| name: Resolve runner profiles | |
| # Resolve on the Nexu ARC fleet for trusted branches, but preserve the | |
| # hosted bootstrap path for external forks and explicit economic mode. | |
| runs-on: >- | |
| ${{ ((github.event_name == 'pull_request' | |
| && github.event.pull_request.head.repo.full_name != github.repository) | |
| || vars.OD_CI_RUNNER_MODE == 'economic') | |
| && 'ubuntu-24.04' | |
| || 'nexu-runners-small' }} | |
| outputs: | |
| runs_on: ${{ steps.runners.outputs.runs_on }} | |
| decision: ${{ steps.runners.outputs.decision }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Resolve runner profiles | |
| id: runners | |
| env: | |
| # Fork PRs cannot rely on repository variables, so keep their required | |
| # validation on GitHub-hosted runners even when private pools are down. | |
| OD_CI_RUNNER_MODE: >- | |
| ${{ github.event_name == 'pull_request' | |
| && github.event.pull_request.head.repo.full_name != github.repository | |
| && 'economic' | |
| || vars.OD_CI_RUNNER_MODE }} | |
| run: python3 .github/scripts/runners.py | |
| plan: | |
| name: Plan validation workloads | |
| needs: [runners] | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} | |
| outputs: | |
| run: ${{ steps.convergence.outputs.run }} | |
| convergence_plan_artifact: ci-convergence-plan-${{ github.run_id }}-${{ github.run_attempt }} | |
| convergence_hit: ${{ steps.convergence.outputs.hit }} | |
| convergence_would_run: ${{ steps.convergence.outputs.would_run }} | |
| scopes: ${{ steps.scopes.outputs.scopes }} | |
| ui_p0_matrix: ${{ steps.scopes.outputs.ui_p0_matrix }} | |
| visual_matrix: ${{ steps.scopes.outputs.visual_matrix }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Resolve validation scopes | |
| id: scopes | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: python3 .github/scripts/scopes.py github-output --output "$RUNNER_TEMP/scope-plan.json" | |
| - name: Resolve reusable workload results | |
| id: convergence | |
| env: | |
| RUNNER_PLAN_JSON: ${{ needs.runners.outputs.runs_on }} | |
| OD_WORKLOAD_RESULTS_BASE_URL: ${{ secrets.OD_WORKLOAD_RESULTS_BASE_URL }} | |
| run: >- | |
| python3 .github/scripts/convergence.py github-output | |
| --workflow ci | |
| --scope-plan "$RUNNER_TEMP/scope-plan.json" | |
| --runner-plan-json "$RUNNER_PLAN_JSON" | |
| --mode shadow | |
| --pending "$RUNNER_TEMP/pending-convergence.json" | |
| # Shadow mode records reusable-result hits without omitting validation. | |
| # The successful gate later turns this exact plan into a trusted handoff. | |
| - name: Upload pending convergence plan | |
| continue-on-error: true | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ci-convergence-plan-${{ github.run_id }}-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/pending-convergence.json | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - name: Create visual report handoff | |
| id: visual_report_handoff | |
| if: ${{ github.event_name == 'pull_request' && fromJSON(steps.convergence.outputs.run).playwright_visual }} | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| RUN_ID: ${{ github.run_id }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| handoff_id="visual-pr" | |
| handoff_root="$RUNNER_TEMP/handoff-report-$handoff_id" | |
| handoff_dir="$(python3 .github/scripts/handoff.py dir report "$handoff_id" --root "$handoff_root")" | |
| mkdir -p "$handoff_dir" | |
| jq -n \ | |
| --arg kind "report" \ | |
| --arg id "$handoff_id" \ | |
| --arg head "$HEAD_SHA" \ | |
| --arg base "$BASE_SHA" \ | |
| --arg report_type "visual-pr" \ | |
| --arg artifact_pattern "visual-pr-capture-$PR_NUMBER-$RUN_ID-*" \ | |
| --arg output_comment_id "visual-pr-report" \ | |
| --arg marker "<!-- visual-regression-bot -->" \ | |
| --argjson schema_version 1 \ | |
| --argjson pr_number "$PR_NUMBER" \ | |
| --argjson run_id "$RUN_ID" \ | |
| '{schema_version: $schema_version, kind: $kind, id: $id, pr_number: $pr_number, head_sha: $head, base_sha: $base, run_id: $run_id, report_type: $report_type, artifact_pattern: $artifact_pattern, output_comment_id: $output_comment_id, marker: $marker}' \ | |
| > "$handoff_dir/metadata.json" | |
| python3 .github/scripts/handoff.py validate report "$handoff_dir" >/dev/null | |
| { | |
| echo "name=$(python3 .github/scripts/handoff.py artifact-name report "$handoff_id")" | |
| echo "path=$handoff_root" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Upload visual report handoff | |
| if: ${{ steps.visual_report_handoff.outputs.name != '' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ steps.visual_report_handoff.outputs.name }} | |
| path: ${{ steps.visual_report_handoff.outputs.path }} | |
| if-no-files-found: error | |
| retention-days: 7 | |
| static_gate: | |
| name: Static gate | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).static_gate }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} | |
| timeout-minutes: 10 | |
| env: | |
| ACTIONLINT_VERSION: 1.7.12 | |
| SHELLCHECK_VERSION: 0.11.0 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Install actionlint and ShellCheck | |
| run: | | |
| case "$(uname -m)" in | |
| x86_64) | |
| actionlint_arch=amd64 | |
| shellcheck_arch=x86_64 | |
| ;; | |
| aarch64|arm64) | |
| actionlint_arch=arm64 | |
| shellcheck_arch=aarch64 | |
| ;; | |
| *) echo "Unsupported actionlint architecture: $(uname -m)" >&2; exit 1 ;; | |
| esac | |
| curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_${actionlint_arch}.tar.gz" \ | |
| | tar -xz actionlint | |
| curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.${shellcheck_arch}.tar.gz" \ | |
| | tar -xz --strip-components=1 "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" | |
| sudo install -m 0755 actionlint /usr/local/bin/actionlint | |
| sudo install -m 0755 shellcheck /usr/local/bin/shellcheck | |
| - name: Check workflow files | |
| run: actionlint -color | |
| - name: Check changed tracked file sizes | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| MAX_CHANGED_FILE_BYTES: "1048576" | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| changed_files="$RUNNER_TEMP/blob-guard-changed-files.txt" | |
| : > "$changed_files" | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| gh api --paginate \ | |
| "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ | |
| --jq '.[].filename' > "$changed_files" | |
| elif [ "${{ github.event_name }}" = "merge_group" ]; then | |
| gh api --paginate \ | |
| "repos/${{ github.repository }}/compare/${{ github.event.merge_group.base_sha }}...${{ github.event.merge_group.head_sha }}" \ | |
| --jq '(.files // [])[] | select(.status != "removed") | .filename' > "$changed_files" | |
| elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| gh api --paginate \ | |
| "repos/${{ github.repository }}/compare/main...${{ github.sha }}" \ | |
| --jq '(.files // [])[] | select(.status != "removed") | .filename' > "$changed_files" | |
| else | |
| git ls-files > "$changed_files" | |
| fi | |
| oversized="$RUNNER_TEMP/blob-guard-oversized.txt" | |
| : > "$oversized" | |
| while IFS= read -r file; do | |
| [ -n "$file" ] || continue | |
| [ -f "$file" ] || continue | |
| if ! git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then | |
| continue | |
| fi | |
| size="$(wc -c < "$file" | tr -d '[:space:]')" | |
| if [ "$size" -gt "$MAX_CHANGED_FILE_BYTES" ]; then | |
| printf '%s\t%s\n' "$size" "$file" >> "$oversized" | |
| fi | |
| done < "$changed_files" | |
| if [ -s "$oversized" ]; then | |
| { | |
| echo "Changed tracked files exceed the current blob guard threshold (${MAX_CHANGED_FILE_BYTES} bytes):" | |
| while IFS=$'\t' read -r size file; do | |
| echo "- ${size} bytes ${file}" | |
| done < "$oversized" | |
| echo | |
| echo "Move large generated/media assets to repository-assets R2 or add a narrow, reviewed exception for source-owned runtime binaries." | |
| } >&2 | |
| exit 1 | |
| fi | |
| echo "Changed-file blob guard passed at ${MAX_CHANGED_FILE_BYTES} bytes." | |
| preflight: | |
| name: Preflight | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).preflight }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).general_medium }} | |
| timeout-minutes: 45 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).general_medium) }} | |
| # `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that | |
| # are needed immediately after install for linked bins and shared | |
| # sidecar/platform imports. It intentionally skips app outputs because | |
| # building all apps would make every install run a Next/Electron-adjacent | |
| # app build, even when a developer only needs packages/tools. | |
| # | |
| # Fresh CI typecheck/test still need these specific generated declarations: | |
| # - `apps/daemon/dist/*.d.ts` for packaged/runtime consumers of the daemon | |
| # package export | |
| # - `apps/desktop/dist/main/index.d.ts` for `apps/packaged` imports of | |
| # `@open-design/desktop/main` | |
| # - `apps/web/dist/sidecar/index.d.ts` for `apps/packaged` imports of | |
| # `@open-design/web/sidecar` | |
| # If postinstall grows a targeted app type-generation phase covering these | |
| # three exports without broad app builds, this CI prebuild can be removed. | |
| - name: Prebuild workspace type declarations | |
| if: ${{ fromJSON(needs.plan.outputs.scopes).run_preflight_typecheck }} | |
| run: | | |
| pnpm --filter @open-design/daemon build | |
| pnpm --filter @open-design/desktop build | |
| pnpm --filter @open-design/web build:sidecar | |
| - name: Typecheck workspaces | |
| if: ${{ fromJSON(needs.plan.outputs.scopes).run_preflight_typecheck }} | |
| run: | | |
| pnpm -r --filter '!open-design' --filter '!@open-design/landing-page' --workspace-concurrency="${OPEN_DESIGN_WORKSPACE_CONCURRENCY:-1}" --if-present run typecheck | |
| pnpm exec tsc -p scripts/tsconfig.json --noEmit | |
| - name: Check repository layout policies | |
| run: pnpm guard | |
| - name: Check i18n structure | |
| run: pnpm i18n:check | |
| workspace_unit_tests: | |
| name: Workspace unit tests | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).workspace_unit_tests }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).workspace_unit }} | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).workspace_unit) }} | |
| - name: Workspace unit tests | |
| run: | | |
| pnpm --filter @open-design/contracts test | |
| pnpm --filter @open-design/host test | |
| pnpm --filter @open-design/platform test | |
| pnpm --filter @open-design/sidecar test | |
| pnpm --filter @open-design/sidecar-proto test | |
| if [ "${{ fromJSON(needs.plan.outputs.scopes).terminal_scene_required }}" = "true" ]; then | |
| pnpm --filter @open-design/standalone test | |
| pnpm --filter @open-design/closure test | |
| pnpm --filter @open-design/terminal test | |
| fi | |
| if [ "${{ fromJSON(needs.plan.outputs.scopes).tools_dev_tests_required }}" = "true" ]; then | |
| pnpm --filter @open-design/tools-dev test | |
| fi | |
| if [ "${{ fromJSON(needs.plan.outputs.scopes).tools_pack_tests_required }}" = "true" ]; then | |
| pnpm --filter @open-design/desktop build | |
| pnpm --filter @open-design/desktop test | |
| pnpm --filter @open-design/packaged test | |
| pnpm --filter @open-design/tools-pack test | |
| if [ "${{ fromJSON(needs.plan.outputs.run).e2e_vitest }}" != "true" ]; then | |
| pnpm --filter @open-design/e2e test tests/packaged-launcher-update-loop.test.ts | |
| fi | |
| fi | |
| daemon_unit_tests: | |
| name: Daemon tests (${{ matrix.shard }}/4) | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).daemon_unit_tests }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).workspace_unit }} | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).workspace_unit) }} | |
| - name: Run daemon test shard | |
| env: | |
| OD_WATCHER_TEST_DEBUG: "1" | |
| run: pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4 | |
| windows_tools_pack_payload_tests: | |
| name: Windows tools-pack payload tests | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).windows_tools_pack_payload_tests }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).windows_tools }} | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).windows_tools) }} | |
| - name: Windows launcher payload archive tests | |
| run: pnpm --filter @open-design/tools-pack exec vitest run tests/launcher/windows/payload.test.ts | |
| web_workspace_tests: | |
| name: Web workspace tests (${{ matrix.shard }}/2) | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).web_workspace_tests }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).js_hot }} | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).js_hot) }} | |
| - name: Prebuild web sidecar declarations | |
| run: pnpm --filter @open-design/web build:sidecar | |
| - name: Web workspace tests | |
| run: pnpm --filter @open-design/web exec vitest run -c vitest.config.ts --maxWorkers=2 --shard=${{ matrix.shard }}/2 | |
| e2e_vitest: | |
| name: E2E Vitest | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).e2e_vitest }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).js_hot }} | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).js_hot) }} | |
| - name: Setup Playwright | |
| uses: ./.github/actions/setup-playwright | |
| with: | |
| package-json-path: e2e/package.json | |
| install-command: pnpm -C e2e exec playwright install --with-deps chromium | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).js_hot) }} | |
| - name: Prebuild workspace type declarations | |
| run: | | |
| pnpm --filter @open-design/daemon build | |
| pnpm --filter @open-design/desktop build | |
| pnpm --filter @open-design/web build:sidecar | |
| - name: E2E Vitest | |
| run: pnpm --filter @open-design/e2e test | |
| playwright_critical: | |
| name: Playwright critical (${{ matrix.group }}) | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).playwright_critical }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).ui_hot }} | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - group: a | |
| files: ui/amr-onboarding.test.ts ui/app.test.ts ui/app-design-files.test.ts ui/app-manual-edit.test.ts ui/api-empty-response.test.ts | |
| - group: b | |
| files: ui/app-restoration.test.ts ui/entry-chrome-flows.test.ts ui/project-management-flows.test.ts ui/settings-api-protocol.test.ts ui/settings-local-cli-codex-fallback.test.ts ui/critical-smoke.test.ts ui/workspace-keyboard-flows.test.ts | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).ui_hot) }} | |
| - name: Setup Playwright | |
| uses: ./.github/actions/setup-playwright | |
| with: | |
| package-json-path: e2e/package.json | |
| install-command: pnpm -C e2e exec playwright install --with-deps chromium | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).ui_hot) }} | |
| - name: Prebuild workspace type declarations | |
| run: | | |
| pnpm --filter @open-design/daemon build | |
| pnpm --filter @open-design/desktop build | |
| pnpm --filter @open-design/web build:sidecar | |
| - name: Clean Playwright state | |
| run: pnpm -C e2e exec tsx scripts/playwright.ts clean | |
| - name: Playwright critical | |
| env: | |
| OD_PLAYWRIGHT_FULLY_PARALLEL: "1" | |
| run: pnpm -C e2e exec playwright test -c playwright.config.ts ${{ matrix.files }} --grep '@critical' | |
| ui_p0: | |
| name: UI P0 (${{ matrix.name }}) | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).ui_p0 }} | |
| runs-on: ${{ matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0 }} | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: ${{ fromJSON(needs.plan.outputs.ui_p0_matrix) }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0) }} | |
| - name: Setup Playwright | |
| uses: ./.github/actions/setup-playwright | |
| with: | |
| package-json-path: e2e/package.json | |
| install-command: pnpm -C e2e exec playwright install --with-deps chromium | |
| runner-labels: ${{ toJSON(matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0) }} | |
| - name: Prebuild workspace type declarations | |
| run: | | |
| pnpm --filter @open-design/daemon build | |
| pnpm --filter @open-design/desktop build | |
| pnpm --filter @open-design/web build:sidecar | |
| - name: Clean Playwright state | |
| run: pnpm -C e2e exec tsx scripts/playwright.ts clean | |
| - name: Run UI P0 domain | |
| run: pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group ${{ matrix.shard }} | |
| - name: Preserve project-runtime domain artifact | |
| if: ${{ success() && matrix.shard == 'project-runtime' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ui-p0-ci-${{ github.run_id }}-${{ matrix.name }}-domain | |
| path: | | |
| e2e/ui/reports/playwright-html-report | |
| e2e/ui/reports/test-results | |
| e2e/ui/reports/results.json | |
| e2e/ui/test-results | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Run UI critical extras | |
| if: ${{ matrix.shard == 'project-runtime' }} | |
| run: pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group critical-extras | |
| - name: Upload Playwright debug artifact | |
| if: ${{ always() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ui-p0-ci-${{ github.run_id }}-${{ matrix.name }} | |
| path: | | |
| e2e/ui/reports/playwright-html-report | |
| e2e/ui/reports/test-results | |
| e2e/ui/reports/results.json | |
| e2e/ui/test-results | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| playwright_visual: | |
| name: Playwright visual (${{ matrix.name }}) | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).playwright_visual }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).visual_hot }} | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: ${{ fromJSON(needs.plan.outputs.visual_matrix) }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Configure CI parallelism | |
| uses: ./.github/actions/configure-ci-parallelism | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).visual_hot) }} | |
| - name: Setup Playwright | |
| uses: ./.github/actions/setup-playwright | |
| with: | |
| package-json-path: e2e/package.json | |
| install-command: pnpm -C e2e exec playwright install --with-deps chromium | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).visual_hot) }} | |
| - name: Prebuild workspace type declarations | |
| run: | | |
| pnpm --filter @open-design/daemon build | |
| pnpm --filter @open-design/desktop build | |
| pnpm --filter @open-design/web build:sidecar | |
| - name: Run strict visual Playwright suite | |
| id: visual | |
| continue-on-error: true | |
| env: | |
| OD_VISUAL_OUTPUT_DIR: ui/reports/visual-screenshots | |
| OD_PLAYWRIGHT_FULLY_PARALLEL: "1" | |
| run: | | |
| pnpm -C e2e exec tsx scripts/playwright.ts clean | |
| pnpm -C e2e exec playwright test -c playwright.visual.config.ts ${{ matrix.files }} | |
| - name: Write capture manifest | |
| if: ${{ always() && github.event_name == 'pull_request' }} | |
| run: | | |
| mkdir -p e2e/ui/reports/visual-report | |
| cat > e2e/ui/reports/visual-report/manifest.json <<'JSON' | |
| { | |
| "pr_number": "${{ github.event.pull_request.number }}", | |
| "head_sha": "${{ github.event.pull_request.head.sha }}", | |
| "base_sha": "${{ github.event.pull_request.base.sha }}", | |
| "run_id": "${{ github.run_id }}", | |
| "group": "${{ matrix.name }}", | |
| "capture_outcome": "${{ steps.visual.outcome }}" | |
| } | |
| JSON | |
| - name: Upload PR visual artifact | |
| id: upload_visual_artifact | |
| if: ${{ always() && github.event_name == 'pull_request' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: visual-pr-capture-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ matrix.name }} | |
| path: | | |
| e2e/ui/reports/visual-screenshots | |
| e2e/ui/reports/visual-test-results | |
| e2e/ui/reports/visual-results.json | |
| e2e/ui/reports/visual-report/manifest.json | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Upload manual visual artifact | |
| if: ${{ always() && github.event_name == 'workflow_dispatch' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: visual-ci-${{ github.run_id }}-${{ matrix.name }} | |
| path: | | |
| e2e/ui/reports/visual-screenshots | |
| e2e/ui/reports/visual-test-results | |
| e2e/ui/reports/visual-results.json | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Fail when strict visual tests fail | |
| if: ${{ steps.visual.outcome != 'success' }} | |
| run: exit 1 | |
| terminal_scene: | |
| name: Terminal distribution scene | |
| needs: [plan, runners] | |
| if: ${{ fromJSON(needs.plan.outputs.run).terminal_scene }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).workspace_unit }} | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).workspace_unit) }} | |
| - name: Download convergence identity | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ needs.plan.outputs.convergence_plan_artifact }} | |
| path: ${{ runner.temp }}/terminal-convergence | |
| - name: Fetch locked official Node carriers | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/terminal-node" | |
| python3 - <<'PY' | |
| import hashlib, json, os, urllib.request | |
| from pathlib import Path | |
| lock = json.loads(Path('shells/terminal/node-lock.json').read_text()) | |
| root = Path(os.environ['RUNNER_TEMP']) / 'terminal-node' | |
| for value in lock['targets'].values(): | |
| destination = root / value['archive'] | |
| urllib.request.urlretrieve(value['url'], destination) | |
| if hashlib.sha256(destination.read_bytes()).hexdigest() != value['sha256']: | |
| raise SystemExit(f"official Node digest mismatch: {destination.name}") | |
| PY | |
| - name: Build release-neutral Terminal scene | |
| run: | | |
| set -euo pipefail | |
| pnpm --filter @open-design/closure build | |
| pnpm --filter @open-design/terminal build | |
| python3 - <<'PY' | |
| import json, os | |
| from pathlib import Path | |
| lock = json.loads(Path('shells/terminal/node-lock.json').read_text()) | |
| closure = json.loads(Path('apps/closure/package.json').read_text()) | |
| standalone = json.loads(Path('packages/standalone/package.json').read_text()) | |
| root = Path(os.environ['RUNNER_TEMP']) | |
| targets = [{'target': target, 'nodeArchiveFile': str((root / 'terminal-node' / value['archive']).resolve()), 'nodeArchiveSha256': value['sha256']} for target, value in lock['targets'].items()] | |
| request = {'schemaVersion': 1, 'operation': 'terminal.scene.build', 'standaloneVersion': standalone['version'], 'closureVersion': closure['version'], 'closureArtifactFile': str(Path('apps/closure/dist/fixture.mjs').resolve()), 'targets': targets, 'sceneDirectory': str((root / 'terminal-scene').resolve())} | |
| (root / 'terminal-scene-request.json').write_text(json.dumps(request, sort_keys=True, separators=(',', ':')) + '\n') | |
| PY | |
| node shells/terminal/dist/exact-pack.mjs --request "$RUNNER_TEMP/terminal-scene-request.json" --receipt "$RUNNER_TEMP/terminal-scene-receipt.json" | |
| - name: Write generic convergence product manifest | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/convergence-manifest/terminal_scene" | |
| python3 - <<'PY' | |
| import json, os | |
| from pathlib import Path | |
| root = Path(os.environ['RUNNER_TEMP']) | |
| pending = json.loads((root / 'terminal-convergence' / 'pending-convergence.json').read_text()) | |
| workload = pending['workloads']['terminal_scene'] | |
| manifest = {'workload': 'terminal_scene', 'digest': workload['digest'], 'executionClass': workload['executionClass'], 'products': {'scene': {'type': 'job', 'source': 'terminal_scene', 'data': {'contract': 'terminal-scene-v1', 'owner': 'shells/terminal'}}}} | |
| (root / 'convergence-manifest' / 'terminal_scene' / 'product-manifest.json').write_text(json.dumps(manifest, sort_keys=True, separators=(',', ':')) + '\n') | |
| PY | |
| - name: Upload Terminal scene product | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: terminal_scene | |
| path: ${{ runner.temp }}/terminal-scene | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - name: Upload Terminal scene convergence manifest | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: terminal-scene-convergence-manifest | |
| path: ${{ runner.temp }}/convergence-manifest | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # This policy is a sibling of the validation workloads, not their prerequisite. | |
| # Labeled merge groups still receive the same workload coverage before the | |
| # required Validate workspace check rejects the converged result. | |
| merge_policy: | |
| name: Merge policy | |
| needs: [plan, runners] | |
| if: ${{ github.event_name == 'merge_group' }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} | |
| timeout-minutes: 5 | |
| # Tells the generic merge-queue failure notice in `validate` whether this job already | |
| # announced the ejection, so a label-only ejection gets exactly one comment. | |
| outputs: | |
| ejection_notice: ${{ steps.merge_blocking_label_gate.outputs.comment_created }} | |
| steps: | |
| # The merge-blocking label gate produces a `handoff/comment` artifact when it ejects a | |
| # queued PR, and that production goes through `.github/scripts/handoff.py` (the only | |
| # sanctioned source of handoff names and layout). | |
| - name: Checkout handoff helper | |
| uses: actions/checkout@v6.0.2 | |
| with: | |
| sparse-checkout: .github | |
| sparse-checkout-cone-mode: true | |
| - name: Block merge while a merge-blocking label is present | |
| id: merge_blocking_label_gate | |
| # Hard gate: a PR that still carries `needs-validation` or `needs-maintainer-check` must | |
| # not merge. Enforced ONLY in the merge_group (merge-queue) context — never on | |
| # pull_request. main requires the merge queue, so every merge passes through merge_group; | |
| # a labeled entry fails this step there and is ejected from the queue, so it can never land. | |
| # | |
| # Why not also fail on pull_request: that turns the PR's own required `Validate workspace` | |
| # check red, and a deliberately-red required check is indistinguishable from a real CI | |
| # failure to everything downstream — it flips the PR's mergeStateStatus to BLOCKED and | |
| # reads as ci_status=failure to the review bot, which deadlocks the very QA/validation that | |
| # is supposed to clear the label (QA never gets requested → label never removed → blocked | |
| # forever). The merge_group run executes on the queue's transient ref, so this failure does | |
| # NOT appear in the PR head's status rollup; the PR stays green until the label is cleared. | |
| # Fails closed: any label-lookup error blocks rather than silently waving the merge through. | |
| # `skip-validation` continues to override only the `needs-validation` producer; it has no | |
| # effect on the independent maintainer-check policy. | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| REPO: ${{ github.repository }} | |
| # Resolving the queued PR via commits/<sha>/pulls alone is unreliable: the merge queue | |
| # squashes, so the queued commits are NEW synthetic SHAs GitHub does not associate back | |
| # to the PR — that found nothing and waved a needs-validation PR through (#4736). So we | |
| # resolve each merge-group commit to a PR from several signals, per commit (see below). | |
| MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }} | |
| BASE_SHA: ${{ github.event.merge_group.base_sha }} | |
| HEAD_SHA: ${{ github.event.merge_group.head_sha }} | |
| RUN_ID: ${{ github.run_id }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$EVENT_NAME" != "merge_group" ]; then | |
| echo "Merge-blocking labels are gated in the merge_group context; nothing to enforce on $EVENT_NAME (PR check stays green)." | |
| else | |
| # An ejection is invisible from the PR: this failure runs on the queue's transient | |
| # ref, the PR's own checks stay green, and `mergeQueueEntry` just goes null. Leave a | |
| # visible trace by producing a `handoff/comment` artifact for the labeled PR; the | |
| # trusted `comment.atom.yml` consumer upserts it as a PR comment after this run | |
| # completes. Best-effort: a handoff failure must never soften the block itself. | |
| emit_ejection_notice() { | |
| local pr="$1" | |
| local blocking_label="$2" | |
| local pr_json pr_head pr_base handoff_id handoff_root handoff_dir marker next_step | |
| pr_json="$(gh api "repos/$REPO/pulls/$pr")" | |
| pr_head="$(jq -r '.head.sha' <<< "$pr_json")" | |
| pr_base="$(jq -r '.base.sha' <<< "$pr_json")" | |
| case "$blocking_label" in | |
| needs-validation) | |
| handoff_id="needs-validation-pr-$pr" | |
| marker="<!-- merge-queue-needs-validation -->" | |
| # Markdown code spans are intentional literal text. | |
| # shellcheck disable=SC2016 | |
| next_step='To land this PR: complete the QA pass the label is tracking, remove the `needs-validation` label, then add the PR back to the merge queue.' | |
| ;; | |
| needs-maintainer-check) | |
| handoff_id="needs-maintainer-check-pr-$pr" | |
| marker="<!-- merge-queue-needs-maintainer-check -->" | |
| # Markdown code spans are intentional literal text. | |
| # shellcheck disable=SC2016 | |
| next_step='To land this PR: have a maintainer complete the check, remove the `needs-maintainer-check` label, then add the PR back to the merge queue.' | |
| ;; | |
| *) | |
| echo "Unsupported merge-blocking label: $blocking_label" >&2 | |
| return 1 | |
| ;; | |
| esac | |
| handoff_root="$RUNNER_TEMP/handoff-comment-$handoff_id" | |
| handoff_dir="$(python3 .github/scripts/handoff.py dir comment "$handoff_id" --root "$handoff_root")" | |
| mkdir -p "$handoff_dir" | |
| # Markdown code spans are intentionally literal in these single-quoted strings. | |
| # shellcheck disable=SC2016 | |
| { | |
| printf '%s\n' "$marker" | |
| printf 'Ejected from the merge queue: this PR still carries the `%s` label.\n\n' "$blocking_label" | |
| printf 'The merge queue gate ([run %s](%s/%s/actions/runs/%s)) blocked the queued group because of the label. That failure runs on the queue'"'"'s transient ref, so it never appears in this PR'"'"'s own checks — they stay green, and this notice is the only visible trace on the PR.\n\n' "$RUN_ID" "$GITHUB_SERVER_URL" "$REPO" "$RUN_ID" | |
| printf '%s\n' "$next_step" | |
| } > "$handoff_dir/body.md" | |
| jq -n \ | |
| --arg kind "comment" \ | |
| --arg id "$handoff_id" \ | |
| --arg head "$pr_head" \ | |
| --arg base "$pr_base" \ | |
| --arg marker "$marker" \ | |
| --argjson schema_version 1 \ | |
| --argjson pr_number "$pr" \ | |
| --argjson run_id "$RUN_ID" \ | |
| '{schema_version: $schema_version, kind: $kind, id: $id, pr_number: $pr_number, head_sha: $head, base_sha: $base, run_id: $run_id, marker: $marker}' \ | |
| > "$handoff_dir/metadata.json" | |
| python3 .github/scripts/handoff.py validate comment "$handoff_dir" >/dev/null | |
| { | |
| echo "comment_created=true" | |
| echo "comment_name=$(python3 .github/scripts/handoff.py artifact-name comment "$handoff_id")" | |
| echo "comment_path=$handoff_root" | |
| } >> "$GITHUB_OUTPUT" | |
| } | |
| # Resolve EVERY merge-group commit to at least one real PR and check its labels. | |
| # Each queued entry can be recovered from up to three signals: | |
| # 1. ref `.../pr-<N>-<sha>` — names the (tip) entry only; squash-safe. | |
| # 2. the commit subject's trailing `(#N)` — the queue appends each entry's own PR | |
| # number, so this recovers the NON-tip PRs of a batched group. | |
| # 3. commits/<sha>/pulls — covers a non-squash queue where commits map back directly. | |
| # A `(#N)` can also be a spurious reference — e.g. #4833, titled "... (#4828)", where | |
| # 4828 is the fixed ISSUE, not a PR; `gh pr view 4828` then fails with "Could not | |
| # resolve to a PullRequest". We must NOT fail closed on that (it ejected a clean, | |
| # label-free PR from the queue forever) — but we must also NOT blindly skip it, or a | |
| # batched non-tip PR whose only recovered number is such a reference would go unchecked | |
| # and a needs-validation entry could slip through (#4736). So we resolve PER COMMIT: | |
| # ignore a non-PR candidate only when its commit is still covered by another candidate | |
| # that DOES resolve to a real PR; a commit that resolves to no PR fails closed. | |
| compare_json="$(gh api "repos/$REPO/compare/$BASE_SHA...$HEAD_SHA")" | |
| ref_prs="$(printf '%s\n' "$MERGE_GROUP_REF" | grep -oE 'pr-[0-9]+' | grep -oE '[0-9]+' || true)" | |
| commit_shas="$(printf '%s' "$compare_json" | jq -r '.commits[].sha')" | |
| if [ -z "$commit_shas" ]; then | |
| echo "::error::merge_group compare $BASE_SHA...$HEAD_SHA returned no commits — cannot resolve queued PRs; blocking merge (fail closed)." | |
| exit 1 | |
| fi | |
| checked="" | |
| for sha in $commit_shas; do | |
| subject="$(printf '%s' "$compare_json" | jq -r --arg s "$sha" '.commits[] | select(.sha==$s) | .commit.message | split("\n")[0]')" | |
| subj_prs="$(printf '%s' "$subject" | grep -oE '\(#[0-9]+\)' | grep -oE '[0-9]+' || true)" | |
| # Required assignment (no `|| true`): a transient API failure must block, not | |
| # silently shrink this commit's candidate set. | |
| assoc_prs="$(gh api "repos/$REPO/commits/$sha/pulls" --jq '.[].number')" | |
| ref_for_commit="" | |
| [ "$sha" = "$HEAD_SHA" ] && ref_for_commit="$ref_prs" | |
| candidates="$(printf '%s\n%s\n%s\n' "$subj_prs" "$assoc_prs" "$ref_for_commit" | grep -E '^[0-9]+$' | sort -u || true)" | |
| covered=0 | |
| for pr in $candidates; do | |
| if ! labels="$(gh pr view "$pr" --repo "$REPO" --json labels --jq '.labels[].name' 2>/tmp/pr_view_err)"; then | |
| if grep -q 'Could not resolve to a PullRequest' /tmp/pr_view_err; then | |
| echo "commit $sha: candidate #$pr is not a pull request (e.g. an issue reference) — ignoring this candidate." | |
| continue | |
| fi | |
| echo "::error::label lookup for #$pr (commit $sha) failed — blocking merge (fail closed):" | |
| cat /tmp/pr_view_err | |
| exit 1 | |
| fi | |
| covered=1 | |
| case " $checked " in *" $pr "*) ;; *) checked="$checked $pr";; esac | |
| blocking_label="" | |
| if printf '%s\n' "$labels" | grep -qx 'needs-maintainer-check'; then | |
| blocking_label="needs-maintainer-check" | |
| elif printf '%s\n' "$labels" | grep -qx 'needs-validation'; then | |
| blocking_label="needs-validation" | |
| fi | |
| if [ -n "$blocking_label" ]; then | |
| echo "::error::PR #$pr still has '$blocking_label' — blocking merge." | |
| # Fail-fast keeps this per-run notice on the FIRST labeled PR found; every | |
| # labeled entry also fails its own queue run, so each still gets its own notice. | |
| emit_ejection_notice "$pr" "$blocking_label" || echo "::warning::could not produce the ejection-notice handoff for PR #$pr; the block itself still stands." | |
| exit 1 | |
| fi | |
| echo "PR #$pr: no merge-blocking label." | |
| done | |
| if [ "$covered" -eq 0 ]; then | |
| echo "::error::merge-group commit $sha resolved to no pull request (candidates: ${candidates:-none}) — cannot verify merge-blocking labels; blocking merge (fail closed)." | |
| exit 1 | |
| fi | |
| done | |
| echo "No merge-blocking label in the queued group ($checked ) — clear to merge." | |
| fi | |
| # `failure()` is required: the gate exits 1 on the very path that produces the handoff, | |
| # and the default `success()` condition would skip this upload exactly when it matters. | |
| - name: Upload merge-queue ejection notice handoff | |
| if: ${{ failure() && steps.merge_blocking_label_gate.outputs.comment_created == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ steps.merge_blocking_label_gate.outputs.comment_name }} | |
| path: ${{ steps.merge_blocking_label_gate.outputs.comment_path }} | |
| validate: | |
| name: Validate workspace | |
| needs: | |
| - plan | |
| - runners | |
| - merge_policy | |
| - static_gate | |
| - preflight | |
| - workspace_unit_tests | |
| - daemon_unit_tests | |
| - windows_tools_pack_payload_tests | |
| - web_workspace_tests | |
| - e2e_vitest | |
| - playwright_critical | |
| - ui_p0 | |
| - playwright_visual | |
| - terminal_scene | |
| if: ${{ always() }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Check workspace validation jobs | |
| env: | |
| NEEDS_JSON: ${{ toJSON(needs) }} | |
| run: | | |
| set -euo pipefail | |
| echo "$NEEDS_JSON" | jq . | |
| failures="$(echo "$NEEDS_JSON" | jq -r ' | |
| to_entries[] | |
| | select(.value.result != "success" and .value.result != "skipped") | |
| | "\(.key)=\(.value.result)"')" | |
| if [ -n "$failures" ]; then | |
| echo "Workspace validation failed:" | |
| echo "$failures" | |
| exit 1 | |
| fi | |
| required_misses="$(echo "$NEEDS_JSON" | jq -r ' | |
| . as $needs | | |
| (($needs.plan.outputs.run // "{}") | fromjson) as $run | | |
| ( | |
| ["plan"] | |
| + [$run | to_entries[] | select(.value) | .key] | |
| )[] | |
| | select(($needs[.].result // "missing") != "success") | |
| | "\(.)=\($needs[.].result // "missing")" | |
| ')" | |
| if [ -n "$required_misses" ]; then | |
| echo "Required validation jobs did not succeed:" | |
| echo "$required_misses" | |
| exit 1 | |
| fi | |
| - name: Checkout convergence control plane | |
| if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} | |
| continue-on-error: true | |
| uses: actions/checkout@v6.0.2 | |
| - name: Download Terminal scene convergence manifest | |
| if: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && fromJSON(needs.plan.outputs.run).terminal_scene && needs.terminal_scene.result == 'success' }} | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: terminal-scene-convergence-manifest | |
| path: ${{ runner.temp }}/convergence-products | |
| - name: Download pending convergence plan | |
| if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| # A failed-job rerun reuses the successful plan job and therefore its | |
| # original attempt-scoped artifact name. | |
| name: ${{ needs.plan.outputs.convergence_plan_artifact }} | |
| path: ${{ runner.temp }} | |
| - name: Create convergence handoff | |
| id: convergence_handoff | |
| if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} | |
| continue-on-error: true | |
| run: >- | |
| python3 .github/scripts/convergence.py handoff | |
| --pending "$RUNNER_TEMP/pending-convergence.json" | |
| --products-root "$RUNNER_TEMP/convergence-products" | |
| --handoff-root "$RUNNER_TEMP/handoff-convergence-ci-results" | |
| - name: Upload convergence handoff | |
| if: ${{ steps.convergence_handoff.outputs.name != '' }} | |
| continue-on-error: true | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ steps.convergence_handoff.outputs.name }} | |
| path: ${{ steps.convergence_handoff.outputs.path }} | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # A failed workload ejects the queued PR just as invisibly as a blocking label does: this | |
| # required check fails on the queue's transient ref, the PR's own checks stay green, and | |
| # its queue entry silently disappears. The merge-blocking label gate announces its own | |
| # ejections; this step announces every other one (failed or cancelled workloads, a | |
| # fail-closed policy error) so the author learns which jobs failed and where to look. | |
| # Best-effort by construction: it only runs after the gate has already failed and can | |
| # never soften, mask, or retry that result. | |
| - name: Checkout handoff helper | |
| if: ${{ failure() && github.event_name == 'merge_group' }} | |
| uses: actions/checkout@v6.0.2 | |
| with: | |
| sparse-checkout: .github | |
| sparse-checkout-cone-mode: true | |
| - name: Produce merge-queue failure notice handoff | |
| id: merge_queue_failure_notice | |
| if: ${{ failure() && github.event_name == 'merge_group' }} | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| RUN_ID: ${{ github.run_id }} | |
| NEEDS_JSON: ${{ toJSON(needs) }} | |
| # The queue names the PR that heads this group in its temporary ref | |
| # (`gh-readonly-queue/<base>/pr-<N>-<base sha>`); that PR owns this run's result. | |
| MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }} | |
| BASE_SHA: ${{ github.event.merge_group.base_sha }} | |
| HEAD_SHA: ${{ github.event.merge_group.head_sha }} | |
| # `true` when the merge-blocking label gate already produced its own ejection notice. | |
| LABEL_NOTICE_EMITTED: ${{ needs.merge_policy.outputs.ejection_notice }} | |
| # This job's own display name: it is still in progress while this step runs, but a | |
| # replay of a completed run would otherwise list the gate itself as a failed job. | |
| SELF_JOB_NAME: Validate workspace | |
| run: | | |
| set -euo pipefail | |
| # Which jobs the gate just rejected. Mirrors its two scans: any non-success, | |
| # non-skipped result plus any plan-required job that did not succeed. A broken plan | |
| # output must not break the notice, so the required-job scan is fault tolerant here. | |
| failed_jobs="$(jq -r ' | |
| . as $needs | | |
| (try (($needs.plan.outputs.run // "{}") | fromjson) catch {}) as $run | | |
| ( | |
| [ $needs | to_entries[] | select(.value.result != "success" and .value.result != "skipped") | .key ] | |
| + [ (["plan"] + [$run | to_entries[] | select(.value) | .key])[] | |
| | select(($needs[.].result // "missing") != "success") ] | |
| ) | unique[]' <<< "$NEEDS_JSON")" | |
| pr="$(printf '%s\n' "$MERGE_GROUP_REF" | grep -oE 'pr-[0-9]+' | head -n 1 | grep -oE '[0-9]+' || true)" | |
| if [ -z "$pr" ]; then | |
| echo "::warning::merge_group ref '$MERGE_GROUP_REF' names no pull request; skipping the ejection notice." | |
| exit 0 | |
| fi | |
| pr_json="$(gh api "repos/$REPO/pulls/$pr")" | |
| pr_head="$(jq -r '.head.sha' <<< "$pr_json")" | |
| pr_base="$(jq -r '.base.sha' <<< "$pr_json")" | |
| # A label ejection is already announced by the gate's own notice (and a PR that still | |
| # carries a blocking label is about to get one). Drop the policy job from this notice | |
| # so a label-only ejection gets exactly one comment; a workload that failed alongside | |
| # the label is still reported. | |
| label_covered=false | |
| if [ "${LABEL_NOTICE_EMITTED:-}" = "true" ]; then | |
| label_covered=true | |
| elif jq -e '[.labels[]?.name] | any(. == "needs-validation" or . == "needs-maintainer-check")' <<< "$pr_json" >/dev/null; then | |
| label_covered=true | |
| fi | |
| skip_job_name="" | |
| if [ "$label_covered" = "true" ]; then | |
| skip_job_name="Merge policy" | |
| failed_jobs="$(printf '%s\n' "$failed_jobs" | grep -vx 'merge_policy' || true)" | |
| fi | |
| if [ -z "$failed_jobs" ]; then | |
| echo "Only the merge-blocking label gate failed; its own notice covers this ejection." | |
| exit 0 | |
| fi | |
| echo "Failed validation jobs: $(printf '%s' "$failed_jobs" | tr '\n' ' ')" | |
| # Job-level detail straight from the run: display names, the failing step, and a log | |
| # link per failed job. Read-only and best-effort; the notice still ships without it. | |
| jobs_json="$(gh api --paginate "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" | jq -s '[.[].jobs[]]' || echo '[]')" | |
| # Markdown code spans are intentionally literal in these single-quoted strings. | |
| # shellcheck disable=SC2016 | |
| failed_lines="$(jq -r --arg skip "$skip_job_name" --arg self "$SELF_JOB_NAME" ' | |
| .[] | |
| | select(.conclusion != null and .conclusion != "success" and .conclusion != "skipped" and .name != $skip and .name != $self) | |
| | "- **\(.name)** — \(.conclusion)" | |
| + ([.steps[]? | select(.conclusion == "failure") | .name] | if length > 0 then " at `" + join("`, `") + "`" else "" end) | |
| + " ([job log](\(.html_url)))"' <<< "$jobs_json")" | |
| if [ -z "$failed_lines" ]; then | |
| # shellcheck disable=SC2016 | |
| failed_lines="$(printf '%s\n' "$failed_jobs" | sed -E 's/^/- `/; s/$/`/')" | |
| fi | |
| # Pull the failing assertions out of the job logs (vitest `FAIL` lines, Playwright | |
| # `✘` lines, runner error annotations), capped so the comment stays readable. | |
| # CI logs are full of terminal escape sequences, and gh >= 2.97 refuses to print such | |
| # a body unless told otherwise (GHSA-3m3g-3wcr-px46) while older gh does not know the | |
| # flag — probe once so the excerpt works with either runner image. | |
| gh --version | head -n 1 | |
| # Capture the help text first: `gh api --help | grep -q` would let grep exit early and | |
| # fail the pipeline under pipefail, silently disabling the flag. | |
| api_help="$(gh api --help 2>/dev/null || true)" | |
| escape_flag="" | |
| if grep -q -- '--allow-escape-sequences' <<< "$api_help"; then | |
| escape_flag="--allow-escape-sequences" | |
| fi | |
| esc="$(printf '\033')" | |
| excerpt="" | |
| for job_id in $(jq -r --arg skip "$skip_job_name" --arg self "$SELF_JOB_NAME" '.[] | select(.conclusion == "failure" and .name != $skip and .name != $self) | .id' <<< "$jobs_json" | head -n 4); do | |
| lines="$(gh api ${escape_flag:+"$escape_flag"} "repos/$REPO/actions/jobs/$job_id/logs" 2>/dev/null \ | |
| | tr -d '\r' \ | |
| | sed -E "s/${esc}\[[0-9;]*[A-Za-z]//g; s/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //" \ | |
| | grep -E '^ ?FAIL |^[[:space:]]*[✘×] |^##\[error\]' \ | |
| | grep -vE '^##\[error\]Process completed with exit code' \ | |
| | sed -E 's/^##\[error\]//; s/^[[:space:]]+//' \ | |
| | awk '!seen[$0]++' \ | |
| | head -n 8 || true)" | |
| if [ -n "$lines" ]; then | |
| excerpt="${excerpt}${lines}"$'\n' | |
| fi | |
| done | |
| # Shards that fail on the same test would otherwise repeat the same lines per job. | |
| if [ -n "$excerpt" ]; then | |
| excerpt="$(printf '%s' "$excerpt" | awk '!seen[$0]++')"$'\n' | |
| fi | |
| # Other PRs batched ahead of this one in the same group (their squashed commits sit | |
| # between the group base and head), so the author can tell whose change may have failed. | |
| group_note="" | |
| others="$(gh api "repos/$REPO/compare/$BASE_SHA...$HEAD_SHA" 2>/dev/null \ | |
| | jq -r '.commits[].commit.message | split("\n")[0]' \ | |
| | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' | grep -vx "$pr" | sort -un | sed 's/^/#/' | paste -s -d ',' - | sed 's/,/, /g' || true)" | |
| if [ -n "$others" ]; then | |
| group_note=" and with the PRs queued ahead of it ($others)" | |
| fi | |
| handoff_id="merge-queue-ci-failure-pr-$pr" | |
| marker="<!-- merge-queue-ci-failure -->" | |
| handoff_root="$RUNNER_TEMP/handoff-comment-$handoff_id" | |
| handoff_dir="$(python3 .github/scripts/handoff.py dir comment "$handoff_id" --root "$handoff_root")" | |
| mkdir -p "$handoff_dir" | |
| run_url="$GITHUB_SERVER_URL/$REPO/actions/runs/$RUN_ID" | |
| # Markdown code spans are intentionally literal in these single-quoted strings. | |
| # shellcheck disable=SC2016 | |
| { | |
| printf '%s\n' "$marker" | |
| printf 'Ejected from the merge queue: CI failed on the queued merge of this PR.\n\n' | |
| printf 'The merge queue gate ([run %s](%s)) failed for the group headed by this PR. That run executes on the queue'"'"'s transient ref, so the failure never shows up in this PR'"'"'s own checks — they stay green, the queue entry simply disappears, and this notice is the only visible trace on the PR.\n\n' "$RUN_ID" "$run_url" | |
| printf 'Failed jobs:\n\n%s\n\n' "$failed_lines" | |
| if [ -n "$excerpt" ]; then | |
| printf 'Failure excerpt (best-effort, from the job logs):\n\n```text\n%s```\n\n' "$excerpt" | |
| fi | |
| printf 'The queued merge combines this PR with everything that landed on `main` after its last CI run%s. When this PR'"'"'s own checks are green, the failure is usually one of: a conflict with newer `main` (for example a test added or changed since this branch diverged), a PR ahead of it in the queue, or a flaky test.\n\n' "$group_note" | |
| printf 'To land this PR: open the run above and check whether the failure is related to this change. If it is, merge or rebase onto the latest `main`, fix, push, and add the PR back to the merge queue. If it is unrelated, add the PR back to the merge queue.\n' | |
| } > "$handoff_dir/body.md" | |
| jq -n \ | |
| --arg kind "comment" \ | |
| --arg id "$handoff_id" \ | |
| --arg head "$pr_head" \ | |
| --arg base "$pr_base" \ | |
| --arg marker "$marker" \ | |
| --argjson schema_version 1 \ | |
| --argjson pr_number "$pr" \ | |
| --argjson run_id "$RUN_ID" \ | |
| '{schema_version: $schema_version, kind: $kind, id: $id, pr_number: $pr_number, head_sha: $head, base_sha: $base, run_id: $run_id, marker: $marker}' \ | |
| > "$handoff_dir/metadata.json" | |
| python3 .github/scripts/handoff.py validate comment "$handoff_dir" >/dev/null | |
| { | |
| echo "comment_created=true" | |
| echo "comment_name=$(python3 .github/scripts/handoff.py artifact-name comment "$handoff_id")" | |
| echo "comment_path=$handoff_root" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Produced merge-queue failure notice for PR #$pr." | |
| - name: Upload merge-queue failure notice handoff | |
| if: ${{ failure() && steps.merge_queue_failure_notice.outputs.comment_created == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ steps.merge_queue_failure_notice.outputs.comment_name }} | |
| path: ${{ steps.merge_queue_failure_notice.outputs.comment_path }} | |
| runtime_summary: | |
| name: Runtime summary | |
| needs: | |
| - runners | |
| - validate | |
| if: ${{ always() }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} | |
| timeout-minutes: 5 | |
| continue-on-error: true | |
| steps: | |
| - name: Summarize workflow runtime | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RUN_ID: ${{ github.run_id }} | |
| run: | | |
| set -euo pipefail | |
| run_json="$RUNNER_TEMP/run.json" | |
| gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion,createdAt,databaseId,displayTitle,event,headBranch,jobs,updatedAt,url > "$run_json" | |
| jq -r ' | |
| def parse_ts: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601; | |
| def seconds($start; $end): | |
| if ($start and $end) then (($end | parse_ts) - ($start | parse_ts)) else null end; | |
| def fmt($seconds): | |
| if $seconds == null then "n/a" | |
| elif $seconds >= 60 then "\(((($seconds / 60) * 10 | round) / 10))m" | |
| else "\(($seconds | round))s" | |
| end; | |
| def row($cells): "| \($cells | join(" | ")) |"; | |
| .jobs as $jobs | | |
| [ | |
| "## Runtime trace", | |
| "", | |
| "Run: [\(.displayTitle)](\(.url))", | |
| "Event: `\(.event)`", | |
| "Branch: `\(.headBranch)`", | |
| "Elapsed: \(fmt(seconds(.createdAt; .updatedAt)))", | |
| "", | |
| "### Jobs", | |
| "| Job | Result | Duration | Slowest step |", | |
| "| --- | --- | ---: | --- |", | |
| ( | |
| $jobs | |
| | sort_by(seconds(.startedAt; .completedAt) // 0) | |
| | reverse | |
| | .[] | |
| | select(.conclusion != "skipped") | |
| | ( | |
| [(.steps // [])[] | select(.startedAt and .completedAt and .conclusion != "skipped") | {name, duration: seconds(.startedAt; .completedAt)}] | |
| | max_by(.duration // 0) | |
| ) as $slow | |
| | row([.name, (.conclusion // .status), fmt(seconds(.startedAt; .completedAt)), "\($slow.name // "n/a") (\(fmt($slow.duration)))"]) | |
| ), | |
| "", | |
| "### Slowest steps", | |
| "| Step | Job | Duration |", | |
| "| --- | --- | ---: |", | |
| ( | |
| [ | |
| $jobs[] as $job | |
| | ($job.steps // [])[] | |
| | select(.startedAt and .completedAt and .conclusion != "skipped") | |
| | {job: $job.name, name, duration: seconds(.startedAt; .completedAt)} | |
| ] | |
| | sort_by(.duration // 0) | |
| | reverse | |
| | .[0:20][] | |
| | row([.name, .job, fmt(.duration)]) | |
| ) | |
| ][] | |
| ' "$run_json" >> "$GITHUB_STEP_SUMMARY" |