| # 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: |
| # One job, not three. A runner slot is scarcer than a minute on shared |
| # infrastructure, and splitting planning from validation made every pull |
| # request queue for a runner three times to reach one verdict. Planning is |
| # the first step; every later step gates on its outputs, so a |
| # documentation-only change costs one short allocation rather than two. |
| # |
| # The name is `test` because that is the required context in `.asf.yaml`. |
| # Renaming it would leave that check unreported on every open pull request |
| # until the rename merged, and nothing could merge while it was unreported. |
| test: |
| # Pinned, not `ubuntu-latest`. The two resolve to the same image, but only |
| # the alias makes this required context wait at the tail, and the steps |
| # below already assume this image. `ci-workflow-policy.test.mjs` holds the |
| # rule for every lane. |
| runs-on: ubuntu-24.04 |
| timeout-minutes: 45 |
| steps: |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| with: |
| fetch-depth: 0 |
| persist-credentials: false |
| |
| - id: comparison |
| name: Resolve checked-out comparison |
| env: |
| CI_EVENT_NAME: ${{ github.event_name }} |
| PUSH_BASE_SHA: ${{ github.event.before }} |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| run: | |
| # Every diff-based gate must compare the same checked-out tree. |
| # PR event bases can predate the main parent of the actual merge. |
| BASE_SHA="" |
| HEAD_SHA="$(git rev-parse --verify HEAD)" |
| if [[ "$CI_EVENT_NAME" == "pull_request" ]]; then |
| MERGED_HEAD_SHA="$(git rev-parse --verify 'HEAD^2')" |
| if [[ -z "$PR_HEAD_SHA" || "$MERGED_HEAD_SHA" != "$PR_HEAD_SHA" ]]; then |
| echo "CI comparison requires the merge of the requested PR head" >&2 |
| exit 1 |
| fi |
| BASE_SHA="$(git rev-parse --verify 'HEAD^1')" |
| elif [[ "$CI_EVENT_NAME" == "push" && ! "$PUSH_BASE_SHA" =~ ^0+$ ]]; then |
| BASE_SHA="$PUSH_BASE_SHA" |
| fi |
| printf 'base=%s\nhead=%s\n' "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT" |
| |
| - id: plan |
| name: Select affected test surfaces |
| env: |
| BASE_SHA: ${{ steps.comparison.outputs.base }} |
| HEAD_SHA: ${{ steps.comparison.outputs.head }} |
| run: | |
| # Dispatches, initial pushes and unavailable history select every surface. |
| if [[ -z "$BASE_SHA" ]] || ! 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/ci-workflow-policy.test.mjs scripts/verify-windows-harness.test.mjs |
| scripts/run-workspace-tests-parallel.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 |
| |
| # Deliberately here and not in the e2e job: that job is conditional, and a |
| # tier this guard only checks when the tier already ran is a guard that |
| # grows back (#4761). Pure Node, no build -- it reads the spec sources. |
| - name: Check Electron e2e budget |
| run: npm run check:e2e-budget |
| |
| # 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' |
| env: |
| BASE_SHA: ${{ steps.comparison.outputs.base }} |
| run: node scripts/protocol-epoch-check.mjs --base "$BASE_SHA" |
| |
| - name: Test the epoch guard |
| run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs |
| |
| - name: Test Storybook audit contracts |
| run: node --test scripts/ax-tree-audit.test.mjs scripts/storybook-visual-smoke.test.mjs |
| - name: Test Computer Use script contracts |
| run: node --test scripts/computer-use/lab-root.test.mjs |
| |
| - name: Test script entrypoint contracts |
| run: node --test scripts/script-entrypoints.test.mjs |
| |
| # The packaged Windows lane drives the updater through this wiring. It |
| # is a few lines, so it is asserted here on every change rather than by |
| # naming its module in that lane's 25-minute path filter. |
| - name: Test the packaged update feed wiring |
| run: node --test --test-concurrency=1 scripts/update-test-feed-wiring.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 |
| |
| # Locale hygiene ratchet (node built-ins only, so it runs install-free): a new UI locale must only ever mean a new |
| # UiCatalog key, so locale-literal branches, silent locale defaults, |
| # and payload sniffing may shrink but never grow. |
| - name: Check locale hygiene |
| if: steps.plan.outputs.code == 'true' |
| env: |
| BASE_SHA: ${{ steps.comparison.outputs.base }} |
| run: | |
| node --test scripts/check-locale-hygiene.test.mjs |
| if [[ -n "$BASE_SHA" ]]; then |
| npm run check:locale-hygiene -- --base "$BASE_SHA" |
| else |
| npm run check:locale-hygiene |
| fi |
| |
| # Everything below this line may need an installed toolchain, so each |
| # step names the selections it belongs to. `setup-node` itself is |
| # unconditional: it costs seconds on a runner the job is already holding, |
| # and gating it needed a twelve-term disjunction over every other |
| # selection that a new lane had to remember to join (#4475). |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 |
| with: |
| node-version: '24' |
| cache: npm |
| |
| - name: Select the release npm toolchain |
| if: steps.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: steps.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: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == '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: steps.plan.outputs.runtime_sandbox == 'true' || steps.plan.outputs.state_root_compat == '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: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' |
| run: npm ci |
| |
| - name: Check localized TUI copy boundaries |
| if: steps.plan.outputs.code == 'true' |
| run: | |
| npm run check:tui-copy |
| node --test scripts/check-tui-copy.test.mjs |
| |
| - name: Test staged Biome hook |
| if: steps.plan.outputs.code == 'true' |
| run: node --test scripts/biome-staged-check.test.mjs |
| |
| # 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: steps.plan.outputs.asf_source == 'true' |
| run: npm run check:asf-source |
| |
| - name: Lint |
| if: steps.plan.outputs.code == 'true' |
| run: npm run lint |
| |
| - name: Check formatting |
| if: steps.plan.outputs.code == 'true' |
| run: npm run format:check |
| |
| # 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: steps.plan.outputs.code == 'true' |
| env: |
| BASE_SHA: ${{ steps.comparison.outputs.base }} |
| run: | |
| if [[ -n "$BASE_SHA" ]]; then |
| npm run check:renderer-architecture -- --base "$BASE_SHA" --strict-base |
| else |
| npm run check:renderer-architecture |
| fi |
| |
| # 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: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' |
| run: | |
| npm run astryx:surface-inventory |
| npm run astryx:surface-inventory:test |
| |
| - name: Build |
| if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' |
| run: npm run build |
| |
| # The DeepSeek Harness toolchain is built outside the npm workspace, then |
| # admitted solely by this digest. Rebuild its pinned linux/amd64 tree in |
| # hosted CI whenever an input or its identity changes, so a typo or a |
| # machine-specific tree cannot be committed as an accepted fingerprint. |
| - name: Verify DeepSeek Harness toolchain fingerprint |
| if: steps.plan.outputs.deepseek_harness_toolchain == 'true' |
| run: | |
| toolchain_root="$RUNNER_TEMP/maka-deepseek-harness-toolchain" |
| actual="$(node scripts/prepare-deepseek-harness-toolchain.mjs --out "$toolchain_root" | tail -n 1)" |
| expected="$(node --input-type=module -e 'import { TOOLCHAIN_IDENTITIES } from "./packages/eval/dist/toolchain-verification.js"; console.log(TOOLCHAIN_IDENTITIES["deepseek-harness"].fingerprint);')" |
| test "$actual" = "$expected" |
| (cd "$toolchain_root" && sha256sum --check --quiet checksums.sha256) |
| |
| - name: Release contracts |
| if: steps.plan.outputs.release_contract == 'true' |
| run: npm run check:release |
| |
| - name: Typecheck |
| if: steps.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 the `APP_ICONS` catalog, or to the packaged-resource |
| # list can go green while the artwork it names no longer matches. Those |
| # are the inputs it selects on; regenerating the artwork costs about a |
| # minute, and every other code change used to pay it. |
| - name: App icon artwork drift |
| if: steps.plan.outputs.app_icons == 'true' |
| run: node --test scripts/verify-packaged-app-icons.test.mjs scripts/generate-app-icons.test.mjs |
| |
| - name: Astryx theme drift |
| if: steps.plan.outputs.code == 'true' |
| run: npm run astryx:theme -- --check |
| |
| - name: Knip (apps/desktop) |
| if: steps.plan.outputs.code == 'true' |
| run: npx knip --workspace apps/desktop |
| |
| - name: Knip (packages/ui) |
| if: steps.plan.outputs.code == 'true' |
| run: npx knip --workspace packages/ui |
| |
| - name: Linux sandbox smoke |
| if: steps.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: steps.plan.outputs.standard_workspaces != '' |
| env: |
| STORAGE_STRESS: ${{ steps.plan.outputs.storage_stress }} |
| WORKSPACES: ${{ steps.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(steps.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: steps.plan.outputs.runtime_host == 'true' |
| run: npm --workspace @maka/runtime-host run test:dist |
| |
| # A published predecessor writes the durable state; the workspace built |
| # above reads it. Release packaging is deliberately not in front of this: |
| # it takes minutes and changes nothing about whether these decoders can |
| # read that state. The release lanes still qualify exact tarballs. |
| - id: forward-roll-baseline |
| name: Resolve the published forward-roll baseline |
| if: steps.plan.outputs.state_root_compat == 'true' |
| run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" |
| |
| - name: Download the forward-roll baseline |
| if: steps.plan.outputs.state_root_compat == 'true' |
| env: |
| SOURCE_URL: ${{ steps.forward-roll-baseline.outputs.tarball_url }} |
| SOURCE_INTEGRITY: ${{ steps.forward-roll-baseline.outputs.integrity }} |
| run: | |
| set -euo pipefail |
| source_path="$RUNNER_TEMP/forward-roll-source.tgz" |
| curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 \ |
| --retry 3 --retry-connrefused --retry-delay 2 "$SOURCE_URL" --output "$source_path" |
| node - "$source_path" "$SOURCE_INTEGRITY" <<'NODE' |
| const { createHash } = require('node:crypto'); |
| const { readFileSync } = require('node:fs'); |
| const bytes = readFileSync(process.argv[2]); |
| const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; |
| if (actual !== process.argv[3]) throw new Error('Forward-roll baseline integrity mismatch'); |
| NODE |
| { |
| echo "FORWARD_ROLL_SOURCE=$source_path" |
| echo "FORWARD_ROLL_SOURCE_SHA256=$(sha256sum "$source_path" | cut -d ' ' -f 1)" |
| } >> "$GITHUB_ENV" |
| |
| - name: Qualify durable state against the published baseline |
| if: steps.plan.outputs.state_root_compat == 'true' |
| env: |
| MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' |
| run: | |
| set -o pipefail |
| npm run --silent release:cli:qualify-state-root -- \ |
| --source "$FORWARD_ROLL_SOURCE" \ |
| --source-sha256 "$FORWARD_ROLL_SOURCE_SHA256" \ |
| --target-workspace "$PWD" \ |
| | tee "$RUNNER_TEMP/durable-state-report.json" |
| |
| - name: Ensure xvfb |
| if: steps.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: steps.plan.outputs.e2e == 'true' |
| run: xvfb-run -a npm exec -w @maka/desktop -- playwright test --config e2e/playwright.config.ts |
| |
| # Playwright keeps a trace, a video and a screenshot for every failed |
| # test. Without this they die with the runner, and an e2e flake can only |
| # be diagnosed by reproducing it. |
| - name: Upload Desktop e2e results |
| if: failure() && steps.plan.outputs.e2e == 'true' |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
| with: |
| name: desktop-e2e-results |
| path: apps/desktop/e2e/test-results/ |
| if-no-files-found: ignore |
| retention-days: 7 |
| |
| - name: Browser WebContentsView semantic smoke |
| if: steps.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: steps.plan.outputs.e2e == 'true' |
| run: xvfb-run -a node scripts/audit-alignment.mjs |
| |
| # The smoke only launches headless; the full Chrome build was downloaded |
| # and never opened. The headed consumer (perf/storybook.mjs) self-installs. |
| - name: Install Playwright Chromium |
| if: steps.plan.outputs.storybook == 'true' |
| run: npx playwright install --with-deps --only-shell chromium |
| |
| - name: Build Storybook |
| if: steps.plan.outputs.storybook == 'true' |
| run: npm --workspace @maka/desktop run build-storybook |
| |
| - name: Storybook smoke |
| if: steps.plan.outputs.storybook == 'true' |
| run: npm --workspace @maka/desktop run smoke:storybook |
| |
| - name: Transcript geometry invariants |
| if: steps.plan.outputs.storybook == 'true' |
| env: |
| GEOMETRY_REPETITIONS: '1' |
| run: xvfb-run -a node scripts/perf/geometry-ablation.mjs --assert-stable |
| |
| - name: Update stable Rust for CLI packaging |
| if: steps.plan.outputs.cli_package == 'true' |
| run: rustup update stable --no-self-update |
| |
| - id: cli-rustc |
| name: Resolve CLI Rust cache version |
| if: steps.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: steps.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: steps.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: steps.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: steps.plan.outputs.cli_package == 'true' |
| run: npm run release:cli:pack -- --allow-dirty |
| |
| - name: Report CLI Rust build cache |
| if: steps.plan.outputs.cli_package == 'true' |
| shell: bash |
| run: | |
| set -o pipefail |
| kache report --format github | tee -a "$GITHUB_STEP_SUMMARY" |
| |
| - name: Save CLI Rust build cache |
| if: steps.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: steps.plan.outputs.cli_package == 'true' |
| run: npm run release:cli:smoke -- packages/cli/release/*.tgz |