name: ci on: pull_request: merge_group: # Release validation is owned by the release workflows rather than this CI # workflow: `release-stable` has a verify job before publishing, and # `release-beta` builds from its selected release commit. Keep this trigger # focused on PRs, main, and manual reruns instead of duplicating tag/release # events that would run after those release workflows have already selected # or validated their commit. push: branches: - main workflow_dispatch: permissions: actions: read contents: read pull-requests: read concurrency: group: ci-${{ 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. cancel-in-progress: true jobs: change_scopes: name: Detect CI change scopes runs-on: ubuntu-latest outputs: daemon_tests_required: ${{ steps.detect.outputs.daemon_tests_required }} web_tests_required: ${{ steps.detect.outputs.web_tests_required }} tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }} tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }} nix_validation_required: ${{ steps.detect.outputs.nix_validation_required }} workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }} steps: - name: Detect workspace and app test scopes id: detect env: GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail daemon_tests_required=false web_tests_required=false tools_dev_tests_required=false tools_pack_tests_required=false nix_validation_required=false workspace_validation_required=false if [ "${{ github.event_name }}" = "pull_request" ]; then gh api --paginate \ "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ --jq '.[].filename' > "$RUNNER_TEMP/changed-files.txt" while IFS= read -r file; do if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then daemon_tests_required=true fi if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then web_tests_required=true fi if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then daemon_tests_required=true web_tests_required=true fi if [[ "$file" == "tools/dev/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then tools_dev_tests_required=true fi if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then tools_pack_tests_required=true fi if [[ "$file" == "package.json" || "$file" == "apps/daemon/package.json" || "$file" == "apps/web/package.json" || "$file" == "apps/desktop/package.json" || "$file" == "apps/packaged/package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then daemon_tests_required=true web_tests_required=true tools_dev_tests_required=true tools_pack_tests_required=true fi # Keep this filter in sync with flake.nix daemonWorkspacePaths / webWorkspacePaths. if [[ "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" || "$file" == ".github/workflows/nix-hash-autofix.yml" || "$file" == "apps/daemon/"* || "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/registry-protocol/"* || "$file" == "packages/agui-adapter/"* || "$file" == "packages/plugin-runtime/"* || "$file" == "packages/sidecar-proto/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/platform/"* || "$file" == "packages/diagnostics/"* || "$file" == "packages/host/"* || "$file" == "assets/"* || "$file" == "plugins/"* || "$file" == "skills/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* || "$file" == "prompt-templates/"* || "$file" == "scripts/update-nix-pnpm-deps-hash.ts" ]]; then nix_validation_required=true fi case "$file" in *.md|*.mdx|*.txt|LICENSE|.gitignore|.editorconfig|.vscode/*|.idea/*|docs/*|.github/ISSUE_TEMPLATE/*|.github/CODEOWNERS) ;; apps/landing-page/*|flake.nix|flake.lock|nix/*|.github/workflows/nix-check.yml|.github/workflows/landing-page-ci.yml|.github/workflows/landing-page-deploy.yml|.github/workflows/blog-indexing-on-deploy.yml|.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) ;; *) workspace_validation_required=true ;; esac if [ "$daemon_tests_required" = "true" ] \ && [ "$web_tests_required" = "true" ] \ && [ "$tools_dev_tests_required" = "true" ] \ && [ "$tools_pack_tests_required" = "true" ] \ && [ "$nix_validation_required" = "true" ] \ && [ "$workspace_validation_required" = "true" ]; then break fi done < "$RUNNER_TEMP/changed-files.txt" if [ "$daemon_tests_required" = "true" ] \ || [ "$web_tests_required" = "true" ] \ || [ "$tools_dev_tests_required" = "true" ] \ || [ "$tools_pack_tests_required" = "true" ]; then workspace_validation_required=true fi elif [ "${{ github.event_name }}" = "push" ]; then daemon_tests_required=true web_tests_required=true tools_dev_tests_required=true tools_pack_tests_required=true # Main already runs .github/workflows/nix-check.yml, so keep this # workflow's push path focused on the non-Nix workspace signal. nix_validation_required=false workspace_validation_required=true else daemon_tests_required=true web_tests_required=true tools_dev_tests_required=true tools_pack_tests_required=true nix_validation_required=true workspace_validation_required=true fi { echo "daemon_tests_required=$daemon_tests_required" echo "web_tests_required=$web_tests_required" echo "tools_dev_tests_required=$tools_dev_tests_required" echo "tools_pack_tests_required=$tools_pack_tests_required" echo "nix_validation_required=$nix_validation_required" echo "workspace_validation_required=$workspace_validation_required" } >> "$GITHUB_OUTPUT" nix_validation: name: Validate Nix flake needs: [change_scopes] if: ${{ needs.change_scopes.outputs.nix_validation_required == 'true' }} 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. - same-repo PRs: the follow-up `nix-hash-autofix` workflow will try to push the hash-only patch back to the PR branch automatically; - fork PRs: a PR comment will include the patch and artifact guidance. 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 preflight: name: Preflight needs: [change_scopes] if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }} runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@v6.0.2 - name: Setup workspace uses: ./.github/actions/setup-workspace # `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that # are needed immediately after install for linked bins and shared # sidecar/platform imports. It intentionally skips app outputs because # building all apps would make every install run a Next/Electron-adjacent # app build, even when a developer only needs packages/tools. # # Fresh CI typecheck/test still need these specific generated declarations: # - `apps/daemon/dist/*.d.ts` for packaged/runtime consumers of the daemon # package export # - `apps/desktop/dist/main/index.d.ts` for `apps/packaged` imports of # `@open-design/desktop/main` # - `apps/web/dist/sidecar/index.d.ts` for `apps/packaged` imports of # `@open-design/web/sidecar` # If postinstall grows a targeted app type-generation phase covering these # three exports without broad app builds, this CI prebuild can be removed. - name: Prebuild workspace type declarations run: | pnpm --filter @open-design/daemon build pnpm --filter @open-design/desktop build pnpm --filter @open-design/web build:sidecar - name: Typecheck workspaces run: | pnpm -r --filter '!open-design' --filter '!@open-design/landing-page' --workspace-concurrency=4 --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: [change_scopes] if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6.0.2 - name: Setup workspace uses: ./.github/actions/setup-workspace - 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.change_scopes.outputs.tools_dev_tests_required }}" = "true" ]; then pnpm --filter @open-design/tools-dev test fi if [ "${{ needs.change_scopes.outputs.tools_pack_tests_required }}" = "true" ]; then pnpm --filter @open-design/tools-pack test fi daemon_workspace_tests: name: Daemon workspace tests needs: [change_scopes] if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6.0.2 - name: Setup workspace uses: ./.github/actions/setup-workspace - name: Prebuild daemon entrypoint declarations run: pnpm --filter @open-design/daemon build - name: Daemon workspace tests run: pnpm --filter @open-design/daemon test web_workspace_tests: name: Web workspace tests needs: [change_scopes] if: ${{ needs.change_scopes.outputs.web_tests_required == 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6.0.2 - name: Setup workspace uses: ./.github/actions/setup-workspace - name: Prebuild web sidecar declarations run: pnpm --filter @open-design/web build:sidecar - name: Web workspace tests run: pnpm --filter @open-design/web test browser_tests: name: Browser tests needs: - change_scopes if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }} 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: E2E vitest run: pnpm --filter @open-design/e2e test - name: Playwright critical run: | pnpm -C e2e exec tsx scripts/playwright.ts clean pnpm -C e2e exec playwright test -c playwright.config.ts ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts build_workspaces: name: Build workspaces needs: [change_scopes] if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }} 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: Prebuild workspace type declarations run: | pnpm --filter @open-design/daemon build pnpm --filter @open-design/desktop build pnpm --filter @open-design/web build:sidecar # Keep workspace builds serialized so generated dist output and local # runtime artifacts are produced in a deterministic order. Parallel # recursive builds would surface late-package failures sooner, but the # current workspace is small enough that safer logs and fewer shared-FS # races outweigh the lost parallelism; revisit if the package count grows. - name: Build workspaces run: pnpm -r --filter '!@open-design/landing-page' --workspace-concurrency=1 --if-present run build validate: name: Validate workspace needs: - change_scopes - preflight - nix_validation - workspace_unit_tests - daemon_workspace_tests - web_workspace_tests - browser_tests - build_workspaces if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check workspace validation jobs env: NEEDS_JSON: ${{ toJSON(needs) }} run: | set -euo pipefail echo "$NEEDS_JSON" | jq . failures="$(echo "$NEEDS_JSON" | jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "skipped") | "\(.key)=\(.value.result)"')" if [ -n "$failures" ]; then echo "Workspace validation failed:" echo "$failures" exit 1 fi runtime_trace: name: Runtime trace needs: - validate if: ${{ always() && github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 5 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"