MUL-5852: 新增 Workspace Billing summary/prices proxy 与 Stripe 回跳落点 #12346
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |