| 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_host: ${{ steps.plan.outputs.runtime_host }} |
| runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }} |
| script_mode: ${{ steps.plan.outputs.script_mode }} |
| standard_workspaces: ${{ steps.plan.outputs.standard_workspaces }} |
| storage_stress: ${{ steps.plan.outputs.storage_stress }} |
| storybook: ${{ steps.plan.outputs.storybook }} |
| unit: ${{ steps.plan.outputs.unit }} |
| workspaces: ${{ steps.plan.outputs.workspaces }} |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| with: |
| fetch-depth: 0 |
| - id: plan |
| name: Select affected test surfaces |
| env: |
| BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} |
| HEAD_SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} |
| run: | |
| # A merged PR has already been tested by impact. Re-run the same |
| # affected surfaces for the exact main-branch delta instead of |
| # turning every push (including docs-only pushes) into a full suite. |
| # New branches and unavailable history still fail safe to full. |
| if [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}"; 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| 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 |
| # Generated-artifact governance. astryx-theme/maka.{css,js} are built |
| # from makaTheme.ts, which is now the renderer's type-scale authority — |
| # editing the scale without regenerating would leave the authority and |
| # the shipped ladder disagreeing, and nothing else would notice. |
| - name: Astryx theme drift |
| run: npm run astryx:theme -- --check |
| # 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_workspaces: |
| needs: changes |
| if: needs.changes.outputs.standard_workspaces != '' || needs.changes.outputs.script_mode != 'none' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| with: |
| node-version: '24' |
| cache: npm |
| - name: Install Linux runtime dependencies |
| if: 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 |
| - 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 standard workspace tests |
| if: needs.changes.outputs.standard_workspaces != '' |
| env: |
| STORAGE_STRESS: ${{ needs.changes.outputs.storage_stress }} |
| WORKSPACES: ${{ needs.changes.outputs.standard_workspaces }} |
| run: | |
| if [[ "$STORAGE_STRESS" == "true" ]]; then |
| export MAKA_STORAGE_STRESS=1 |
| fi |
| node scripts/run-workspace-tests-parallel.mjs --concurrency=3 --workspaces="$WORKSPACES" |
| |
| test_runtime_host: |
| needs: changes |
| if: needs.changes.outputs.runtime_host == 'true' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| with: |
| node-version: '24' |
| cache: npm |
| - run: npm ci |
| - run: npm run build:test |
| - name: Run Runtime Host tests |
| run: npm --workspace @maka/runtime-host run test:dist |
| |
| test_headless: |
| needs: changes |
| if: needs.changes.outputs.headless == 'true' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| with: |
| node-version: '24' |
| cache: npm |
| - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 |
| - name: Install pinned Harbor contract runtime |
| run: | |
| uv tool install "harbor==0.13.2" |
| uv tool dir --bin >> "$GITHUB_PATH" |
| - name: Install Linux runtime dependencies |
| run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap |
| - run: npm ci |
| - run: npm run build:test |
| - name: Run Headless tests |
| env: |
| MAKA_REQUIRE_HARBOR_CONTRACT: '1' |
| run: npm --workspace @maka/headless run test:dist |
| # The Harbor adapters are Python, so their contracts cannot ride the |
| # workspace suite. This one needs only the stdlib. |
| - name: Run Harbor adapter tests |
| run: python3 packages/headless/harbor/tests/test_process_scope.py |
| |
| # Preserve one stable required check while letting independent heavy suites |
| # occupy their own runners. The aggregator waits for every selected lane. |
| test: |
| needs: [changes, test_workspaces, test_runtime_host, test_headless] |
| if: always() |
| runs-on: ubuntu-latest |
| steps: |
| - name: Require successful test lanes |
| env: |
| CHANGES_RESULT: ${{ needs.changes.result }} |
| HEADLESS_RESULT: ${{ needs.test_headless.result }} |
| HEADLESS_SELECTED: ${{ needs.changes.outputs.headless }} |
| RUNTIME_HOST_RESULT: ${{ needs.test_runtime_host.result }} |
| RUNTIME_HOST_SELECTED: ${{ needs.changes.outputs.runtime_host }} |
| SCRIPT_MODE: ${{ needs.changes.outputs.script_mode }} |
| STANDARD_WORKSPACES: ${{ needs.changes.outputs.standard_workspaces }} |
| WORKSPACES_RESULT: ${{ needs.test_workspaces.result }} |
| run: | |
| if [[ "$CHANGES_RESULT" != "success" ]]; then |
| echo "changes failed: $CHANGES_RESULT" >&2 |
| exit 1 |
| fi |
| require_lane() { |
| local name="$1" |
| local selected="$2" |
| local result="$3" |
| local expected="skipped" |
| if [[ "$selected" == "true" ]]; then |
| expected="success" |
| fi |
| if [[ "$result" != "$expected" ]]; then |
| echo "$name expected $expected but was $result" >&2 |
| exit 1 |
| fi |
| } |
| workspaces_selected="false" |
| if [[ -n "$STANDARD_WORKSPACES" || "$SCRIPT_MODE" != "none" ]]; then |
| workspaces_selected="true" |
| fi |
| require_lane "workspace tests" "$workspaces_selected" "$WORKSPACES_RESULT" |
| require_lane "Runtime Host tests" "$RUNTIME_HOST_SELECTED" "$RUNTIME_HOST_RESULT" |
| require_lane "Headless tests" "$HEADLESS_SELECTED" "$HEADLESS_RESULT" |
| |
| e2e_shard: |
| needs: changes |
| if: needs.changes.outputs.e2e == 'true' |
| name: e2e_shard (${{ matrix.shard }}/2) |
| runs-on: ubuntu-latest |
| strategy: |
| fail-fast: false |
| matrix: |
| shard: [1, 2] |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| with: |
| node-version: '24' |
| cache: npm |
| - run: npm ci |
| # Each shard keeps one Playwright worker on its own runner and X display. |
| # This overlaps independent Electron cold starts without reintroducing |
| # the OS-focus races observed with multiple workers on one display. |
| - name: Ensure xvfb |
| run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } |
| - name: E2E shard ${{ matrix.shard }}/2 |
| run: xvfb-run -a npm --workspace @maka/desktop run e2e -- --shard=${{ matrix.shard }}/2 |
| |
| # 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. It shares the e2e selection gate and the built |
| # renderer, but not the suite's critical path: it has no data dependency on |
| # Playwright, and riding shard 1 made that shard ~51s longer than shard 2 for |
| # work the shard did not need. Its own runner pays one extra checkout and |
| # build in exchange for balanced shards. |
| alignment_audit: |
| needs: changes |
| if: needs.changes.outputs.e2e == 'true' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| with: |
| node-version: '24' |
| cache: npm |
| - run: npm ci |
| - name: Ensure xvfb |
| run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } |
| - name: Build renderer |
| run: npm --workspace @maka/desktop run build:with-deps |
| - name: Alignment audit |
| run: xvfb-run -a node scripts/audit-alignment.mjs |
| |
| # Preserve the existing required check while every selected shard retains |
| # its own failure and diagnostic output. The audit lane joins the same gate |
| # so splitting it out does not drop it from the required check. |
| e2e: |
| needs: [changes, e2e_shard, alignment_audit] |
| if: always() |
| runs-on: ubuntu-latest |
| steps: |
| - name: Require successful E2E shards |
| env: |
| CHANGES_RESULT: ${{ needs.changes.result }} |
| E2E_SELECTED: ${{ needs.changes.outputs.e2e }} |
| SHARDS_RESULT: ${{ needs.e2e_shard.result }} |
| AUDIT_RESULT: ${{ needs.alignment_audit.result }} |
| run: | |
| if [[ "$CHANGES_RESULT" != "success" ]]; then |
| echo "changes failed: $CHANGES_RESULT" >&2 |
| exit 1 |
| fi |
| expected="skipped" |
| if [[ "$E2E_SELECTED" == "true" ]]; then |
| expected="success" |
| fi |
| if [[ "$SHARDS_RESULT" != "$expected" ]]; then |
| echo "E2E shards expected $expected but were $SHARDS_RESULT" >&2 |
| exit 1 |
| fi |
| if [[ "$AUDIT_RESULT" != "$expected" ]]; then |
| echo "Alignment audit expected $expected but was $AUDIT_RESULT" >&2 |
| exit 1 |
| fi |
| |
| # Storybook build + initial render smoke. Embedded mode disables every play |
| # function, so this lane never duplicates desktop interaction or layout E2E. |
| # It launches Chromium only to catch catalog runtime errors. See FIDELITY.md. |
| storybook: |
| needs: changes |
| if: needs.changes.outputs.storybook == 'true' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| with: |
| node-version: '24' |
| cache: npm |
| - run: npm ci |
| # Stories import @maka/core / @maka/ui package exports (dist/). The old |
| # e2e job paid for this via `build:with-deps`; the split job must too. |
| - name: Build workspace packages |
| run: npm --workspace @maka/desktop run build:workspace-deps |
| # The smoke calls `chromium.launch()`; Electron's binary is irrelevant. |
| - 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 |