Skip to content

fix(packaged): isolate headless MCP runtime namespace #20307

fix(packaged): isolate headless MCP runtime namespace

fix(packaged): isolate headless MCP runtime namespace #20307

Workflow file for this run

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"