Skip to content

MUL-5852: 新增 Workspace Billing summary/prices proxy 与 Stripe 回跳落点 #12346

MUL-5852: 新增 Workspace Billing summary/prices proxy 与 Stripe 回跳落点

MUL-5852: 新增 Workspace Billing summary/prices proxy 与 Stripe 回跳落点 #12346

Workflow file for this run

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, ~6min) frontend job has anything to do.
# The frontend job validates 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. This job emits a single `frontend`
# output consumed by the frontend job below.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
frontend: ${{ steps.decide.outputs.frontend }}
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, so a
# docs-only change does not need 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/**'
- 'packages/**'
- 'package.json'
- '.npmrc'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- '.github/workflows/ci.yml'
- 'scripts/generate-reserved-slugs.mjs'
- 'server/internal/handler/reserved_slugs.json'
- 'scripts/selfhost-config.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'
- name: Decide
id: decide
# Always run the frontend job on push to main (full validation);
# on pull_request, run only when frontend-relevant paths changed.
# The frontend job itself always runs and reports success — its
# steps are gated on this output rather than the job being skipped
# — so the required "frontend" status check is satisfied with a
# genuine green instead of being left pending on filtered PRs.
env:
EVENT_NAME: ${{ github.event_name }}
FRONTEND_CHANGED: ${{ steps.filter.outputs.frontend }}
run: |
if [ "$EVENT_NAME" != "pull_request" ] || [ "$FRONTEND_CHANGED" = "true" ]; then
echo "frontend=true" >> "$GITHUB_OUTPUT"
else
echo "frontend=false" >> "$GITHUB_OUTPUT"
fi
# The frontend validation is split across two runners on purpose. Both
# `@multica/web:build` (a webpack production build) and `@multica/views:test`
# (259 jsdom files) are CPU-saturating, and a standard runner only has
# 4 vCPUs. Running them in one job made them starve each other: the views
# suite needs ~104s wall when it owns 4 cores but took ~500s sharing them,
# and the identical webpack compile went from ~26s to ~342s. Splitting buys
# a second 4-vCPU box rather than reducing the work; total runner-minutes go
# up slightly, wall-clock feedback time goes down.
#
# The split is weighted, not even: the test group is by far the heavier half
# (~575 CPU-seconds vs ~250 for build + typecheck + lint), so it gets a
# runner to itself and everything else shares the other one.
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. The two frontend jobs run disjoint task sets, so
# they get their own prefixes 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: 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'
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
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'
# 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 either half fails.
# It also inherits the old contract that the check goes green (rather than
# staying pending) on PRs the path filter excluded — both halves succeed
# trivially in that case because every step is gated off.
frontend:
needs: [frontend-build, frontend-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 }}
run: |
echo "frontend-build: $BUILD_RESULT"
echo "frontend-test: $TEST_RESULT"
if [ "$BUILD_RESULT" != "success" ] || [ "$TEST_RESULT" != "success" ]; then
echo "::error::frontend validation failed"
exit 1
fi
backend:
runs-on: ubuntu-latest
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
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26.1"
cache-dependency-path: server/go.sum
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Test Helm chart
run: bash scripts/helm-config.test.sh
- name: Build
run: cd server && go build ./...
- 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
run: bash scripts/test-go.sh --race
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.
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26.1"
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 agent launcher argv/stdin handling
working-directory: server
# Agent prompts must never reach a Windows launcher through argv: the
# official cursor-agent.ps1 and pi.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|TestPlatformCursorInvocation|TestPlatformCopilotInvocation|TestPlatformPiInvocation)' -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)$' -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 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 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|TestResolveAgentEntry(FollowsRetargetedInstallerJunction|CanonicalizesRediscoveredJunction|ForLaunchKeepsCmdShimLaunchable|ForLaunchRejectsUnverifiedInitialJunctionTarget|ForLaunchRejectsRediscoveredJunctionWhenFinalPathResolutionFails|DoesNotSharePreRetargetSingleflightResult|ForLaunchFailsWhenJunctionKeepsRetargeting)|TestHandleTaskReportsWindowsCodexProcessStartFailure)' -count=1 -timeout=5m
- name: Build Windows CLI helper entrypoint
working-directory: server
run: go build ./cmd/multica
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