mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* test: remove implementation-only coverage Co-authored-by: multica-agent <[email protected]> * test: remove remaining implementation-only coverage Co-authored-by: multica-agent <[email protected]> * test: restore compatibility and fragment navigation coverage Co-authored-by: multica-agent <[email protected]> * test: preserve behavioral cleanup contracts Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
837 lines
40 KiB
YAML
837 lines
40 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
# Decides whether the heavy frontend validation jobs, the backend Go jobs
|
|
# and sqlc verification have anything to do.
|
|
# The frontend jobs validate the web/desktop apps, the shared packages,
|
|
# the install graph, and the selfhost / reserved-slugs scripts it runs;
|
|
# a pure backend-only or docs-only PR touches none of those and gains
|
|
# nothing from a full web build. Symmetrically, the backend jobs compile
|
|
# and test server/, so a frontend-only or docs-only PR gains nothing from
|
|
# a `go test -race` run. The outputs are consumed by the focused jobs
|
|
# below.
|
|
changes:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
outputs:
|
|
frontend: ${{ steps.decide.outputs.frontend }}
|
|
backend: ${{ steps.decide.outputs.backend }}
|
|
sqlc: ${{ steps.decide.outputs.sqlc }}
|
|
images: ${{ steps.filter.outputs.images }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Filter paths
|
|
id: filter
|
|
uses: dorny/paths-filter@v3
|
|
with:
|
|
# apps/docs is excluded from the frontend turbo run, but knip does
|
|
# analyze it, so a docs-only change still needs this job.
|
|
# apps/mobile has its own mobile-verify workflow. Everything else
|
|
# the frontend job touches is listed here; bias toward
|
|
# over-matching since a missed path silently skips validation.
|
|
filters: |
|
|
frontend:
|
|
- 'apps/web/**'
|
|
- 'apps/desktop/**'
|
|
- 'apps/docs/**'
|
|
- 'packages/**'
|
|
- 'package.json'
|
|
- '.npmrc'
|
|
- 'pnpm-lock.yaml'
|
|
- 'pnpm-workspace.yaml'
|
|
- 'turbo.json'
|
|
- 'knip.jsonc'
|
|
- '.github/workflows/ci.yml'
|
|
- 'scripts/generate-reserved-slugs.mjs'
|
|
- 'server/internal/handler/reserved_slugs.json'
|
|
- 'scripts/check-ui-wildcard-exports.mjs'
|
|
- 'scripts/check-ui-wildcard-exports.test.sh'
|
|
- 'scripts/selfhost-config.test.sh'
|
|
- 'scripts/drop-database.sh'
|
|
- 'scripts/remove-worktree.sh'
|
|
- 'scripts/worktree-db.test.sh'
|
|
- 'scripts/dev-env.sh'
|
|
- 'scripts/dev-env.test.sh'
|
|
- 'scripts/selfhost-wait.sh'
|
|
# selfhost-config.test.sh asserts on the installers' side of
|
|
# the port contract, so a change to either must run it.
|
|
- 'scripts/install.sh'
|
|
- 'scripts/install.ps1'
|
|
- 'scripts/check.sh'
|
|
- 'scripts/dev.sh'
|
|
- 'scripts/local-env.sh'
|
|
- '.env.example'
|
|
- 'docker-compose.selfhost.yml'
|
|
- 'docker-compose.selfhost.build.yml'
|
|
- 'Makefile'
|
|
# Everything the backend jobs (backend-tests,
|
|
# go-vulnerability-scan, windows-execenv) read, which is NOT the
|
|
# same thing as server/**: four Go tests reach out of the module
|
|
# to pin a contract against a file the other side owns, across
|
|
# three cross-module file dependencies. Each of those files has
|
|
# to be listed here or the PR that breaks the contract is exactly
|
|
# the PR that skips the test. Same bias as the frontend filter:
|
|
# over-match rather than silently skip validation.
|
|
backend:
|
|
- 'server/**'
|
|
- 'deploy/helm/**'
|
|
# pkg/agent/worktree_capability_contract_test.go reads this file
|
|
# and compares LOCAL_WORKTREE_CAPABILITY against the daemon's
|
|
# protocol constant. A typo on either side disables worktree
|
|
# mode for everyone with no error anywhere, so a change to the
|
|
# frontend half must run the backend suite.
|
|
- 'packages/core/runtimes/cli-version.ts'
|
|
# internal/daemon/agent_command_names_test.go exercises the
|
|
# guard script; scripts/test-go.sh also uses it as the backend
|
|
# test entry point, and the script reads the command names file.
|
|
- 'scripts/agent-cli-command-names.txt'
|
|
- 'scripts/go-test-with-agent-cli-guard.sh'
|
|
- 'scripts/helm-config.test.sh'
|
|
- 'scripts/entrypoint.test.sh'
|
|
- 'docker/entrypoint.sh'
|
|
- 'scripts/test-go.sh'
|
|
- 'scripts/test-go.test.sh'
|
|
# makefile-build.test.sh asserts how `make build` names its
|
|
# output binaries, so a change to either side must run it.
|
|
- 'scripts/makefile-build.test.sh'
|
|
- 'Makefile'
|
|
- '.github/workflows/ci.yml'
|
|
sqlc:
|
|
- 'server/pkg/db/queries/**'
|
|
- 'server/pkg/db/generated/**'
|
|
- 'server/migrations/**'
|
|
- 'server/sqlc.yaml'
|
|
- 'Makefile'
|
|
- '.github/workflows/ci.yml'
|
|
# Gates the image-budget job so the full-history checkout it
|
|
# needs is only paid for on PRs that actually touch a bitmap.
|
|
images:
|
|
- '**/*.png'
|
|
- '**/*.jpg'
|
|
- '**/*.jpeg'
|
|
- '**/*.gif'
|
|
- '**/*.bmp'
|
|
- '**/*.tif'
|
|
- '**/*.tiff'
|
|
- '**/*.webp'
|
|
- '**/*.avif'
|
|
- 'scripts/check-image-budget.mjs'
|
|
|
|
- name: Decide
|
|
id: decide
|
|
# Always run everything on push to main (full validation); on
|
|
# pull_request, run only the areas whose paths changed.
|
|
# The gated jobs themselves always run and report success — their
|
|
# steps are gated on these outputs rather than the job being skipped
|
|
# — so the required "frontend" / "backend" status checks are
|
|
# satisfied with a genuine green instead of being left pending on
|
|
# filtered PRs. It also keeps the aggregate gates strict: a job that
|
|
# goes missing for any OTHER reason (a failed `changes`, a cancelled
|
|
# run) still reports non-success and fails the gate.
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
FRONTEND_CHANGED: ${{ steps.filter.outputs.frontend }}
|
|
BACKEND_CHANGED: ${{ steps.filter.outputs.backend }}
|
|
SQLC_CHANGED: ${{ steps.filter.outputs.sqlc }}
|
|
run: |
|
|
if [ "$EVENT_NAME" != "pull_request" ] || [ "$FRONTEND_CHANGED" = "true" ]; then
|
|
echo "frontend=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "frontend=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
if [ "$EVENT_NAME" != "pull_request" ] || [ "$BACKEND_CHANGED" = "true" ]; then
|
|
echo "backend=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "backend=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
if [ "$EVENT_NAME" != "pull_request" ] || [ "$SQLC_CHANGED" = "true" ]; then
|
|
echo "sqlc=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "sqlc=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
sqlc-check:
|
|
needs: changes
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
if: ${{ needs.changes.outputs.sqlc == 'true' }}
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Go
|
|
if: ${{ needs.changes.outputs.sqlc == 'true' }}
|
|
uses: actions/setup-go@v5
|
|
with:
|
|
go-version: "1.26.x"
|
|
check-latest: true
|
|
cache-dependency-path: server/go.sum
|
|
|
|
- name: Verify generated SQL is up to date
|
|
if: ${{ needs.changes.outputs.sqlc == 'true' }}
|
|
run: |
|
|
make sqlc
|
|
git diff --exit-code -- server/pkg/db/generated
|
|
untracked="$(git ls-files --others --exclude-standard -- server/pkg/db/generated)"
|
|
if [ -n "$untracked" ]; then
|
|
printf 'Untracked generated SQL files:\n%s\n' "$untracked"
|
|
exit 1
|
|
fi
|
|
|
|
# The frontend validation is split across four runners on purpose. A prior
|
|
# split isolated build/typecheck/lint from the test suites; on a full test
|
|
# cache miss, however, web/core/desktop/views still competed for the four
|
|
# vCPUs on the test runner and stretched the job to ~8 minutes. Giving views
|
|
# its own runner only reduced that to 7m23s because the suite itself remained
|
|
# the long pole, so two Vitest shards now split it across two runners while
|
|
# the remaining suites continue in parallel on another. The extra setup
|
|
# increases total runner-minutes but reduces wall-clock feedback time.
|
|
frontend-build:
|
|
needs: changes
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
# Pin turbo's filesystem cache somewhere actions/cache can address.
|
|
TURBO_CACHE_DIR: .turbo/cache
|
|
steps:
|
|
- name: Checkout
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup pnpm
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: pnpm/action-setup@v4
|
|
|
|
- name: Setup Node.js
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
|
|
- name: Install dependencies
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: pnpm install
|
|
|
|
# `node-version: 22` above floats across patch releases, and turbo's
|
|
# global hash does not include the interpreter at all (`engines` is null
|
|
# in its dry-run cache inputs). Without the resolved version in the key,
|
|
# a runner silently moving to another 22.x would restore a cache built by
|
|
# the old interpreter and report green without executing anything.
|
|
- name: Resolve runtime for cache key
|
|
id: runtime
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
|
|
|
|
# Cache entries are immutable, so the key carries the commit SHA to make
|
|
# every run publish a fresh one and `restore-keys` falls back to the most
|
|
# recent prefix match. Each frontend worker runs a disjoint task set, so
|
|
# it gets its own prefix rather than racing to save one key.
|
|
# GitHub scopes caches by branch: a PR reads main's entries (so unchanged
|
|
# tasks hit on the first push) and writes its own (so re-pushes hit too).
|
|
- name: Restore turbo cache
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/cache@v6
|
|
with:
|
|
path: .turbo/cache
|
|
key: turbo-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }}
|
|
restore-keys: |
|
|
turbo-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-
|
|
|
|
- name: Test self-host env derivation
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: bash scripts/selfhost-config.test.sh
|
|
|
|
- name: Test worktree database cleanup
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: bash scripts/worktree-db.test.sh
|
|
|
|
- name: Test development environment registry
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: bash scripts/dev-env.test.sh
|
|
|
|
- name: Verify reserved-slugs.ts is up to date
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
# Re-runs the generator and fails on any drift from the
|
|
# checked-in TypeScript output. The Go side embeds the JSON
|
|
# source directly, so a passing diff here proves both sides
|
|
# share one source of truth.
|
|
run: |
|
|
pnpm generate:reserved-slugs
|
|
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
|
|
|
|
- name: Build, type check, and lint
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
# Mobile lives in a parallel mobile-verify workflow (path-filtered
|
|
# to apps/mobile/** + packages/core/**) so it doesn't add
|
|
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
|
|
# filter in sync with the root package.json scripts, which also
|
|
# exclude @multica/mobile.
|
|
run: pnpm exec turbo build typecheck lint --filter='!@multica/docs' --filter='!@multica/mobile'
|
|
|
|
# Blocking from the start: it reports nothing today, and it covers the
|
|
# one regression knip structurally cannot see. packages/ui exports four
|
|
# wildcards, which knip expands into entry-point globs over every file
|
|
# in those directories — so a newly stranded component there always
|
|
# reads as "used". See the script header for the options ruled out.
|
|
- name: Check packages/ui wildcard exports
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: |
|
|
bash scripts/check-ui-wildcard-exports.test.sh
|
|
node scripts/check-ui-wildcard-exports.mjs
|
|
|
|
# WARN PHASE — reports, never blocks. `shamefully-hoist=true` in .npmrc
|
|
# hides both halves of the drift this catches: an undeclared import still
|
|
# resolves, and an obsolete declaration still installs. Neither shows up
|
|
# in build, typecheck, or lint.
|
|
#
|
|
# `continue-on-error` alone, with no `|| true`: the real exit code has to
|
|
# reach Actions for the step to show as a warning rather than a silent
|
|
# pass, and it is what makes the promotion below a one-line change.
|
|
#
|
|
# TO PROMOTE TO BLOCKING: once this step has run clean for a full cycle
|
|
# on main, drop `continue-on-error`. The findings it reports today are
|
|
# pre-existing dead code left out of the cleanup that introduced it
|
|
# (MUL-6353) — clear those first, or the flip turns them into a red main.
|
|
- name: Check for dead code and phantom dependencies (knip)
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
continue-on-error: true
|
|
run: pnpm knip
|
|
|
|
frontend-test:
|
|
needs: changes
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
TURBO_CACHE_DIR: .turbo/cache
|
|
steps:
|
|
- name: Checkout
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup pnpm
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: pnpm/action-setup@v4
|
|
|
|
- name: Setup Node.js
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
|
|
- name: Install dependencies
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: pnpm install
|
|
|
|
- name: Resolve runtime for cache key
|
|
id: runtime
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
|
|
|
|
# See frontend-build for the key strategy. These entries are tiny (~60KB
|
|
# measured): `test` declares no outputs, so turbo caches exit codes and
|
|
# logs rather than artifacts -- yet a hit still skips the whole suite,
|
|
# which is the single most expensive task in the graph.
|
|
- name: Restore turbo cache
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/cache@v6
|
|
with:
|
|
path: .turbo/cache
|
|
key: turbo-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }}
|
|
restore-keys: |
|
|
turbo-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-
|
|
|
|
- name: Test web, core, and desktop
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
# Same filter rationale as frontend-build. Type errors are not this
|
|
# job's responsibility -- frontend-build owns the `typecheck` task.
|
|
# `test` reaches dependency sources through the hash-only
|
|
# `^cache-inputs` edge (see turbo.json), so no `tsc` runs here.
|
|
run: pnpm exec turbo test --filter='!@multica/docs' --filter='!@multica/mobile' --filter='!@multica/views'
|
|
|
|
# Split views across two runners. GitHub reports the matrix job's combined
|
|
# result, so the aggregate gate can continue depending on this one job ID.
|
|
frontend-views-test:
|
|
name: frontend-views-test (${{ matrix.index }}/${{ matrix.total }})
|
|
needs: changes
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- index: 1
|
|
total: 2
|
|
- index: 2
|
|
total: 2
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
TURBO_CACHE_DIR: .turbo/cache
|
|
steps:
|
|
- name: Checkout
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup pnpm
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: pnpm/action-setup@v4
|
|
|
|
- name: Setup Node.js
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
|
|
- name: Install dependencies
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: pnpm install
|
|
|
|
- name: Resolve runtime for cache key
|
|
id: runtime
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
|
|
|
|
# Each shard has its own prefix so concurrent jobs never race to save the
|
|
# same immutable cache key. Turbo also includes the passthrough `--shard`
|
|
# argument in the task hash, while dependency sources still flow through
|
|
# turbo.json's hash-only `^cache-inputs` edge.
|
|
- name: Restore turbo cache
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
uses: actions/cache@v6
|
|
with:
|
|
path: .turbo/cache
|
|
key: turbo-views-test-${{ matrix.index }}-of-${{ matrix.total }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }}
|
|
restore-keys: |
|
|
turbo-views-test-${{ matrix.index }}-of-${{ matrix.total }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-
|
|
|
|
- name: Test views shard ${{ matrix.index }}/${{ matrix.total }}
|
|
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
|
run: pnpm exec turbo test --filter='@multica/views' -- --shard=${{ matrix.index }}/${{ matrix.total }}
|
|
|
|
# Aggregate gate. `frontend` is the status-check name the repository's
|
|
# branch rules refer to, so it has to survive the split above: this job
|
|
# keeps reporting under that name and simply fails when any worker fails.
|
|
# It also inherits the old contract that the check goes green (rather than
|
|
# staying pending) on PRs the path filter excluded — all workers succeed
|
|
# trivially in that case because every step is gated off.
|
|
frontend:
|
|
needs: [frontend-build, frontend-test, frontend-views-test]
|
|
# `!cancelled()` rather than `always()`: a run superseded by a newer push
|
|
# is cancelled by the concurrency group above, and there is no reason to
|
|
# spend a runner reporting a verdict nobody will read.
|
|
if: ${{ !cancelled() }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Check frontend job results
|
|
env:
|
|
BUILD_RESULT: ${{ needs.frontend-build.result }}
|
|
TEST_RESULT: ${{ needs.frontend-test.result }}
|
|
VIEWS_TEST_RESULT: ${{ needs.frontend-views-test.result }}
|
|
run: |
|
|
echo "frontend-build: $BUILD_RESULT"
|
|
echo "frontend-test: $TEST_RESULT"
|
|
echo "frontend-views-test: $VIEWS_TEST_RESULT"
|
|
if [ "$BUILD_RESULT" != "success" ] || [ "$TEST_RESULT" != "success" ] || [ "$VIEWS_TEST_RESULT" != "success" ]; then
|
|
echo "::error::frontend validation failed"
|
|
exit 1
|
|
fi
|
|
|
|
backend-tests:
|
|
needs: changes
|
|
runs-on: ubuntu-latest
|
|
# `services` cannot be conditional, so the Postgres and Redis containers
|
|
# still start on a filtered PR. That is ~1 minute of runner time against
|
|
# the several minutes the gated steps below cost; job-level `if` would
|
|
# save it but report `skipped` to the aggregate gate, which then has to
|
|
# accept `skipped` as success and stops catching jobs that go missing for
|
|
# reasons other than the path filter.
|
|
services:
|
|
postgres:
|
|
image: pgvector/pgvector:pg17
|
|
env:
|
|
POSTGRES_DB: multica
|
|
POSTGRES_USER: multica
|
|
POSTGRES_PASSWORD: multica
|
|
ports:
|
|
- 5432:5432
|
|
options: >-
|
|
--health-cmd "pg_isready -U multica -d multica"
|
|
--health-interval 5s
|
|
--health-timeout 5s
|
|
--health-retries 20
|
|
redis:
|
|
image: redis:7-alpine
|
|
ports:
|
|
- 6379:6379
|
|
options: >-
|
|
--health-cmd "redis-cli ping"
|
|
--health-interval 5s
|
|
--health-timeout 5s
|
|
--health-retries 10
|
|
env:
|
|
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
|
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
|
|
# (which would flip the server binary itself onto the Redis-backed
|
|
# realtime relay + request stores); the tests talk to this Redis
|
|
# directly so they run alongside the Postgres-backed suite.
|
|
REDIS_TEST_URL: redis://localhost:6379/1
|
|
steps:
|
|
- name: Checkout
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Go
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
uses: actions/setup-go@v5
|
|
with:
|
|
# Deliberately independent from the minimum patch in server/go.mod:
|
|
# CI follows the newest 1.26 patch available to setup-go.
|
|
go-version: "1.26.x"
|
|
check-latest: true
|
|
cache-dependency-path: server/go.sum
|
|
|
|
- name: Setup Helm
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
uses: azure/setup-helm@v4
|
|
|
|
- name: Test Helm chart
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: bash scripts/helm-config.test.sh
|
|
|
|
- name: Test backend entrypoint signals
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: bash scripts/entrypoint.test.sh
|
|
|
|
- name: Test build output naming
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: bash scripts/makefile-build.test.sh
|
|
|
|
- name: Build
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: cd server && go build ./...
|
|
|
|
# The `agentintegration` files execute real agent CLIs, so no CI job runs
|
|
# them. Nothing compiled them either, which let them rot silently: a
|
|
# helper renamed in an ordinary test file, or two branches each defining
|
|
# the same helper in one package, breaks the tagged build with every
|
|
# untagged job still green. vet type-checks test files, so this is the
|
|
# cheapest gate that keeps them honest without needing an agent CLI.
|
|
- name: Vet tag-gated agent integration tests
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: cd server && go vet -tags agentintegration ./...
|
|
|
|
- name: Run migrations
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: cd server && go run ./cmd/migrate up
|
|
|
|
- name: Verify Go test wrapper
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: bash scripts/test-go.test.sh
|
|
|
|
- name: Test
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
run: bash scripts/test-go.sh --race
|
|
|
|
# Keep the live vulnerability database from hiding build and test results.
|
|
# The aggregate backend job below still treats both jobs as one merge gate.
|
|
go-vulnerability-scan:
|
|
needs: changes
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Go
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
uses: actions/setup-go@v5
|
|
with:
|
|
# Deliberately independent from the minimum patch in server/go.mod:
|
|
# CI follows the newest 1.26 patch available to setup-go.
|
|
go-version: "1.26.x"
|
|
check-latest: true
|
|
cache-dependency-path: server/go.sum
|
|
|
|
- name: Scan Go vulnerabilities
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
run: go tool govulncheck ./...
|
|
|
|
# Preserve the existing required-check name while allowing tests, generated
|
|
# SQL verification, and the vulnerability scan to run independently.
|
|
backend:
|
|
needs: [backend-tests, sqlc-check, go-vulnerability-scan]
|
|
if: ${{ !cancelled() }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Check backend job results
|
|
env:
|
|
TEST_RESULT: ${{ needs.backend-tests.result }}
|
|
SQLC_RESULT: ${{ needs.sqlc-check.result }}
|
|
VULNERABILITY_RESULT: ${{ needs.go-vulnerability-scan.result }}
|
|
run: |
|
|
echo "backend-tests: $TEST_RESULT"
|
|
echo "sqlc-check: $SQLC_RESULT"
|
|
echo "go-vulnerability-scan: $VULNERABILITY_RESULT"
|
|
if [ "$TEST_RESULT" != "success" ] || [ "$SQLC_RESULT" != "success" ] || [ "$VULNERABILITY_RESULT" != "success" ]; then
|
|
echo "::error::backend validation failed"
|
|
exit 1
|
|
fi
|
|
|
|
windows-execenv:
|
|
# The environment-preparation deadline owns a process tree, not just a Go
|
|
# process. This Windows runtime test verifies Job Object cancellation kills
|
|
# a delayed descendant before an immediate retry can reuse the same root.
|
|
# Every step here is a Go test under server/, so it rides the same path
|
|
# filter as the backend jobs; steps are gated rather than the job so the
|
|
# check still reports green on a filtered PR.
|
|
needs: changes
|
|
runs-on: windows-latest
|
|
steps:
|
|
- name: Checkout
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Go
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
uses: actions/setup-go@v5
|
|
with:
|
|
# Deliberately independent from the minimum patch in server/go.mod:
|
|
# CI follows the newest 1.26 patch available to setup-go.
|
|
go-version: "1.26.x"
|
|
check-latest: true
|
|
cache-dependency-path: server/go.sum
|
|
|
|
- name: Test Windows execution-environment isolation
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# Keep this job scoped to the runtime regression it exists to prove.
|
|
# The package's legacy OpenClaw HOME tests are not Windows-safe and are
|
|
# outside this PR; the normal backend job still runs the full package.
|
|
run: go test ./internal/daemon/execenv -run '^TestPrepareIsolated_WindowsKillsDescendantBeforeRetry$' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows agent launcher argv/stdin handling
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# Agent prompts must never reach a Windows launcher through argv: the
|
|
# official cursor-agent.ps1, pi.ps1, and qwen.ps1 launch native children with
|
|
# `$args`, and PowerShell re-serialises them onto the child command
|
|
# line. Under
|
|
# Legacy native argument passing (powershell.exe 5.1, pwsh <= 7.2) a
|
|
# prompt holding embedded quotes is re-tokenised and fragments like
|
|
# `-X` become flags (#5649). Only a real PowerShell host proves this,
|
|
# so it cannot live in the ubuntu backend job. Scoped to the launcher
|
|
# tests, which are windows-tagged and therefore run nowhere else today;
|
|
# the backend job still runs the full package on Linux.
|
|
# -v so a silent skip (no PowerShell host resolved, or a -run pattern
|
|
# that stops matching) is visible in the log instead of passing as "ok".
|
|
run: go test ./pkg/agent -v -run '^(TestCursorExecutePromptSurvivesPowerShellShim|TestPiExecutePromptSurvivesPowerShellShim|TestQwenExecutePromptSurvivesPowerShellShim|TestPlatformCursorInvocation|TestPlatformCopilotInvocation|TestPlatformPiInvocation|TestPlatformQwenInvocation)' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows OpenCode oversized prompt reaches stdin
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# #6538: the daemon inlined the whole task prompt as an argv element,
|
|
# so every OpenCode task whose prompt cleared CreateProcess's 32,767
|
|
# character lpCommandLine limit failed to start at all, with Go
|
|
# reporting ERROR_FILENAME_EXCED_RANGE as the misleading "The filename
|
|
# or extension is too long". Only a real CreateProcess enforces that
|
|
# ceiling, so this cannot run in the ubuntu backend job. The test
|
|
# spawns a native .exe directly (a Chocolatey-installed opencode.exe is
|
|
# a real PE binary, not a .cmd shim) with an oversized prompt and pins
|
|
# that the process starts, argv stays free of the prompt, and the full
|
|
# payload arrives on stdin.
|
|
# -v so a silent skip or a -run pattern that stops matching is visible
|
|
# in the log instead of passing as "ok".
|
|
run: go test ./pkg/agent -v -run '^TestOpencodeExecuteOversizedPromptStartsOnWindows$' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows agent process-tree ownership
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# A Job Object is the only way to prove whole-tree termination on
|
|
# Windows, and only a real Windows runner can exercise it: that a
|
|
# grandchild dies with the tree that owns it, that an unowned process
|
|
# still reports cleanup as unconfirmed, and that a descendant holding
|
|
# inherited stdout neither keeps Result blocked nor survives cleanup.
|
|
# -v makes RUN/PASS evidence explicit in CI logs.
|
|
run: go test ./pkg/agent -v -run '^(TestStartOwnedProcessTreeCapturesImmediateDescendants|TestStartOwnedProcessTreeLeavesNoSuspendedChild|TestWaitProcessGroupGoneWithoutOwnershipReportsUnconfirmed|TestCodexInitializeRetrySupportedWithOwnedProcessTree|TestCodexWindowsDescendantsDieWithTheOwnedProcessTree|TestCodeArtsWindowsCancellationTerminatesDescendants)$' -count=1 -timeout=5m
|
|
|
|
- name: Test bounded CLI output collection on Windows
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# MUL-5467: RunCollect / RunCollectQuiet own the pipes as well as the
|
|
# process tree on the OpenClaw CLI paths, which outputOwned cannot. A real
|
|
# Windows host proves its Job Object owns and
|
|
# terminates descendants, a CLI that prints its answer and then refuses
|
|
# to exit still yields that answer, a response still streaming at the
|
|
# deadline is NOT reported as success, and no collector goroutine
|
|
# outlives the call. Unix uses process groups, so it cannot exercise this
|
|
# platform lifecycle.
|
|
# -v so a skip (a -run pattern that stops matching) is visible in the log
|
|
# instead of passing as "ok".
|
|
run: go test ./pkg/agent -v -run '^TestWindowsRunCollect' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows Pi session lock leaves the transcript readable
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# #7840: the session lock claimed the transcript itself, and Windows
|
|
# byte-range locks are mandatory. Pi's own read of the file we hand it
|
|
# through --session failed with EBUSY, so every Pi/omp task on Windows
|
|
# died ~0.5s after launch. Unix flock is advisory and never reproduces
|
|
# it, which is exactly how the regression shipped; the tests are
|
|
# windows-tagged and therefore run nowhere else today.
|
|
# -v so a skip (a -run pattern that stops matching) is visible in the
|
|
# log instead of passing as "ok".
|
|
run: go test ./pkg/agent -v -run '^TestPiSessionFileLock' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows OpenClaw npm shim interpreter resolution
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# #6061: every OpenClaw task failed execenv prep on a Windows host with
|
|
# a bare `exit status 1` and no stderr. A batch shim resolves and runs
|
|
# fine while the `node` it re-execs is unreachable, and npm's real
|
|
# template prefers a co-located node.exe over PATH — none of which can
|
|
# be proven without a real cmd.exe host. These tests pin: the positive
|
|
# control (node on PATH → success), that a missing node surfaces
|
|
# cmd.exe's own stderr (the first run of this job disproved #6061's
|
|
# premise that it does not), that a genuinely silent shim DOES reach the
|
|
# new diagnostic, that a co-located interpreter is credited, that a
|
|
# context timeout is not misdiagnosed as a missing interpreter, and that
|
|
# TEMP/TMP are NOT load-bearing (the originally reported root cause,
|
|
# since retracted upstream).
|
|
# Scoped to the windows-tagged shim tests — the package's legacy
|
|
# OpenClaw HOME tests are not Windows-safe; the backend job still runs
|
|
# the full package plus the cross-platform half on Linux.
|
|
# -v so a skip (no node on the runner) is visible instead of passing
|
|
# silently as "ok".
|
|
run: go test ./internal/daemon/execenv -v -run '^TestWindowsOpenclawShim' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows isolated repo checkout is committable
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# #6449: on Windows the daemon now hands Codex tasks a checkout whose
|
|
# .git lives inside the task workdir, because a linked worktree's
|
|
# external gitdir stays read-only under the native sandbox and breaks
|
|
# `git add` / `git commit` at the end of a task. Two of the guarantees
|
|
# are claims about Windows itself and cannot be made on ubuntu: that
|
|
# Git puts the gitdir inside the task directory, and that the clone's
|
|
# objects are private copies rather than NTFS hard links sharing one
|
|
# file and one security descriptor with the daemon-owned cache. The
|
|
# cross-volume test covers what a hard link cannot express at all.
|
|
# Scoped to the two windows-tagged tests by exact name. A prefix match
|
|
# also caught the package's cross-platform isolated-checkout test,
|
|
# which cannot run here: repocache derives a cache directory name from
|
|
# the full source repo path, so a t.TempDir() path that already embeds
|
|
# a long test name doubles and blows past MAX_PATH. That test belongs
|
|
# to the ubuntu backend job, which runs the whole package.
|
|
# -v so a skip (single-volume runner) is visible instead of passing
|
|
# silently as "ok".
|
|
run: go test ./internal/daemon/repocache -v -run '^(TestIsolatedCheckoutIsCommittableOnWindows|TestIsolatedCheckoutAcrossVolumesOnWindows)$' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows directory-junction link safety
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# MUL-6000: the per-task codex-home links the user's real skills into
|
|
# the task directory instead of copying them. On Windows that link is a
|
|
# directory junction whenever os.Symlink is denied (no Developer Mode),
|
|
# and a junction is the one link shape a ModeSymlink check misses:
|
|
# since Go 1.23 os.Lstat reports it as ModeDir|ModeIrregular with no
|
|
# ModeSymlink bit, while its DirEntry still answers IsDir() == true, so
|
|
# filepath.WalkDir descends into the target. Two claims about Windows
|
|
# itself cannot be made on ubuntu: that the per-task skills wipe
|
|
# (os.RemoveAll) drops the junction rather than the user's files, and
|
|
# that the GC's artifact sweep and size accounting refuse to walk
|
|
# through one. The tests call mklink /J directly so the junction shape
|
|
# is exercised even on a runner where symlinks are permitted.
|
|
# -v so a skip is visible instead of passing silently as "ok".
|
|
run: |
|
|
go test ./internal/daemon/execenv -v -run '^(TestSeedUserCodexSkills|TestHydrateCodexSkills)' -count=1 -timeout=5m
|
|
go test ./internal/daemon -v -run '^(TestCleanTaskArtifacts_DoesNotFollowDirectoryJunction|TestTaskSize_DoesNotCountDirectoryJunction)$' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows task temp dir sweep survives a sharing violation
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# The GC's task temp sweep has to cope with a file it cannot delete: a
|
|
# leftover child process holding one open is what left 174 directories
|
|
# behind in #7364. Only a real Windows filesystem produces that sharing
|
|
# violation — on unix an open file unlinks fine — so this is the only
|
|
# place we can prove the two things that matter: the failed cleanup
|
|
# keeps .task_lock (without it the directory reads as a pre-lock
|
|
# leftover and stops being reclaimable on liveness), and the next cycle
|
|
# removes the directory once the handle closes.
|
|
# -v so a skip is visible instead of passing silently as "ok".
|
|
run: go test ./internal/daemon/execenv -v -run '^TestPruneTaskTempDirsSurvivesRealSharingViolation$' -count=1 -timeout=5m
|
|
|
|
- name: Test Windows agent executable junction resolution
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
# The standalone Codex installer exposes bin as a directory junction.
|
|
# filepath.EvalSymlinks cannot traverse that reparse-point shape, so
|
|
# only a real Windows filesystem proves both PATH discovery and an
|
|
# explicitly configured path reach the release executable. The same job
|
|
# covers the npm shape, where the entry point is a `.cmd` shim that only
|
|
# the command interpreter can run.
|
|
run: go test ./internal/daemon -v -run '^(TestCanonicalExecutablePath|TestTrimExtendedLengthPrefix|TestResolveAgentExecutablePathKeeps|TestResolveAgentEntry(FollowsRetargetedInstallerJunction|CanonicalizesRediscoveredJunction|ForLaunchKeepsCmdShimLaunchable|ForLaunchRejectsUnverifiedInitialJunctionTarget|ForLaunchRejectsRediscoveredJunctionWhenFinalPathResolutionFails|DoesNotSharePreRetargetSingleflightResult|ForLaunchFailsWhenJunctionKeepsRetargeting)|TestHandleTaskReportsWindowsCodexProcessStartFailure)' -count=1 -timeout=5m
|
|
|
|
- name: Build Windows CLI helper entrypoint
|
|
if: ${{ needs.changes.outputs.backend == 'true' }}
|
|
working-directory: server
|
|
run: go build ./cmd/multica
|
|
|
|
image-budget:
|
|
# Soft gate against the 21.7MB of raw PNG/JPG that MUL-6352 cleared out:
|
|
# a bitmap added or grown past 300KB fails until the PR description says
|
|
# why. Pull requests only — a push to main has no description to read,
|
|
# and the branch it came from was already checked.
|
|
needs: changes
|
|
if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.images == 'true' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
# The check diffs against the base branch, which a shallow
|
|
# clone does not contain.
|
|
fetch-depth: 0
|
|
|
|
- name: Check image budget
|
|
env:
|
|
PR_BODY: ${{ github.event.pull_request.body }}
|
|
run: node scripts/check-image-budget.mjs --base "origin/${{ github.base_ref }}"
|
|
|
|
installer:
|
|
# Stub-driven shell tests for scripts/install.sh and scripts/install.ps1.
|
|
# Kept off the heavy backend job so installer regressions surface
|
|
# independently, and exercised on macOS too because the installer targets
|
|
# macOS/Homebrew and `tar` / `sed` / `mktemp` differ between BSD and GNU
|
|
# userlands. Windows runs the PowerShell installer's own suite: the two
|
|
# installers share one port contract, and neither is covered by the
|
|
# frontend job.
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Test shell installers
|
|
if: runner.os != 'Windows'
|
|
run: bash scripts/install.test.sh
|
|
|
|
- name: Test PowerShell installer
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
run: ./scripts/install.ps1.test.ps1
|