[codex] Consolidate PR validation into CI (#4451)

* Consolidate PR validation into ci

* Tune absorbed CI runner tiers

* Fold visual validation into CI Playwright shards

* Run PR shards during manual CI dispatch
This commit is contained in:
PerishFire
2026-06-17 14:21:04 +08:00
committed by GitHub
parent 40056a9ae6
commit 4b9a15734d
32 changed files with 545 additions and 2713 deletions

View File

@@ -1,55 +0,0 @@
name: Visual screenshot setup
description: Prepare the workspace for visual Playwright screenshot capture.
runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
- name: Resolve Playwright version
id: playwright-version
shell: bash
run: |
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
uses: actions/cache/restore@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
shell: bash
run: pnpm -C e2e exec playwright install --with-deps chromium
- name: Prebuild workspace type declarations
shell: bash
run: |
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/web build:sidecar

View File

@@ -1,34 +0,0 @@
name: actionlint
on:
pull_request:
paths:
- .github/workflows/**
push:
branches:
- main
paths:
- .github/workflows/**
permissions:
contents: read
jobs:
actionlint:
name: Lint GitHub Actions workflows
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Install actionlint
run: |
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz actionlint
sudo install -m 0755 actionlint /usr/local/bin/actionlint
- name: Check workflow files
run: actionlint -color

View File

@@ -1,137 +0,0 @@
name: ci-gate
on:
workflow_dispatch:
inputs:
target_sha:
description: Optional SHA to aggregate. Empty uses the triggering SHA.
required: false
type: string
default: ""
target_event:
description: Optional event to aggregate. Empty uses the triggering event.
required: false
type: string
default: ""
owned_run_id:
description: Optional ci-owned run id override
required: false
type: string
default: ""
github_run_id:
description: Optional ci-github run id override
required: false
type: string
default: ""
permissions:
# Keep write access here even while ci-gate is manual-only: the parked manual
# path still needs to dispatch ci-owned / ci-github runs when operators do
# not provide explicit run-id overrides. Do not downgrade this to read-only
# unless the dispatch bootstrap moves elsewhere.
actions: write
contents: read
concurrency:
group: ci-gate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
validate:
# Keep ci-gate on its own check namespace while legacy `ci` continues to
# publish the current factual gate `Validate workspace`. Do not rename this
# back to `Validate workspace` until the repository ruleset is explicitly
# switched over to ci-gate in a separate follow-up.
#
# Manual-only parking note:
# ci-gate / ci-owned / ci-github no longer auto-trigger on PR / push /
# merge_group. Keep the manual path usable so operators can still run the
# parked stack on demand while deeper ci-gate redesign work is in flight.
name: Validate workspace gate
runs-on: ubuntu-latest
timeout-minutes: 90
env:
GITHUB_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && (inputs.target_sha || github.sha) || (github.event_name != 'workflow_dispatch' && (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) || '') }}
TARGET_EVENT: ${{ github.event_name == 'workflow_dispatch' && (inputs.target_event || 'workflow_dispatch') || (github.event_name != 'workflow_dispatch' && github.event_name || '') }}
OWNED_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.owned_run_id || '' }}
GITHUB_RUN_ID_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.github_run_id || '' }}
OWNED_WORKFLOW: ci-owned
GITHUB_HOSTED_WORKFLOW: ci-github
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Validate manual override combinations
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
target_sha='${{ inputs.target_sha }}'
target_event='${{ inputs.target_event }}'
owned_run_id='${{ inputs.owned_run_id }}'
github_run_id='${{ inputs.github_run_id }}'
has_target_override=0
has_run_id_override=0
if [ -n "$target_sha" ] || [ -n "$target_event" ]; then
has_target_override=1
fi
if [ -n "$owned_run_id" ] || [ -n "$github_run_id" ]; then
has_run_id_override=1
fi
# Manual-only parking rule:
# - no overrides: ci-gate may dispatch fresh provider runs itself
# - explicit historical aggregation: both run ids must be supplied
#
# Do not "relax" this by letting target overrides proceed without both
# run ids. ci-gate can only bootstrap fresh child runs for the current
# ref / workflow_dispatch event, so a looser combination would just
# poll until timeout.
if [ "$has_target_override" = "1" ] && { [ -z "$owned_run_id" ] || [ -z "$github_run_id" ]; }; then
echo "target_sha/target_event overrides require both owned_run_id and github_run_id" >&2
exit 1
fi
if [ "$has_run_id_override" = "1" ] && { [ -z "$owned_run_id" ] || [ -z "$github_run_id" ]; }; then
echo "owned_run_id and github_run_id must be supplied together" >&2
exit 1
fi
- name: Record provider dispatch window
if: ${{ github.event_name == 'workflow_dispatch' && inputs.owned_run_id == '' && inputs.github_run_id == '' }}
shell: bash
run: echo "PROVIDER_RUN_CREATED_AFTER=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
- name: Dispatch manual provider runs
if: ${{ github.event_name == 'workflow_dispatch' && inputs.owned_run_id == '' && inputs.github_run_id == '' }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
# Keep this bootstrap colocated with ci-gate while the workflow family
# is parked in manual-only mode. If ci-gate becomes auto-triggered
# again or is restructured around reusable workflows, revisit rather
# than deleting this as "redundant".
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
"repos/${{ github.repository }}/actions/workflows/ci-owned.yml/dispatches" \
-f ref='${{ github.ref_name }}'
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
"repos/${{ github.repository }}/actions/workflows/ci-github.yml/dispatches" \
-f ref='${{ github.ref_name }}' \
-f inputs[mode]='nix'
- name: Aggregate CI results
run: node --experimental-strip-types .github/workflows/scripts/ci/aggregate-results.ts

View File

@@ -1,60 +0,0 @@
name: ci-github
on:
workflow_dispatch:
inputs:
mode:
description: GitHub-hosted CI mode
required: true
type: choice
default: nix
options:
- nix
- full
permissions:
contents: read
concurrency:
group: ci-github-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'nix' }}
cancel-in-progress: true
jobs:
github:
name: GitHub-hosted CI
runs-on: ubuntu-latest
timeout-minutes: 120
env:
CI_GATE_GITHUB_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'nix' }}
CI_GATE_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
CI_GATE_PLAYWRIGHT_INSTALL_FLAGS: --with-deps chromium
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
COREPACK_HOME: /home/runner/.cache/open-design-ci/corepack
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
extra_nix_config: |
experimental-features = nix-command flakes
accept-flake-config = true
- name: Run GitHub-hosted CI protocol
run: .github/workflows/scripts/ci-gate.sh --provider github --mode "$CI_GATE_GITHUB_MODE" --results-path .od/ci-gate/ci-results.json
- name: Upload GitHub-hosted CI results
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: ci-results-github
path: .od/ci-gate/ci-results.json
if-no-files-found: error
retention-days: 7

View File

@@ -1,147 +0,0 @@
name: ci-nix
on:
pull_request:
paths:
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- flake.nix
- flake.lock
- nix/**
- .github/workflows/ci-nix.yml
- .github/workflows/nix-check.yml
- .github/workflows/nix-hash-autofix.yml
- apps/daemon/**
- apps/web/**
- packages/components/**
- packages/contracts/**
- packages/registry-protocol/**
- packages/agui-adapter/**
- packages/plugin-runtime/**
- packages/sidecar-proto/**
- packages/sidecar/**
- packages/platform/**
- packages/diagnostics/**
- packages/host/**
- assets/**
- plugins/**
- skills/**
- design-systems/**
- design-templates/**
- craft/**
- prompt-templates/**
- scripts/update-nix-pnpm-deps-hash.ts
merge_group:
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
validate:
name: Validate Nix flake
runs-on: ubuntu-latest
timeout-minutes: 45
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: Upload Nix hash refresh artifact
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' && steps.hash_refresh.outputs.status != 'no-change' }}
uses: actions/upload-artifact@v4
with:
name: nix-hash-refresh
path: ${{ runner.temp }}/nix-hash-refresh
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 patch for `nix/pnpm-deps.nix` and uploaded it as the
`nix-hash-refresh` artifact for this run.
Apply the patch from the artifact to refresh the generated Nix pnpm
dependency hash, then rerun this workflow.
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. See the `nix-hash-refresh` artifact for `update.log`.
EOF
;;
esac
- name: Fail when Nix validation fails
if: ${{ steps.flake_check.outcome == 'failure' }}
run: exit 1

View File

@@ -1,38 +0,0 @@
name: ci-owned
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-owned-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
owned:
name: Owned CI
runs-on: [self-hosted, Linux, ARM64, nexu-spark, open-design-ci]
timeout-minutes: 120
env:
CI_GATE_PLAYWRIGHT_INSTALL_FLAGS: chromium
CI_GATE_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
COREPACK_HOME: /home/runner/.cache/open-design-ci/corepack
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Run owned CI protocol
run: .github/workflows/scripts/ci-gate.sh --provider owned --mode default --results-path .od/ci-gate/ci-results.json
- name: Upload owned CI results
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: ci-results-owned
path: .od/ci-gate/ci-results.json
if-no-files-found: error
retention-days: 7

View File

@@ -40,6 +40,8 @@ jobs:
tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }}
nix_validation_required: ${{ steps.detect.outputs.nix_validation_required }}
ui_p0_pr_required: ${{ steps.detect.outputs.ui_p0_pr_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 }}
steps:
@@ -471,10 +473,241 @@ jobs:
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: [change_scopes]
if: ${{ needs.change_scopes.outputs.ui_p0_pr_required == 'true' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && !github.event.pull_request.draft)) }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- 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
- 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/ui-p0-shards.ts 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: [change_scopes]
if: ${{ needs.change_scopes.outputs.ui_p0_pr_required == 'true' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && !github.event.pull_request.draft)) }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- name: entry-onboarding
shard: entry-onboarding
- name: project-workspace
shard: project-workspace
- name: workspace-restoration
shard: workspace-restoration
- name: runtime-recovery
shard: runtime-recovery
- name: settings-connectors
shard: settings-connectors
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- 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
- 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/ui-p0-shards.ts ${{ 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: [change_scopes]
if: ${{ needs.change_scopes.outputs.visual_validation_required == 'true' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && !github.event.pull_request.draft)) }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: entry
files: ui/visual-entry.test.ts
- name: navigation
files: ui/visual-navigation.test.ts
- name: settings
files: ui/visual-settings.test.ts
- name: workspace
files: ui/visual-workspace.test.ts
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- 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
- 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_WORKERS: "4"
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
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: [change_scopes]
if: ${{ needs.change_scopes.outputs.docker_validation_required == 'true' && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
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=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 }}
cache-from: type=gha
validate:
# Legacy `ci` remains the current factual gate surface. Keep this job on
# `Validate workspace` until a later ruleset follow-up explicitly moves the
# required check over to `Validate workspace gate` from `ci-gate`.
name: Validate workspace
needs:
- change_scopes
@@ -487,6 +720,10 @@ jobs:
- web_workspace_tests
- e2e_vitest
- playwright_critical
- ui_p0_smoke
- ui_p0
- playwright_visual
- docker_pr
if: ${{ always() }}
runs-on: blacksmith-2vcpu-ubuntu-2404-arm
timeout-minutes: 5

View File

@@ -26,7 +26,6 @@ on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
jobs:
build-and-push:

View File

@@ -3,10 +3,9 @@ name: fork-pr-workflow-approval
# This workflow runs in the trusted base-repository context. It never checks out
# or executes the fork head; the TypeScript policy script only reads PR metadata
# through the GitHub API and approves pending pull_request runs when the touched
# paths are inside the low-risk source allowlist. It only approves low-privilege
# pull_request workflows (`ci`, visual verify, and strict web-source visual
# capture); privileged
# workflow_run / release / deploy workflows stay on manual gates.
# paths are inside the low-risk source allowlist. It only approves the unified
# low-privilege pull_request `ci` workflow; privileged workflow_run / release /
# deploy workflows stay on manual gates.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, edited]

View File

@@ -1,216 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
provider=""
mode=""
results_path=""
while [ "$#" -gt 0 ]; do
case "$1" in
--provider)
provider="${2:-}"
shift 2
;;
--mode)
mode="${2:-}"
shift 2
;;
--results-path)
results_path="${2:-}"
shift 2
;;
*)
echo "unknown argument: $1" >&2
exit 2
;;
esac
done
if [ -z "$provider" ] || [ -z "$mode" ]; then
echo "usage: $0 --provider <owned|github> --mode <default|nix|full> [--results-path <path>]" >&2
exit 2
fi
case "$provider" in
owned)
if [ "$mode" != "default" ]; then
echo "owned only supports --mode default" >&2
exit 2
fi
;;
github)
if [ "$mode" != "nix" ] && [ "$mode" != "full" ]; then
echo "github only supports --mode nix|full" >&2
exit 2
fi
;;
*)
echo "unknown provider: $provider" >&2
exit 2
;;
esac
ci_root="${GITHUB_WORKSPACE:-$(pwd)}"
results_path="${results_path:-$ci_root/.od/ci-gate/ci-results.json}"
results_dir="$(dirname "$results_path")"
actions_jsonl="$results_dir/actions.jsonl"
export COREPACK_ENABLE_DOWNLOAD_PROMPT="${COREPACK_ENABLE_DOWNLOAD_PROMPT:-0}"
export COREPACK_HOME="${COREPACK_HOME:-$HOME/.cache/open-design-ci/corepack}"
export npm_config_store_dir="${npm_config_store_dir:-$HOME/.cache/open-design-ci/pnpm-store}"
export npm_config_fetch_retries="${npm_config_fetch_retries:-6}"
export npm_config_fetch_retry_maxtimeout="${npm_config_fetch_retry_maxtimeout:-120000}"
export npm_config_fetch_retry_mintimeout="${npm_config_fetch_retry_mintimeout:-20000}"
export npm_config_network_timeout="${npm_config_network_timeout:-180000}"
mkdir -p "$results_dir"
mkdir -p "$COREPACK_HOME"
mkdir -p "$npm_config_store_dir"
: > "$actions_jsonl"
event_name="${GITHUB_EVENT_NAME:-unknown}"
head_sha="${CI_GATE_HEAD_SHA:-${GITHUB_SHA:-unknown}}"
run_id="${GITHUB_RUN_ID:-unknown}"
run_attempt="${GITHUB_RUN_ATTEMPT:-unknown}"
actions=(
nix
guard
i18n
unit
typecheck
daemon
web
build
browser
)
append_result() {
local action="$1"
local kind="$2"
local status="$3"
local steps_path="${4:-}"
if [ -n "$steps_path" ] && [ -s "$steps_path" ]; then
jq -nc \
--arg action "$action" \
--arg kind "$kind" \
--arg status "$status" \
--slurpfile steps "$steps_path" \
'{
action: $action,
kind: $kind,
status: $status,
steps: $steps
}' >> "$actions_jsonl"
return 0
fi
jq -nc \
--arg action "$action" \
--arg kind "$kind" \
--arg status "$status" \
'{
action: $action,
kind: $kind,
status: $status
}' >> "$actions_jsonl"
}
is_real_action() {
local action="$1"
case "$provider:$mode:$action" in
owned:default:nix)
return 1
;;
github:nix:nix)
return 0
;;
github:nix:*)
return 1
;;
*)
return 0
;;
esac
}
has_real_non_nix=false
for action in "${actions[@]}"; do
if is_real_action "$action" && [ "$action" != "nix" ]; then
has_real_non_nix=true
break
fi
done
setup_status="success"
if [ "$has_real_non_nix" = "true" ]; then
package_manager="$(node -p "require('./package.json').packageManager")"
echo "preparing workspace with $package_manager"
set +e
timeout 180s bash -lc 'corepack enable && corepack prepare "$1" --activate' _ "$package_manager"
corepack_exit="$?"
set -e
if [ "$corepack_exit" != "0" ]; then
setup_status="failure"
else
set +e
timeout 1800s pnpm install --frozen-lockfile --prefer-offline --network-concurrency=8
install_exit="$?"
set -e
if [ "$install_exit" != "0" ]; then
setup_status="failure"
fi
fi
fi
overall_exit=0
for action in "${actions[@]}"; do
action_steps_jsonl="$results_dir/${action}-steps.jsonl"
: > "$action_steps_jsonl"
export CI_GATE_ACTION_TIMINGS_PATH="$action_steps_jsonl"
if ! is_real_action "$action"; then
append_result "$action" "placeholder" "not-run"
continue
fi
if [ "$action" != "nix" ] && [ "$setup_status" != "success" ]; then
append_result "$action" "real" "failure"
overall_exit=1
continue
fi
echo "running action: $action"
set +e
"$ci_root/.github/workflows/scripts/ci/actions/$action.sh"
action_exit="$?"
set -e
if [ "$action_exit" = "0" ]; then
append_result "$action" "real" "success" "$action_steps_jsonl"
else
append_result "$action" "real" "failure" "$action_steps_jsonl"
overall_exit=1
fi
done
jq -sc \
--arg provider "$provider" \
--arg mode "$mode" \
--arg eventName "$event_name" \
--arg headSha "$head_sha" \
--arg runId "$run_id" \
--arg runAttempt "$run_attempt" \
'{
schemaVersion: 1,
provider: $provider,
mode: $mode,
eventName: $eventName,
headSha: $headSha,
runId: $runId,
runAttempt: $runAttempt,
actions: .
}' "$actions_jsonl" > "$results_path"
echo "ci results: $results_path"
echo "OD_CI_RESULTS_JSON $(base64 < "$results_path" | tr -d '\n')"
exit "$overall_exit"

View File

@@ -1,868 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
mode="${1:-${OD_CI_MODE:-}}"
if [ -z "$mode" ]; then
echo "usage: $0 <probe|setup|core|policy|unit|typecheck|daemon|daemon-shard|daemon-parallel|web|build|browser>" >&2
exit 2
fi
ci_root="${GITHUB_WORKSPACE:-$(pwd)}"
out_dir="$ci_root/.od/ci"
manifest="$out_dir/$mode-manifest.json"
summary="${GITHUB_STEP_SUMMARY:-}"
mkdir -p "$out_dir"
append_summary() {
if [ -n "$summary" ]; then
printf '%s\n' "$*" >> "$summary"
fi
}
append_toolchain_storage_summary() {
append_summary ""
append_summary "### Toolchain"
append_summary ""
append_summary "| Tool | Version |"
append_summary "| --- | --- |"
append_summary "| git | \`$git_version\` |"
append_summary "| node | \`$node_version\` |"
append_summary "| npm | \`$npm_version\` |"
append_summary "| corepack | \`$corepack_version\` |"
append_summary "| pnpm | \`$pnpm_version\` |"
append_summary "| docker | \`$docker_version\` |"
append_summary ""
append_summary "### Storage"
append_summary ""
append_summary "| Path | Available |"
append_summary "| --- | --- |"
append_summary "| / | \`$disk_root\` |"
append_summary "| workspace | \`$workspace_disk\` |"
append_summary "| pnpm store | \`$pnpm_store\` |"
}
json_escape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\n'/\\n}"
value="${value//$'\r'/}"
printf '%s' "$value"
}
capture_cmd() {
local name="$1"
shift
local value
if value="$("$@" 2>/dev/null | head -1)"; then
printf '%s' "$value"
else
printf ''
fi
}
require_mode() {
case "$mode" in
probe | setup | core | policy | unit | typecheck | daemon | daemon-shard | daemon-parallel | web | build | browser) ;;
*)
echo "unknown CI mode: $mode" >&2
exit 2
;;
esac
}
require_mode
lane="${OD_CI_LANE:-unknown}"
allow_docker="${OD_CI_ALLOW_DOCKER:-0}"
install_timeout_seconds="${OD_CI_INSTALL_TIMEOUT_SECONDS:-1500}"
pnpm_fetch_retries="${OD_CI_PNPM_FETCH_RETRIES:-6}"
pnpm_fetch_retry_maxtimeout="${OD_CI_PNPM_FETCH_RETRY_MAXTIMEOUT:-120000}"
pnpm_fetch_retry_mintimeout="${OD_CI_PNPM_FETCH_RETRY_MINTIMEOUT:-20000}"
pnpm_install_flags="${OD_CI_PNPM_INSTALL_FLAGS:---frozen-lockfile}"
pnpm_network_timeout="${OD_CI_PNPM_NETWORK_TIMEOUT:-180000}"
pnpm_store_dir="${OD_CI_PNPM_STORE_DIR:-}"
playwright_install_flags="${OD_CI_PLAYWRIGHT_INSTALL_FLAGS:-chromium}"
step_timeout_seconds="${OD_CI_STEP_TIMEOUT_SECONDS:-600}"
corepack_home="${COREPACK_HOME:-}"
daemon_shard="${OD_CI_DAEMON_SHARD:-}"
daemon_max_workers="${OD_CI_DAEMON_MAX_WORKERS:-}"
runner_name="${RUNNER_NAME:-unknown}"
runner_os="${RUNNER_OS:-unknown}"
runner_arch="${RUNNER_ARCH:-unknown}"
github_sha="${GITHUB_SHA:-unknown}"
github_ref="${GITHUB_REF:-unknown}"
github_run_id="${GITHUB_RUN_ID:-unknown}"
echo "ci mode: $mode"
echo "ci lane: $lane"
echo "runner: $runner_name / $runner_os / $runner_arch"
echo "ref: $github_ref"
echo "sha: $github_sha"
if [ -n "$corepack_home" ]; then
mkdir -p "$corepack_home"
export COREPACK_HOME="$corepack_home"
fi
export COREPACK_ENABLE_DOWNLOAD_PROMPT="${COREPACK_ENABLE_DOWNLOAD_PROMPT:-0}"
append_summary "## CI runner"
append_summary ""
append_summary "| Field | Value |"
append_summary "| --- | --- |"
append_summary "| Lane | \`$lane\` |"
append_summary "| Mode | \`$mode\` |"
append_summary "| Runner | \`$runner_name\` |"
append_summary "| Runner OS | \`$runner_os\` |"
append_summary "| Runner arch | \`$runner_arch\` |"
append_summary "| Ref | \`$github_ref\` |"
append_summary "| SHA | \`$github_sha\` |"
if [ -n "$daemon_shard" ]; then
append_summary "| Daemon shard | \`$daemon_shard\` |"
fi
if [ -n "$daemon_max_workers" ]; then
append_summary "| Daemon max workers | \`$daemon_max_workers\` |"
fi
node_version="$(capture_cmd node node --version)"
npm_version="$(capture_cmd npm npm --version)"
corepack_version="$(capture_cmd corepack corepack --version)"
git_version="$(capture_cmd git git --version)"
docker_version="$(capture_cmd docker docker --version)"
kernel="$(capture_cmd uname uname -a)"
disk_root="$(df -h / | awk 'NR==2 {print $4 " available of " $2}')"
workspace_disk="$(df -h "$ci_root" | awk 'NR==2 {print $4 " available of " $2}')"
pnpm_version="not-prepared"
pnpm_store=""
if [ -z "$node_version" ] || [ -z "$npm_version" ] || [ -z "$corepack_version" ]; then
echo "missing required Node package-manager toolchain" >&2
exit 1
fi
node_major="${node_version#v}"
node_major="${node_major%%.*}"
if [ "$node_major" != "24" ]; then
echo "Node 24 is required for CI, got $node_version" >&2
exit 1
fi
if [ -n "$pnpm_store_dir" ]; then
mkdir -p "$pnpm_store_dir"
export npm_config_store_dir="$pnpm_store_dir"
fi
export npm_config_fetch_retries="$pnpm_fetch_retries"
export npm_config_fetch_retry_maxtimeout="$pnpm_fetch_retry_maxtimeout"
export npm_config_fetch_retry_mintimeout="$pnpm_fetch_retry_mintimeout"
export npm_config_network_timeout="$pnpm_network_timeout"
if [ "$mode" = "probe" ]; then
append_toolchain_storage_summary
fi
docker_status="skipped"
if [ "$allow_docker" = "1" ]; then
timeout 30s docker ps >/dev/null
docker_status="ok"
fi
append_summary ""
append_summary "### Docker"
append_summary ""
append_summary "Docker smoke: \`$docker_status\`"
install_status="skipped"
install_seconds="0"
install_exit_code="0"
node_modules_size="not-created"
pnpm_store_size="unknown"
corepack_prepare_status="skipped"
corepack_prepare_exit_code="0"
corepack_prepare_seconds="0"
policy_status="skipped"
policy_exit_code="0"
policy_seconds="0"
guard_exit_code="0"
guard_seconds="0"
i18n_exit_code="0"
i18n_seconds="0"
unit_status="skipped"
unit_exit_code="0"
unit_seconds="0"
contracts_test_exit_code="0"
contracts_test_seconds="0"
host_test_exit_code="0"
host_test_seconds="0"
platform_test_exit_code="0"
platform_test_seconds="0"
sidecar_test_exit_code="0"
sidecar_test_seconds="0"
sidecar_proto_test_exit_code="0"
sidecar_proto_test_seconds="0"
tools_dev_test_exit_code="0"
tools_dev_test_seconds="0"
tools_pack_test_exit_code="0"
tools_pack_test_seconds="0"
typecheck_status="skipped"
typecheck_exit_code="0"
typecheck_seconds="0"
daemon_build_exit_code="0"
daemon_build_seconds="0"
desktop_build_exit_code="0"
desktop_build_seconds="0"
web_sidecar_build_exit_code="0"
web_sidecar_build_seconds="0"
workspace_typecheck_exit_code="0"
workspace_typecheck_seconds="0"
scripts_typecheck_exit_code="0"
scripts_typecheck_seconds="0"
daemon_status="skipped"
daemon_exit_code="0"
daemon_seconds="0"
daemon_test_exit_code="0"
daemon_test_seconds="0"
web_status="skipped"
web_exit_code="0"
web_seconds="0"
web_test_exit_code="0"
web_test_seconds="0"
build_status="skipped"
build_exit_code="0"
build_seconds="0"
workspace_build_exit_code="0"
workspace_build_seconds="0"
browser_status="skipped"
browser_exit_code="0"
browser_seconds="0"
playwright_install_exit_code="0"
playwright_install_seconds="0"
e2e_vitest_exit_code="0"
e2e_vitest_seconds="0"
playwright_critical_exit_code="0"
playwright_critical_seconds="0"
if [ "$mode" = "setup" ] || [ "$mode" = "core" ] || [ "$mode" = "policy" ] || [ "$mode" = "unit" ] || [ "$mode" = "typecheck" ] || [ "$mode" = "daemon" ] || [ "$mode" = "daemon-shard" ] || [ "$mode" = "daemon-parallel" ] || [ "$mode" = "web" ] || [ "$mode" = "build" ] || [ "$mode" = "browser" ]; then
package_manager="$(node -p "require('./package.json').packageManager")"
append_summary ""
append_summary "### Corepack"
append_summary ""
append_summary "Command: \`corepack prepare $package_manager --activate\`"
append_summary ""
echo "corepack home: ${COREPACK_HOME:-default}"
echo "corepack package manager: $package_manager"
corepack_prepare_start="$(date +%s)"
set +e
timeout 180s bash -c 'corepack enable && corepack prepare "$1" --activate' _ "$package_manager"
corepack_prepare_exit_code="$?"
set -e
corepack_prepare_seconds="$(( $(date +%s) - corepack_prepare_start ))"
if [ "$corepack_prepare_exit_code" = "0" ]; then
corepack_prepare_status="ok"
pnpm_version="$(capture_cmd pnpm pnpm --version)"
pnpm_store="$(capture_cmd pnpm-store pnpm store path --silent)"
else
corepack_prepare_status="failed"
fi
if [ "$corepack_prepare_exit_code" = "0" ] && [ -z "$pnpm_version" ]; then
echo "missing required pnpm shim after corepack prepare" >&2
exit 1
fi
append_toolchain_storage_summary
append_summary ""
append_summary "### Install"
append_summary ""
append_summary "Command: \`pnpm install $pnpm_install_flags\`"
append_summary ""
echo "pnpm store: $pnpm_store"
echo "pnpm install flags: $pnpm_install_flags"
echo "install timeout seconds: $install_timeout_seconds"
echo "pnpm fetch retries: $pnpm_fetch_retries"
echo "pnpm fetch retry min timeout: $pnpm_fetch_retry_mintimeout"
echo "pnpm fetch retry max timeout: $pnpm_fetch_retry_maxtimeout"
echo "pnpm network timeout: $pnpm_network_timeout"
install_start="$(date +%s)"
set +e
# shellcheck disable=SC2086
timeout "${install_timeout_seconds}s" pnpm install $pnpm_install_flags
install_exit_code="$?"
set -e
install_seconds="$(( $(date +%s) - install_start ))"
if [ "$install_exit_code" = "0" ]; then
install_status="ok"
else
install_status="failed"
fi
if [ -d "$ci_root/node_modules" ]; then
node_modules_size="$(du -sh "$ci_root/node_modules" 2>/dev/null | awk '{print $1}')"
fi
fi
run_ci_command() {
local label="$1"
shift
local started
local exit_code
local seconds
echo "running: $label"
started="$(date +%s)"
set +e
timeout "${step_timeout_seconds}s" "$@"
exit_code="$?"
set -e
seconds="$(( $(date +%s) - started ))"
echo "completed: $label exit=$exit_code seconds=$seconds"
echo "OD_CI_COMMAND {\"lane\":\"$(json_escape "$lane")\",\"mode\":\"$(json_escape "$mode")\",\"label\":\"$(json_escape "$label")\",\"exitCode\":$exit_code,\"seconds\":$seconds}"
last_command_exit_code="$exit_code"
last_command_seconds="$seconds"
}
if { [ "$mode" = "policy" ] || [ "$mode" = "core" ]; } && [ "$install_exit_code" = "0" ]; then
append_summary ""
append_summary "### Policy checks"
append_summary ""
append_summary "| Check | Exit code | Seconds |"
append_summary "| --- | ---: | ---: |"
policy_status="ok"
policy_start="$(date +%s)"
run_ci_command "pnpm guard" pnpm guard
guard_exit_code="$last_command_exit_code"
guard_seconds="$last_command_seconds"
append_summary "| \`pnpm guard\` | \`$guard_exit_code\` | \`$guard_seconds\` |"
if [ "$guard_exit_code" != "0" ]; then
policy_status="failed"
fi
run_ci_command "pnpm i18n:check" pnpm i18n:check
i18n_exit_code="$last_command_exit_code"
i18n_seconds="$last_command_seconds"
append_summary "| \`pnpm i18n:check\` | \`$i18n_exit_code\` | \`$i18n_seconds\` |"
if [ "$i18n_exit_code" != "0" ]; then
policy_status="failed"
fi
policy_seconds="$(( $(date +%s) - policy_start ))"
if [ "$policy_status" != "ok" ]; then
if [ "$guard_exit_code" != "0" ]; then
policy_exit_code="$guard_exit_code"
else
policy_exit_code="$i18n_exit_code"
fi
fi
fi
record_unit_result() {
local label="$1"
local exit_code="$2"
local seconds="$3"
append_summary "| \`$label\` | \`$exit_code\` | \`$seconds\` |"
if [ "$exit_code" != "0" ] && [ "$unit_status" = "ok" ]; then
unit_status="failed"
unit_exit_code="$exit_code"
fi
}
if { [ "$mode" = "unit" ] || [ "$mode" = "core" ]; } && [ "$install_exit_code" = "0" ]; then
append_summary ""
append_summary "### Workspace unit tests"
append_summary ""
append_summary "| Check | Exit code | Seconds |"
append_summary "| --- | ---: | ---: |"
unit_status="ok"
unit_start="$(date +%s)"
run_ci_command "@open-design/contracts test" pnpm --filter @open-design/contracts test
contracts_test_exit_code="$last_command_exit_code"
contracts_test_seconds="$last_command_seconds"
record_unit_result "@open-design/contracts" "$contracts_test_exit_code" "$contracts_test_seconds"
run_ci_command "@open-design/host test" pnpm --filter @open-design/host test
host_test_exit_code="$last_command_exit_code"
host_test_seconds="$last_command_seconds"
record_unit_result "@open-design/host" "$host_test_exit_code" "$host_test_seconds"
run_ci_command "@open-design/platform test" pnpm --filter @open-design/platform test
platform_test_exit_code="$last_command_exit_code"
platform_test_seconds="$last_command_seconds"
record_unit_result "@open-design/platform" "$platform_test_exit_code" "$platform_test_seconds"
run_ci_command "@open-design/sidecar test" pnpm --filter @open-design/sidecar test
sidecar_test_exit_code="$last_command_exit_code"
sidecar_test_seconds="$last_command_seconds"
record_unit_result "@open-design/sidecar" "$sidecar_test_exit_code" "$sidecar_test_seconds"
run_ci_command "@open-design/sidecar-proto test" pnpm --filter @open-design/sidecar-proto test
sidecar_proto_test_exit_code="$last_command_exit_code"
sidecar_proto_test_seconds="$last_command_seconds"
record_unit_result "@open-design/sidecar-proto" "$sidecar_proto_test_exit_code" "$sidecar_proto_test_seconds"
run_ci_command "@open-design/tools-dev test" pnpm --filter @open-design/tools-dev test
tools_dev_test_exit_code="$last_command_exit_code"
tools_dev_test_seconds="$last_command_seconds"
record_unit_result "@open-design/tools-dev" "$tools_dev_test_exit_code" "$tools_dev_test_seconds"
run_ci_command "@open-design/tools-pack test" pnpm --filter @open-design/tools-pack test
tools_pack_test_exit_code="$last_command_exit_code"
tools_pack_test_seconds="$last_command_seconds"
record_unit_result "@open-design/tools-pack" "$tools_pack_test_exit_code" "$tools_pack_test_seconds"
unit_seconds="$(( $(date +%s) - unit_start ))"
fi
record_typecheck_result() {
local label="$1"
local exit_code="$2"
local seconds="$3"
append_summary "| \`$label\` | \`$exit_code\` | \`$seconds\` |"
if [ "$exit_code" != "0" ] && [ "$typecheck_status" = "ok" ]; then
typecheck_status="failed"
typecheck_exit_code="$exit_code"
fi
}
if [ "$mode" = "typecheck" ] && [ "$install_exit_code" = "0" ]; then
append_summary ""
append_summary "### Typecheck"
append_summary ""
append_summary "| Check | Exit code | Seconds |"
append_summary "| --- | ---: | ---: |"
typecheck_status="ok"
typecheck_start="$(date +%s)"
run_ci_command "@open-design/daemon build" pnpm --filter @open-design/daemon build
daemon_build_exit_code="$last_command_exit_code"
daemon_build_seconds="$last_command_seconds"
record_typecheck_result "@open-design/daemon build" "$daemon_build_exit_code" "$daemon_build_seconds"
run_ci_command "@open-design/desktop build" pnpm --filter @open-design/desktop build
desktop_build_exit_code="$last_command_exit_code"
desktop_build_seconds="$last_command_seconds"
record_typecheck_result "@open-design/desktop build" "$desktop_build_exit_code" "$desktop_build_seconds"
run_ci_command "@open-design/web build:sidecar" pnpm --filter @open-design/web build:sidecar
web_sidecar_build_exit_code="$last_command_exit_code"
web_sidecar_build_seconds="$last_command_seconds"
record_typecheck_result "@open-design/web build:sidecar" "$web_sidecar_build_exit_code" "$web_sidecar_build_seconds"
run_ci_command "workspace typecheck" pnpm -r --filter '!open-design' --filter '!@open-design/landing-page' --workspace-concurrency=4 --if-present run typecheck
workspace_typecheck_exit_code="$last_command_exit_code"
workspace_typecheck_seconds="$last_command_seconds"
record_typecheck_result "workspace typecheck" "$workspace_typecheck_exit_code" "$workspace_typecheck_seconds"
run_ci_command "scripts typecheck" pnpm exec tsc -p scripts/tsconfig.json --noEmit
scripts_typecheck_exit_code="$last_command_exit_code"
scripts_typecheck_seconds="$last_command_seconds"
record_typecheck_result "scripts typecheck" "$scripts_typecheck_exit_code" "$scripts_typecheck_seconds"
typecheck_seconds="$(( $(date +%s) - typecheck_start ))"
fi
record_daemon_result() {
local label="$1"
local exit_code="$2"
local seconds="$3"
append_summary "| \`$label\` | \`$exit_code\` | \`$seconds\` |"
if [ "$exit_code" != "0" ] && [ "$daemon_status" = "ok" ]; then
daemon_status="failed"
daemon_exit_code="$exit_code"
fi
}
if { [ "$mode" = "daemon" ] || [ "$mode" = "daemon-shard" ] || [ "$mode" = "daemon-parallel" ]; } && [ "$install_exit_code" = "0" ]; then
append_summary ""
append_summary "### Daemon workspace tests"
append_summary ""
append_summary "| Check | Exit code | Seconds |"
append_summary "| --- | ---: | ---: |"
daemon_status="ok"
daemon_start="$(date +%s)"
run_ci_command "@open-design/daemon build" pnpm --filter @open-design/daemon build
daemon_build_exit_code="$last_command_exit_code"
daemon_build_seconds="$last_command_seconds"
record_daemon_result "@open-design/daemon build" "$daemon_build_exit_code" "$daemon_build_seconds"
if [ "$mode" = "daemon-shard" ]; then
run_ci_command "@open-design/daemon test shard $daemon_shard" pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts --shard "$daemon_shard"
elif [ "$mode" = "daemon-parallel" ]; then
run_ci_command "@open-design/daemon test parallel workers ${daemon_max_workers:-4}" pnpm --filter @open-design/daemon exec vitest run -c vitest.parallel.config.ts
else
run_ci_command "@open-design/daemon test" pnpm --filter @open-design/daemon test
fi
daemon_test_exit_code="$last_command_exit_code"
daemon_test_seconds="$last_command_seconds"
record_daemon_result "@open-design/daemon test${daemon_shard:+ shard $daemon_shard}" "$daemon_test_exit_code" "$daemon_test_seconds"
daemon_seconds="$(( $(date +%s) - daemon_start ))"
fi
record_web_result() {
local label="$1"
local exit_code="$2"
local seconds="$3"
append_summary "| \`$label\` | \`$exit_code\` | \`$seconds\` |"
if [ "$exit_code" != "0" ] && [ "$web_status" = "ok" ]; then
web_status="failed"
web_exit_code="$exit_code"
fi
}
if [ "$mode" = "web" ] && [ "$install_exit_code" = "0" ]; then
append_summary ""
append_summary "### Web workspace tests"
append_summary ""
append_summary "| Check | Exit code | Seconds |"
append_summary "| --- | ---: | ---: |"
web_status="ok"
web_start="$(date +%s)"
run_ci_command "@open-design/web build:sidecar" pnpm --filter @open-design/web build:sidecar
web_sidecar_build_exit_code="$last_command_exit_code"
web_sidecar_build_seconds="$last_command_seconds"
record_web_result "@open-design/web build:sidecar" "$web_sidecar_build_exit_code" "$web_sidecar_build_seconds"
run_ci_command "@open-design/web test" pnpm --filter @open-design/web test
web_test_exit_code="$last_command_exit_code"
web_test_seconds="$last_command_seconds"
record_web_result "@open-design/web test" "$web_test_exit_code" "$web_test_seconds"
web_seconds="$(( $(date +%s) - web_start ))"
fi
record_build_result() {
local label="$1"
local exit_code="$2"
local seconds="$3"
append_summary "| \`$label\` | \`$exit_code\` | \`$seconds\` |"
if [ "$exit_code" != "0" ] && [ "$build_status" = "ok" ]; then
build_status="failed"
build_exit_code="$exit_code"
fi
}
if [ "$mode" = "build" ] && [ "$install_exit_code" = "0" ]; then
append_summary ""
append_summary "### Build workspaces"
append_summary ""
append_summary "| Check | Exit code | Seconds |"
append_summary "| --- | ---: | ---: |"
build_status="ok"
build_start="$(date +%s)"
run_ci_command "@open-design/daemon build" pnpm --filter @open-design/daemon build
daemon_build_exit_code="$last_command_exit_code"
daemon_build_seconds="$last_command_seconds"
record_build_result "@open-design/daemon build" "$daemon_build_exit_code" "$daemon_build_seconds"
run_ci_command "@open-design/desktop build" pnpm --filter @open-design/desktop build
desktop_build_exit_code="$last_command_exit_code"
desktop_build_seconds="$last_command_seconds"
record_build_result "@open-design/desktop build" "$desktop_build_exit_code" "$desktop_build_seconds"
run_ci_command "@open-design/web build:sidecar" pnpm --filter @open-design/web build:sidecar
web_sidecar_build_exit_code="$last_command_exit_code"
web_sidecar_build_seconds="$last_command_seconds"
record_build_result "@open-design/web build:sidecar" "$web_sidecar_build_exit_code" "$web_sidecar_build_seconds"
run_ci_command "workspace build" pnpm -r --filter '!@open-design/landing-page' --workspace-concurrency=1 --if-present run build
workspace_build_exit_code="$last_command_exit_code"
workspace_build_seconds="$last_command_seconds"
record_build_result "workspace build" "$workspace_build_exit_code" "$workspace_build_seconds"
build_seconds="$(( $(date +%s) - build_start ))"
fi
record_browser_result() {
local label="$1"
local exit_code="$2"
local seconds="$3"
append_summary "| \`$label\` | \`$exit_code\` | \`$seconds\` |"
if [ "$exit_code" != "0" ] && [ "$browser_status" = "ok" ]; then
browser_status="failed"
browser_exit_code="$exit_code"
fi
}
if [ "$mode" = "browser" ] && [ "$install_exit_code" = "0" ]; then
append_summary ""
append_summary "### Browser tests"
append_summary ""
append_summary "Playwright install flags: \`$playwright_install_flags\`"
append_summary ""
append_summary "| Check | Exit code | Seconds |"
append_summary "| --- | ---: | ---: |"
browser_status="ok"
browser_start="$(date +%s)"
# shellcheck disable=SC2086
run_ci_command "playwright install" pnpm -C e2e exec playwright install $playwright_install_flags
playwright_install_exit_code="$last_command_exit_code"
playwright_install_seconds="$last_command_seconds"
record_browser_result "playwright install" "$playwright_install_exit_code" "$playwright_install_seconds"
run_ci_command "@open-design/daemon build" pnpm --filter @open-design/daemon build
daemon_build_exit_code="$last_command_exit_code"
daemon_build_seconds="$last_command_seconds"
record_browser_result "@open-design/daemon build" "$daemon_build_exit_code" "$daemon_build_seconds"
run_ci_command "@open-design/desktop build" pnpm --filter @open-design/desktop build
desktop_build_exit_code="$last_command_exit_code"
desktop_build_seconds="$last_command_seconds"
record_browser_result "@open-design/desktop build" "$desktop_build_exit_code" "$desktop_build_seconds"
run_ci_command "@open-design/web build:sidecar" pnpm --filter @open-design/web build:sidecar
web_sidecar_build_exit_code="$last_command_exit_code"
web_sidecar_build_seconds="$last_command_seconds"
record_browser_result "@open-design/web build:sidecar" "$web_sidecar_build_exit_code" "$web_sidecar_build_seconds"
run_ci_command "e2e vitest" pnpm --filter @open-design/e2e test
e2e_vitest_exit_code="$last_command_exit_code"
e2e_vitest_seconds="$last_command_seconds"
record_browser_result "e2e vitest" "$e2e_vitest_exit_code" "$e2e_vitest_seconds"
run_ci_command "playwright clean" pnpm -C e2e exec tsx scripts/playwright.ts clean
record_browser_result "playwright clean" "$last_command_exit_code" "$last_command_seconds"
run_ci_command "playwright critical" pnpm -C e2e run test:ui:critical
playwright_critical_exit_code="$last_command_exit_code"
playwright_critical_seconds="$last_command_seconds"
record_browser_result "playwright critical" "$playwright_critical_exit_code" "$playwright_critical_seconds"
browser_seconds="$(( $(date +%s) - browser_start ))"
fi
if [ -n "$pnpm_store" ] && [ -d "$pnpm_store" ]; then
pnpm_store_size="$(du -sh "$pnpm_store" 2>/dev/null | awk '{print $1}')"
fi
append_summary ""
append_summary "### Dependency setup"
append_summary ""
append_summary "| Field | Value |"
append_summary "| --- | --- |"
append_summary "| Install status | \`$install_status\` |"
append_summary "| Corepack prepare status | \`$corepack_prepare_status\` |"
append_summary "| Corepack prepare seconds | \`$corepack_prepare_seconds\` |"
append_summary "| Install exit code | \`$install_exit_code\` |"
append_summary "| Install seconds | \`$install_seconds\` |"
append_summary "| node_modules size | \`$node_modules_size\` |"
append_summary "| pnpm store size | \`$pnpm_store_size\` |"
append_summary "| Policy status | \`$policy_status\` |"
append_summary "| Policy seconds | \`$policy_seconds\` |"
append_summary "| Unit status | \`$unit_status\` |"
append_summary "| Unit seconds | \`$unit_seconds\` |"
append_summary "| Typecheck status | \`$typecheck_status\` |"
append_summary "| Typecheck seconds | \`$typecheck_seconds\` |"
append_summary "| Daemon status | \`$daemon_status\` |"
append_summary "| Daemon seconds | \`$daemon_seconds\` |"
append_summary "| Web status | \`$web_status\` |"
append_summary "| Web seconds | \`$web_seconds\` |"
append_summary "| Build status | \`$build_status\` |"
append_summary "| Build seconds | \`$build_seconds\` |"
append_summary "| Browser status | \`$browser_status\` |"
append_summary "| Browser seconds | \`$browser_seconds\` |"
emit_ci_metric() {
local name="$1"
local status="$2"
local exit_code="$3"
local seconds="$4"
echo "OD_CI_METRIC {\"lane\":\"$(json_escape "$lane")\",\"mode\":\"$(json_escape "$mode")\",\"name\":\"$(json_escape "$name")\",\"status\":\"$(json_escape "$status")\",\"exitCode\":$exit_code,\"seconds\":$seconds}"
}
echo "OD_CI_SUMMARY {\"lane\":\"$(json_escape "$lane")\",\"mode\":\"$(json_escape "$mode")\",\"runner\":\"$(json_escape "$runner_name")\",\"sha\":\"$(json_escape "$github_sha")\",\"installStatus\":\"$(json_escape "$install_status")\",\"policyStatus\":\"$(json_escape "$policy_status")\",\"unitStatus\":\"$(json_escape "$unit_status")\",\"typecheckStatus\":\"$(json_escape "$typecheck_status")\",\"daemonStatus\":\"$(json_escape "$daemon_status")\",\"webStatus\":\"$(json_escape "$web_status")\",\"buildStatus\":\"$(json_escape "$build_status")\",\"browserStatus\":\"$(json_escape "$browser_status")\"}"
emit_ci_metric "corepack_prepare" "$corepack_prepare_status" "$corepack_prepare_exit_code" "$corepack_prepare_seconds"
emit_ci_metric "install" "$install_status" "$install_exit_code" "$install_seconds"
emit_ci_metric "policy_total" "$policy_status" "$policy_exit_code" "$policy_seconds"
emit_ci_metric "policy_guard" "$policy_status" "$guard_exit_code" "$guard_seconds"
emit_ci_metric "policy_i18n" "$policy_status" "$i18n_exit_code" "$i18n_seconds"
emit_ci_metric "unit_total" "$unit_status" "$unit_exit_code" "$unit_seconds"
emit_ci_metric "unit_contracts" "$unit_status" "$contracts_test_exit_code" "$contracts_test_seconds"
emit_ci_metric "unit_host" "$unit_status" "$host_test_exit_code" "$host_test_seconds"
emit_ci_metric "unit_platform" "$unit_status" "$platform_test_exit_code" "$platform_test_seconds"
emit_ci_metric "unit_sidecar" "$unit_status" "$sidecar_test_exit_code" "$sidecar_test_seconds"
emit_ci_metric "unit_sidecar_proto" "$unit_status" "$sidecar_proto_test_exit_code" "$sidecar_proto_test_seconds"
emit_ci_metric "unit_tools_dev" "$unit_status" "$tools_dev_test_exit_code" "$tools_dev_test_seconds"
emit_ci_metric "unit_tools_pack" "$unit_status" "$tools_pack_test_exit_code" "$tools_pack_test_seconds"
emit_ci_metric "typecheck_total" "$typecheck_status" "$typecheck_exit_code" "$typecheck_seconds"
emit_ci_metric "typecheck_daemon_build" "$typecheck_status" "$daemon_build_exit_code" "$daemon_build_seconds"
emit_ci_metric "typecheck_desktop_build" "$typecheck_status" "$desktop_build_exit_code" "$desktop_build_seconds"
emit_ci_metric "typecheck_web_sidecar_build" "$typecheck_status" "$web_sidecar_build_exit_code" "$web_sidecar_build_seconds"
emit_ci_metric "typecheck_workspace" "$typecheck_status" "$workspace_typecheck_exit_code" "$workspace_typecheck_seconds"
emit_ci_metric "typecheck_scripts" "$typecheck_status" "$scripts_typecheck_exit_code" "$scripts_typecheck_seconds"
emit_ci_metric "daemon_total" "$daemon_status" "$daemon_exit_code" "$daemon_seconds"
emit_ci_metric "daemon_build" "$daemon_status" "$daemon_build_exit_code" "$daemon_build_seconds"
emit_ci_metric "daemon_test" "$daemon_status" "$daemon_test_exit_code" "$daemon_test_seconds"
emit_ci_metric "web_total" "$web_status" "$web_exit_code" "$web_seconds"
emit_ci_metric "web_test" "$web_status" "$web_test_exit_code" "$web_test_seconds"
emit_ci_metric "build_total" "$build_status" "$build_exit_code" "$build_seconds"
emit_ci_metric "build_workspace" "$build_status" "$workspace_build_exit_code" "$workspace_build_seconds"
emit_ci_metric "browser_total" "$browser_status" "$browser_exit_code" "$browser_seconds"
emit_ci_metric "browser_playwright_install" "$browser_status" "$playwright_install_exit_code" "$playwright_install_seconds"
emit_ci_metric "browser_e2e_vitest" "$browser_status" "$e2e_vitest_exit_code" "$e2e_vitest_seconds"
emit_ci_metric "browser_playwright_critical" "$browser_status" "$playwright_critical_exit_code" "$playwright_critical_seconds"
cat > "$manifest" <<JSON
{
"mode": "$(json_escape "$mode")",
"lane": "$(json_escape "$lane")",
"runnerName": "$(json_escape "$runner_name")",
"runnerOs": "$(json_escape "$runner_os")",
"runnerArch": "$(json_escape "$runner_arch")",
"githubRef": "$(json_escape "$github_ref")",
"githubSha": "$(json_escape "$github_sha")",
"githubRunId": "$(json_escape "$github_run_id")",
"daemonShard": "$(json_escape "$daemon_shard")",
"kernel": "$(json_escape "$kernel")",
"gitVersion": "$(json_escape "$git_version")",
"nodeVersion": "$(json_escape "$node_version")",
"npmVersion": "$(json_escape "$npm_version")",
"corepackVersion": "$(json_escape "$corepack_version")",
"pnpmVersion": "$(json_escape "$pnpm_version")",
"pnpmStore": "$(json_escape "$pnpm_store")",
"pnpmStoreSize": "$(json_escape "$pnpm_store_size")",
"pnpmFetchRetries": "$(json_escape "$pnpm_fetch_retries")",
"pnpmFetchRetryMaxTimeout": "$(json_escape "$pnpm_fetch_retry_maxtimeout")",
"pnpmFetchRetryMinTimeout": "$(json_escape "$pnpm_fetch_retry_mintimeout")",
"pnpmInstallFlags": "$(json_escape "$pnpm_install_flags")",
"pnpmNetworkTimeout": "$(json_escape "$pnpm_network_timeout")",
"playwrightInstallFlags": "$(json_escape "$playwright_install_flags")",
"stepTimeoutSeconds": "$(json_escape "$step_timeout_seconds")",
"corepackHome": "$(json_escape "${COREPACK_HOME:-}")",
"corepackPrepareStatus": "$(json_escape "$corepack_prepare_status")",
"corepackPrepareExitCode": "$(json_escape "$corepack_prepare_exit_code")",
"corepackPrepareSeconds": "$(json_escape "$corepack_prepare_seconds")",
"installStatus": "$(json_escape "$install_status")",
"installExitCode": "$(json_escape "$install_exit_code")",
"installSeconds": "$(json_escape "$install_seconds")",
"nodeModulesSize": "$(json_escape "$node_modules_size")",
"policyStatus": "$(json_escape "$policy_status")",
"policyExitCode": "$(json_escape "$policy_exit_code")",
"policySeconds": "$(json_escape "$policy_seconds")",
"guardExitCode": "$(json_escape "$guard_exit_code")",
"guardSeconds": "$(json_escape "$guard_seconds")",
"i18nExitCode": "$(json_escape "$i18n_exit_code")",
"i18nSeconds": "$(json_escape "$i18n_seconds")",
"unitStatus": "$(json_escape "$unit_status")",
"unitExitCode": "$(json_escape "$unit_exit_code")",
"unitSeconds": "$(json_escape "$unit_seconds")",
"contractsTestExitCode": "$(json_escape "$contracts_test_exit_code")",
"contractsTestSeconds": "$(json_escape "$contracts_test_seconds")",
"hostTestExitCode": "$(json_escape "$host_test_exit_code")",
"hostTestSeconds": "$(json_escape "$host_test_seconds")",
"platformTestExitCode": "$(json_escape "$platform_test_exit_code")",
"platformTestSeconds": "$(json_escape "$platform_test_seconds")",
"sidecarTestExitCode": "$(json_escape "$sidecar_test_exit_code")",
"sidecarTestSeconds": "$(json_escape "$sidecar_test_seconds")",
"sidecarProtoTestExitCode": "$(json_escape "$sidecar_proto_test_exit_code")",
"sidecarProtoTestSeconds": "$(json_escape "$sidecar_proto_test_seconds")",
"toolsDevTestExitCode": "$(json_escape "$tools_dev_test_exit_code")",
"toolsDevTestSeconds": "$(json_escape "$tools_dev_test_seconds")",
"toolsPackTestExitCode": "$(json_escape "$tools_pack_test_exit_code")",
"toolsPackTestSeconds": "$(json_escape "$tools_pack_test_seconds")",
"typecheckStatus": "$(json_escape "$typecheck_status")",
"typecheckExitCode": "$(json_escape "$typecheck_exit_code")",
"typecheckSeconds": "$(json_escape "$typecheck_seconds")",
"daemonBuildExitCode": "$(json_escape "$daemon_build_exit_code")",
"daemonBuildSeconds": "$(json_escape "$daemon_build_seconds")",
"desktopBuildExitCode": "$(json_escape "$desktop_build_exit_code")",
"desktopBuildSeconds": "$(json_escape "$desktop_build_seconds")",
"webSidecarBuildExitCode": "$(json_escape "$web_sidecar_build_exit_code")",
"webSidecarBuildSeconds": "$(json_escape "$web_sidecar_build_seconds")",
"workspaceTypecheckExitCode": "$(json_escape "$workspace_typecheck_exit_code")",
"workspaceTypecheckSeconds": "$(json_escape "$workspace_typecheck_seconds")",
"scriptsTypecheckExitCode": "$(json_escape "$scripts_typecheck_exit_code")",
"scriptsTypecheckSeconds": "$(json_escape "$scripts_typecheck_seconds")",
"daemonStatus": "$(json_escape "$daemon_status")",
"daemonExitCode": "$(json_escape "$daemon_exit_code")",
"daemonSeconds": "$(json_escape "$daemon_seconds")",
"daemonTestExitCode": "$(json_escape "$daemon_test_exit_code")",
"daemonTestSeconds": "$(json_escape "$daemon_test_seconds")",
"webStatus": "$(json_escape "$web_status")",
"webExitCode": "$(json_escape "$web_exit_code")",
"webSeconds": "$(json_escape "$web_seconds")",
"webTestExitCode": "$(json_escape "$web_test_exit_code")",
"webTestSeconds": "$(json_escape "$web_test_seconds")",
"buildStatus": "$(json_escape "$build_status")",
"buildExitCode": "$(json_escape "$build_exit_code")",
"buildSeconds": "$(json_escape "$build_seconds")",
"workspaceBuildExitCode": "$(json_escape "$workspace_build_exit_code")",
"workspaceBuildSeconds": "$(json_escape "$workspace_build_seconds")",
"browserStatus": "$(json_escape "$browser_status")",
"browserExitCode": "$(json_escape "$browser_exit_code")",
"browserSeconds": "$(json_escape "$browser_seconds")",
"playwrightInstallExitCode": "$(json_escape "$playwright_install_exit_code")",
"playwrightInstallSeconds": "$(json_escape "$playwright_install_seconds")",
"e2eVitestExitCode": "$(json_escape "$e2e_vitest_exit_code")",
"e2eVitestSeconds": "$(json_escape "$e2e_vitest_seconds")",
"playwrightCriticalExitCode": "$(json_escape "$playwright_critical_exit_code")",
"playwrightCriticalSeconds": "$(json_escape "$playwright_critical_seconds")",
"dockerVersion": "$(json_escape "$docker_version")",
"dockerStatus": "$(json_escape "$docker_status")",
"rootDisk": "$(json_escape "$disk_root")",
"workspaceDisk": "$(json_escape "$workspace_disk")"
}
JSON
echo "manifest: $manifest"
if [ "$install_exit_code" != "0" ]; then
exit "$install_exit_code"
fi
if [ "$corepack_prepare_exit_code" != "0" ]; then
exit "$corepack_prepare_exit_code"
fi
if [ "$policy_exit_code" != "0" ]; then
exit "$policy_exit_code"
fi
if [ "$unit_exit_code" != "0" ]; then
exit "$unit_exit_code"
fi
if [ "$typecheck_exit_code" != "0" ]; then
exit "$typecheck_exit_code"
fi
if [ "$daemon_exit_code" != "0" ]; then
exit "$daemon_exit_code"
fi
if [ "$web_exit_code" != "0" ]; then
exit "$web_exit_code"
fi
if [ "$build_exit_code" != "0" ]; then
exit "$build_exit_code"
fi
if [ "$browser_exit_code" != "0" ]; then
exit "$browser_exit_code"
fi

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
playwright_flags="${CI_GATE_PLAYWRIGHT_INSTALL_FLAGS:-chromium}"
read -r daemon_port web_port < <(
node --input-type=module -e '
import net from "node:net";
const listen = () =>
new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address == null || typeof address === "string") {
server.close(() => reject(new Error("expected TCP address")));
return;
}
server.close(() => resolve(address.port));
});
});
const ports = await Promise.all([listen(), listen()]);
console.log(ports.join(" "));
'
)
export OD_PORT="$daemon_port"
export OD_WEB_PORT="$web_port"
export OD_E2E_NAMESPACE="ci-browser-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
# shellcheck disable=SC2086
ci_gate_timed_step "playwright-install" pnpm -C e2e exec playwright install $playwright_flags
ci_gate_timed_step "daemon-build" pnpm --filter @open-design/daemon build
ci_gate_timed_step "desktop-build" pnpm --filter @open-design/desktop build
ci_gate_timed_step "web-build-sidecar" pnpm --filter @open-design/web build:sidecar
ci_gate_timed_step "e2e-vitest" pnpm --filter @open-design/e2e test
ci_gate_timed_step "playwright-clean" pnpm -C e2e exec tsx scripts/playwright.ts clean
ci_gate_timed_step "playwright-critical" pnpm -C e2e run test:ui:critical

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "daemon-build" pnpm --filter @open-design/daemon build
ci_gate_timed_step "desktop-build" pnpm --filter @open-design/desktop build
ci_gate_timed_step "web-build-sidecar" pnpm --filter @open-design/web build:sidecar
ci_gate_timed_step "workspace-build" pnpm -r --filter '!@open-design/landing-page' --workspace-concurrency=1 --if-present run build

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "daemon-build" pnpm --filter @open-design/daemon build
ci_gate_timed_step "daemon-test" pnpm --filter @open-design/daemon test

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "guard" pnpm guard

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "i18n-check" pnpm i18n:check

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "flake-check" nix flake check --print-build-logs --keep-going

View File

@@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "daemon-build" pnpm --filter @open-design/daemon build
ci_gate_timed_step "desktop-build" pnpm --filter @open-design/desktop build
ci_gate_timed_step "web-build-sidecar" pnpm --filter @open-design/web build:sidecar
ci_gate_timed_step "workspace-typecheck" pnpm -r --filter '!open-design' --filter '!@open-design/landing-page' --workspace-concurrency=4 --if-present run typecheck
ci_gate_timed_step "scripts-tsc" pnpm exec tsc -p scripts/tsconfig.json --noEmit

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "contracts-test" pnpm --filter @open-design/contracts test
ci_gate_timed_step "host-test" pnpm --filter @open-design/host test
ci_gate_timed_step "platform-test" pnpm --filter @open-design/platform test
ci_gate_timed_step "sidecar-test" pnpm --filter @open-design/sidecar test
ci_gate_timed_step "sidecar-proto-test" pnpm --filter @open-design/sidecar-proto test
ci_gate_timed_step "tools-dev-test" pnpm --filter @open-design/tools-dev test
ci_gate_timed_step "tools-pack-test" pnpm --filter @open-design/tools-pack test

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source "$(dirname "$0")/../lib.sh"
ci_gate_timed_step "web-build-sidecar" pnpm --filter @open-design/web build:sidecar
ci_gate_timed_step "web-test" pnpm --filter @open-design/web test

View File

@@ -1,378 +0,0 @@
import { execFile } from "node:child_process";
import { appendFile, mkdtemp, readdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
type Provider = "owned" | "github";
type ActionName =
| "nix"
| "guard"
| "i18n"
| "unit"
| "typecheck"
| "daemon"
| "web"
| "build"
| "browser";
type ActionKind = "real" | "placeholder";
type ActionStatus = "success" | "failure" | "not-run";
type StepStatus = "success" | "failure";
type ActionStepTiming = {
name: string;
durationMs: number;
status: StepStatus;
};
type ActionResult = {
action: ActionName;
kind: ActionKind;
status: ActionStatus;
steps?: ActionStepTiming[];
};
type WorkflowResult = {
schemaVersion: number;
provider: Provider;
mode: string;
eventName: string;
headSha: string;
runId: string;
runAttempt: string;
actions: ActionResult[];
};
type WorkflowRun = {
id: number;
name: string;
status: string;
conclusion: string | null;
head_sha: string;
event: string;
html_url: string;
created_at: string;
};
const ACTIONS: ActionName[] = [
"nix",
"guard",
"i18n",
"unit",
"typecheck",
"daemon",
"web",
"build",
"browser",
];
const token = process.env.GITHUB_TOKEN ?? "";
const repository = process.env.GITHUB_REPOSITORY ?? "";
let targetSha = process.env.TARGET_SHA ?? "";
let targetEvent = process.env.TARGET_EVENT ?? "";
const ownedWorkflow = process.env.OWNED_WORKFLOW ?? "ci-owned";
const githubWorkflow = process.env.GITHUB_HOSTED_WORKFLOW ?? "ci-github";
const ownedRunId = process.env.OWNED_RUN_ID ?? "";
const githubRunId = process.env.GITHUB_RUN_ID_OVERRIDE ?? "";
const timeoutSeconds = Number(process.env.POLL_TIMEOUT_SECONDS ?? "3600");
const pollIntervalSeconds = Number(process.env.POLL_INTERVAL_SECONDS ?? "20");
const summaryPath = process.env.GITHUB_STEP_SUMMARY ?? "";
const providerRunCreatedAfter = process.env.PROVIDER_RUN_CREATED_AFTER ?? "";
if (!token || !repository) {
throw new Error("GITHUB_TOKEN and GITHUB_REPOSITORY are required");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function github<T>(path: string): Promise<T> {
const response = await fetch(`https://api.github.com${path}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"User-Agent": "open-design-ci-gate",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`GitHub API ${path} failed: ${response.status} ${await response.text()}`);
}
return (await response.json()) as T;
}
function sortNewest(runs: WorkflowRun[]): WorkflowRun[] {
return [...runs].sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at));
}
async function fetchRunById(id: string): Promise<WorkflowRun> {
return await github<WorkflowRun>(`/repos/${repository}/actions/runs/${id}`);
}
async function findRunByWorkflowName(workflowName: string): Promise<WorkflowRun | null> {
const payload = await github<{ workflow_runs: WorkflowRun[] }>(
`/repos/${repository}/actions/runs?head_sha=${encodeURIComponent(targetSha)}&event=${encodeURIComponent(targetEvent)}&per_page=100`,
);
const createdAfterMs = providerRunCreatedAfter ? Date.parse(providerRunCreatedAfter) : Number.NaN;
const matches = sortNewest(payload.workflow_runs).filter((run) => {
if (run.name !== workflowName) return false;
if (Number.isNaN(createdAfterMs)) return true;
return Date.parse(run.created_at) >= createdAfterMs;
});
return matches[0] ?? null;
}
async function waitForRun(workflowName: string, explicitRunId: string): Promise<WorkflowRun> {
const deadline = Date.now() + timeoutSeconds * 1000;
while (true) {
const run = explicitRunId ? await fetchRunById(explicitRunId) : await findRunByWorkflowName(workflowName);
if (run != null) {
if (run.head_sha !== targetSha) {
throw new Error(`${workflowName} run ${run.id} head_sha ${run.head_sha} does not match target ${targetSha}`);
}
if (run.status === "completed") {
return run;
}
}
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for ${workflowName} to complete for ${targetSha}`);
}
await sleep(pollIntervalSeconds * 1000);
}
}
async function downloadResultArtifact(provider: Provider, runId: number): Promise<WorkflowResult> {
const dir = await mkdtemp(join(tmpdir(), `od-ci-${provider}-`));
try {
await execFileAsync(
"gh",
["run", "download", String(runId), "--repo", repository, "--name", `ci-results-${provider}`, "--dir", dir],
{
env: { ...process.env, GH_TOKEN: token },
},
);
const resultPath = await findResultFile(dir);
const raw = await readFile(resultPath, "utf8");
return parseWorkflowResult(JSON.parse(raw));
} catch (error) {
console.warn(`artifact download failed for ${provider} run ${runId}; falling back to structured log payload`);
return await downloadResultFromLog(runId);
}
}
async function findResultFile(root: string): Promise<string> {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const path = join(root, entry.name);
if (entry.isFile() && entry.name === "ci-results.json") {
return path;
}
if (entry.isDirectory()) {
try {
return await findResultFile(path);
} catch {
// Keep walking until a matching result file is found.
}
}
}
throw new Error(`ci-results.json not found under ${root}`);
}
async function downloadResultFromLog(runId: number): Promise<WorkflowResult> {
const { stdout } = await execFileAsync("gh", ["run", "view", String(runId), "--repo", repository, "--log"], {
env: { ...process.env, GH_TOKEN: token },
maxBuffer: 1024 * 1024 * 32,
});
const marker = "OD_CI_RESULTS_JSON ";
const payload = stdout
.split("\n")
.map((line) => {
const index = line.indexOf(marker);
return index >= 0 ? line.slice(index + marker.length).trim() : "";
})
.filter((line) => line.length > 0)
.at(-1);
if (!payload) {
throw new Error(`OD_CI_RESULTS_JSON marker not found in run ${runId} logs`);
}
const raw = Buffer.from(payload, "base64").toString("utf8");
return parseWorkflowResult(JSON.parse(raw));
}
function parseWorkflowResult(raw: unknown): WorkflowResult {
if (typeof raw !== "object" || raw == null) {
throw new Error("workflow result must be an object");
}
const data = raw as Record<string, unknown>;
if (data.schemaVersion !== 1) {
throw new Error(`unsupported schemaVersion: ${String(data.schemaVersion)}`);
}
if (data.provider !== "owned" && data.provider !== "github") {
throw new Error(`unsupported provider: ${String(data.provider)}`);
}
if (!Array.isArray(data.actions)) {
throw new Error("workflow result actions must be an array");
}
const actions = data.actions.map((entry) => {
if (typeof entry !== "object" || entry == null) {
throw new Error("workflow result action must be an object");
}
const action = entry as Record<string, unknown>;
const actionName = String(action.action);
const kind = String(action.kind);
const status = String(action.status);
if (!ACTIONS.includes(actionName as ActionName)) {
throw new Error(`unknown action: ${actionName}`);
}
if (kind !== "real" && kind !== "placeholder") {
throw new Error(`unknown action kind: ${kind}`);
}
if (status !== "success" && status !== "failure" && status !== "not-run") {
throw new Error(`unknown action status: ${status}`);
}
let steps: ActionStepTiming[] | undefined;
if (action.steps != null) {
if (!Array.isArray(action.steps)) {
throw new Error(`action ${actionName} steps must be an array`);
}
steps = action.steps.map((stepEntry) => {
if (typeof stepEntry !== "object" || stepEntry == null) {
throw new Error(`action ${actionName} step must be an object`);
}
const step = stepEntry as Record<string, unknown>;
const name = String(step.name ?? "");
const durationMs = Number(step.durationMs);
const stepStatus = String(step.status);
if (!name) {
throw new Error(`action ${actionName} step name is required`);
}
if (!Number.isFinite(durationMs) || durationMs < 0) {
throw new Error(`action ${actionName} step ${name} has invalid durationMs`);
}
if (stepStatus !== "success" && stepStatus !== "failure") {
throw new Error(`action ${actionName} step ${name} has invalid status ${stepStatus}`);
}
return {
name,
durationMs,
status: stepStatus as StepStatus,
};
});
}
return {
action: actionName as ActionName,
kind: kind as ActionKind,
status: status as ActionStatus,
steps,
};
});
return {
schemaVersion: 1,
provider: data.provider as Provider,
mode: String(data.mode ?? ""),
eventName: String(data.eventName ?? ""),
headSha: String(data.headSha ?? ""),
runId: String(data.runId ?? ""),
runAttempt: String(data.runAttempt ?? ""),
actions,
};
}
function validateIdentity(result: WorkflowResult, provider: Provider): void {
if (result.provider !== provider) {
throw new Error(`expected provider ${provider}, got ${result.provider}`);
}
if (result.headSha !== targetSha) {
throw new Error(`${provider} result headSha ${result.headSha} does not match target ${targetSha}`);
}
const explicitRunId = provider === "owned" ? ownedRunId : githubRunId;
const skipStrictEventMatch = targetEvent === "workflow_dispatch" && explicitRunId !== "";
if (!skipStrictEventMatch && result.eventName !== targetEvent && result.eventName !== "workflow_dispatch") {
throw new Error(`${provider} result event ${result.eventName} does not match target ${targetEvent}`);
}
const actionNames = new Set(result.actions.map((action) => action.action));
if (actionNames.size !== ACTIONS.length) {
throw new Error(`${provider} result does not contain exactly one entry for each action`);
}
}
function summarizeAction(action: ActionName, owned: WorkflowResult, github: WorkflowResult): {
passed: boolean;
reason: string;
} {
const candidates = [owned, github]
.flatMap((result) => result.actions.filter((entry) => entry.action === action).map((entry) => ({ ...entry, provider: result.provider })));
const realCandidates = candidates.filter((entry) => entry.kind === "real");
if (realCandidates.some((entry) => entry.status === "success")) {
const providers = realCandidates.filter((entry) => entry.status === "success").map((entry) => entry.provider).join(", ");
return { passed: true, reason: `success via ${providers}` };
}
if (realCandidates.length > 0) {
const providers = realCandidates.map((entry) => `${entry.provider}:${entry.status}`).join(", ");
return { passed: false, reason: `real results but no success (${providers})` };
}
return { passed: false, reason: "no real result available" };
}
async function appendSummary(lines: string[]): Promise<void> {
if (!summaryPath) return;
await appendFile(summaryPath, `${lines.join("\n")}\n`, "utf8");
}
async function main(): Promise<void> {
if (!targetSha || !targetEvent) {
const seedRunId = ownedRunId || githubRunId;
if (!seedRunId) {
throw new Error("TARGET_SHA and TARGET_EVENT are required unless an owned_run_id or github_run_id is provided");
}
const seedRun = await fetchRunById(seedRunId);
targetSha ||= seedRun.head_sha;
targetEvent ||= seedRun.event;
}
const ownedRun = await waitForRun(ownedWorkflow, ownedRunId);
const githubRun = await waitForRun(githubWorkflow, githubRunId);
const ownedResult = await downloadResultArtifact("owned", ownedRun.id);
const githubResult = await downloadResultArtifact("github", githubRun.id);
validateIdentity(ownedResult, "owned");
validateIdentity(githubResult, "github");
const failures: string[] = [];
const summaryLines = [
"## CI Gate",
"",
`Target SHA: \`${targetSha}\``,
`Target event: \`${targetEvent}\``,
`Owned run: [${ownedRun.id}](${ownedRun.html_url}) conclusion=\`${ownedRun.conclusion ?? "null"}\``,
`GitHub-hosted run: [${githubRun.id}](${githubRun.html_url}) conclusion=\`${githubRun.conclusion ?? "null"}\` mode=\`${githubResult.mode}\``,
"",
"| Action | Result | Reason |",
"| --- | --- | --- |",
];
for (const action of ACTIONS) {
const outcome = summarizeAction(action, ownedResult, githubResult);
summaryLines.push(`| \`${action}\` | ${outcome.passed ? "pass" : "fail"} | ${outcome.reason} |`);
if (!outcome.passed) {
failures.push(`${action}: ${outcome.reason}`);
}
}
await appendSummary(summaryLines);
for (const line of summaryLines) {
console.log(line);
}
if (failures.length > 0) {
throw new Error(`ci-gate failed\n${failures.join("\n")}`);
}
}
await main();

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ci_gate_now_ms() {
python3 -c 'import time; print(int(time.time() * 1000))'
}
ci_gate_append_step_timing() {
local step_name="$1"
local duration_ms="$2"
local step_status="$3"
local timings_path="${CI_GATE_ACTION_TIMINGS_PATH:-}"
if [ -z "$timings_path" ]; then
return 0
fi
jq -nc \
--arg name "$step_name" \
--argjson durationMs "$duration_ms" \
--arg status "$step_status" \
'{
name: $name,
durationMs: $durationMs,
status: $status
}' >> "$timings_path"
}
ci_gate_timed_step() {
local step_name="$1"
shift
local started_at
local finished_at
local duration_ms
local step_exit
local step_timeout_seconds="${CI_GATE_STEP_TIMEOUT_SECONDS:-600}"
started_at="$(ci_gate_now_ms)"
set +e
timeout "${step_timeout_seconds}s" "$@"
step_exit="$?"
set -e
finished_at="$(ci_gate_now_ms)"
duration_ms="$((finished_at - started_at))"
if [ "$step_exit" = "0" ]; then
ci_gate_append_step_timing "$step_name" "$duration_ms" "success"
else
ci_gate_append_step_timing "$step_name" "$duration_ms" "failure"
fi
return "$step_exit"
}

View File

@@ -1,237 +0,0 @@
name: ui-p0-pr
on:
schedule:
# Daily UI P0 sweep on main. Includes the runtime-recovery shard, which
# covers real-daemon golden paths without adding more work to every PR.
- cron: '30 18 * * *'
pull_request:
paths:
- apps/web/**
- apps/daemon/**
- packages/components/**
- packages/contracts/**
- packages/host/**
- packages/platform/**
- packages/sidecar/**
- packages/sidecar-proto/**
- e2e/ui/**
- e2e/lib/**
- e2e/resources/**
- e2e/scripts/**
- e2e/package.json
- e2e/playwright.config.ts
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .github/actions/setup-playwright/**
- .github/actions/setup-workspace/**
- .github/workflows/ci.yml
- .github/workflows/ui-extended-main.yml
- .github/workflows/ui-p0-pr.yml
workflow_dispatch:
permissions:
actions: read
contents: read
concurrency:
group: ui-p0-pr-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
ui_smoke:
name: UI P0 smoke
if: ${{ github.repository == 'nexu-io/open-design' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- 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
- 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/ui-p0-shards.ts smoke
- name: Upload Playwright debug artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: ui-p0-pr-${{ 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 }})
if: ${{ github.repository == 'nexu-io/open-design' }}
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- name: entry-onboarding
shard: entry-onboarding
- name: project-workspace
shard: project-workspace
- name: workspace-restoration
shard: workspace-restoration
- name: runtime-recovery
shard: runtime-recovery
- name: settings-connectors
shard: settings-connectors
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- 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
- 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/ui-p0-shards.ts ${{ matrix.shard }}
- name: Upload Playwright debug artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: ui-p0-pr-${{ 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
ui_p0_gate:
name: UI P0 PR gate
needs:
- ui_smoke
- ui_p0
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Summarize UI P0 jobs
if: ${{ always() }}
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
jobs_json="$RUNNER_TEMP/ui-p0-jobs.json"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/jobs?per_page=100" > "$jobs_json"
{
echo "## UI P0 PR summary"
echo
echo "| Job | Result | Duration | Slowest step |"
echo "| --- | --- | ---: | --- |"
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
| map(select(.name | startswith("UI P0")))
| sort_by(.name)
| .[]
| (
[(.steps // [])[] | select(.started_at and .completed_at and .conclusion != "skipped") | {
name,
duration: seconds(.started_at; .completed_at)
}]
| max_by(.duration // 0)
) as $slow
| row([
.name,
(.conclusion // .status),
fmt(seconds(.started_at; .completed_at)),
"\($slow.name // "n/a") (\(fmt($slow.duration)))"
])
' "$jobs_json"
echo
echo "### Failed UI P0 jobs"
echo
failed_rows="$(jq -r --arg run "$RUN_ID" '
def artifact_name:
if .name == "UI P0 smoke" then "ui-p0-pr-\($run)-smoke"
elif (.name | startswith("UI P0 (")) then
"ui-p0-pr-\($run)-\(.name | sub("^UI P0 \\("; "") | sub("\\)$"; ""))"
else "ui-p0-pr-\($run)-unknown"
end;
.jobs
| map(select((.name | startswith("UI P0")) and ((.conclusion // .status) != "success") and ((.conclusion // .status) != "skipped")))
| sort_by(.name)
| .[]
| "| [\(.name)](\(.html_url)) | \(.conclusion // .status) | `\(artifact_name)` |"
' "$jobs_json")"
if [ -n "$failed_rows" ]; then
echo "| Job | Result | Debug artifact |"
echo "| --- | --- | --- |"
echo "$failed_rows"
echo
echo "Use \`gh run view $RUN_ID --log-failed\` for failed-step logs; download the listed artifact for Playwright traces, screenshots, and HTML report."
else
echo "_No failed UI P0 jobs._"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Check UI P0 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 "UI P0 PR validation failed:"
echo "$failures"
exit 1
fi

View File

@@ -16,7 +16,7 @@ concurrency:
jobs:
baseline:
name: Capture visual baselines
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
@@ -25,12 +25,26 @@ jobs:
with:
fetch-depth: 0
- name: Prepare visual screenshot environment
uses: ./.github/actions/visual-screenshot
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- 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
- 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: Capture baseline screenshots
env:
OD_VISUAL_OUTPUT_DIR: ui/reports/visual-screenshots
OD_PLAYWRIGHT_WORKERS: "4"
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

View File

@@ -1,74 +0,0 @@
name: visual-pr-capture
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'apps/web/**'
- '.github/actions/visual-screenshot/**'
- '.github/workflows/visual-*.yml'
- 'e2e/package.json'
- 'e2e/playwright.visual.config.ts'
- 'e2e/lib/playwright/**'
- 'e2e/scripts/playwright.ts'
- 'e2e/scripts/visual-report.ts'
- 'e2e/ui/visual-*.test.ts'
- 'pnpm-lock.yaml'
permissions:
actions: read
contents: read
concurrency:
group: visual-pr-capture-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
capture:
name: Capture PR visual screenshots
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Prepare visual screenshot environment
uses: ./.github/actions/visual-screenshot
- name: Capture PR screenshots
id: capture
continue-on-error: true
env:
OD_VISUAL_OUTPUT_DIR: ui/reports/visual-screenshots
run: |
pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm -C e2e exec playwright test -c playwright.visual.config.ts
- name: Write capture manifest
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 }}",
"capture_outcome": "${{ steps.capture.outcome }}"
}
JSON
- name: Upload PR visual artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: visual-pr-capture-${{ github.event.pull_request.number }}-${{ github.run_id }}
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

View File

@@ -2,12 +2,12 @@ name: visual-pr-comment
on:
workflow_run:
workflows: [visual-pr-capture]
workflows: [ci]
types: [completed]
workflow_dispatch:
inputs:
capture_run_id:
description: GitHub Actions run id for the Visual PR capture workflow to comment on.
description: GitHub Actions run id for the CI workflow that uploaded visual PR capture artifacts.
required: true
type: string
pr_number:
@@ -30,7 +30,7 @@ jobs:
# Fork PR capture artifacts are untrusted. Always validate the live PR state
# and only execute trusted base-repository code before publishing comments
# or reports.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
@@ -54,15 +54,51 @@ jobs:
node-version: 24
package-manager-cache: false
- name: Download PR visual artifact
- name: Resolve PR visual artifacts
id: artifacts
shell: bash
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
run: |
set -euo pipefail
artifacts_json="$RUNNER_TEMP/artifacts.json"
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$WORKFLOW_RUN_ID/artifacts" > "$artifacts_json"
if [ -n "$WORKFLOW_PR_NUMBER" ]; then
name_prefix="visual-pr-capture-${WORKFLOW_PR_NUMBER}-${WORKFLOW_RUN_ID}-"
else
name_prefix="visual-pr-capture-"
fi
artifact_names="$(jq -r --arg prefix "$name_prefix" '.artifacts[]? | select(.expired == false and (.name | startswith($prefix))) | .name' "$artifacts_json")"
artifact_count="$(printf '%s\n' "$artifact_names" | sed '/^$/d' | wc -l | tr -d ' ')"
if [ "$artifact_count" = "0" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "No visual PR artifacts found for run $WORKFLOW_RUN_ID." >&2
exit 1
fi
echo "No visual PR artifacts found for run $WORKFLOW_RUN_ID; skipping visual comment."
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
{
echo "found=true"
echo "pattern=${name_prefix}*"
echo "count=$artifact_count"
} >> "$GITHUB_OUTPUT"
- name: Download PR visual artifacts
if: ${{ steps.artifacts.outputs.found == 'true' }}
uses: actions/download-artifact@v8
with:
pattern: ${{ steps.artifacts.outputs.pattern }}
run-id: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
path: ${{ runner.temp }}/visual-artifact
merge-multiple: true
path: ${{ runner.temp }}/visual-artifacts
merge-multiple: false
github-token: ${{ github.token }}
- name: Read capture manifest
if: ${{ steps.artifacts.outputs.found == 'true' }}
id: manifest
shell: bash
env:
@@ -74,20 +110,71 @@ jobs:
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
run: |
set -euo pipefail
artifact_dir="$RUNNER_TEMP/visual-artifact"
manifest="$artifact_dir/visual-report/manifest.json"
if [ ! -f "$manifest" ]; then
manifest="$artifact_dir/manifest.json"
artifacts_dir="$RUNNER_TEMP/visual-artifacts"
combined_dir="$RUNNER_TEMP/visual-combined"
screenshots_dir="$combined_dir/visual-screenshots"
mkdir -p "$screenshots_dir"
mapfile -t manifests < <(find "$artifacts_dir" -type f -path '*/visual-report/manifest.json' | sort)
if [ "${#manifests[@]}" -eq 0 ]; then
mapfile -t manifests < <(find "$artifacts_dir" -type f -name manifest.json | sort)
fi
if [ ! -f "$manifest" ]; then
echo "Capture manifest not found" >&2
find "$artifact_dir" -maxdepth 4 -type f >&2
if [ "${#manifests[@]}" -eq 0 ]; then
echo "Capture manifests not found" >&2
find "$artifacts_dir" -maxdepth 5 -type f >&2
exit 1
fi
manifest_pr_number="$(jq -r '.pr_number' "$manifest")"
base_sha="$(jq -r '.base_sha' "$manifest")"
run_id="$(jq -r '.run_id' "$manifest")"
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
manifest="${manifests[0]}"
manifest_pr_number=""
base_sha=""
run_id=""
manifest_head=""
capture_outcome="success"
for current_manifest in "${manifests[@]}"; do
current_pr_number="$(jq -r '.pr_number' "$current_manifest")"
current_base_sha="$(jq -r '.base_sha' "$current_manifest")"
current_run_id="$(jq -r '.run_id' "$current_manifest")"
current_head_sha="$(jq -r '.head_sha' "$current_manifest")"
current_outcome="$(jq -r '.capture_outcome // "success"' "$current_manifest")"
if [ -z "$manifest_pr_number" ]; then
manifest_pr_number="$current_pr_number"
base_sha="$current_base_sha"
run_id="$current_run_id"
manifest_head="$current_head_sha"
else
if [ "$current_pr_number" != "$manifest_pr_number" ]; then
echo "Mismatched visual manifest PR numbers: $current_pr_number vs $manifest_pr_number" >&2
exit 1
fi
if [ "$current_base_sha" != "$base_sha" ]; then
echo "Mismatched visual manifest base SHAs: $current_base_sha vs $base_sha" >&2
exit 1
fi
if [ "$current_run_id" != "$run_id" ]; then
echo "Mismatched visual manifest run IDs: $current_run_id vs $run_id" >&2
exit 1
fi
if [ "$current_head_sha" != "$manifest_head" ]; then
echo "Mismatched visual manifest head SHAs: $current_head_sha vs $manifest_head" >&2
exit 1
fi
fi
if ! [[ "$current_outcome" =~ ^(success|failure|cancelled|skipped)$ ]]; then
echo "Invalid manifest capture_outcome: $current_outcome" >&2
exit 1
fi
if [ "$current_outcome" != "success" ]; then
capture_outcome="failure"
fi
manifest_dir="$(dirname "$current_manifest")"
if [ "$(basename "$manifest_dir")" = "visual-report" ]; then
artifact_root="$(dirname "$manifest_dir")"
else
artifact_root="$manifest_dir"
fi
if [ -d "$artifact_root/visual-screenshots" ]; then
find "$artifact_root/visual-screenshots" -type f -name '*.png' -exec cp '{}' "$screenshots_dir/" \;
fi
done
if ! [[ "$manifest_pr_number" =~ ^[0-9]+$ ]]; then
echo "Invalid manifest pr_number: $manifest_pr_number" >&2
exit 1
@@ -100,10 +187,6 @@ jobs:
echo "Artifact run_id ($run_id) does not match workflow_run id ($WORKFLOW_RUN_ID)." >&2
exit 1
fi
if ! [[ "$capture_outcome" =~ ^(success|failure|cancelled|skipped)$ ]]; then
echo "Invalid manifest capture_outcome: $capture_outcome" >&2
exit 1
fi
source_head_sha="$WORKFLOW_HEAD_SHA"
source_head_repository="$WORKFLOW_HEAD_REPOSITORY"
source_head_branch="$WORKFLOW_HEAD_BRANCH"
@@ -119,7 +202,6 @@ jobs:
source_head_branch="$(jq -r '.head_branch // empty' <<< "$run_json")"
fi
fi
manifest_head="$(jq -r '.head_sha' "$manifest")"
if ! [[ "$manifest_head" =~ ^[0-9a-f]{40}$ ]]; then
echo "Invalid manifest head_sha: $manifest_head" >&2
exit 1
@@ -180,11 +262,13 @@ jobs:
echo "base_sha=$base_sha"
echo "run_id=$run_id"
echo "capture_outcome=$capture_outcome"
echo "screenshots_dir=$screenshots_dir"
echo "source_head_repository=$source_head_repository"
echo "source_head_branch=$source_head_branch"
} >> "$GITHUB_OUTPUT"
- name: Validate live PR state for trusted checkout
if: ${{ steps.artifacts.outputs.found == 'true' }}
id: trusted-pr
shell: bash
env:
@@ -243,7 +327,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Checkout trusted base revision
if: ${{ steps.trusted-pr.outputs.stale != 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.head_repository.full_name != github.repository) }}
if: ${{ steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.head_repository.full_name != github.repository) }}
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
@@ -251,24 +335,24 @@ jobs:
ref: ${{ steps.trusted-pr.outputs.base_sha }}
- name: Resolve pnpm store path
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
if: ${{ steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' }}
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
if: ${{ steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' }}
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
if: ${{ steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' }}
run: pnpm install --frozen-lockfile
- name: Stop stale visual runs
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
if: ${{ steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' }}
id: stale
env:
GH_TOKEN: ${{ github.token }}
@@ -305,7 +389,7 @@ jobs:
echo "stale=false" >> "$GITHUB_OUTPUT"
- name: Build visual diff report
if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
if: ${{ steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -324,12 +408,12 @@ jobs:
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
--screenshots "$RUNNER_TEMP/visual-artifact/visual-screenshots" \
--screenshots "${{ steps.manifest.outputs.screenshots_dir }}" \
--comment-out ui/reports/visual-report/comment.md \
--manifest-out ui/reports/visual-report/report-manifest.json
- name: Upsert PR comment
if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
if: ${{ steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
@@ -369,7 +453,7 @@ jobs:
fi
- name: Upload visual report artifact
if: ${{ always() && steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
if: ${{ always() && steps.artifacts.outputs.found == 'true' && steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
uses: actions/upload-artifact@v7
with:
name: visual-pr-report-${{ steps.manifest.outputs.pr_number }}-${{ steps.manifest.outputs.run_id }}

View File

@@ -1,66 +0,0 @@
name: visual-pr-verify
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
concurrency:
group: visual-pr-verify-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify:
name: Strict PR visual tests
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Detect visual-relevant changes
id: changes
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
changed_files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
if printf '%s\n' "$changed_files" | grep -Eq '^(apps/web/|\.github/actions/visual-screenshot/|\.github/workflows/visual-.*\.yml$|e2e/package\.json$|e2e/playwright\.visual\.config\.ts$|e2e/lib/playwright/|e2e/scripts/playwright\.ts$|e2e/scripts/visual-report\.ts$|e2e/ui/visual-.*\.test\.ts$|pnpm-lock\.yaml$)'; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "No visual-relevant file changes; skipping strict visual suite."
echo "should_run=false" >> "$GITHUB_OUTPUT"
- name: Prepare visual screenshot environment
if: ${{ steps.changes.outputs.should_run == 'true' }}
uses: ./.github/actions/visual-screenshot
- name: Run strict visual Playwright suite
if: ${{ steps.changes.outputs.should_run == 'true' }}
env:
OD_VISUAL_OUTPUT_DIR: ui/reports/visual-screenshots
run: |
pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm -C e2e exec playwright test -c playwright.visual.config.ts
- name: Upload visual debug artifact
if: ${{ always() && steps.changes.outputs.should_run == 'true' }}
uses: actions/upload-artifact@v7
with:
name: visual-pr-verify-${{ github.event.pull_request.number }}-${{ github.run_id }}
path: |
e2e/ui/reports/visual-screenshots
e2e/ui/reports/visual-results.json
if-no-files-found: ignore
retention-days: 7

View File

@@ -15,7 +15,7 @@ export default defineConfig({
outputDir: './ui/reports/visual-test-results',
timeout: Number(process.env.OD_PLAYWRIGHT_TIMEOUT) || 30_000,
retries: 0,
fullyParallel: false,
fullyParallel: process.env.OD_PLAYWRIGHT_FULLY_PARALLEL === '1',
workers: parseWorkerCount(process.env.OD_PLAYWRIGHT_WORKERS),
reporter: process.env.CI
? [['github'], ['list'], ['json', { outputFile: './ui/reports/visual-results.json' }]]

View File

@@ -5,7 +5,6 @@ import test from "node:test";
import {
hasPullApprovalStateDrift,
isAllowedChangedPath,
isAllowedVisualCaptureChangedPath,
isDeniedChangedPath,
isPendingApprovalRun,
listPendingApprovalRuns,
@@ -127,23 +126,6 @@ test("isPendingApprovalRun rejects runs outside the allowlist or without action_
false,
);
assert.equal(
isPendingApprovalRun(
{
id: 26273463771,
name: "Visual PR Capture",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-capture.yml@main",
pull_requests: [],
},
pull,
),
false,
);
assert.equal(
isPendingApprovalRun(
{
@@ -162,56 +144,6 @@ test("isPendingApprovalRun rejects runs outside the allowlist or without action_
);
});
test("isPendingApprovalRun approves visual capture only for strict web source changes", () => {
const pull = {
number: 2683,
state: "open",
changed_files: 1,
head: {
ref: "fix/button-copy",
sha: "734076155c44e569304856590019cea54506fdab",
repo: { full_name: "someone/open-design" },
},
base: {
ref: "main",
sha: "4cd93a5c7a7b0db1961c854e55f8e0e6b1b45542",
repo: { full_name: "nexu-io/open-design" },
},
};
const run = {
id: 26273463771,
name: "Visual PR Capture",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-capture.yml@main",
pull_requests: [],
};
assert.equal(
isPendingApprovalRun(run, pull, [
{ filename: "apps/web/src/components/Button.tsx", status: "modified" },
{ filename: "apps/web/src/styles/button.css", status: "modified" },
]),
true,
);
assert.equal(
isPendingApprovalRun(run, pull, [{ filename: "apps/web/public/logo.png", status: "modified" }]),
false,
);
assert.equal(
isPendingApprovalRun(run, pull, [
{
filename: "apps/web/src/components/Button.tsx",
previous_filename: "scripts/build.ts",
status: "renamed",
},
]),
false,
);
});
test("runTargetsPullRequest accepts empty run.pull_requests only when the head SHA maps to this one open PR", () => {
const pull = {
number: 2683,
@@ -604,7 +536,7 @@ test("listPendingApprovalRuns paginates all pull_request runs for the head SHA a
);
});
test("listPendingApprovalRuns applies strict changed-path filtering only to visual capture", async () => {
test("listPendingApprovalRuns only approves the unified CI workflow", async () => {
const pull = {
number: 2683,
state: "open",
@@ -633,18 +565,6 @@ test("listPendingApprovalRuns applies strict changed-path filtering only to visu
path: ".github/workflows/ci.yml@main",
pull_requests: [],
},
{
id: 26273463770,
name: "Visual PR Capture",
event: "pull_request",
head_branch: pull.head.ref,
head_repository: pull.head.repo,
status: "completed",
conclusion: "action_required",
head_sha: pull.head.sha,
path: ".github/workflows/visual-pr-capture.yml@main",
pull_requests: [],
},
];
const deps = {
loadWorkflowRunsResponsePage: async () => ({ workflow_runs: workflowRuns }),
@@ -662,7 +582,7 @@ test("listPendingApprovalRuns applies strict changed-path filtering only to visu
[{ filename: "apps/web/src/components/Button.tsx", status: "modified" }],
deps,
)).map((run) => run.id),
[26273463769, 26273463770],
[26273463769],
);
});
@@ -731,17 +651,6 @@ test("isAllowedChangedPath allows ordinary app and package source/test paths whi
assert.equal(isAllowedChangedPath("apps/packaged/vitest.config.ts"), false);
});
test("isAllowedVisualCaptureChangedPath is limited to web ts tsx and css source", () => {
assert.equal(isAllowedVisualCaptureChangedPath("apps/web/src/app/page.tsx"), true);
assert.equal(isAllowedVisualCaptureChangedPath("apps/web/src/lib/theme.ts"), true);
assert.equal(isAllowedVisualCaptureChangedPath("apps/web/src/components/Button.css"), true);
assert.equal(isAllowedVisualCaptureChangedPath("apps/web/src/assets/icon.svg"), false);
assert.equal(isAllowedVisualCaptureChangedPath("apps/web/public/logo.png"), false);
assert.equal(isAllowedVisualCaptureChangedPath("apps/web/package.json"), false);
assert.equal(isAllowedVisualCaptureChangedPath("apps/web/tests/Button.test.tsx"), false);
assert.equal(isAllowedVisualCaptureChangedPath("packages/contracts/src/api.ts"), false);
});
test("waitForPendingApprovalRuns retries until action_required runs appear and keeps polling through the retry window", async () => {
const run = {
id: 26273463769,
@@ -783,23 +692,23 @@ test("waitForPendingApprovalRuns keeps polling and returns the latest eligible r
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const visualRun = {
const laterCiRun = {
id: 26273463770,
name: "Visual PR Verify",
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-verify.yml@main",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const batches = [[ciRun], [ciRun], [ciRun, visualRun], [ciRun, visualRun]];
const batches = [[ciRun], [ciRun], [ciRun, laterCiRun], [ciRun, laterCiRun]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [ciRun, visualRun],
async () => batches.shift() ?? [ciRun, laterCiRun],
async (ms) => {
sleeps.push(ms);
now += ms;
@@ -808,7 +717,7 @@ test("waitForPendingApprovalRuns keeps polling and returns the latest eligible r
{ settlingWindowMs: 9_000 },
);
assert.deepEqual(pendingRuns, [ciRun, visualRun]);
assert.deepEqual(pendingRuns, [ciRun, laterCiRun]);
assert.deepEqual(sleeps, [3000, 3000, 3000, 3000, 3000]);
});
@@ -826,8 +735,8 @@ test("waitForPendingApprovalRuns drops runs that disappear in later polls", asyn
const survivingRun = {
...staleRun,
id: 26273463770,
name: "Visual PR Verify",
path: ".github/workflows/visual-pr-verify.yml@main",
name: "CI",
path: ".github/workflows/ci.yml@main",
};
const batches = [[staleRun], [staleRun, survivingRun], [survivingRun], [survivingRun]];
@@ -856,23 +765,23 @@ test("waitForPendingApprovalRuns keeps polling until the run set is stable, even
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const visualRun = {
const laterCiRun = {
id: 26273463770,
name: "Visual PR Verify",
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-verify.yml@main",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const batches = [[ciRun], [ciRun], [ciRun], [ciRun], [ciRun], [ciRun, visualRun], [ciRun, visualRun]];
const batches = [[ciRun], [ciRun], [ciRun], [ciRun], [ciRun], [ciRun, laterCiRun], [ciRun, laterCiRun]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [ciRun, visualRun],
async () => batches.shift() ?? [ciRun, laterCiRun],
async (ms) => {
sleeps.push(ms);
now += ms;
@@ -884,7 +793,7 @@ test("waitForPendingApprovalRuns keeps polling until the run set is stable, even
},
);
assert.deepEqual(pendingRuns, [ciRun, visualRun]);
assert.deepEqual(pendingRuns, [ciRun, laterCiRun]);
assert.equal(sleeps.length, 10);
});
@@ -899,23 +808,23 @@ test("waitForPendingApprovalRuns gives late first appearances their own full set
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const visualRun = {
const laterCiRun = {
id: 26273463770,
name: "Visual PR Verify",
name: "CI",
event: "pull_request",
status: "completed",
conclusion: "action_required",
head_sha: "734076155c44e569304856590019cea54506fdab",
path: ".github/workflows/visual-pr-verify.yml@main",
path: ".github/workflows/ci.yml@main",
pull_requests: [],
};
const batches = [[], [], [], [ciRun], [ciRun], [ciRun, visualRun], [ciRun, visualRun], [ciRun, visualRun]];
const batches = [[], [], [], [ciRun], [ciRun], [ciRun, laterCiRun], [ciRun, laterCiRun], [ciRun, laterCiRun]];
const sleeps: number[] = [];
let now = 0;
const pendingRuns = await waitForPendingApprovalRuns(
async () => batches.shift() ?? [ciRun, visualRun],
async () => batches.shift() ?? [ciRun, laterCiRun],
async (ms) => {
sleeps.push(ms);
now += ms;
@@ -927,7 +836,7 @@ test("waitForPendingApprovalRuns gives late first appearances their own full set
},
);
assert.deepEqual(pendingRuns, [ciRun, visualRun]);
assert.deepEqual(pendingRuns, [ciRun, laterCiRun]);
assert.deepEqual(sleeps, [3000, 3000, 3000, 3000, 3000, 3000, 3000, 3000]);
});

View File

@@ -74,12 +74,8 @@ function pendingRunSetSignature(runs: WorkflowRun[]): string {
// this set.
const allowedWorkflowPaths = new Set([
".github/workflows/ci.yml",
".github/workflows/visual-pr-capture.yml",
".github/workflows/visual-pr-verify.yml",
]);
const visualPrCaptureWorkflowPath = ".github/workflows/visual-pr-capture.yml";
export function normalizeWorkflowPath(path: string): string {
const suffixIndex = path.indexOf("@");
return suffixIndex >= 0 ? path.slice(0, suffixIndex) : path;
@@ -116,10 +112,6 @@ export function isAllowedChangedPath(path: string): boolean {
);
}
export function isAllowedVisualCaptureChangedPath(path: string): boolean {
return /^apps\/web\/src\/.+\.(?:css|ts|tsx)$/.test(path);
}
export function isDeniedChangedPath(path: string): boolean {
return (
path.startsWith(".github/") ||
@@ -155,23 +147,14 @@ function changedPathSet(file: PullRequestFile): string[] {
return [file.filename, file.previous_filename].filter((path): path is string => Boolean(path));
}
function allChangedPathsMatch(files: PullRequestFile[], predicate: (path: string) => boolean): boolean {
return files.every((file) => changedPathSet(file).every(predicate));
}
function workflowAllowsChangedFiles(workflowPath: string, files: PullRequestFile[] | undefined): boolean {
if (workflowPath !== visualPrCaptureWorkflowPath) return true;
return files != null && files.length > 0 && allChangedPathsMatch(files, isAllowedVisualCaptureChangedPath);
}
export function isPendingApprovalRun(run: WorkflowRun, pull: PullRequest, files?: PullRequestFile[]): boolean {
const workflowPath = normalizeWorkflowPath(run.path);
void files;
return (
run.head_sha === pull.head.sha &&
run.event === "pull_request" &&
(run.status === "action_required" || run.conclusion === "action_required") &&
allowedWorkflowPaths.has(workflowPath) &&
workflowAllowsChangedFiles(workflowPath, files)
allowedWorkflowPaths.has(workflowPath)
);
}

View File

@@ -2,93 +2,103 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
const uiP0WorkflowPath = ".github/workflows/ui-p0-pr.yml";
const ciWorkflowPath = ".github/workflows/ci.yml";
const ciChangeScopesPath = "scripts/ci-change-scopes.ts";
type NormalizedPathRule = {
kind: "exact" | "prefix";
value: string;
};
const uiP0ShardsPath = "e2e/scripts/ui-p0-shards.ts";
async function main(): Promise<void> {
const [uiP0Workflow, ciChangeScopes] = await Promise.all([
readFile(path.join(repoRoot, uiP0WorkflowPath), "utf8"),
const [ciWorkflow, ciChangeScopes, uiP0Shards] = await Promise.all([
readFile(path.join(repoRoot, ciWorkflowPath), "utf8"),
readFile(path.join(repoRoot, ciChangeScopesPath), "utf8"),
readFile(path.join(repoRoot, uiP0ShardsPath), "utf8"),
]);
const uiP0Rules = normalizeRules(extractUiP0WorkflowPaths(uiP0Workflow));
const ciRules = normalizeRules(extractCiUiP0Rules(ciChangeScopes));
const errors = [
...checkCiScopeOutput(ciWorkflow),
...checkUiP0Jobs(ciWorkflow),
...checkShardMatrix(ciWorkflow, uiP0Shards),
...checkScopeRules(ciChangeScopes),
];
const missingFromCi = difference(uiP0Rules, ciRules);
const missingFromWorkflow = difference(ciRules, uiP0Rules);
if (missingFromCi.length || missingFromWorkflow.length) {
console.error(`UI P0 PR path rules drifted between ${uiP0WorkflowPath} and ${ciChangeScopesPath}.`);
if (missingFromCi.length) {
console.error(`\nRules present in ${uiP0WorkflowPath} but missing from ${ciChangeScopesPath}:`);
for (const rule of missingFromCi) console.error(`- ${rule}`);
}
if (missingFromWorkflow.length) {
console.error(`\nRules present in ${ciChangeScopesPath} but missing from ${uiP0WorkflowPath}:`);
for (const rule of missingFromWorkflow) console.error(`- ${rule}`);
}
if (errors.length > 0) {
console.error("UI P0 CI wiring check failed.");
for (const error of errors) console.error(`- ${error}`);
process.exitCode = 1;
return;
}
console.log(`UI P0 PR path parity passed (${uiP0Rules.length} rules).`);
console.log("UI P0 CI wiring check passed.");
}
function extractUiP0WorkflowPaths(source: string): string[] {
const blockMatch = source.match(/pull_request:\n\s+paths:\n(?<block>(?:\s+- .+\n)+)/u);
const block = blockMatch?.groups?.block;
if (!block) {
throw new Error(`Unable to find pull_request.paths in ${uiP0WorkflowPath}.`);
}
return block
.split("\n")
.map((line) => line.trim().match(/^-\s+(.+)$/u)?.[1]?.trim())
.filter((value): value is string => Boolean(value));
function checkCiScopeOutput(source: string): string[] {
const required = [
"ui_p0_pr_required: ${{ steps.detect.outputs.ui_p0_pr_required }}",
"needs.change_scopes.outputs.ui_p0_pr_required == 'true'",
];
return required
.filter((needle) => !source.includes(needle))
.map((needle) => `${ciWorkflowPath} is missing ${needle}`);
}
function extractCiUiP0Rules(source: string): string[] {
const functionMatch = source.match(/function isUiP0RelevantFile\(file: string\): boolean \{(?<body>[\s\S]+?)\n\}/u);
const body = functionMatch?.groups?.body;
if (!body) {
throw new Error(`Unable to find isUiP0RelevantFile in ${ciChangeScopesPath}.`);
}
const prefixBlock = body.match(/startsWithAny\(file,\s+\[(?<block>[\s\S]+?)\]\)/u)?.groups?.block;
const exactBlock = body.match(/\]\)\s+\|\|\s+\[(?<block>[\s\S]+?)\]\.includes\(file\)/u)?.groups?.block;
if (!prefixBlock || !exactBlock) {
throw new Error(`Unable to find UI P0 prefix/exact rules in ${ciChangeScopesPath}.`);
}
const prefixes = extractQuotedStrings(prefixBlock).map((value) => `${value}*`);
const exact = extractQuotedStrings(exactBlock);
return [...prefixes, ...exact];
function checkUiP0Jobs(source: string): string[] {
const required = [
"ui_p0_smoke:",
"name: UI P0 smoke",
"ui_p0:",
"name: UI P0 (${{ matrix.name }})",
"pnpm -C e2e exec tsx scripts/ui-p0-shards.ts smoke",
"pnpm -C e2e exec tsx scripts/ui-p0-shards.ts ${{ matrix.shard }}",
];
return required
.filter((needle) => !source.includes(needle))
.map((needle) => `${ciWorkflowPath} is missing ${needle}`);
}
function extractQuotedStrings(source: string): string[] {
return [...source.matchAll(/"([^"]+)"/gu)].map((match) => match[1]).filter((value): value is string => Boolean(value));
function checkShardMatrix(ciWorkflow: string, uiP0Shards: string): string[] {
const matrixShards = extractCiMatrixShards(ciWorkflow);
const definedShards = extractUiP0ShardNames(uiP0Shards).filter((name) => !["smoke", "settings-smoke"].includes(name));
return [
...difference(definedShards, matrixShards).map((name) => `${ciWorkflowPath} matrix is missing UI P0 shard ${name}`),
...difference(matrixShards, definedShards).map((name) => `${ciWorkflowPath} matrix contains unknown UI P0 shard ${name}`),
];
}
function normalizeRules(paths: string[]): string[] {
return paths
.map(normalizeRule)
.map((rule) => `${rule.kind}:${rule.value}`)
function checkScopeRules(source: string): string[] {
const required = [
"function isUiP0RelevantFile(file: string): boolean",
'"apps/web/"',
'"apps/daemon/"',
'"e2e/ui/"',
'"e2e/lib/"',
'"e2e/scripts/"',
'".github/actions/setup-playwright/"',
'".github/actions/setup-workspace/"',
'".github/workflows/ci.yml"',
'".github/workflows/ui-extended-main.yml"',
];
return required
.filter((needle) => !source.includes(needle))
.map((needle) => `${ciChangeScopesPath} is missing ${needle}`);
}
function extractCiMatrixShards(source: string): string[] {
const jobMatch = source.match(/\n ui_p0:\n(?<body>[\s\S]+?)\n\n playwright_visual:/u);
const body = jobMatch?.groups?.body;
if (!body) throw new Error(`Unable to find ui_p0 job in ${ciWorkflowPath}.`);
return [...body.matchAll(/^\s+shard:\s+([a-z0-9-]+)\s*$/gmu)]
.map((match) => match[1])
.filter((name): name is string => Boolean(name))
.sort();
}
function normalizeRule(value: string): NormalizedPathRule {
if (value.endsWith("/**")) {
return { kind: "prefix", value: value.slice(0, -2) };
}
if (value.endsWith("*")) {
return { kind: "prefix", value: value.slice(0, -1) };
}
return { kind: "exact", value };
function extractUiP0ShardNames(source: string): string[] {
const objectMatch = source.match(/const shards: Record<string, Shard> = \{(?<body>[\s\S]+?)\n\};/u);
const body = objectMatch?.groups?.body;
if (!body) throw new Error(`Unable to find shards object in ${uiP0ShardsPath}.`);
return [...body.matchAll(/^\s{2}'?([a-z0-9-]+)'?:\s+\{/gmu)]
.map((match) => match[1])
.filter((name): name is string => Boolean(name))
.sort();
}
function difference(left: string[], right: string[]): string[] {

View File

@@ -8,6 +8,8 @@ type ScopeOutputs = {
tools_pack_tests_required: boolean;
nix_validation_required: boolean;
ui_p0_pr_required: boolean;
visual_validation_required: boolean;
docker_validation_required: boolean;
workspace_validation_required: boolean;
};
@@ -24,6 +26,8 @@ const outputs: ScopeOutputs = {
tools_pack_tests_required: false,
nix_validation_required: false,
ui_p0_pr_required: false,
visual_validation_required: false,
docker_validation_required: false,
workspace_validation_required: false,
};
@@ -51,6 +55,8 @@ if (eventName === "pull_request") {
// Main already runs .github/workflows/nix-check.yml, so keep this workflow's
// push path focused on the non-Nix workspace signal.
outputs.nix_validation_required = false;
// Main Docker publishing stays owned by .github/workflows/docker-image.yml.
outputs.docker_validation_required = false;
outputs.workspace_validation_required = true;
} else {
outputs.daemon_tests_required = true;
@@ -58,6 +64,11 @@ if (eventName === "pull_request") {
outputs.tools_dev_tests_required = true;
outputs.tools_pack_tests_required = true;
outputs.nix_validation_required = true;
if (eventName === "workflow_dispatch") {
outputs.ui_p0_pr_required = true;
}
outputs.visual_validation_required = true;
outputs.docker_validation_required = true;
outputs.workspace_validation_required = true;
}
@@ -142,6 +153,14 @@ function applyChangedFile(file: string, target: ScopeOutputs): void {
target.ui_p0_pr_required = true;
}
if (isVisualRelevantFile(file)) {
target.visual_validation_required = true;
}
if (isDockerRelevantFile(file)) {
target.docker_validation_required = true;
}
if (isNixRelevantFile(file)) {
target.nix_validation_required = true;
}
@@ -190,7 +209,56 @@ function isUiP0RelevantFile(file: string): boolean {
"pnpm-workspace.yaml",
".github/workflows/ci.yml",
".github/workflows/ui-extended-main.yml",
".github/workflows/ui-p0-pr.yml",
].includes(file)
);
}
function isVisualRelevantFile(file: string): boolean {
return (
startsWithAny(file, [
"apps/web/",
"e2e/lib/playwright/",
".github/actions/setup-playwright/",
".github/actions/setup-workspace/",
]) ||
/^e2e\/ui\/visual-[^/]+\.test\.ts$/.test(file) ||
[
"e2e/package.json",
"e2e/playwright.visual.config.ts",
"e2e/scripts/playwright.ts",
"e2e/scripts/visual-report.ts",
"pnpm-lock.yaml",
".github/workflows/ci.yml",
".github/workflows/visual-baseline.yml",
".github/workflows/visual-pr-comment.yml",
].includes(file)
);
}
function isDockerRelevantFile(file: string): boolean {
return (
startsWithAny(file, [
"deploy/",
"apps/daemon/",
"apps/web/",
"apps/packaged/",
"packages/",
"tools/",
"assets/",
"plugins/",
"skills/",
"design-systems/",
"design-templates/",
"craft/",
"prompt-templates/",
]) ||
[
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
".dockerignore",
".github/workflows/ci.yml",
".github/workflows/docker-image.yml",
].includes(file)
);
}
@@ -252,9 +320,8 @@ function isWorkspaceValidationExemptFile(file: string): boolean {
".github/workflows/blog-indexing-monitor.yml",
".github/workflows/blog-3day-report.yml",
".github/workflows/seo-daily-report.yml",
".github/workflows/actionlint.yml",
".github/workflows/visual-pr-capture.yml",
".github/workflows/visual-pr-comment.yml",
".github/workflows/docker-image.yml",
].includes(file)
);
}