diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0d91c59ab..9fd1439cf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,7 +198,8 @@ jobs: # Pull requests run a Windows smoke suite: the packages that actually # carry *_windows.go code, the startup/checkpoint packages with portable - # filesystem contracts, plus cmd/. The full ./... sweep stays on pushes + # filesystem contracts, plus cmd/. The isolation/worktree owners have a + # complete JSON inventory below. The full ./... sweep stays on pushes # to main-v2; the Unix legs always run the full suite. Keep the job-level # budget above normal 7-9 minute runner variance; each package test binary # remains independently bounded by Go's per-package timeout below. @@ -222,11 +223,36 @@ jobs: WINDOWS_SANDBOX_WAIT_MS: "20000" run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/... - # Worktree rollback is absent from the broad Windows PR smoke list. - - name: test (Windows worktree rollback) + - name: test (Windows isolation owners) if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' - timeout-minutes: 3 - run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree + timeout-minutes: 25 + shell: bash + run: | + set -o pipefail + go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" | + jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output' + jq -se ' + [.[] | select(.Test == null and .Action == "pass") | .Package] as $passed | + [.[] | select(.Test != null and .Action == "pass") | .Test] as $tests | + (["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree"] - $passed | length) == 0 and + (["TestResultOperationUsesCrossProcessOwnership", + "TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore", + "TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove", + "TestApplyPathsWithUntrustedModesPreservesTheIndex/content", + "TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode", + "TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode", + "TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type", + "TestApplyPathsEmulatedLinksUseSnapshotFilters"] - $tests | length) == 0 + ' "$RUNNER_TEMP/isolation-owners.jsonl" + + - name: upload Windows isolation test inventory + if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: isolation-owners-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/isolation-owners.jsonl + if-no-files-found: ignore + retention-days: 7 # The general Windows PR smoke list intentionally omits internal/contract/tool. # Keep the session-temp portability contract covered without widening the @@ -315,7 +341,7 @@ jobs: if: github.event_name == 'pull_request' env: REASONIX_RELEASE_CACHE_GUARD: "1" - run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/... + run: go test -race ./internal/runtime/agent/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/... - name: test -race (full) if: github.event_name != 'pull_request' diff --git a/docs/ISOLATION_NATIVE_CI.md b/docs/ISOLATION_NATIVE_CI.md new file mode 100644 index 0000000000..32c9014943 --- /dev/null +++ b/docs/ISOLATION_NATIVE_CI.md @@ -0,0 +1,81 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-25 +--- + +# Isolation CI coverage / 隔离机制 CI 覆盖 + +## Gap and repair + +An all-green CI summary did not mean the isolation owners ran on native +Windows. PRs used a smoke package list; the extra worktree step selected only +rollback tests. The PR race list also omitted both full owning packages. + +Pure internal/runtime/isolation changes could additionally be classified as +platform-neutral because that package has no OS-constrained file. Its Git, +filesystem and process contracts are nevertheless platform-specific. + +The path gate now names that owner explicitly. Windows PRs execute complete +isolation and worktree packages with JSON terminal inventory; Linux PR race +checks include both owners. The full worktree run replaces the redundant +rollback-only step. + +## Actual matrix + +| Surface | PR coverage | +| --- | --- | +| Platform decision | Isolation production/test paths request native legs; existing neutral/unknown-path policies stay intact | +| Linux and macOS | Existing full root suites, when the native leg is required | +| Windows isolation/worktree | Native uncached complete package runs, with both package-pass terminals required | +| Windows file locks | Existing full filelock package in the broad smoke command | +| Linux race | Both complete owning packages join the existing concurrency suite | +| Windows inventory | Uploaded JSON includes individual outcomes and conditional skips | +| Full push suite | Existing full root Windows/race checks remain unchanged | + +Windows owner tests use the established 18-minute package and 25-minute step +bounds, with -count=1 and package concurrency four. Product deadlines, the +broad smoke timeout, workflow permissions and runner labels are unchanged. +Record observed durations before making future capacity adjustments. + +The inventory requires terminal passes for both packages and eight portable +ownership/checkout cases. Package passes with those cases skipped cannot pass +the gate. An earlier failing step leaves the job failed; a missing upload never +establishes a passing owner result. + +## Regression and security scope + +Real path-gate tests fail on the preceding classifier for pure isolation paths. +The existing YAML test owner now also checks that unfiltered PR commands select +both complete packages on native Windows and with race enabled. A focused +rollback command cannot satisfy that coverage contract. + +No application Go, data format, tool permission or default is changed. No new +secret, credential, privileged trigger or write scope is introduced. Artifact +upload reuses the existing pinned action and seven-day retention; its input is +only the disposable test inventory. + +## Evidence limits + +Workflow configuration and local tests do not prove a native run happened. +Read the actual checkout, job outcome and JSON before claiming platform evidence. +POSIX-only skips do not count as Windows behavior coverage; Windows amd64 +functional tests do not prove Windows ARM64 or Windows race support. + +Individual package passes do not establish the full concurrency/isolation plan. +Crash recovery, safe retention, snapshot-bound verification, compatibility, +performance and complete combined-tree acceptance remain separate requirements. + +## 中文说明 + +此前 Windows PR 白名单没有执行完整的隔离与 worktree 所有者包,额外步骤只测了 +回滚子集;Linux PR race 清单也缺少这两个包。纯隔离运行时代码还可能被误判为 +平台无关,导致原生检查完全跳过。 + +现在平台准入明确包含隔离所有者,Windows 执行两个完整包并要求各自成功结束; +原始 JSON 上传用于核对实际执行、条件跳过和故障。完整 worktree 测试包含原有 +回滚用例,不重复运行子集。Linux race 同时加入两个完整所有者包。 + +沿用已有超时、并发数、权限、固定版本 action 和结果缓存规则,不改变产品行为。 +本地命令、工作流配置和宿主 CI 是不同证据;实际原生结果必须按提交及平台核实。 diff --git a/internal/base/testenv/ci_workflow_test.go b/internal/base/testenv/ci_workflow_test.go index 3796fb7774..7f7621417b 100644 --- a/internal/base/testenv/ci_workflow_test.go +++ b/internal/base/testenv/ci_workflow_test.go @@ -86,11 +86,8 @@ func goTestArgs(script string) [][]string { return out } -// A root-module package list run on Windows with go test's result cache on -// pays, before and after each package, one filepath.EvalSymlinks per path its -// binary recorded outside the module. internal/assembly/boot records ~1.9M, so -// the step spends minutes printing nothing and runs into its job timeout. -func TestWindowsCILegsBypassTheTestResultCache(t *testing.T) { +func readCIWorkflow(t *testing.T) ciWorkflow { + t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "..", ".github", "workflows", "ci.yml")) if err != nil { t.Fatal(err) @@ -99,6 +96,15 @@ func TestWindowsCILegsBypassTheTestResultCache(t *testing.T) { if err := yaml.Unmarshal(raw, &wf); err != nil { t.Fatal(err) } + return wf +} + +// A root-module package list run on Windows with go test's result cache on +// pays, before and after each package, one filepath.EvalSymlinks per path its +// binary recorded outside the module. internal/assembly/boot records ~1.9M, so +// the step spends minutes printing nothing and runs into its job timeout. +func TestWindowsCILegsBypassTheTestResultCache(t *testing.T) { + wf := readCIWorkflow(t) checked := 0 for jobName, job := range wf.Jobs { if !job.runsOnWindows() { @@ -120,3 +126,55 @@ func TestWindowsCILegsBypassTheTestResultCache(t *testing.T) { t.Fatal("ci.yml has no root-module go test step that runs on Windows; this guard no longer reads the workflow's shape") } } + +func prOwnerTestArgs(wf ciWorkflow, windows bool) [][]string { + job := wf.Jobs["race"] + if windows { + job = wf.Jobs["test"] + if !job.runsOnWindows() { + return nil + } + } + var commands [][]string + for _, step := range job.Steps { + if !job.rootModule(step) || strings.Contains(step.If, "github.event_name != 'pull_request'") || + (windows && strings.Contains(step.If, "runner.os != 'Windows'")) { + continue + } + for _, args := range goTestArgs(step.Run) { + if (!windows && !slices.Contains(args, "-race")) || slices.ContainsFunc(args, func(arg string) bool { + return arg == "-run" || strings.HasPrefix(arg, "-run=") || arg == "-list" || strings.HasPrefix(arg, "-list=") + }) { + continue + } + commands = append(commands, args) + } + } + return commands +} + +func coversPackage(args []string, target string) bool { + return slices.ContainsFunc(args, func(pattern string) bool { + if pattern == target || pattern == "./..." { + return true + } + prefix, recursive := strings.CutSuffix(pattern, "/...") + return recursive && (target == prefix || strings.HasPrefix(target, prefix+"/")) + }) +} + +func TestPRCIExecutesIsolationOwnersOnWindowsAndRace(t *testing.T) { + wf := readCIWorkflow(t) + for _, windows := range []bool{true, false} { + t.Run(map[bool]string{true: "native_windows", false: "race"}[windows], func(t *testing.T) { + commands := prOwnerTestArgs(wf, windows) + for _, owner := range []string{"./internal/runtime/isolation", "./internal/platform/worktree"} { + t.Run(owner, func(t *testing.T) { + if !slices.ContainsFunc(commands, func(args []string) bool { return coversPackage(args, owner) }) { + t.Fatalf("PR CI does not execute the full owning package %s in this matrix leg", owner) + } + }) + } + }) + } +} diff --git a/scripts/ci-platform-scope.sh b/scripts/ci-platform-scope.sh index 1992bb38a9..9643366492 100755 --- a/scripts/ci-platform-scope.sh +++ b/scripts/ci-platform-scope.sh @@ -9,7 +9,7 @@ set -euo pipefail neutral_dir='^(docs/|release-notes/|workers/|benchmarks/|npm/|examples/|desktop/frontend-next/)' # Kernel packages whose behaviour is OS-specific (files, processes, shells, # sandboxes, sockets, paths) even where no file carries a build tag. -platform_dir='^(internal/(safety|platform|base|state|ext|tools|session|assembly)/|cmd/|sdk/|tools/|scripts/|desktop/(electron|cmd|packaging|computer-helper|internal)/|\.github/)' +platform_dir='^(internal/(safety|platform|base|state|ext|tools|session|assembly)/|internal/runtime/isolation/|cmd/|sdk/|tools/|scripts/|desktop/(electron|cmd|packaging|computer-helper|internal)/|\.github/)' platform_file='(^|/)(go\.(mod|sum)|Makefile)$|\.(nsh|nsi|ps1|sh|bat|cmd)$|_(windows|darwin|linux|unix|bsd)(_test)?\.go$' seen=0 diff --git a/scripts/ci-platform-scope.test.mjs b/scripts/ci-platform-scope.test.mjs index c08ff5394d..9fb6ff32b3 100644 --- a/scripts/ci-platform-scope.test.mjs +++ b/scripts/ci-platform-scope.test.mjs @@ -19,6 +19,8 @@ test("neutral paths do not need the macOS and Windows legs", () => { test("platform paths and unknown paths need them", () => { for (const f of [ "internal/safety/sandbox/a.go", + "internal/runtime/isolation/store.go", + "internal/runtime/isolation/durable_test.go", "internal/platform/update/a.go", "cmd/reasonix/main.go", "desktop/electron/main.ts",