| name: CI |
| |
| on: |
| pull_request: |
| branches: [main] |
| push: |
| branches: [main] |
| |
| concurrency: |
| group: ci-${{ github.workflow }}-${{ github.ref }} |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} |
| |
| jobs: |
| changes: |
| runs-on: ubuntu-latest |
| outputs: |
| code: ${{ steps.plan.outputs.code }} |
| e2e: ${{ steps.plan.outputs.e2e }} |
| headless: ${{ steps.plan.outputs.headless }} |
| runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }} |
| script_mode: ${{ steps.plan.outputs.script_mode }} |
| storage_stress: ${{ steps.plan.outputs.storage_stress }} |
| unit: ${{ steps.plan.outputs.unit }} |
| workspaces: ${{ steps.plan.outputs.workspaces }} |
| steps: |
| - uses: actions/checkout@v4 |
| with: |
| fetch-depth: 0 |
| - id: plan |
| name: Select affected test surfaces |
| env: |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| EVENT_NAME: ${{ github.event_name }} |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| run: | |
| if [[ "$EVENT_NAME" == "push" ]]; then |
| node scripts/ci-test-plan.mjs --full >> "$GITHUB_OUTPUT" |
| else |
| node scripts/ci-test-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT" |
| fi |
| |
| typecheck: |
| needs: changes |
| if: needs.changes.outputs.code == 'true' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@v4 |
| - uses: actions/setup-node@v4 |
| with: |
| node-version: '24' |
| cache: npm |
| - run: npm ci |
| - run: npm run lint |
| - run: npm run format:check |
| - run: npm run build |
| - run: npm run typecheck |
| # Dead-code / dependency governance. Entry points and reasoned ignores |
| # live in knip.json; both workspaces must stay at zero findings. |
| - name: Knip (apps/desktop) |
| run: npx knip --workspace apps/desktop |
| - name: Knip (packages/ui) |
| run: npx knip --workspace packages/ui |
| # Dead-CSS governance. Lived only under `check:release`, so a regression |
| # stayed invisible until a release was cut; it was red on main when this |
| # step was added. Same class of source-scanning guard as knip above. |
| - name: Dead CSS |
| run: node scripts/check-dead-css.mjs --check |
| # Storybook fidelity: every Product/* story must name the real user path |
| # to the state it renders. Presence is mechanical; truth stays with the |
| # reviewer. See apps/desktop/stories/FIDELITY.md. |
| - name: Story annotations |
| run: node scripts/check-story-annotations.mjs |
| |
| test: |
| needs: changes |
| if: needs.changes.outputs.unit == 'true' || needs.changes.outputs.script_mode != 'none' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@v4 |
| - uses: actions/setup-node@v4 |
| with: |
| node-version: '24' |
| cache: npm |
| - uses: astral-sh/setup-uv@v6 |
| if: needs.changes.outputs.headless == 'true' |
| - name: Install pinned Harbor contract runtime |
| if: needs.changes.outputs.headless == 'true' |
| run: | |
| uv tool install "harbor==0.13.2" |
| uv tool dir --bin >> "$GITHUB_PATH" |
| - name: Install Linux runtime dependencies |
| if: needs.changes.outputs.headless == 'true' || needs.changes.outputs.runtime_sandbox == 'true' |
| run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap |
| # Ubuntu 24.04 hosted runners gate unprivileged user namespaces through |
| # AppArmor, which otherwise makes bwrap fail while configuring loopback. |
| - name: Enable bubblewrap user namespaces |
| if: needs.changes.outputs.runtime_sandbox == 'true' |
| run: | |
| if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then |
| sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 |
| fi |
| if [[ -e /proc/sys/kernel/unprivileged_userns_clone ]]; then |
| sudo sysctl -w kernel.unprivileged_userns_clone=1 |
| fi |
| - run: npm ci |
| # build:test skips the renderer bundle (vite); test:dist only consumes |
| # tsc outputs (dist/main + dist/renderer side-files), never the vite |
| # bundle. e2e builds its own renderer in a separate job. |
| - run: npm run build:test |
| if: needs.changes.outputs.unit == 'true' || needs.changes.outputs.script_mode != 'none' |
| - name: Linux sandbox smoke |
| if: needs.changes.outputs.runtime_sandbox == 'true' |
| env: |
| MAKA_REQUIRE_LINUX_SANDBOX_SMOKE: '1' |
| run: npm exec -w @maka/runtime -- node --test dist/__tests__/linux-sandbox-smoke.test.js |
| - name: Run fast script tests |
| if: needs.changes.outputs.script_mode == 'fast' || needs.changes.outputs.script_mode == 'full' |
| run: npm run test:scripts |
| - name: Run extended script tests |
| if: needs.changes.outputs.script_mode == 'extended' || needs.changes.outputs.script_mode == 'full' |
| run: npm run test:scripts:extended |
| # Workspaces consume the dist built above. Selection includes reverse |
| # dependencies, while bounded concurrency avoids both the old serial |
| # critical path and an unbounded process stampede on two-core runners. |
| - name: Run affected workspace tests |
| if: needs.changes.outputs.unit == 'true' |
| env: |
| HEADLESS_AFFECTED: ${{ needs.changes.outputs.headless }} |
| STORAGE_STRESS: ${{ needs.changes.outputs.storage_stress }} |
| WORKSPACES: ${{ needs.changes.outputs.workspaces }} |
| run: | |
| if [[ "$HEADLESS_AFFECTED" == "true" ]]; then |
| export MAKA_REQUIRE_HARBOR_CONTRACT=1 |
| fi |
| if [[ "$STORAGE_STRESS" == "true" ]]; then |
| export MAKA_STORAGE_STRESS=1 |
| fi |
| node scripts/run-workspace-tests-parallel.mjs --concurrency=3 --workspaces="$WORKSPACES" |
| |
| e2e: |
| needs: changes |
| if: needs.changes.outputs.e2e == 'true' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@v4 |
| - uses: actions/setup-node@v4 |
| with: |
| node-version: '24' |
| cache: npm |
| - run: npm ci |
| # Electron is a GUI app; on a headless Linux runner it needs a virtual |
| # display. The suite already runs with show:false, but the process still |
| # requires an X server to start. |
| - name: Ensure xvfb |
| run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } |
| - name: E2E |
| run: xvfb-run -a npm --workspace @maka/desktop run e2e |
| # Design governance: the CDP alignment auditor walks the e2e-fixture |
| # fixtures and fails on same-type height mismatches, mixed-type |
| # centerline drift, or radius-family splits. Reuses the renderer the |
| # e2e step just built; same xvfb pattern. |
| - name: Alignment audit |
| run: xvfb-run -a node scripts/audit-alignment.mjs |
| # Storybook is where pixel work happens (see stories/FIDELITY.md), but |
| # nothing verified that it still renders: story typechecking rides along |
| # in `typecheck`, while the static build and the render/play smoke ran |
| # only if someone remembered to run them locally. |
| # |
| # The E2E suite above drives Electron through `_electron.launch`, which |
| # uses Electron's own binary — it never downloads a browser. The smoke |
| # calls `chromium.launch()`, so the Chromium build must be fetched here |
| # explicitly; without it the step fails on a missing executable. |
| - name: Install Playwright Chromium |
| run: npx playwright install --with-deps chromium |
| - name: Build Storybook |
| run: npm --workspace @maka/desktop run build-storybook |
| - name: Storybook smoke |
| run: npm --workspace @maka/desktop run smoke:storybook |