name: Quality (Extended) # Additional quality gates layered on top of `ci.yaml`. These are the # eliza-derived checks that have no equivalent job in the canonical # eliza CI: the homepage build (so PRs that touch packages/homepage # don't first regress on `develop` push) and a workspace-level # typecheck/biome format gate. on: # PRs to `develop` use the lightweight lint/typecheck/build gate # (develop-pr.yml); these extended quality gates run on `main` PRs and # post-merge on push to both branches. pull_request: branches: [main] paths-ignore: - "docs/**" - "packages/docs/**" - "CHANGELOG.md" - "LICENSE" - ".github/ISSUE_TEMPLATE/**" - ".github/pull_request_template.md" push: branches: [main, develop] paths-ignore: - "docs/**" - "packages/docs/**" - "CHANGELOG.md" - "LICENSE" - ".github/ISSUE_TEMPLATE/**" - ".github/pull_request_template.md" workflow_dispatch: # CI fan-out fix: the PR-scoped concurrency from #14069 fell back to # github.run_id on push, so every develop push got a unique group and # nothing ever superseded. A 37-merge wave queued thousands of push runs # and starved the self-hosted fleet. Fall back to github.ref (all develop # pushes share a group) and cancel superseded runs on push too. PR behavior # is unchanged (pull_request.number still wins the key); merge_group and # schedule events are not in the cancel set, so the merge queue and nightly # runs always complete. concurrency: group: quality-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} env: BUN_VERSION: "canary" NODE_VERSION: "24.15.0" # Default to least privilege. Override per-job where needed. permissions: contents: read jobs: homepage-build: name: Homepage Build (PR smoke) runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 15 permissions: contents: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: submodules: false fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: ${{ env.NODE_VERSION }} - name: Setup workspace dependencies uses: ./.github/actions/setup-bun-workspace env: # The legacy fixture bundle contains stale visual baselines; homepage # tests must use the expectations committed with the checked-out SHA. ELIZA_SKIP_ARTIFACT_SYNC: "1" with: bun-version: ${{ env.BUN_VERSION }} install-command: bun install --ignore-scripts --no-frozen-lockfile install-native-deps: "false" skip-avatar-clone: "true" no-vision-deps: "true" - name: Check homepage smoke scope id: homepage-scope shell: bash run: | set -euo pipefail if [ "${{ github.event_name }}" != "pull_request" ]; then echo "run=true" >> "$GITHUB_OUTPUT" exit 0 fi git fetch --no-tags origin "${{ github.base_ref }}" if merge_base="$(git merge-base "origin/${{ github.base_ref }}" HEAD)"; then changed_files="$(git diff --name-only "${merge_base}" HEAD)" else echo "No merge base found with origin/${{ github.base_ref }}; falling back to direct base/head diff." >&2 changed_files="$(git diff --name-only "origin/${{ github.base_ref }}" HEAD)" fi if grep -Eq '^(packages/homepage/|packages/shared/|packages/app-core/scripts/write-homepage-release-data\.mjs|\.github/workflows/(deploy-homepage|release-electrobun|release-orchestrator)\.yml)' <<< "${changed_files}"; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" fi - name: Skip unrelated homepage smoke if: steps.homepage-scope.outputs.run != 'true' run: echo "Skipping homepage smoke; this PR does not touch homepage release surfaces." - name: Build homepage if: steps.homepage-scope.outputs.run == 'true' working-directory: packages/homepage run: bun run build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install homepage browser if: steps.homepage-scope.outputs.run == 'true' working-directory: packages/homepage run: ./node_modules/.bin/playwright install --with-deps chromium - name: Generate missing snapshot baselines if: steps.homepage-scope.outputs.run == 'true' id: gen-baselines working-directory: packages/homepage run: | SNAP_DIR="tests/e2e/visual.spec.ts-snapshots" # 5 routes × 2 viewports × 1 browser project = 10 expected snapshots EXPECTED=10 ACTUAL=$(ls "$SNAP_DIR"/*.png 2>/dev/null | wc -l || echo 0) if [ "$ACTUAL" -lt "$EXPECTED" ]; then echo "Found $ACTUAL of $EXPECTED snapshot baselines — regenerating all." rm -f "$SNAP_DIR"/*.png 2>/dev/null || true bun run test:e2e -- --update-snapshots || true echo "generated=true" >> "$GITHUB_OUTPUT" else echo "generated=false" >> "$GITHUB_OUTPUT" fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Commit new snapshot baselines if: steps.homepage-scope.outputs.run == 'true' && steps.gen-baselines.outputs.generated == 'true' continue-on-error: true run: | SNAP_DIR="packages/homepage/tests/e2e/visual.spec.ts-snapshots" if [ -n "$(git status --porcelain $SNAP_DIR)" ]; then git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add $SNAP_DIR git commit -m "chore: generate visual regression snapshot baselines [skip ci]" git push origin HEAD:${{ github.ref_name }} || (git pull --rebase origin ${{ github.ref_name }} && git push origin HEAD:${{ github.ref_name }}) echo "Pushed new snapshot baselines." fi - name: Test homepage downloads if: steps.homepage-scope.outputs.run == 'true' && steps.gen-baselines.outputs.generated == 'false' working-directory: packages/homepage run: bun run test:e2e env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} comment-only-diff: name: Comment-only diff guard runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 5 if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'comment-cleanup') steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: submodules: false fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: ${{ env.NODE_VERSION }} - name: Setup workspace dependencies uses: ./.github/actions/setup-bun-workspace with: bun-version: ${{ env.BUN_VERSION }} install-command: bun install --ignore-scripts --no-frozen-lockfile install-native-deps: "false" setup-python: "false" install-protoc: "false" run-postinstall: "false" - name: Run comment-only guard self-test run: node scripts/assert-comment-only-diff.mjs --self-test - name: Check comment-only diff run: bun run check:comment-only origin/${{ github.base_ref }} format-check: name: Format + Type Safety Ratchet runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: submodules: false - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: ${{ env.NODE_VERSION }} - name: Setup workspace dependencies uses: ./.github/actions/setup-bun-workspace with: bun-version: ${{ env.BUN_VERSION }} install-command: bun install --ignore-scripts --no-frozen-lockfile install-native-deps: "false" # Biome only needs JS dependencies; skipping Python/protoc keeps this # 5-minute format gate away from apt mirrors and Rust build tooling. setup-python: "false" install-protoc: "false" run-postinstall: "false" - name: Run CLAUDE/AGENTS guide parity self-test run: node --test scripts/assert-agents-claude-identical.test.mjs - name: Run CLAUDE/AGENTS guide parity run: bun run check:agents-claude - name: Run type-safety ratchet self-test run: bun run audit:type-safety-ratchet:self-test - name: Run type-safety ratchet run: bun run audit:type-safety-ratchet # The type-duplication finder is advisory (it never fails the build); only # its AST classifier is gated here so the candidate-report tooling stays # honest. The full scan + `--check` drift report is run on demand / # periodically, not on the hot path (#10195, #10201). - name: Run type-duplication finder self-test run: bun run audit:type-duplication:self-test - name: Run biome format check run: bun run format:check # Develop-gate extension (#10104): the canonical `ci.yaml` quality job — which # runs the prompt-secret scan, UI-determinism gate, and biome lint — fires only # on PRs to `main` (`ci.yaml` `pull_request: branches: [main]`), while the # default merge target is `develop`. So these checks never gated a develop PR. # The static job below re-runs the build-free pair on develop (and main) PRs, # mirroring the lean install used by `format-check`. develop-static-gate: name: Develop Gate (secret scan + UI determinism) runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 8 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: submodules: false # `audit:test-realness` compares touched tests against origin/develop. # Keep the merge base available so the diff-scoped ratchet runs in CI. fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: ${{ env.NODE_VERSION }} - name: Setup workspace dependencies uses: ./.github/actions/setup-bun-workspace with: bun-version: ${{ env.BUN_VERSION }} install-command: bun install --ignore-scripts --no-frozen-lockfile install-native-deps: "false" # Both gates are static AST/text scans over source — no Python/protoc, # Rust, or postinstall artifacts needed (same lean install as the # format gate above). setup-python: "false" install-protoc: "false" run-postinstall: "false" - name: Prompt secret scan run: cd packages/prompts && bun run check:secrets - name: UI determinism self-test run: bun run audit:ui-determinism:self-test - name: UI determinism gate run: bun run audit:ui-determinism # Develop PRs do not run the canonical `ci.yaml` main-branch quality job, # so keep the anti-larp/test-realness gates enforced here too. - name: Anti-larp test gate self-test run: bun run audit:focused-tests:self-test - name: Anti-larp test gate run: bun run audit:focused-tests - name: Test realness ratchet run: bun run audit:test-realness # `bun run lint` is intentionally separate from the static scans above: the # Turbo lint task depends on selected package builds, so it needs the normal # build-enabled workspace setup rather than the lean no-postinstall path. develop-lint-gate: name: Develop Gate (lint) runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 25 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: submodules: false # Full history so `turbo run lint --affected` can resolve the PR's # merge base (#12341); on a shallow clone turbo safely degrades to # linting the whole graph. fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: ${{ env.NODE_VERSION }} - name: Setup workspace dependencies uses: ./.github/actions/setup-bun-workspace with: bun-version: ${{ env.BUN_VERSION }} install-command: bun install install-native-deps: "false" install-protoc: "false" setup-python: "false" skip-avatar-clone: "true" no-vision-deps: "true" # PR lane: lint only the packages in the merge base's dependency cone; on # push/merge_group lint the whole graph (exhaustive + turbo cache seeding). # Turbo re-derives per-task hashes, so affected scoping never under-lints a # genuinely changed package (#12341). - name: Run lint (affected — PR) if: github.event_name == 'pull_request' run: node packages/scripts/run-turbo.mjs run lint --affected env: TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} - name: Run lint (full — push/merge_group) if: github.event_name != 'pull_request' run: bun run lint