mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
968 lines
42 KiB
YAML
968 lines
42 KiB
YAML
name: ci
|
|
|
|
on:
|
|
pull_request:
|
|
merge_group:
|
|
workflow_dispatch:
|
|
inputs:
|
|
ci_mode:
|
|
description: Validation strength for this manual run
|
|
type: choice
|
|
options:
|
|
- full
|
|
- hot
|
|
default: full
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
pull-requests: read
|
|
|
|
concurrency:
|
|
group: ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event.pull_request.number || github.ref }}
|
|
# Prefer current-head signal over preserving superseded logs: PR authors often
|
|
# push fixups while this workflow is still running, and stale runs can report
|
|
# failures for commits reviewers no longer need to evaluate. Release workflows
|
|
# use cancel-in-progress: false where preserving build evidence matters more.
|
|
# Manual comparison runs need isolated groups so queued/stuck experiments do
|
|
# not block the next measurement on this branch.
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
runners:
|
|
name: Resolve runner profiles
|
|
# Resolve on the Nexu ARC fleet for trusted branches, but preserve the
|
|
# hosted bootstrap path for external forks and explicit economic mode.
|
|
runs-on: >-
|
|
${{ ((github.event_name == 'pull_request'
|
|
&& github.event.pull_request.head.repo.full_name != github.repository)
|
|
|| vars.OD_CI_RUNNER_MODE == 'economic')
|
|
&& 'ubuntu-24.04'
|
|
|| 'nexu-runners-small' }}
|
|
outputs:
|
|
runs_on: ${{ steps.runners.outputs.runs_on }}
|
|
decision: ${{ steps.runners.outputs.decision }}
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Resolve runner profiles
|
|
id: runners
|
|
env:
|
|
# Fork PRs cannot rely on repository variables, so keep their required
|
|
# validation on GitHub-hosted runners even when private pools are down.
|
|
OD_CI_RUNNER_MODE: >-
|
|
${{ github.event_name == 'pull_request'
|
|
&& github.event.pull_request.head.repo.full_name != github.repository
|
|
&& 'economic'
|
|
|| vars.OD_CI_RUNNER_MODE }}
|
|
run: python3 .github/scripts/runners.py
|
|
|
|
scopes:
|
|
name: Detect validation scopes
|
|
needs: [runners]
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }}
|
|
outputs:
|
|
ci_mode: ${{ steps.detect.outputs.ci_mode }}
|
|
daemon_tests_required: ${{ steps.detect.outputs.daemon_tests_required }}
|
|
web_tests_required: ${{ steps.detect.outputs.web_tests_required }}
|
|
tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }}
|
|
tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }}
|
|
ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }}
|
|
visual_validation_required: ${{ steps.detect.outputs.visual_validation_required }}
|
|
workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }}
|
|
run_e2e_vitest: ${{ steps.detect.outputs.run_e2e_vitest }}
|
|
run_playwright_critical: ${{ steps.detect.outputs.run_playwright_critical }}
|
|
run_playwright_visual: ${{ steps.detect.outputs.run_playwright_visual }}
|
|
run_preflight: ${{ steps.detect.outputs.run_preflight }}
|
|
run_preflight_typecheck: ${{ steps.detect.outputs.run_preflight_typecheck }}
|
|
run_ui_p0: ${{ steps.detect.outputs.run_ui_p0 }}
|
|
run_web_workspace_tests: ${{ steps.detect.outputs.run_web_workspace_tests }}
|
|
run_windows_tools_pack_payload_tests: ${{ steps.detect.outputs.run_windows_tools_pack_payload_tests }}
|
|
run_workspace_unit_tests: ${{ steps.detect.outputs.run_workspace_unit_tests }}
|
|
ui_p0_matrix: ${{ steps.detect.outputs.ui_p0_matrix }}
|
|
visual_matrix: ${{ steps.detect.outputs.visual_matrix }}
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Setup Node
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
|
|
- name: Detect workspace and app test scopes
|
|
id: detect
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: node --experimental-strip-types scripts/scopes.ts github-output
|
|
|
|
- name: Create visual report handoff
|
|
id: visual_report_handoff
|
|
if: ${{ github.event_name == 'pull_request' && steps.detect.outputs.run_playwright_visual == 'true' }}
|
|
env:
|
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
handoff_id="visual-pr"
|
|
handoff_root="$RUNNER_TEMP/handoff-report-$handoff_id"
|
|
handoff_dir="$(python3 .github/scripts/handoff.py dir report "$handoff_id" --root "$handoff_root")"
|
|
mkdir -p "$handoff_dir"
|
|
jq -n \
|
|
--arg kind "report" \
|
|
--arg id "$handoff_id" \
|
|
--arg head "$HEAD_SHA" \
|
|
--arg base "$BASE_SHA" \
|
|
--arg report_type "visual-pr" \
|
|
--arg artifact_pattern "visual-pr-capture-$PR_NUMBER-$RUN_ID-*" \
|
|
--arg output_comment_id "visual-pr-report" \
|
|
--arg marker "<!-- visual-regression-bot -->" \
|
|
--argjson schema_version 1 \
|
|
--argjson pr_number "$PR_NUMBER" \
|
|
--argjson run_id "$RUN_ID" \
|
|
'{schema_version: $schema_version, kind: $kind, id: $id, pr_number: $pr_number, head_sha: $head, base_sha: $base, run_id: $run_id, report_type: $report_type, artifact_pattern: $artifact_pattern, output_comment_id: $output_comment_id, marker: $marker}' \
|
|
> "$handoff_dir/metadata.json"
|
|
python3 .github/scripts/handoff.py validate report "$handoff_dir" >/dev/null
|
|
{
|
|
echo "name=$(python3 .github/scripts/handoff.py artifact-name report "$handoff_id")"
|
|
echo "path=$handoff_root"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Upload visual report handoff
|
|
if: ${{ steps.visual_report_handoff.outputs.name != '' }}
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: ${{ steps.visual_report_handoff.outputs.name }}
|
|
path: ${{ steps.visual_report_handoff.outputs.path }}
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
static_gate:
|
|
name: Static gate
|
|
needs: [runners]
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }}
|
|
timeout-minutes: 10
|
|
env:
|
|
ACTIONLINT_VERSION: 1.7.12
|
|
SHELLCHECK_VERSION: 0.11.0
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Install actionlint and ShellCheck
|
|
run: |
|
|
case "$(uname -m)" in
|
|
x86_64)
|
|
actionlint_arch=amd64
|
|
shellcheck_arch=x86_64
|
|
;;
|
|
aarch64|arm64)
|
|
actionlint_arch=arm64
|
|
shellcheck_arch=aarch64
|
|
;;
|
|
*) echo "Unsupported actionlint architecture: $(uname -m)" >&2; exit 1 ;;
|
|
esac
|
|
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_${actionlint_arch}.tar.gz" \
|
|
| tar -xz actionlint
|
|
curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.${shellcheck_arch}.tar.gz" \
|
|
| tar -xz --strip-components=1 "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
|
|
sudo install -m 0755 actionlint /usr/local/bin/actionlint
|
|
sudo install -m 0755 shellcheck /usr/local/bin/shellcheck
|
|
|
|
- name: Check workflow files
|
|
run: actionlint -color
|
|
|
|
- name: Check changed tracked file sizes
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
MAX_CHANGED_FILE_BYTES: "1048576"
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
changed_files="$RUNNER_TEMP/blob-guard-changed-files.txt"
|
|
: > "$changed_files"
|
|
|
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
gh api --paginate \
|
|
"repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
|
|
--jq '.[].filename' > "$changed_files"
|
|
elif [ "${{ github.event_name }}" = "merge_group" ]; then
|
|
gh api --paginate \
|
|
"repos/${{ github.repository }}/compare/${{ github.event.merge_group.base_sha }}...${{ github.event.merge_group.head_sha }}" \
|
|
--jq '(.files // [])[] | select(.status != "removed") | .filename' > "$changed_files"
|
|
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
gh api --paginate \
|
|
"repos/${{ github.repository }}/compare/main...${{ github.sha }}" \
|
|
--jq '(.files // [])[] | select(.status != "removed") | .filename' > "$changed_files"
|
|
else
|
|
git ls-files > "$changed_files"
|
|
fi
|
|
|
|
oversized="$RUNNER_TEMP/blob-guard-oversized.txt"
|
|
: > "$oversized"
|
|
|
|
while IFS= read -r file; do
|
|
[ -n "$file" ] || continue
|
|
[ -f "$file" ] || continue
|
|
if ! git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then
|
|
continue
|
|
fi
|
|
size="$(wc -c < "$file" | tr -d '[:space:]')"
|
|
if [ "$size" -gt "$MAX_CHANGED_FILE_BYTES" ]; then
|
|
printf '%s\t%s\n' "$size" "$file" >> "$oversized"
|
|
fi
|
|
done < "$changed_files"
|
|
|
|
if [ -s "$oversized" ]; then
|
|
{
|
|
echo "Changed tracked files exceed the current blob guard threshold (${MAX_CHANGED_FILE_BYTES} bytes):"
|
|
while IFS=$'\t' read -r size file; do
|
|
echo "- ${size} bytes ${file}"
|
|
done < "$oversized"
|
|
echo
|
|
echo "Move large generated/media assets to repository-assets R2 or add a narrow, reviewed exception for source-owned runtime binaries."
|
|
} >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "Changed-file blob guard passed at ${MAX_CHANGED_FILE_BYTES} bytes."
|
|
|
|
preflight:
|
|
name: Preflight
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_preflight == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).general_medium }}
|
|
timeout-minutes: 45
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).general_medium) }}
|
|
|
|
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
|
|
# are needed immediately after install for linked bins and shared
|
|
# sidecar/platform imports. It intentionally skips app outputs because
|
|
# building all apps would make every install run a Next/Electron-adjacent
|
|
# app build, even when a developer only needs packages/tools.
|
|
#
|
|
# Fresh CI typecheck/test still need these specific generated declarations:
|
|
# - `apps/daemon/dist/*.d.ts` for packaged/runtime consumers of the daemon
|
|
# package export
|
|
# - `apps/desktop/dist/main/index.d.ts` for `apps/packaged` imports of
|
|
# `@open-design/desktop/main`
|
|
# - `apps/web/dist/sidecar/index.d.ts` for `apps/packaged` imports of
|
|
# `@open-design/web/sidecar`
|
|
# If postinstall grows a targeted app type-generation phase covering these
|
|
# three exports without broad app builds, this CI prebuild can be removed.
|
|
- name: Prebuild workspace type declarations
|
|
if: ${{ needs.scopes.outputs.run_preflight_typecheck == 'true' }}
|
|
run: |
|
|
pnpm --filter @open-design/daemon build
|
|
pnpm --filter @open-design/desktop build
|
|
pnpm --filter @open-design/web build:sidecar
|
|
|
|
- name: Typecheck workspaces
|
|
if: ${{ needs.scopes.outputs.run_preflight_typecheck == 'true' }}
|
|
run: |
|
|
pnpm -r --filter '!open-design' --filter '!@open-design/landing-page' --workspace-concurrency="${OPEN_DESIGN_WORKSPACE_CONCURRENCY:-1}" --if-present run typecheck
|
|
pnpm exec tsc -p scripts/tsconfig.json --noEmit
|
|
|
|
- name: Check repository layout policies
|
|
run: pnpm guard
|
|
|
|
- name: Check i18n structure
|
|
run: pnpm i18n:check
|
|
|
|
workspace_unit_tests:
|
|
name: Workspace unit tests
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_workspace_unit_tests == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).workspace_unit }}
|
|
timeout-minutes: 20
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).workspace_unit) }}
|
|
|
|
- name: Workspace unit tests
|
|
run: |
|
|
pnpm --filter @open-design/contracts test
|
|
pnpm --filter @open-design/host test
|
|
pnpm --filter @open-design/platform test
|
|
pnpm --filter @open-design/sidecar test
|
|
pnpm --filter @open-design/sidecar-proto test
|
|
if [ "${{ needs.scopes.outputs.tools_dev_tests_required }}" = "true" ]; then
|
|
pnpm --filter @open-design/tools-dev test
|
|
fi
|
|
if [ "${{ needs.scopes.outputs.tools_pack_tests_required }}" = "true" ]; then
|
|
pnpm --filter @open-design/desktop build
|
|
pnpm --filter @open-design/desktop test
|
|
pnpm --filter @open-design/packaged test
|
|
pnpm --filter @open-design/tools-pack test
|
|
if [ "${{ needs.scopes.outputs.run_e2e_vitest }}" != "true" ]; then
|
|
pnpm --filter @open-design/e2e test tests/packaged-launcher-update-loop.test.ts
|
|
fi
|
|
fi
|
|
|
|
|
|
daemon_unit_tests:
|
|
name: Daemon tests (${{ matrix.shard }}/4)
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.daemon_tests_required == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).workspace_unit }}
|
|
timeout-minutes: 15
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4]
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).workspace_unit) }}
|
|
|
|
- name: Run daemon test shard
|
|
env:
|
|
OD_WATCHER_TEST_DEBUG: "1"
|
|
run: pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4
|
|
|
|
windows_tools_pack_payload_tests:
|
|
name: Windows tools-pack payload tests
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_windows_tools_pack_payload_tests == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).windows_tools }}
|
|
timeout-minutes: 20
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).windows_tools) }}
|
|
|
|
- name: Windows launcher payload archive tests
|
|
run: pnpm --filter @open-design/tools-pack exec vitest run tests/launcher-payload.test.ts
|
|
|
|
web_workspace_tests:
|
|
name: Web workspace tests (${{ matrix.shard }}/2)
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_web_workspace_tests == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).js_hot }}
|
|
timeout-minutes: 20
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2]
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).js_hot) }}
|
|
|
|
- name: Prebuild web sidecar declarations
|
|
run: pnpm --filter @open-design/web build:sidecar
|
|
|
|
- name: Web workspace tests
|
|
run: pnpm --filter @open-design/web exec vitest run -c vitest.config.ts --maxWorkers=2 --shard=${{ matrix.shard }}/2
|
|
|
|
e2e_vitest:
|
|
name: E2E Vitest
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_e2e_vitest == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).js_hot }}
|
|
timeout-minutes: 20
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).js_hot) }}
|
|
|
|
- name: Setup Playwright
|
|
uses: ./.github/actions/setup-playwright
|
|
with:
|
|
package-json-path: e2e/package.json
|
|
install-command: pnpm -C e2e exec playwright install --with-deps chromium
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).js_hot) }}
|
|
|
|
- name: Prebuild workspace type declarations
|
|
run: |
|
|
pnpm --filter @open-design/daemon build
|
|
pnpm --filter @open-design/desktop build
|
|
pnpm --filter @open-design/web build:sidecar
|
|
|
|
- name: E2E Vitest
|
|
run: pnpm --filter @open-design/e2e test
|
|
|
|
playwright_critical:
|
|
name: Playwright critical (${{ matrix.group }})
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_playwright_critical == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).ui_hot }}
|
|
timeout-minutes: 30
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- group: a
|
|
files: ui/amr-onboarding.test.ts ui/app.test.ts ui/app-design-files.test.ts ui/app-manual-edit.test.ts ui/api-empty-response.test.ts
|
|
- group: b
|
|
files: ui/app-restoration.test.ts ui/entry-chrome-flows.test.ts ui/project-management-flows.test.ts ui/settings-api-protocol.test.ts ui/settings-local-cli-codex-fallback.test.ts ui/critical-smoke.test.ts ui/workspace-keyboard-flows.test.ts
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).ui_hot) }}
|
|
|
|
- name: Setup Playwright
|
|
uses: ./.github/actions/setup-playwright
|
|
with:
|
|
package-json-path: e2e/package.json
|
|
install-command: pnpm -C e2e exec playwright install --with-deps chromium
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).ui_hot) }}
|
|
|
|
- name: Prebuild workspace type declarations
|
|
run: |
|
|
pnpm --filter @open-design/daemon build
|
|
pnpm --filter @open-design/desktop build
|
|
pnpm --filter @open-design/web build:sidecar
|
|
|
|
- name: Clean Playwright state
|
|
run: pnpm -C e2e exec tsx scripts/playwright.ts clean
|
|
|
|
- name: Playwright critical
|
|
env:
|
|
OD_PLAYWRIGHT_FULLY_PARALLEL: "1"
|
|
run: pnpm -C e2e exec playwright test -c playwright.config.ts ${{ matrix.files }} --grep '@critical'
|
|
|
|
ui_p0:
|
|
name: UI P0 (${{ matrix.name }})
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_ui_p0 == 'true' }}
|
|
runs-on: ${{ matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0 }}
|
|
timeout-minutes: 45
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0) }}
|
|
|
|
- name: Setup Playwright
|
|
uses: ./.github/actions/setup-playwright
|
|
with:
|
|
package-json-path: e2e/package.json
|
|
install-command: pnpm -C e2e exec playwright install --with-deps chromium
|
|
runner-labels: ${{ toJSON(matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0) }}
|
|
|
|
- name: Prebuild workspace type declarations
|
|
run: |
|
|
pnpm --filter @open-design/daemon build
|
|
pnpm --filter @open-design/desktop build
|
|
pnpm --filter @open-design/web build:sidecar
|
|
|
|
- name: Clean Playwright state
|
|
run: pnpm -C e2e exec tsx scripts/playwright.ts clean
|
|
|
|
- name: Run UI P0 domain
|
|
run: pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group ${{ matrix.shard }}
|
|
|
|
- name: Preserve project-runtime domain artifact
|
|
if: ${{ success() && matrix.shard == 'project-runtime' }}
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: ui-p0-ci-${{ github.run_id }}-${{ matrix.name }}-domain
|
|
path: |
|
|
e2e/ui/reports/playwright-html-report
|
|
e2e/ui/reports/test-results
|
|
e2e/ui/reports/results.json
|
|
e2e/ui/test-results
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|
|
|
|
- name: Run UI critical extras
|
|
if: ${{ matrix.shard == 'project-runtime' }}
|
|
run: pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group critical-extras
|
|
|
|
- name: Upload Playwright debug artifact
|
|
if: ${{ always() }}
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: ui-p0-ci-${{ github.run_id }}-${{ matrix.name }}
|
|
path: |
|
|
e2e/ui/reports/playwright-html-report
|
|
e2e/ui/reports/test-results
|
|
e2e/ui/reports/results.json
|
|
e2e/ui/test-results
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|
|
|
|
playwright_visual:
|
|
name: Playwright visual (${{ matrix.name }})
|
|
needs: [scopes, runners]
|
|
if: ${{ needs.scopes.outputs.run_playwright_visual == 'true' }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).visual_hot }}
|
|
timeout-minutes: 30
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include: ${{ fromJSON(needs.scopes.outputs.visual_matrix) }}
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
|
|
- name: Configure CI parallelism
|
|
uses: ./.github/actions/configure-ci-parallelism
|
|
|
|
- name: Setup workspace
|
|
uses: ./.github/actions/setup-workspace
|
|
with:
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).visual_hot) }}
|
|
|
|
- name: Setup Playwright
|
|
uses: ./.github/actions/setup-playwright
|
|
with:
|
|
package-json-path: e2e/package.json
|
|
install-command: pnpm -C e2e exec playwright install --with-deps chromium
|
|
runner-labels: ${{ toJSON(fromJSON(needs.runners.outputs.runs_on).visual_hot) }}
|
|
|
|
- name: Prebuild workspace type declarations
|
|
run: |
|
|
pnpm --filter @open-design/daemon build
|
|
pnpm --filter @open-design/desktop build
|
|
pnpm --filter @open-design/web build:sidecar
|
|
|
|
- name: Run strict visual Playwright suite
|
|
id: visual
|
|
continue-on-error: true
|
|
env:
|
|
OD_VISUAL_OUTPUT_DIR: ui/reports/visual-screenshots
|
|
OD_PLAYWRIGHT_FULLY_PARALLEL: "1"
|
|
run: |
|
|
pnpm -C e2e exec tsx scripts/playwright.ts clean
|
|
pnpm -C e2e exec playwright test -c playwright.visual.config.ts ${{ matrix.files }}
|
|
|
|
- name: Write capture manifest
|
|
if: ${{ always() && github.event_name == 'pull_request' }}
|
|
run: |
|
|
mkdir -p e2e/ui/reports/visual-report
|
|
cat > e2e/ui/reports/visual-report/manifest.json <<'JSON'
|
|
{
|
|
"pr_number": "${{ github.event.pull_request.number }}",
|
|
"head_sha": "${{ github.event.pull_request.head.sha }}",
|
|
"base_sha": "${{ github.event.pull_request.base.sha }}",
|
|
"run_id": "${{ github.run_id }}",
|
|
"group": "${{ matrix.name }}",
|
|
"capture_outcome": "${{ steps.visual.outcome }}"
|
|
}
|
|
JSON
|
|
|
|
- name: Upload PR visual artifact
|
|
id: upload_visual_artifact
|
|
if: ${{ always() && github.event_name == 'pull_request' }}
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: visual-pr-capture-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ matrix.name }}
|
|
path: |
|
|
e2e/ui/reports/visual-screenshots
|
|
e2e/ui/reports/visual-test-results
|
|
e2e/ui/reports/visual-results.json
|
|
e2e/ui/reports/visual-report/manifest.json
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|
|
|
|
- name: Upload manual visual artifact
|
|
if: ${{ always() && github.event_name == 'workflow_dispatch' }}
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: visual-ci-${{ github.run_id }}-${{ matrix.name }}
|
|
path: |
|
|
e2e/ui/reports/visual-screenshots
|
|
e2e/ui/reports/visual-test-results
|
|
e2e/ui/reports/visual-results.json
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|
|
|
|
- name: Fail when strict visual tests fail
|
|
if: ${{ steps.visual.outcome != 'success' }}
|
|
run: exit 1
|
|
|
|
validate:
|
|
name: Validate workspace
|
|
needs:
|
|
- scopes
|
|
- runners
|
|
- static_gate
|
|
- preflight
|
|
- workspace_unit_tests
|
|
- daemon_unit_tests
|
|
- windows_tools_pack_payload_tests
|
|
- web_workspace_tests
|
|
- e2e_vitest
|
|
- playwright_critical
|
|
- ui_p0
|
|
- playwright_visual
|
|
if: ${{ always() }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }}
|
|
timeout-minutes: 5
|
|
|
|
steps:
|
|
# The merge-blocking label gate below produces a `handoff/comment` artifact when it ejects a
|
|
# queued PR, and that production goes through `.github/scripts/handoff.py` (the only
|
|
# sanctioned source of handoff names and layout). Only the merge_group context can eject,
|
|
# so pull_request runs skip the checkout entirely.
|
|
- name: Checkout handoff helper
|
|
if: ${{ github.event_name == 'merge_group' }}
|
|
uses: actions/checkout@v6.0.2
|
|
with:
|
|
sparse-checkout: .github
|
|
sparse-checkout-cone-mode: true
|
|
|
|
- name: Check workspace validation jobs
|
|
env:
|
|
NEEDS_JSON: ${{ toJSON(needs) }}
|
|
run: |
|
|
set -euo pipefail
|
|
echo "$NEEDS_JSON" | jq .
|
|
failures="$(echo "$NEEDS_JSON" | jq -r '
|
|
to_entries[]
|
|
| select(.value.result != "success" and .value.result != "skipped")
|
|
| "\(.key)=\(.value.result)"')"
|
|
if [ -n "$failures" ]; then
|
|
echo "Workspace validation failed:"
|
|
echo "$failures"
|
|
exit 1
|
|
fi
|
|
|
|
required_misses="$(echo "$NEEDS_JSON" | jq -r '
|
|
. as $needs |
|
|
($needs.scopes.outputs // {}) as $out |
|
|
def when($condition; $jobs): if $condition then $jobs else [] end;
|
|
(
|
|
["scopes", "static_gate"]
|
|
+ when($out.run_preflight == "true"; ["preflight"])
|
|
+ when($out.run_workspace_unit_tests == "true"; ["workspace_unit_tests"])
|
|
+ when($out.daemon_tests_required == "true"; ["daemon_unit_tests"])
|
|
+ when($out.run_windows_tools_pack_payload_tests == "true"; ["windows_tools_pack_payload_tests"])
|
|
+ when($out.run_web_workspace_tests == "true"; ["web_workspace_tests"])
|
|
+ when($out.run_e2e_vitest == "true"; ["e2e_vitest"])
|
|
+ when($out.run_playwright_critical == "true"; ["playwright_critical"])
|
|
+ when($out.run_ui_p0 == "true"; ["ui_p0"])
|
|
+ when($out.run_playwright_visual == "true"; ["playwright_visual"])
|
|
)[]
|
|
| select(($needs[.].result // "missing") != "success")
|
|
| "\(.)=\($needs[.].result // "missing")"
|
|
')"
|
|
if [ -n "$required_misses" ]; then
|
|
echo "Required validation jobs did not succeed:"
|
|
echo "$required_misses"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Block merge while a merge-blocking label is present
|
|
id: merge_blocking_label_gate
|
|
# Hard gate: a PR that still carries `needs-validation` or `needs-maintainer-check` must
|
|
# not merge. Enforced ONLY in the merge_group (merge-queue) context — never on
|
|
# pull_request. main requires the merge queue, so every merge passes through merge_group;
|
|
# a labeled entry fails this step there and is ejected from the queue, so it can never land.
|
|
#
|
|
# Why not also fail on pull_request: that turns the PR's own required `Validate workspace`
|
|
# check red, and a deliberately-red required check is indistinguishable from a real CI
|
|
# failure to everything downstream — it flips the PR's mergeStateStatus to BLOCKED and
|
|
# reads as ci_status=failure to the review bot, which deadlocks the very QA/validation that
|
|
# is supposed to clear the label (QA never gets requested → label never removed → blocked
|
|
# forever). The merge_group run executes on the queue's transient ref, so this failure does
|
|
# NOT appear in the PR head's status rollup; the PR stays green until the label is cleared.
|
|
# Fails closed: any label-lookup error blocks rather than silently waving the merge through.
|
|
# `skip-validation` continues to override only the `needs-validation` producer; it has no
|
|
# effect on the independent maintainer-check policy.
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
REPO: ${{ github.repository }}
|
|
# Resolving the queued PR via commits/<sha>/pulls alone is unreliable: the merge queue
|
|
# squashes, so the queued commits are NEW synthetic SHAs GitHub does not associate back
|
|
# to the PR — that found nothing and waved a needs-validation PR through (#4736). So we
|
|
# resolve each merge-group commit to a PR from several signals, per commit (see below).
|
|
MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }}
|
|
BASE_SHA: ${{ github.event.merge_group.base_sha }}
|
|
HEAD_SHA: ${{ github.event.merge_group.head_sha }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ "$EVENT_NAME" != "merge_group" ]; then
|
|
echo "Merge-blocking labels are gated in the merge_group context; nothing to enforce on $EVENT_NAME (PR check stays green)."
|
|
else
|
|
# An ejection is invisible from the PR: this failure runs on the queue's transient
|
|
# ref, the PR's own checks stay green, and `mergeQueueEntry` just goes null. Leave a
|
|
# visible trace by producing a `handoff/comment` artifact for the labeled PR; the
|
|
# trusted `comment.atom.yml` consumer upserts it as a PR comment after this run
|
|
# completes. Best-effort: a handoff failure must never soften the block itself.
|
|
emit_ejection_notice() {
|
|
local pr="$1"
|
|
local blocking_label="$2"
|
|
local pr_json pr_head pr_base handoff_id handoff_root handoff_dir marker next_step
|
|
pr_json="$(gh api "repos/$REPO/pulls/$pr")"
|
|
pr_head="$(jq -r '.head.sha' <<< "$pr_json")"
|
|
pr_base="$(jq -r '.base.sha' <<< "$pr_json")"
|
|
case "$blocking_label" in
|
|
needs-validation)
|
|
handoff_id="needs-validation-pr-$pr"
|
|
marker="<!-- merge-queue-needs-validation -->"
|
|
# Markdown code spans are intentional literal text.
|
|
# shellcheck disable=SC2016
|
|
next_step='To land this PR: complete the QA pass the label is tracking, remove the `needs-validation` label, then add the PR back to the merge queue.'
|
|
;;
|
|
needs-maintainer-check)
|
|
handoff_id="needs-maintainer-check-pr-$pr"
|
|
marker="<!-- merge-queue-needs-maintainer-check -->"
|
|
# Markdown code spans are intentional literal text.
|
|
# shellcheck disable=SC2016
|
|
next_step='To land this PR: have a maintainer complete the check, remove the `needs-maintainer-check` label, then add the PR back to the merge queue.'
|
|
;;
|
|
*)
|
|
echo "Unsupported merge-blocking label: $blocking_label" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
handoff_root="$RUNNER_TEMP/handoff-comment-$handoff_id"
|
|
handoff_dir="$(python3 .github/scripts/handoff.py dir comment "$handoff_id" --root "$handoff_root")"
|
|
mkdir -p "$handoff_dir"
|
|
# Markdown code spans are intentionally literal in these single-quoted strings.
|
|
# shellcheck disable=SC2016
|
|
{
|
|
printf '%s\n' "$marker"
|
|
printf 'Ejected from the merge queue: this PR still carries the `%s` label.\n\n' "$blocking_label"
|
|
printf 'The merge queue gate ([run %s](%s/%s/actions/runs/%s)) blocked the queued group because of the label. That failure runs on the queue'"'"'s transient ref, so it never appears in this PR'"'"'s own checks — they stay green, and this notice is the only visible trace on the PR.\n\n' "$RUN_ID" "$GITHUB_SERVER_URL" "$REPO" "$RUN_ID"
|
|
printf '%s\n' "$next_step"
|
|
} > "$handoff_dir/body.md"
|
|
jq -n \
|
|
--arg kind "comment" \
|
|
--arg id "$handoff_id" \
|
|
--arg head "$pr_head" \
|
|
--arg base "$pr_base" \
|
|
--arg marker "$marker" \
|
|
--argjson schema_version 1 \
|
|
--argjson pr_number "$pr" \
|
|
--argjson run_id "$RUN_ID" \
|
|
'{schema_version: $schema_version, kind: $kind, id: $id, pr_number: $pr_number, head_sha: $head, base_sha: $base, run_id: $run_id, marker: $marker}' \
|
|
> "$handoff_dir/metadata.json"
|
|
python3 .github/scripts/handoff.py validate comment "$handoff_dir" >/dev/null
|
|
{
|
|
echo "comment_created=true"
|
|
echo "comment_name=$(python3 .github/scripts/handoff.py artifact-name comment "$handoff_id")"
|
|
echo "comment_path=$handoff_root"
|
|
} >> "$GITHUB_OUTPUT"
|
|
}
|
|
# Resolve EVERY merge-group commit to at least one real PR and check its labels.
|
|
# Each queued entry can be recovered from up to three signals:
|
|
# 1. ref `.../pr-<N>-<sha>` — names the (tip) entry only; squash-safe.
|
|
# 2. the commit subject's trailing `(#N)` — the queue appends each entry's own PR
|
|
# number, so this recovers the NON-tip PRs of a batched group.
|
|
# 3. commits/<sha>/pulls — covers a non-squash queue where commits map back directly.
|
|
# A `(#N)` can also be a spurious reference — e.g. #4833, titled "... (#4828)", where
|
|
# 4828 is the fixed ISSUE, not a PR; `gh pr view 4828` then fails with "Could not
|
|
# resolve to a PullRequest". We must NOT fail closed on that (it ejected a clean,
|
|
# label-free PR from the queue forever) — but we must also NOT blindly skip it, or a
|
|
# batched non-tip PR whose only recovered number is such a reference would go unchecked
|
|
# and a needs-validation entry could slip through (#4736). So we resolve PER COMMIT:
|
|
# ignore a non-PR candidate only when its commit is still covered by another candidate
|
|
# that DOES resolve to a real PR; a commit that resolves to no PR fails closed.
|
|
compare_json="$(gh api "repos/$REPO/compare/$BASE_SHA...$HEAD_SHA")"
|
|
ref_prs="$(printf '%s\n' "$MERGE_GROUP_REF" | grep -oE 'pr-[0-9]+' | grep -oE '[0-9]+' || true)"
|
|
commit_shas="$(printf '%s' "$compare_json" | jq -r '.commits[].sha')"
|
|
if [ -z "$commit_shas" ]; then
|
|
echo "::error::merge_group compare $BASE_SHA...$HEAD_SHA returned no commits — cannot resolve queued PRs; blocking merge (fail closed)."
|
|
exit 1
|
|
fi
|
|
checked=""
|
|
for sha in $commit_shas; do
|
|
subject="$(printf '%s' "$compare_json" | jq -r --arg s "$sha" '.commits[] | select(.sha==$s) | .commit.message | split("\n")[0]')"
|
|
subj_prs="$(printf '%s' "$subject" | grep -oE '\(#[0-9]+\)' | grep -oE '[0-9]+' || true)"
|
|
# Required assignment (no `|| true`): a transient API failure must block, not
|
|
# silently shrink this commit's candidate set.
|
|
assoc_prs="$(gh api "repos/$REPO/commits/$sha/pulls" --jq '.[].number')"
|
|
ref_for_commit=""
|
|
[ "$sha" = "$HEAD_SHA" ] && ref_for_commit="$ref_prs"
|
|
candidates="$(printf '%s\n%s\n%s\n' "$subj_prs" "$assoc_prs" "$ref_for_commit" | grep -E '^[0-9]+$' | sort -u || true)"
|
|
covered=0
|
|
for pr in $candidates; do
|
|
if ! labels="$(gh pr view "$pr" --repo "$REPO" --json labels --jq '.labels[].name' 2>/tmp/pr_view_err)"; then
|
|
if grep -q 'Could not resolve to a PullRequest' /tmp/pr_view_err; then
|
|
echo "commit $sha: candidate #$pr is not a pull request (e.g. an issue reference) — ignoring this candidate."
|
|
continue
|
|
fi
|
|
echo "::error::label lookup for #$pr (commit $sha) failed — blocking merge (fail closed):"
|
|
cat /tmp/pr_view_err
|
|
exit 1
|
|
fi
|
|
covered=1
|
|
case " $checked " in *" $pr "*) ;; *) checked="$checked $pr";; esac
|
|
blocking_label=""
|
|
if printf '%s\n' "$labels" | grep -qx 'needs-maintainer-check'; then
|
|
blocking_label="needs-maintainer-check"
|
|
elif printf '%s\n' "$labels" | grep -qx 'needs-validation'; then
|
|
blocking_label="needs-validation"
|
|
fi
|
|
if [ -n "$blocking_label" ]; then
|
|
echo "::error::PR #$pr still has '$blocking_label' — blocking merge."
|
|
# Fail-fast keeps this per-run notice on the FIRST labeled PR found; every
|
|
# labeled entry also fails its own queue run, so each still gets its own notice.
|
|
emit_ejection_notice "$pr" "$blocking_label" || echo "::warning::could not produce the ejection-notice handoff for PR #$pr; the block itself still stands."
|
|
exit 1
|
|
fi
|
|
echo "PR #$pr: no merge-blocking label."
|
|
done
|
|
if [ "$covered" -eq 0 ]; then
|
|
echo "::error::merge-group commit $sha resolved to no pull request (candidates: ${candidates:-none}) — cannot verify merge-blocking labels; blocking merge (fail closed)."
|
|
exit 1
|
|
fi
|
|
done
|
|
echo "No merge-blocking label in the queued group ($checked ) — clear to merge."
|
|
fi
|
|
|
|
# `failure()` is required: the gate exits 1 on the very path that produces the handoff,
|
|
# and the default `success()` condition would skip this upload exactly when it matters.
|
|
- name: Upload merge-queue ejection notice handoff
|
|
if: ${{ failure() && steps.merge_blocking_label_gate.outputs.comment_created == 'true' }}
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: ${{ steps.merge_blocking_label_gate.outputs.comment_name }}
|
|
path: ${{ steps.merge_blocking_label_gate.outputs.comment_path }}
|
|
|
|
runtime_summary:
|
|
name: Runtime summary
|
|
needs:
|
|
- runners
|
|
- validate
|
|
if: ${{ always() }}
|
|
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }}
|
|
timeout-minutes: 5
|
|
continue-on-error: true
|
|
|
|
steps:
|
|
- name: Summarize workflow runtime
|
|
continue-on-error: true
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
run_json="$RUNNER_TEMP/run.json"
|
|
gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion,createdAt,databaseId,displayTitle,event,headBranch,jobs,updatedAt,url > "$run_json"
|
|
jq -r '
|
|
def parse_ts: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
|
|
def seconds($start; $end):
|
|
if ($start and $end) then (($end | parse_ts) - ($start | parse_ts)) else null end;
|
|
def fmt($seconds):
|
|
if $seconds == null then "n/a"
|
|
elif $seconds >= 60 then "\(((($seconds / 60) * 10 | round) / 10))m"
|
|
else "\(($seconds | round))s"
|
|
end;
|
|
def row($cells): "| \($cells | join(" | ")) |";
|
|
|
|
.jobs as $jobs |
|
|
[
|
|
"## Runtime trace",
|
|
"",
|
|
"Run: [\(.displayTitle)](\(.url))",
|
|
"Event: `\(.event)`",
|
|
"Branch: `\(.headBranch)`",
|
|
"Elapsed: \(fmt(seconds(.createdAt; .updatedAt)))",
|
|
"",
|
|
"### Jobs",
|
|
"| Job | Result | Duration | Slowest step |",
|
|
"| --- | --- | ---: | --- |",
|
|
(
|
|
$jobs
|
|
| sort_by(seconds(.startedAt; .completedAt) // 0)
|
|
| reverse
|
|
| .[]
|
|
| select(.conclusion != "skipped")
|
|
| (
|
|
[(.steps // [])[] | select(.startedAt and .completedAt and .conclusion != "skipped") | {name, duration: seconds(.startedAt; .completedAt)}]
|
|
| max_by(.duration // 0)
|
|
) as $slow
|
|
| row([.name, (.conclusion // .status), fmt(seconds(.startedAt; .completedAt)), "\($slow.name // "n/a") (\(fmt($slow.duration)))"])
|
|
),
|
|
"",
|
|
"### Slowest steps",
|
|
"| Step | Job | Duration |",
|
|
"| --- | --- | ---: |",
|
|
(
|
|
[
|
|
$jobs[] as $job
|
|
| ($job.steps // [])[]
|
|
| select(.startedAt and .completedAt and .conclusion != "skipped")
|
|
| {job: $job.name, name, duration: seconds(.startedAt; .completedAt)}
|
|
]
|
|
| sort_by(.duration // 0)
|
|
| reverse
|
|
| .[0:20][]
|
|
| row([.name, .job, fmt(.duration)])
|
|
)
|
|
][]
|
|
' "$run_json" >> "$GITHUB_STEP_SUMMARY"
|