[backport release/v0.17.0] fix(byok): withdraw Windows DPAPI backend #17016
Workflow file for this run
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 | |
| runs-on: ubuntu-24.04 | |
| 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: | |
| OD_CI_RUNNER_MODE: ${{ vars.OD_CI_RUNNER_MODE }} | |
| run: python3 .github/scripts/runners.py | |
| scopes: | |
| name: Detect validation scopes | |
| needs: [runners] | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} | |
| outputs: | |
| ci_mode: ${{ steps.detect.outputs.ci_mode }} | |
| daemon_tests_required: ${{ steps.detect.outputs.daemon_tests_required }} | |
| web_tests_required: ${{ steps.detect.outputs.web_tests_required }} | |
| tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }} | |
| tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }} | |
| ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }} | |
| visual_validation_required: ${{ steps.detect.outputs.visual_validation_required }} | |
| workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }} | |
| run_e2e_vitest: ${{ steps.detect.outputs.run_e2e_vitest }} | |
| run_playwright_critical: ${{ steps.detect.outputs.run_playwright_critical }} | |
| run_playwright_visual: ${{ steps.detect.outputs.run_playwright_visual }} | |
| run_preflight: ${{ steps.detect.outputs.run_preflight }} | |
| run_preflight_typecheck: ${{ steps.detect.outputs.run_preflight_typecheck }} | |
| run_ui_p0: ${{ steps.detect.outputs.run_ui_p0 }} | |
| run_web_workspace_tests: ${{ steps.detect.outputs.run_web_workspace_tests }} | |
| run_windows_tools_pack_payload_tests: ${{ steps.detect.outputs.run_windows_tools_pack_payload_tests }} | |
| run_workspace_unit_tests: ${{ steps.detect.outputs.run_workspace_unit_tests }} | |
| ui_p0_matrix: ${{ steps.detect.outputs.ui_p0_matrix }} | |
| visual_matrix: ${{ steps.detect.outputs.visual_matrix }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6.0.2 | |
| - name: Setup Node | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: package.json | |
| - name: Detect workspace and app test scopes | |
| id: detect | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: node --experimental-strip-types scripts/scopes.ts github-output | |
| - name: Create visual report handoff | |
| id: visual_report_handoff | |
| if: ${{ github.event_name == 'pull_request' && steps.detect.outputs.run_playwright_visual == 'true' }} | |
| 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: [runners] | |
| 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: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_preflight == 'true' }} | |
| 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: ${{ needs.scopes.outputs.run_preflight_typecheck == 'true' }} | |
| run: | | |
| pnpm --filter @open-design/daemon build | |
| pnpm --filter @open-design/desktop build | |
| pnpm --filter @open-design/web build:sidecar | |
| - name: Typecheck workspaces | |
| if: ${{ needs.scopes.outputs.run_preflight_typecheck == 'true' }} | |
| 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: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_workspace_unit_tests == 'true' }} | |
| 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 [ "${{ needs.scopes.outputs.tools_dev_tests_required }}" = "true" ]; then | |
| pnpm --filter @open-design/tools-dev test | |
| fi | |
| if [ "${{ needs.scopes.outputs.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 [ "${{ needs.scopes.outputs.run_e2e_vitest }}" != "true" ]; then | |
| pnpm --filter @open-design/e2e test tests/packaged-launcher-update-loop.test.ts | |
| fi | |
| fi | |
| - name: Probe watcher environment | |
| if: ${{ needs.scopes.outputs.daemon_tests_required == 'true' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version, platform:process.platform, arch:process.arch, tmpdir:os.tmpdir(), env:{NODE_ENV:process.env.NODE_ENV, VITEST:process.env.VITEST, CHOKIDAR_USEPOLLING:process.env.CHOKIDAR_USEPOLLING, CHOKIDAR_INTERVAL:process.env.CHOKIDAR_INTERVAL}}, null, 2))" | |
| df -T /tmp "$RUNNER_TEMP" "$GITHUB_WORKSPACE" || true | |
| mount | grep -E ' /tmp |/home/runner|_work' || true | |
| cat /proc/sys/fs/inotify/max_user_watches /proc/sys/fs/inotify/max_user_instances || true | |
| - name: Prebuild daemon entrypoint declarations | |
| if: ${{ needs.scopes.outputs.daemon_tests_required == 'true' }} | |
| run: pnpm --filter @open-design/daemon build | |
| - name: Daemon workspace tests | |
| if: ${{ needs.scopes.outputs.daemon_tests_required == 'true' }} | |
| env: | |
| OD_WATCHER_TEST_DEBUG: "1" | |
| run: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts tests/project-watchers.test.ts | |
| windows_tools_pack_payload_tests: | |
| name: Windows tools-pack payload tests | |
| needs: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_windows_tools_pack_payload_tests == 'true' }} | |
| 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-payload.test.ts | |
| web_workspace_tests: | |
| name: Web workspace tests | |
| needs: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_web_workspace_tests == 'true' }} | |
| 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: Prebuild web sidecar declarations | |
| run: pnpm --filter @open-design/web build:sidecar | |
| - name: Web workspace tests | |
| run: pnpm --filter @open-design/web test | |
| e2e_vitest: | |
| name: E2E Vitest | |
| needs: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_e2e_vitest == 'true' }} | |
| 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: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_playwright_critical == 'true' }} | |
| 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: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_ui_p0 == 'true' }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).ui_hot }} | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: ${{ fromJSON(needs.scopes.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(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: 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: [scopes, runners] | |
| if: ${{ needs.scopes.outputs.run_playwright_visual == 'true' }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).visual_hot }} | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: ${{ fromJSON(needs.scopes.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-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-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 | |
| validate: | |
| name: Validate workspace | |
| needs: | |
| - scopes | |
| - runners | |
| - static_gate | |
| - preflight | |
| - workspace_unit_tests | |
| - windows_tools_pack_payload_tests | |
| - web_workspace_tests | |
| - e2e_vitest | |
| - playwright_critical | |
| - ui_p0 | |
| - playwright_visual | |
| if: ${{ always() }} | |
| runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }} | |
| timeout-minutes: 5 | |
| steps: | |
| # The needs-validation gate below 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). Only the merge_group context can eject, | |
| # so pull_request runs skip the checkout entirely. | |
| - name: Checkout handoff helper | |
| if: ${{ github.event_name == 'merge_group' }} | |
| uses: actions/checkout@v6.0.2 | |
| with: | |
| sparse-checkout: .github | |
| sparse-checkout-cone-mode: true | |
| - 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.scopes.outputs // {}) as $out | | |
| def when($condition; $jobs): if $condition then $jobs else [] end; | |
| ( | |
| ["scopes", "static_gate"] | |
| + when($out.run_preflight == "true"; ["preflight"]) | |
| + when($out.run_workspace_unit_tests == "true"; ["workspace_unit_tests"]) | |
| + when($out.run_windows_tools_pack_payload_tests == "true"; ["windows_tools_pack_payload_tests"]) | |
| + when($out.run_web_workspace_tests == "true"; ["web_workspace_tests"]) | |
| + when($out.run_e2e_vitest == "true"; ["e2e_vitest"]) | |
| + when($out.run_playwright_critical == "true"; ["playwright_critical"]) | |
| + when($out.run_ui_p0 == "true"; ["ui_p0"]) | |
| + when($out.run_playwright_visual == "true"; ["playwright_visual"]) | |
| )[] | |
| | 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: Block merge while the needs-validation label is present | |
| id: needs_validation_gate | |
| # Hard gate: a PR that still carries `needs-validation` 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 needs-validation 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. | |
| # Respects skip-validation implicitly (that override means needs-validation is never added). | |
| 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 "needs-validation is gated at merge time 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 pr_json pr_head pr_base handoff_id handoff_root handoff_dir marker | |
| pr_json="$(gh api "repos/$REPO/pulls/$pr")" | |
| pr_head="$(jq -r '.head.sha' <<< "$pr_json")" | |
| pr_base="$(jq -r '.base.sha' <<< "$pr_json")" | |
| handoff_id="needs-validation-pr-$pr" | |
| 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" | |
| marker="<!-- merge-queue-needs-validation -->" | |
| { | |
| printf '%s\n' "$marker" | |
| # Markdown code spans are intentional literal text. | |
| # shellcheck disable=SC2016 | |
| printf 'Ejected from the merge queue: this PR still carries the `needs-validation` label.\n\n' | |
| 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" | |
| # shellcheck disable=SC2016 | |
| printf '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.\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" | |
| } | |
| # 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 | |
| if printf '%s\n' "$labels" | grep -qx 'needs-validation'; then | |
| echo "::error::PR #$pr still has 'needs-validation' β 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" || echo "::warning::could not produce the ejection-notice handoff for PR #$pr; the block itself still stands." | |
| exit 1 | |
| fi | |
| echo "PR #$pr: no needs-validation label." | |
| done | |
| if [ "$covered" -eq 0 ]; then | |
| echo "::error::merge-group commit $sha resolved to no pull request (candidates: ${candidates:-none}) β cannot verify needs-validation; blocking merge (fail closed)." | |
| exit 1 | |
| fi | |
| done | |
| echo "No 'needs-validation' 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.needs_validation_gate.outputs.comment_created == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ steps.needs_validation_gate.outputs.comment_name }} | |
| path: ${{ steps.needs_validation_gate.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" |