Files
multica/.github/workflows/ci.yml
Cursor Agentandklinsmaya 5145706b07 chore(market): drop comments that restate the code
Two comments described rules the types do not enforce. ParseListingID is not the only way to build a ListingID, and decodeVersion returns more than ErrInvalidListing.

Co-authored-by: klinsmaya <[email protected]>
2026-10-18 04:48:50 +00:00

943 lines
45 KiB
YAML

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# PRs and main pushes use the same paths. Scheduled/manual runs validate all
# scopes, including platform stress tests, without charging every merge for them.
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 }}
runtime: ${{ steps.decide.outputs.runtime }}
quality_only: ${{ steps.decide.outputs.quality_only }}
scripts: ${{ steps.decide.outputs.scripts }}
installer: ${{ steps.decide.outputs.installer }}
images: ${{ steps.decide.outputs.images }}
full: ${{ steps.decide.outputs.full }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Test CI selection and gates
run: node --test scripts/ci-scope.test.mjs scripts/check-image-budget.test.mjs
- name: Filter paths
id: filter
if: github.event_name == 'pull_request' || github.event_name == 'push'
uses: dorny/paths-filter@v3
with:
# Pushes compare before/head SHAs. PRs use the pull-request file API;
# paths-filter ignores base/ref for pull_request events.
base: ${{ github.event_name == 'push' && github.event.before || github.base_ref }}
ref: ${{ github.sha }}
filters: .github/ci-paths.json
- name: Decide
id: decide
env:
EVENT_NAME: ${{ github.event_name }}
FILTER_RESULTS: ${{ toJSON(steps.filter.outputs) }}
run: node scripts/ci-scope.mjs decide
sqlc-check:
needs: changes
if: ${{ needs.changes.outputs.sqlc == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "~1.26.9"
check-latest: true
cache-dependency-path: server/go.sum
- name: Verify generated SQL is up to date
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
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
env:
# Pin turbo's filesystem cache somewhere actions/cache can address.
TURBO_CACHE_DIR: .turbo/cache
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
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
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
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: Build, type check, and lint
# 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'
- name: Check frontend quality
uses: ./.github/actions/frontend-quality
frontend-test:
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
env:
TURBO_CACHE_DIR: .turbo/cache
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Resolve runtime for cache key
id: runtime
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
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
# 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
if: ${{ needs.changes.outputs.frontend == 'true' }}
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
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Resolve runtime for cache key
id: runtime
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
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 }}
run: pnpm exec turbo test --filter='@multica/views' -- --shard=${{ matrix.index }}/${{ matrix.total }}
# Preserve the aggregate check names while validating intentional skips.
frontend:
needs: [changes, frontend-build, frontend-test, frontend-views-test, frontend-quality]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check frontend job results
env:
NEEDS_JSON: ${{ toJSON(needs) }}
JOB_SCOPES: '{"frontend-build":"frontend","frontend-test":"frontend","frontend-views-test":"frontend","frontend-quality":"quality_only"}'
run: node scripts/ci-scope.mjs gate
# The Go caches are handled explicitly here rather than through setup-go's
# `cache: true`. setup-go keys its entry on go.sum alone and shares that key
# with every Linux job in this workflow, so whichever job finished first,
# go-vulnerability-scan in under a minute, saved a ~95 MB entry holding
# little beyond module downloads; this job then "hit" that key on every run
# and never saved its own. The result was a from-scratch race-instrumented
# compile of the whole module on every backend run, measured at roughly
# half of the job's ~8 minutes (MUL-7280).
#
# Two entries now. The module cache is keyed by go.sum: immutable, saved on
# a miss, never grows. The build cache is keyed by commit SHA with a prefix
# fallback, so each run starts from the newest entry and recompiles only
# what its change touched. Only pushes to main publish a build-cache entry;
# PR runs restore and leave, which keeps storage churn at one entry per
# merge, the same shape as the turbo caches above. The key carries the
# resolved Go version because `check-latest` floats across patch releases,
# and the runner image (ImageOS) because cached cgo and race objects are
# linked against that image's glibc: when ubuntu-latest moves to a new
# release, an entry built on the old one must not be restored. setup-go
# keys on ImageOS for the same reason (actions/setup-go#368).
#
# A rolling entry only grows on its own: each save is the restored entry
# plus this commit's objects, and Go trims only what has sat unused for
# five days, so the entry carried every recent main commit's race objects.
# It reached 3.5 GB in ten days, and restoring and saving it cost more than
# the compile it saved (MUL-7547). Before saving, the job therefore drops
# every entry this run did not use, leaving the next run the objects of the
# tree it is most likely to build.
#
# This job is the only writer of either entry. backend-agent-tests restores
# both, but it downloads just pkg/agent's slice of the module graph and
# finishes first, so letting it save would repeat the first-writer-wins
# problem above: an immutable go.sum key pinned to a partial module cache.
#
# Every compile below runs in race mode so build, vet and test share one set
# of objects. A plain `go build` fed nothing to the -race test binaries and
# was pure duplicate work; nothing in server/ carries a `race` build tag, so
# both modes accept exactly the same code.
#
# `GOFLAGS=-count=1` because a rolling cache would otherwise let `go test`
# replay cached results. Migrations are read from disk at run time rather
# than embedded, so a schema change is invisible to a test binary's hash and
# a cached "ok" from an untouched package could mask a real failure.
backend-tests:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
# Job-level filtering skips both services on unrelated changes.
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
GOFLAGS: -count=1
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
id: go
uses: actions/setup-go@v5
with:
# Keep the security floor even when the setup-go manifest lags;
# continue accepting newer patches within Go 1.26.
go-version: "~1.26.9"
check-latest: true
cache: false
- name: Restore Go module cache
uses: actions/cache@v6
with:
path: ~/go/pkg/mod
key: go-mod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('server/go.sum') }}
# ImageOS is a runner environment variable, not a context value, so a
# shell step spells the build-cache key prefix once for restore and save.
- name: Resolve Go build cache key
id: gocache
run: echo "prefix=go-build-race-${{ runner.os }}-${{ runner.arch }}-$ImageOS-${{ steps.go.outputs.go-version }}-" >> "$GITHUB_OUTPUT"
- name: Restore Go build cache
uses: actions/cache/restore@v6
with:
path: ~/.cache/go-build
key: ${{ steps.gocache.outputs.prefix }}${{ github.sha }}
restore-keys: ${{ steps.gocache.outputs.prefix }}
# Go refreshes a cache entry's mtime when it uses it, but only once that
# mtime is an hour old. So after the run, an entry no newer than an hour
# before the first compile is one this run did not use. The prune step
# before the save compares against this marker.
- name: Mark Go build cache epoch
id: goepoch
run: touch -d '-1 hour' "$RUNNER_TEMP/go-build-epoch"
- name: Build
run: cd server && go build -race ./...
# 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
run: cd server && go vet -race -tags agentintegration ./...
- name: Run migrations
run: cd server && go run ./cmd/migrate up
- name: Verify Go test wrapper
run: bash scripts/test-go.test.sh
- name: Test
# pkg/agent runs on its own runner: backend-agent-tests below.
run: bash scripts/test-go.sh --race --only regular
# Go's own trim (cmd/go/internal/cache) with this run as the window
# instead of five days: only `-a`/`-d` entries, and an executable entry
# is a directory, so it goes whole. See the job comment.
- name: Prune Go build cache to this run's entries
if: ${{ !cancelled() && github.event_name == 'push' && steps.goepoch.outcome == 'success' }}
run: |
du -sh ~/.cache/go-build
find ~/.cache/go-build -mindepth 2 -maxdepth 2 \( -name '*-a' -o -name '*-d' \) \
! -newer "$RUNNER_TEMP/go-build-epoch" -exec rm -rf {} +
du -sh ~/.cache/go-build
# Publishes from main only; see the job comment. `!cancelled()` so a red
# main still refreshes the objects for the next run, and the key step's
# outcome so a run that never reached Go does not save an empty entry
# under a half-formed key.
- name: Save Go build cache
if: ${{ !cancelled() && github.event_name == 'push' && steps.gocache.outcome == 'success' }}
uses: actions/cache/save@v6
with:
path: ~/.cache/go-build
key: ${{ steps.gocache.outputs.prefix }}${{ github.sha }}
# pkg/agent used to run as the tail of backend-tests: ~2m20s for a single
# package, throttled to -parallel 2 because its subprocess-backed tests carry
# hard deadlines and starve when a race build competes for the runner (see
# scripts/test-go.sh). Those tests are timer-bound rather than CPU-bound, so
# the throttle stays and the package simply gets its own runner; it reads no
# database, so no service containers either. Same trade as the frontend
# split: more runner-minutes, less wall clock. Both Go caches are restored
# but never saved here: backend-tests owns publishing (see its comment), and
# this job's only unique output is the pkg/agent test binary, which is cheap
# to relink.
backend-agent-tests:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
env:
# Same policy as backend-tests: CI always executes the tests it reports.
GOFLAGS: -count=1
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
id: go
uses: actions/setup-go@v5
with:
go-version: "~1.26.9"
check-latest: true
cache: false
- name: Restore Go module cache
uses: actions/cache/restore@v6
with:
path: ~/go/pkg/mod
key: go-mod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('server/go.sum') }}
# Must match backend-tests' key step exactly.
- name: Resolve Go build cache key
id: gocache
run: echo "prefix=go-build-race-${{ runner.os }}-${{ runner.arch }}-$ImageOS-${{ steps.go.outputs.go-version }}-" >> "$GITHUB_OUTPUT"
- name: Restore Go build cache
uses: actions/cache/restore@v6
with:
path: ~/.cache/go-build
key: ${{ steps.gocache.outputs.prefix }}${{ github.sha }}
restore-keys: ${{ steps.gocache.outputs.prefix }}
- name: Test agent package
run: bash scripts/test-go.sh --race --only agent
# 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
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
# Keep the security floor even when the setup-go manifest lags;
# continue accepting newer patches within Go 1.26.
go-version: "~1.26.9"
check-latest: true
cache-dependency-path: server/go.sum
- name: Scan Go vulnerabilities
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: [changes, backend-tests, backend-agent-tests, sqlc-check, go-vulnerability-scan, macos-runtime, windows-execenv, script-checks, installer]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check backend job results
env:
NEEDS_JSON: ${{ toJSON(needs) }}
JOB_SCOPES: '{"backend-tests":"backend","backend-agent-tests":"backend","sqlc-check":"sqlc","go-vulnerability-scan":"backend","macos-runtime":"full","windows-execenv":"runtime","script-checks":"scripts","installer":"installer"}'
run: node scripts/ci-scope.mjs gate
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.
# The runtime scope includes daemon/agent sources and their Go dependencies.
needs: changes
if: ${{ needs.changes.outputs.runtime == 'true' }}
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
# Keep the security floor even when the setup-go manifest lags;
# continue accepting newer patches within Go 1.26.
go-version: "~1.26.9"
check-latest: true
cache-dependency-path: server/go.sum
- name: Test Windows execution-environment isolation
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 daemon local-skill discovery
working-directory: server
# These fixtures must redirect both the Windows user profile and the
# platform-native Hermes home; otherwise discovery scans the runner's
# real skills instead of the test directories.
run: go test ./internal/daemon -v -run '^(TestListRuntimeLocalSkills_HermesFollowsTaskHome|TestLocalSkills_DiscoversACPProviderRoots)$' -count=1 -timeout=5m
- name: Test Windows Git Bash temp lifecycle
working-directory: server
run: |
$env:MULTICA_TEST_GIT_BASH = "$env:ProgramFiles\Git\usr\bin\bash.exe"
if (-not (Test-Path -LiteralPath $env:MULTICA_TEST_GIT_BASH)) { throw "Git for Windows is required" }
go test -tags=gitbashintegration ./internal/daemon -v -run '^(TestWindowsGitBashTaskTempLifecycle|TestTaskMulticaEnvironment.*|TestTaskTempBaseDir)$' -count=1 -timeout=5m
- name: Test Windows agent launcher argv/stdin handling
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
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
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
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
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
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 workdir containment guard in the CLI
if: ${{ needs.changes.outputs.backend == 'true' }}
working-directory: server
# The CLI's --content-file / --attachment guard decides whether a path is
# inside the task workdir by canonicalizing both sides. Two of the things
# that decision rests on are Windows-only and covered nowhere else: a
# candidate on another volume, where filepath.Rel returns an error rather
# than a "..", so the guard has to read that as "outside" instead of
# surfacing `Rel: can't make Z:\... relative to C:\...` as a resolve
# failure; and a directory junction — the containment-relevant link shape
# that needs no elevation, so the one real hosts have — which resolves to
# itself rather than to its target under this module's go directive. The
# junction test pins that gap deliberately, records the observations it
# rests on, and fails with instructions if the platform ever starts
# resolving them.
# cmd/multica has no Windows job otherwise, so these windows-tagged
# tests run nowhere else. It matters twice over on a non-admin host,
# where the package's cross-platform cases skip for want of the symlink
# privilege; the ubuntu backend job covers those shapes instead.
# -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 ./cmd/multica -v -run '^TestFileWithinWorkingDirWindows' -count=1 -timeout=5m
- name: Test Windows link-target classification in internal/util
if: ${{ needs.changes.outputs.backend == 'true' }}
working-directory: server
# The guard's Windows correctness rests on internal/util's link-target
# grammar (classifyTarget), whose branch ordering is load-bearing:
# VolumeName is non-empty for drive-absolute paths too, so asking it
# before IsAbs misreads a junction's absolute target as drive-relative
# and resolves it against the working directory — exactly how a
# junction escape read as inside the workdir for one CI run. The
# fixture-free table pins every branch without needing a filesystem,
# but windows-tagged util tests run nowhere else. Scoped to the table
# by name; the package's symlinked-workdir cases belong to the ubuntu
# backend job, which runs the whole package.
# -v so a silent skip or a pattern that stops matching is visible.
run: go test ./internal/util -v -run '^TestClassifyTarget' -count=1 -timeout=5m
- name: Test Windows isolated repo checkout is committable
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
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
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
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|TestResolveAgentExecutablePath_ProfileOverride|TestResolveAgentEntry(FollowsRetargetedInstallerJunction|CanonicalizesRediscoveredJunction|ForLaunchKeepsCmdShimLaunchable|ForLaunchRejectsUnverifiedInitialJunctionTarget|ForLaunchRejectsRediscoveredJunctionWhenFinalPathResolutionFails|DoesNotSharePreRetargetSingleflightResult|ForLaunchFailsWhenJunctionKeepsRetargeting)|TestHandleTaskReportsWindowsCodexProcessStartFailure)' -count=1 -timeout=5m
- name: Test Windows task workdirs under a junctioned workspaces root
working-directory: server
# #8946: a workspaces root moved to another drive and left behind as a
# directory junction puts a junction in the middle of every task path.
# filepath.EvalSymlinks fails with ENOTDIR there, so every repo checkout
# was refused as "not owned by the active task" and every follow-up
# silently lost its workdir. The strict resolver also has to walk
# extended-length (`\\?\`) spellings instead of comparing them as
# strings, and refuse volume-GUID paths it cannot walk. Only a real
# Windows filesystem has junctions; the ubuntu backend job covers the
# symlink shapes.
# -v so a skip or a pattern that stops matching is visible.
run: |
go test ./internal/util -v -run '^(TestResolveSymlinksFollowsJunctions|TestResolveSymlinksExtendedLengthPaths)$' -count=1 -timeout=5m
go test ./internal/daemon -v -run '^(TestAuthorizeRepoCheckoutWorkDirThroughJunctionedRoot|TestAuthorizeRepoCheckoutWorkDirFollowsJunctionsInsideTheWorkdir|TestAuthorizeRepoCheckoutWorkDirExtendedLengthSpelling|TestShouldReusePriorWorkdirThroughJunctionedRoot)$' -count=1 -timeout=5m
- name: Build Windows CLI helper entrypoint
working-directory: server
run: go build ./cmd/multica
- name: Test Windows Cursor background ownership
working-directory: server
# Cursor's background shell is captured through a second Job Object, and
# TestCaptureCursorBackgroundProcessRejectsForeignJob is windows-tagged,
# so it builds nowhere else. This used to be its own windows-latest job
# on the same runtime scope as this one; same runner, same package, same
# concern, so it is a step here instead of a second runner per merge.
# Last on purpose: absorbing another job's steps means this job's own
# failure modes now truncate that job's coverage too, and Cursor
# ownership is the newest arrival, not the load-bearing one. The ubuntu
# backend job still covers the untagged and Unix arms.
run: go test -race ./pkg/agent -v -run 'TestCursorBackground|TestCaptureCursorBackground' -count=1 -timeout=5m
# The only macOS runner here, and the only place cursor_background_process_darwin.go
# is ever built: it signals a process group through XNU's per-process unique
# identity so a recycled PID cannot redirect a kill, and the two tests pinning
# that are `//go:build darwin`. Windows keeps its arm as a step in
# windows-execenv, but macOS has no sibling job to join, and this covers one
# integration's background shell rather than the shared launch path every
# agent uses. So the daily full run carries it instead of charging every
# backend merge for a macOS runner; `full` also means the stress counts below
# are now the only finalization pass, replacing the single-count merge variant.
macos-runtime:
needs: changes
if: ${{ needs.changes.outputs.full == 'true' }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v5
with:
go-version: "~1.26.9"
cache-dependency-path: server/go.sum
- name: Verify Cursor background lifecycle and watchdog races
shell: bash
working-directory: server
# Explicit, verbose selection prevents the Unix regression from silently
# disappearing behind a build tag.
run: |
go test -race ./pkg/agent -v -run 'TestCursorBackground|TestCaptureCursorBackground' -count=1
go test -race ./internal/daemon -v -run 'Test.*Background.*Watchdog|Test.*IdleWatchdog' -count=1
- name: Stress macOS finalization
shell: bash
working-directory: server
run: |
go test ./pkg/agent -run '^TestCursorBackgroundLifecycle/finish$' -count=5
go test -race ./pkg/agent -run '^TestCursorBackgroundLifecycle/finish$' -count=5
- name: Verify cgo-free macOS ownership
shell: bash
working-directory: server
run: CGO_ENABLED=0 go test ./pkg/agent -run '^TestCaptureCursorBackground(KernelRejectsStaleIdentity|DetachedSession|LateDescendant)$' -count=1
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:
fetch-depth: 1
- name: Fetch comparison base
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: git fetch --no-tags --depth=1 origin "$BASE_SHA"
- name: Check image budget
env:
PR_BODY: ${{ github.event.pull_request.body }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: node scripts/check-image-budget.mjs --base "$BASE_SHA"
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.
needs: changes
if: ${{ needs.changes.outputs.installer == 'true' }}
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 installer with Windows PowerShell 5.1
if: runner.os == 'Windows'
shell: powershell
run: ./scripts/install.ps1.test.ps1
- name: Test installer with PowerShell 7
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/install.ps1.test.ps1
script-checks:
needs: changes
if: ${{ needs.changes.outputs.scripts == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Test worktree database cleanup
run: bash scripts/worktree-db.test.sh
- name: Test development environment registry
run: bash scripts/dev-env.test.sh
- name: Test UI performance comparison runner
run: bash scripts/perf-compare.test.sh
- name: Verify reserved-slugs.ts is up to date
# 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: |
node scripts/generate-reserved-slugs.mjs
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Verify market-block-reasons.ts is up to date
run: |
node scripts/generate-market-block-reasons.mjs
git diff --exit-code -- packages/core/types/market-block-reasons.ts
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Test Helm chart
run: bash scripts/helm-config.test.sh
- name: Test backend entrypoint signals
run: bash scripts/entrypoint.test.sh
- name: Test build output naming
run: bash scripts/makefile-build.test.sh
frontend-quality:
needs: changes
if: ${{ needs.changes.outputs.quality_only == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check frontend quality
uses: ./.github/actions/frontend-quality