# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. name: CI on: pull_request: branches: [main] push: branches: [main] workflow_dispatch: concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read jobs: # Path planning and install-free repository contracts stay on the cheap lane # that every pull request must run. The outputs select the separate heavy lane # without teaching the workflow a second copy of the path rules. plan: runs-on: ubuntu-latest timeout-minutes: 45 outputs: asf_source: ${{ steps.plan.outputs.asf_source }} astryx_surface: ${{ steps.plan.outputs.astryx_surface }} cli_package: ${{ steps.plan.outputs.cli_package }} code: ${{ steps.plan.outputs.code }} e2e: ${{ steps.plan.outputs.e2e }} heavy: ${{ steps.plan.outputs.heavy }} release_contract: ${{ steps.plan.outputs.release_contract }} runtime_host: ${{ steps.plan.outputs.runtime_host }} runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }} storage_stress: ${{ steps.plan.outputs.storage_stress }} storybook: ${{ steps.plan.outputs.storybook }} standard_workspaces: ${{ steps.plan.outputs.standard_workspaces }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - 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: | # PR checks may predate later main changes, so the planning lane also # validates the exact merged delta. Dispatches and unavailable history # fail safe to every surface. if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "$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 - name: Test CI planner run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs scripts/verify-windows-harness.test.mjs # Pure Node like the planner test, and the labelling workflow imports this # module directly, so a tier or exclusion change is caught here rather # than by mislabelling live pull requests. - name: Test PR effort classification run: node --test --test-concurrency=1 scripts/pr-effort.test.mjs # The scheduled lifecycle workflow imports this pure policy module. Keep # its time boundaries and exemptions deterministic before it can write. - name: Test issue and PR lifecycle policy run: node --test --test-concurrency=1 scripts/issue-pr-lifecycle.test.mjs # Same shape and the same needs: a regenerate-and-diff contract that runs # on Node alone, so it belongs beside the planner test rather than behind # an install. - name: Check Windows test inventory run: npm run windows:inventory # Runs on the PR merge result: after a sibling protocol change lands on # main with the same epoch text, the silently merged tree still carries # the current base parent's epoch and this fails instead of shipping two # incompatible protocols under one number (#3313). - name: Guard the protocol compatibility epoch if: github.event_name == 'pull_request' run: node scripts/protocol-epoch-check.mjs --base 'HEAD^1' - name: Test the epoch guard run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs - name: Test Computer Use script contracts run: node --test scripts/ax-tree-audit.test.mjs scripts/computer-use/lab-root.test.mjs - name: Test script entrypoint contracts run: node --test scripts/script-entrypoints.test.mjs # Install-free like its neighbours: the gate reads one source file and # compares it to a hand-edited inventory, so a hook that silently widens # its scope to the whole tree fails here rather than in a profile (#4109). - name: Check the hooks scoped to the whole shell run: npm run check:app-shell-hooks - name: Test the app-shell hook gate run: node --test --test-concurrency=1 scripts/check-app-shell-hooks.test.mjs - name: Verify ASF npm preflight policy run: npm run check:asf-npm # The source-header gate has to see every file that lands, not only the # files an affected surface selects, so it runs unconditionally beside # the other install-free checks. - name: Check ASF source headers run: npm run check:asf-headers heavy: needs: plan if: needs.plan.outputs.heavy == 'true' runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' cache: npm - name: Select the release npm toolchain if: needs.plan.outputs.cli_package == 'true' run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Restore Electron artifact cache if: needs.plan.outputs.code == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: electron-${{ runner.os }}- - name: Install Linux runtime dependencies if: needs.plan.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.plan.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 - name: Install dependencies if: needs.plan.outputs.code == 'true' || needs.plan.outputs.astryx_surface == 'true' || needs.plan.outputs.asf_source == 'true' || needs.plan.outputs.cli_package == 'true' || needs.plan.outputs.release_contract == 'true' run: npm ci # The header audit above remains install-free. The complete source gate # also exercises generation and therefore runs after its pinned formatter # dependency is installed, matching the source-candidate workflow. - name: Verify ASF source release mechanics if: needs.plan.outputs.asf_source == 'true' run: npm run check:asf-source # Parsed dependency rules and an exact legacy-debt ledger keep the # renderer root from absorbing new feature or Desktop ownership while # the existing AppShell is migrated behind stable boundaries. - name: Check renderer architecture if: needs.plan.outputs.code == 'true' env: BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} run: | if [[ -n "$BASE_SHA" && ! "$BASE_SHA" =~ ^0+$ ]]; then npm run check:renderer-architecture -- --base "$BASE_SHA" else npm run check:renderer-architecture fi - name: Lint if: needs.plan.outputs.code == 'true' run: npm run lint - name: Check formatting if: needs.plan.outputs.code == 'true' run: npm run format:check # This generated artifact describes the whole renderer/UI tree, so every # code-validation run checks it even when the triggering diff is outside # an Astryx surface. Keep it before Build so stale output is reported # directly instead of being hidden behind an earlier compilation failure. - name: Astryx surface inventory if: needs.plan.outputs.code == 'true' || needs.plan.outputs.astryx_surface == 'true' run: | npm run astryx:surface-inventory npm run astryx:surface-inventory:test - name: Build if: needs.plan.outputs.code == 'true' || needs.plan.outputs.cli_package == 'true' || needs.plan.outputs.release_contract == 'true' run: npm run build - name: Release contracts if: needs.plan.outputs.release_contract == 'true' run: npm run check:release - name: Typecheck if: needs.plan.outputs.code == 'true' run: npm run typecheck # Two drift contracts over the shipped app-icon artwork, sitting beside # the theme drift check for the same reason: the committed bytes are a # build output that nothing else re-derives, so without this a change to # the generator, to DEFAULT_APP_ICON, or to the packaging config can go # green while the artwork it names no longer matches. - name: App icon artwork drift if: needs.plan.outputs.code == 'true' run: node --test scripts/verify-packaged-app-icons.test.mjs scripts/generate-app-icons.test.mjs - name: Astryx theme drift if: needs.plan.outputs.code == 'true' run: npm run astryx:theme -- --check - name: Knip (apps/desktop) if: needs.plan.outputs.code == 'true' run: npx knip --workspace apps/desktop - name: Knip (packages/ui) if: needs.plan.outputs.code == 'true' run: npx knip --workspace packages/ui - name: Linux sandbox smoke if: needs.plan.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 affected standard workspace tests if: needs.plan.outputs.standard_workspaces != '' env: STORAGE_STRESS: ${{ needs.plan.outputs.storage_stress }} WORKSPACES: ${{ needs.plan.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" - name: Run live Eval egress proxy test if: contains(needs.plan.outputs.standard_workspaces, 'packages/eval') env: MAKA_EVAL_EGRESS_PROXY_TEST: '1' run: | docker pull python:3.12-slim docker build \ --tag maka-eval-egress-proxy:12.2.3 \ --file packages/eval/harbor/egress-proxy/Dockerfile \ packages/eval/harbor npm --workspace @maka/eval run test:egress-proxy:live - name: Run Runtime Host tests if: needs.plan.outputs.runtime_host == 'true' run: npm --workspace @maka/runtime-host run test:dist - name: Ensure xvfb if: needs.plan.outputs.e2e == 'true' run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; } - name: Desktop e2e if: needs.plan.outputs.e2e == 'true' run: xvfb-run -a npm exec -w @maka/desktop -- playwright test --config e2e/playwright.config.ts - name: Browser WebContentsView semantic smoke if: needs.plan.outputs.e2e == 'true' # Hosted Linux runners cannot configure Electron's SUID helper. This # smoke loads only its loopback fixture; production stays sandboxed. run: xvfb-run -a npm exec --workspace @maka/desktop -- electron --no-sandbox scripts/browser-observe-act-smoke.mjs - name: Alignment audit if: needs.plan.outputs.e2e == 'true' run: xvfb-run -a node scripts/audit-alignment.mjs - name: Install Playwright Chromium if: needs.plan.outputs.storybook == 'true' run: npx playwright install --with-deps chromium - name: Build Storybook if: needs.plan.outputs.storybook == 'true' run: npm --workspace @maka/desktop run build-storybook - name: Storybook smoke if: needs.plan.outputs.storybook == 'true' run: npm --workspace @maka/desktop run smoke:storybook - name: Update stable Rust for CLI packaging if: needs.plan.outputs.cli_package == 'true' run: rustup update stable --no-self-update - id: cli-rustc name: Resolve CLI Rust cache version if: needs.plan.outputs.cli_package == 'true' shell: bash run: | echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" { echo "KACHE_CACHE_DIR=${{ runner.temp }}/kache-cli-package" echo "KACHE_RUNTIME_DIR=${{ runner.temp }}/kache-cli-package-runtime" echo "RUSTC_WRAPPER=kache" } >> "$GITHUB_ENV" - name: Install Kache for CLI packaging if: needs.plan.outputs.cli_package == 'true' uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: kache@0.16.0 - id: cli-kache-cache name: Restore CLI Rust build cache if: needs.plan.outputs.cli_package == 'true' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/kache-cli-package key: kache-runtime-host-peer-cli-package-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.cli-rustc.outputs.version }}-${{ steps.cli-rustc.outputs.revision }} restore-keys: | kache-runtime-host-peer-cli-package-v0.16.0-${{ runner.os }}-${{ runner.arch }}-rust-${{ steps.cli-rustc.outputs.version }}- - name: Install cargo-deny for CLI packaging if: needs.plan.outputs.cli_package == 'true' uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2 with: tool: cargo-deny@0.20.2 - name: Build CLI release candidate if: needs.plan.outputs.cli_package == 'true' run: npm run release:cli:pack -- --allow-dirty - name: Report CLI Rust build cache if: needs.plan.outputs.cli_package == 'true' shell: bash run: kache report --format github >> "$GITHUB_STEP_SUMMARY" - name: Save CLI Rust build cache if: needs.plan.outputs.cli_package == 'true' && github.ref_name == github.event.repository.default_branch uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/kache-cli-package key: ${{ steps.cli-kache-cache.outputs.cache-primary-key }} - name: Validate installed CLI release candidate if: needs.plan.outputs.cli_package == 'true' run: npm run release:cli:smoke -- packages/cli/release/*.tgz # Branch protection requires the `test` context on every pull request. Keep # this aggregation job unconditional so documentation-only plans report # success while selected heavy validation still propagates every failure. test: needs: [plan, heavy] if: always() runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Report required test status env: HEAVY_RESULT: ${{ needs.heavy.result }} HEAVY_SELECTED: ${{ needs.plan.outputs.heavy }} PLAN_RESULT: ${{ needs.plan.result }} run: | if [[ "$PLAN_RESULT" != "success" ]]; then echo "::error::CI planning and lightweight checks ended with $PLAN_RESULT" exit 1 fi case "$HEAVY_SELECTED" in true) if [[ "$HEAVY_RESULT" != "success" ]]; then echo "::error::Selected heavy CI ended with $HEAVY_RESULT" exit 1 fi ;; false) if [[ "$HEAVY_RESULT" != "skipped" ]]; then echo "::error::Unselected heavy CI ended with $HEAVY_RESULT instead of skipped" exit 1 fi ;; *) echo "::error::CI planner produced an invalid heavy selection: $HEAVY_SELECTED" exit 1 ;; esac echo "Required CI passed (heavy selected: $HEAVY_SELECTED)"