Files
nexu-io-open-design/.github/workflows/ci.yml
lefarcen 110774e891 fix(ci): merge-queue needs-validation gate skips non-PR refs, not eject clean PRs (#4898)
* fix(ci): merge-queue needs-validation gate skips non-PR refs, not eject clean PRs

The merge_group needs-validation gate enumerates queued PRs from three sources;
source 2 scrapes a trailing `(#N)` from each commit subject. That `(#N)` can be
an ISSUE reference an author put in the PR title, not a queued PR — e.g. #4833,
titled "fix(plugins): ... (#4828)", where 4828 is the fixed issue. The gate then
ran `gh pr view 4828`, which fails with "Could not resolve to a PullRequest";
as a plain assignment under `set -e` that ejected a clean, label-free, approved
PR from the merge queue every time it entered — permanently unmergeable.

Distinguish a definitive "not a PR" (skip — the number was a spurious reference)
from a transient API failure (still fail closed → block), so real needs-validation
PRs are still enforced while issue references no longer block clean merges.

* fix(ci): resolve merge-group needs-validation per commit, not a flat union

Addresses review: skipping a non-PR `(#N)` unconditionally could, in a batched
merge group, drop the only candidate that would have checked a non-tip PR — so a
non-tip PR still carrying `needs-validation` could slip through (#4736 hazard).

Resolve each merge-group commit to at least one real PR from its own signals
(subject `(#N)`, commits/<sha>/pulls, and — for the tip — the queue ref). A `(#N)`
that does not resolve to a PR (e.g. an issue reference like #4833's "(#4828)") is
ignored ONLY when its commit is still covered by another candidate that resolves
to a real PR; a commit that resolves to no PR fails closed. Real needs-validation
PRs are still blocked; clean PRs whose title references an issue are no longer
ejected.

Verified with a mocked gh harness: (A) tip "(#4828) (#4833)" → ignores 4828,
checks 4833, clears; (B) batched non-tip whose only number is an issue ref + carries
needs-validation → fails closed; (C) tip with needs-validation → blocks.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>
2026-06-29 13:36:50 +00:00

1131 lines
48 KiB
YAML

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 }}
nix_validation_required: ${{ steps.detect.outputs.nix_validation_required }}
ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }}
visual_validation_required: ${{ steps.detect.outputs.visual_validation_required }}
docker_validation_required: ${{ steps.detect.outputs.docker_validation_required }}
workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }}
run_docker_build: ${{ steps.detect.outputs.run_docker_build }}
run_e2e_vitest: ${{ steps.detect.outputs.run_e2e_vitest }}
run_nix_validation: ${{ steps.detect.outputs.run_nix_validation }}
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_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
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Install actionlint
run: |
case "$(uname -m)" in
x86_64) actionlint_arch=amd64 ;;
aarch64|arm64) actionlint_arch=arm64 ;;
*) 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
sudo install -m 0755 actionlint /usr/local/bin/actionlint
- 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."
nix_validation:
name: Validate Nix flake
needs: [scopes, runners]
if: ${{ needs.scopes.outputs.run_nix_validation == 'true' }}
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).general_medium }}
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
extra_nix_config: |
experimental-features = nix-command flakes
accept-flake-config = true
- name: Setup Node for Nix hash refresh
if: ${{ github.event_name == 'pull_request' }}
uses: actions/setup-node@v4
with:
node-version-file: package.json
- name: nix flake check
id: flake_check
continue-on-error: true
run: nix flake check --print-build-logs --keep-going
- name: Generate Nix hash refresh patch
id: hash_refresh
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' }}
shell: bash
run: |
set -euo pipefail
out_dir="$RUNNER_TEMP/nix-hash-refresh"
mkdir -p "$out_dir"
status="update-failed"
if node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts >"$out_dir/update.log" 2>&1; then
if git diff --quiet --exit-code -- nix/pnpm-deps.nix; then
status="no-change"
else
git diff -- nix/pnpm-deps.nix >"$out_dir/nix-pnpm-deps.patch"
cp nix/pnpm-deps.nix "$out_dir/pnpm-deps.nix"
status="patch-generated"
fi
fi
printf '{"status":"%s","runId":%s,"prNumber":%s,"headSha":"%s"}\n' \
"$status" \
'${{ github.run_id }}' \
'${{ github.event.pull_request.number }}' \
'${{ github.event.pull_request.head.sha }}' >"$out_dir/metadata.json"
echo "status=$status" >> "$GITHUB_OUTPUT"
- name: Create Nix hash refresh handoffs
id: nix_handoff
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' && steps.hash_refresh.outputs.status != 'no-change' }}
env:
HASH_REFRESH_STATUS: ${{ steps.hash_refresh.outputs.status }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
shell: bash
run: |
set -euo pipefail
source_dir="$RUNNER_TEMP/nix-hash-refresh"
echo "comment_created=false" >> "$GITHUB_OUTPUT"
echo "autofix_created=false" >> "$GITHUB_OUTPUT"
if [ "$HASH_REFRESH_STATUS" = "patch-generated" ] && [ "$HEAD_REPOSITORY" = "$REPOSITORY" ]; then
handoff_id="nix-pnpm-deps"
handoff_root="$RUNNER_TEMP/handoff-autofix-$handoff_id"
handoff_dir="$(python3 .github/scripts/handoff.py dir autofix "$handoff_id" --root "$handoff_root")"
mkdir -p "$handoff_dir"
cp "$source_dir/nix-pnpm-deps.patch" "$handoff_dir/patch.diff"
jq -n \
--arg kind "autofix" \
--arg id "$handoff_id" \
--arg head "$HEAD_SHA" \
--arg base "$BASE_SHA" \
--arg message "chore(nix): refresh pnpm deps hash" \
--argjson schema_version 1 \
--argjson pr_number "$PR_NUMBER" \
--argjson run_id "$RUN_ID" \
--argjson allowed_paths '["nix/pnpm-deps.nix"]' \
'{schema_version: $schema_version, kind: $kind, id: $id, pr_number: $pr_number, head_sha: $head, base_sha: $base, run_id: $run_id, allowed_paths: $allowed_paths, commit_message: $message}' \
> "$handoff_dir/metadata.json"
python3 .github/scripts/handoff.py validate autofix "$handoff_dir" >/dev/null
{
echo "autofix_created=true"
echo "autofix_name=$(python3 .github/scripts/handoff.py artifact-name autofix "$handoff_id")"
echo "autofix_path=$handoff_root"
} >> "$GITHUB_OUTPUT"
fi
if [ "$HASH_REFRESH_STATUS" != "patch-generated" ] || [ "$HEAD_REPOSITORY" != "$REPOSITORY" ]; then
handoff_id="nix-hash-refresh"
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="<!-- nix-hash-refresh -->"
body_path="$handoff_dir/body.md"
{
printf '%s\n' "$marker"
if [ "$HASH_REFRESH_STATUS" = "patch-generated" ]; then
printf "A generated Nix hash refresh is available for this PR.\n\n"
printf "Apply the diff below with \`git apply\`:\n\n"
printf '```diff\n'
cat "$source_dir/nix-pnpm-deps.patch"
printf '\n```\n'
else
printf "The failed \`ci\` run attempted to refresh \`nix/pnpm-deps.nix\`, but it could not produce a hash-only patch.\n\n"
printf "Inspect the \`update.log\` file in the failed CI run artifacts for details.\n"
fi
} > "$body_path"
jq -n \
--arg kind "comment" \
--arg id "$handoff_id" \
--arg head "$HEAD_SHA" \
--arg base "$BASE_SHA" \
--arg marker "$marker" \
--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, marker: $marker}' \
> "$handoff_dir/metadata.json"
if [ -f "$source_dir/update.log" ]; then
cp "$source_dir/update.log" "$handoff_dir/update.log"
fi
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"
fi
- name: Upload Nix hash autofix handoff
if: ${{ steps.nix_handoff.outputs.autofix_created == 'true' }}
uses: actions/upload-artifact@v4
with:
name: ${{ steps.nix_handoff.outputs.autofix_name }}
path: ${{ steps.nix_handoff.outputs.autofix_path }}
if-no-files-found: error
retention-days: 14
- name: Upload Nix hash comment handoff
if: ${{ steps.nix_handoff.outputs.comment_created == 'true' }}
uses: actions/upload-artifact@v4
with:
name: ${{ steps.nix_handoff.outputs.comment_name }}
path: ${{ steps.nix_handoff.outputs.comment_path }}
if-no-files-found: error
retention-days: 14
- name: Summarize Nix hash refresh guidance
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' }}
env:
HASH_REFRESH_STATUS: ${{ steps.hash_refresh.outputs.status }}
shell: bash
run: |
set -euo pipefail
case "${HASH_REFRESH_STATUS:-not-run}" in
patch-generated)
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
## Generated Nix hash refresh
CI regenerated a hash-only patch for `nix/pnpm-deps.nix` and uploaded
it through the shared handoff contract.
- same-repo PRs: `autofix.atom.yml` will try to push the patch back to the
PR branch automatically and will publish patch guidance only if the
trusted writer cannot complete the autofix;
- fork PRs: `comment.atom.yml` will publish patch guidance on the PR.
EOF
;;
no-change)
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
## Nix hash refresh unavailable
`nix flake check` failed, but `nix/pnpm-deps.nix` did not change after
running the hash refresh helper. Inspect the Nix build logs for a
non-hash failure.
EOF
;;
*)
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
## Nix hash refresh failed
`nix flake check` failed and the helper could not generate a hash-only
patch. `comment.atom.yml` will publish the generated guidance when a
comment handoff is available.
EOF
;;
esac
- name: Fail when Nix validation fails
# Enforced everywhere EXCEPT pull_request. A stale `nix/pnpm-deps.nix` hash is the routine,
# self-healing consequence of any `pnpm-lock.yaml` change: the steps above already regenerate
# the hash and hand it to `autofix.atom.yml`, which pushes the refresh back to the PR branch.
# Failing the job on the PR turns a transient, auto-fixed hash drift into a red required-ish
# check that reads as a real CI failure downstream (BLOCKED mergeStateStatus / ci_status=failure
# to the review bot) — the same deadlock the `needs-validation` gate documents below. So the PR
# check stays green while the autofix lands, and the real gate runs at merge time: merge_group
# (and manual full runs) still fail closed, so a genuinely broken Nix build can never reach main.
if: ${{ steps.flake_check.outcome == 'failure' && github.event_name != 'pull_request' }}
run: exit 1
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
run: |
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/web build:sidecar
- name: Typecheck workspaces
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/tools-pack test
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_smoke:
name: UI P0 smoke
needs: [scopes, runners]
if: ${{ needs.scopes.outputs.run_ui_p0 == 'true' }}
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).ui_hot }}
timeout-minutes: 30
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 shell smoke
run: pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group smoke
- name: Upload Playwright debug artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: ui-p0-ci-${{ github.run_id }}-smoke
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
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: 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
docker_pr:
name: Docker image build
needs: [scopes, runners]
if: ${{ needs.scopes.outputs.run_docker_build == 'true' }}
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).general_medium }}
timeout-minutes: 30
permissions:
contents: read
packages: read
steps:
- name: Check out repository
uses: actions/checkout@v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/od
tags: |
type=sha,prefix=pr-${{ github.event.pull_request.number }}-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
type=sha,prefix=queue-sha-,format=short,enable=${{ github.event_name == 'merge_group' }}
type=sha,prefix=manual-sha-,format=short,enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: deploy/Dockerfile
platforms: linux/amd64
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
NODE_IMAGE=public.ecr.aws/docker/library/node:24-alpine
RUNTIME_IMAGE=public.ecr.aws/docker/library/node:24-alpine
cache-from: type=gha
validate:
name: Validate workspace
needs:
- scopes
- runners
- static_gate
- preflight
- nix_validation
- workspace_unit_tests
- windows_tools_pack_payload_tests
- web_workspace_tests
- e2e_vitest
- playwright_critical
- ui_p0_smoke
- ui_p0
- playwright_visual
- docker_pr
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) }}
# `nix_validation` is advisory on pull_request and enforced only at merge time (see the
# `Fail when Nix validation fails` step). The gate honors that split below: on pull_request a
# red Nix job is ignored, on merge_group / manual full runs it is a hard requirement.
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
echo "$NEEDS_JSON" | jq .
failures="$(echo "$NEEDS_JSON" | jq -r --arg event "$EVENT_NAME" '
to_entries[]
| select(.value.result != "success" and .value.result != "skipped")
| select(.key != "nix_validation" or $event != "pull_request")
| "\(.key)=\(.value.result)"')"
if [ -n "$failures" ]; then
echo "Workspace validation failed:"
echo "$failures"
exit 1
fi
required_misses="$(echo "$NEEDS_JSON" | jq -r --arg event "$EVENT_NAME" '
. 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_smoke", "ui_p0"])
+ when($out.run_playwright_visual == "true"; ["playwright_visual"])
+ when($out.run_nix_validation == "true" and $event != "pull_request"; ["nix_validation"])
+ when($out.run_docker_build == "true"; ["docker_pr"])
)[]
| 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
# 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: |
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
# 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."
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
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"