mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-03 12:27:55 +08:00
[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:
55
.github/actions/visual-screenshot/action.yml
vendored
55
.github/actions/visual-screenshot/action.yml
vendored
@@ -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
|
||||
34
.github/workflows/actionlint.yml
vendored
34
.github/workflows/actionlint.yml
vendored
@@ -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
|
||||
137
.github/workflows/ci-gate.yml
vendored
137
.github/workflows/ci-gate.yml
vendored
@@ -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
|
||||
60
.github/workflows/ci-github.yml
vendored
60
.github/workflows/ci-github.yml
vendored
@@ -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
|
||||
147
.github/workflows/ci-nix.yml
vendored
147
.github/workflows/ci-nix.yml
vendored
@@ -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
|
||||
38
.github/workflows/ci-owned.yml
vendored
38
.github/workflows/ci-owned.yml
vendored
@@ -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
|
||||
243
.github/workflows/ci.yml
vendored
243
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/docker-image.yml
vendored
1
.github/workflows/docker-image.yml
vendored
@@ -26,7 +26,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*.*.*']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
||||
@@ -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]
|
||||
|
||||
216
.github/workflows/scripts/ci-gate.sh
vendored
216
.github/workflows/scripts/ci-gate.sh
vendored
@@ -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"
|
||||
868
.github/workflows/scripts/ci.sh
vendored
868
.github/workflows/scripts/ci.sh
vendored
@@ -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
|
||||
43
.github/workflows/scripts/ci/actions/browser.sh
vendored
43
.github/workflows/scripts/ci/actions/browser.sh
vendored
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
source "$(dirname "$0")/../lib.sh"
|
||||
|
||||
ci_gate_timed_step "guard" pnpm guard
|
||||
6
.github/workflows/scripts/ci/actions/i18n.sh
vendored
6
.github/workflows/scripts/ci/actions/i18n.sh
vendored
@@ -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
|
||||
6
.github/workflows/scripts/ci/actions/nix.sh
vendored
6
.github/workflows/scripts/ci/actions/nix.sh
vendored
@@ -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
|
||||
@@ -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
|
||||
12
.github/workflows/scripts/ci/actions/unit.sh
vendored
12
.github/workflows/scripts/ci/actions/unit.sh
vendored
@@ -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
|
||||
7
.github/workflows/scripts/ci/actions/web.sh
vendored
7
.github/workflows/scripts/ci/actions/web.sh
vendored
@@ -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
|
||||
378
.github/workflows/scripts/ci/aggregate-results.ts
vendored
378
.github/workflows/scripts/ci/aggregate-results.ts
vendored
@@ -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();
|
||||
54
.github/workflows/scripts/ci/lib.sh
vendored
54
.github/workflows/scripts/ci/lib.sh
vendored
@@ -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"
|
||||
}
|
||||
237
.github/workflows/ui-p0-pr.yml
vendored
237
.github/workflows/ui-p0-pr.yml
vendored
@@ -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
|
||||
20
.github/workflows/visual-baseline.yml
vendored
20
.github/workflows/visual-baseline.yml
vendored
@@ -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
|
||||
|
||||
74
.github/workflows/visual-pr-capture.yml
vendored
74
.github/workflows/visual-pr-capture.yml
vendored
@@ -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
|
||||
146
.github/workflows/visual-pr-comment.yml
vendored
146
.github/workflows/visual-pr-comment.yml
vendored
@@ -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 }}
|
||||
|
||||
66
.github/workflows/visual-pr-verify.yml
vendored
66
.github/workflows/visual-pr-verify.yml
vendored
@@ -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
|
||||
@@ -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' }]]
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user