diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 223e1f80..b120497f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,31 +4,52 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + schedule: + - cron: '23 19 * * *' # 03:23 Asia/Hong_Kong permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true jobs: classify: - name: Classify pull request changes + name: Select affected checks runs-on: ubuntu-latest outputs: - docs_only: ${{ steps.changes.outputs.docs_only }} + full: ${{ steps.changes.outputs.full }} + backend: ${{ steps.changes.outputs.backend }} + store: ${{ steps.changes.outputs.store }} + web: ${{ steps.changes.outputs.web }} + lsp: ${{ steps.changes.outputs.lsp }} + rust: ${{ steps.changes.outputs.rust }} + native: ${{ steps.changes.outputs.native }} + ui: ${{ steps.changes.outputs.ui }} + race: ${{ steps.changes.outputs.race }} + audit: ${{ steps.changes.outputs.audit }} + packaging: ${{ steps.changes.outputs.packaging }} + checks: ${{ steps.changes.outputs.checks }} + go_plan: ${{ steps.changes.outputs.go_plan }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + - name: Set up current Go 1.25 patch + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.x' + check-latest: true + cache: true - name: Test CI helpers run: python -B -m unittest discover -s scripts/ci -p 'test_*.py' -v - name: Classify the exact change set id: changes env: EVENT_NAME: ${{ github.event_name }} - BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: >- python -B scripts/ci/classify_changes.py @@ -41,7 +62,7 @@ jobs: go-checks: name: Go checks needs: classify - if: ${{ !cancelled() }} + if: ${{ !cancelled() && needs.classify.result == 'success' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -62,14 +83,14 @@ jobs: run: go mod tidy -diff - name: Verify classified protocol registry and reader history env: - PROTOCOL_REGISTRY_BASELINE: ${{ github.event.pull_request.base.sha || github.event.before }} + PROTOCOL_REGISTRY_BASELINE: ${{ github.event.pull_request.base.sha || github.event.before || github.sha }} run: >- go run ./cmd/protocolregistry -check -baseline "$PROTOCOL_REGISTRY_BASELINE" - name: Materialize reviewed base Surface registry shell: bash env: - BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || github.sha }} run: | if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "GitHub base commit identity is invalid" @@ -85,53 +106,66 @@ jobs: fi - name: Verify Surface tier registry and generated inventory run: go run ./cmd/surfacecheck - - name: Verify documentation release contracts - if: ${{ github.event_name == 'pull_request' && needs.classify.result == 'success' && needs.classify.outputs.docs_only == 'true' }} - run: go test -count=1 ./internal/releasegate ./internal/producte2e - - name: Run complete Go suite except sharded Store package - if: ${{ github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true' }} - # The migration-heavy Store package can exceed 30 minutes on a shared - # Linux runner after a forward schema is added. Shared-runner variance - # has pushed the Store package past 45 minutes. It is split below by - # compiled test name while this job retains every other package. - shell: bash + - name: Verify documentation and release contracts + env: + GO_PLAN: ${{ needs.classify.outputs.go_plan }} run: | - set -euo pipefail - module="$(go list -m)" - store_package="$module/internal/store" - package_file="$RUNNER_TEMP/go-packages.txt" - go list ./... > "$package_file" - mapfile -t packages < "$package_file" - filtered=() - store_seen=false - for package in "${packages[@]}"; do - if [[ "$package" == "$store_package" ]]; then - store_seen=true - else - filtered+=("$package") - fi - done - if [[ "$store_seen" != true || "${#filtered[@]}" -eq 0 ]]; then - echo "Exact internal/store package exclusion failed" >&2 - exit 1 - fi - go test -timeout 60m -count=1 "${filtered[@]}" + python - <<'PY' + import json, os, subprocess + plan = json.loads(os.environ["GO_PLAN"]) + packages = [name for name in ( + "cyberagent-workbench/internal/releasegate", + "cyberagent-workbench/internal/producte2e", + ) if name not in plan.get("test", [])] + if packages: + subprocess.run(["go", "test", "-count=1", *packages], check=True) + PY + - name: Test and vet selected Go packages + if: ${{ needs.classify.outputs.backend == 'true' }} + env: + GO_PLAN: ${{ needs.classify.outputs.go_plan }} + run: | + printf '%s' "$GO_PLAN" > "$RUNNER_TEMP/go-plan.json" + python -B scripts/ci/run_go_checks.py --plan "$RUNNER_TEMP/go-plan.json" + + go-race: + name: Go authority race checks + needs: classify + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.race == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Set up current Go 1.25 patch + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.x' + check-latest: true + cache: true - name: Race-check Workspace Access permission and authority fencing - if: ${{ github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true' }} run: >- go test -race -timeout 15m -count=1 ./internal/domain ./internal/executionauth ./internal/application ./internal/store ./internal/httpapi -run 'WorkspaceAccess|RunExecutionPermission|ExecutionPermission|PermissionDrift|SchemaV126|AgentCodeExecutorCreatesReviewedFileAndFailsClosedOnCASConflict|CommandOperationApprovalRealNativeThreeModes' - - if: ${{ github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true' }} - run: go vet ./... + + go-audit: + name: Go dependency audit + needs: classify + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.audit == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Set up current Go 1.25 patch + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.x' + check-latest: true + cache: true - name: Install pinned govulncheck - if: ${{ github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true' }} env: GOBIN: ${{ runner.temp }} run: go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 - name: Scan for reachable Go vulnerabilities - if: ${{ github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true' }} env: GOVULNCHECK: ${{ runner.temp }}/govulncheck run: | @@ -141,7 +175,7 @@ jobs: store-tests: name: Store package tests (shard ${{ matrix.shard }}) needs: classify - if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true') }} + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.store == 'true' }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -172,42 +206,34 @@ jobs: go: name: Go control plane - needs: - - classify - - go-checks - - store-tests + needs: [classify, go-checks, store-tests, go-race, go-audit] if: ${{ always() }} runs-on: ubuntu-latest steps: - - name: Require every applicable Go gate - shell: bash + - name: Require every selected Go gate env: - EVENT_NAME: ${{ github.event_name }} - DOCS_ONLY: ${{ needs.classify.outputs.docs_only }} - CLASSIFY_RESULT: ${{ needs.classify.result }} - GO_CHECKS_RESULT: ${{ needs.go-checks.result }} - STORE_TESTS_RESULT: ${{ needs.store-tests.result }} + NEEDS_JSON: ${{ toJSON(needs) }} run: | - set -euo pipefail - if [[ "$CLASSIFY_RESULT" != success || "$GO_CHECKS_RESULT" != success ]]; then - echo "Change classification or Go checks did not succeed" >&2 - exit 1 - fi - if [[ "$EVENT_NAME" == pull_request && "$DOCS_ONLY" == true ]]; then - if [[ "$STORE_TESTS_RESULT" != skipped ]]; then - echo "Documentation-only Store shards were not skipped as required" >&2 - exit 1 - fi - elif [[ "$STORE_TESTS_RESULT" != success ]]; then - echo "One or more Store shards did not succeed" >&2 - exit 1 - fi - echo "All applicable Go gates succeeded" + python - <<'PY' + import json, os + needs = json.loads(os.environ["NEEDS_JSON"]) + if needs["classify"]["result"] != "success": + raise SystemExit("Change selection failed") + checks = json.loads(needs["classify"]["outputs"]["checks"]) + required = ["go-checks"] + required += [job for job, flag in ( + ("store-tests", "store"), ("go-race", "race"), ("go-audit", "audit") + ) if checks[flag]] + failed = [job for job in required if needs[job]["result"] != "success"] + if failed: + raise SystemExit("Required Go checks did not succeed: " + ", ".join(failed)) + print("All selected Go checks succeeded") + PY code-intel-lsp: name: Real LSP (${{ matrix.os }}) needs: classify - if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true') }} + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.lsp == 'true' }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -263,7 +289,7 @@ jobs: web: name: TypeScript console needs: classify - if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true') }} + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.web == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -279,6 +305,17 @@ jobs: - run: npm run check:api - run: npm test - run: npm run build + - name: Set up current Go 1.25 patch + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.x' + check-latest: true + cache: true + - name: Verify Go bundle loader and embedded production UI bundle + working-directory: . + run: | + go test -count=1 ./internal/webui + go test -count=1 -tags desktop ./web - name: Audit dependencies run: | for attempt in 1 2 3; do @@ -294,7 +331,7 @@ jobs: rust: name: Rust analyzer fixtures needs: classify - if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true') }} + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.rust == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -359,11 +396,11 @@ jobs: path: analyzers/target/wasm32-wasip1/release/cyberagent-analyzer-fixture.wasm if-no-files-found: error retention-days: 1 - - name: Verify embedded WASI import and memory boundaries + - name: Verify embedded WASI boundaries and shared protocol fixtures working-directory: . env: CYBERAGENT_WASI_FIXTURE: ${{ github.workspace }}/analyzers/target/wasm32-wasip1/release/cyberagent-analyzer-fixture.wasm - run: go test -count=1 ./internal/analyzer -run '^TestAnalyzerEmbeddedWASIProductionFixtureAssessment$' + run: go test -count=1 ./internal/analyzer -run '^Test(AnalyzerEmbeddedWASIProductionFixtureAssessment|AnalyzerProtocolSharedGoldenVectors|ArchiveInventorySharedGoldenVectors)$' - name: Verify Linux analyzer conformance boundaries working-directory: . env: @@ -373,7 +410,7 @@ jobs: desktop-macos: name: macOS Desktop shell needs: classify - if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true') }} + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.native == 'true' }} runs-on: macos-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -402,9 +439,16 @@ jobs: run: go test -v -tags keychainintegration -count=1 -timeout=90s ./internal/credential - name: Verify POSIX command runtime transport run: go test -v -count=1 ./internal/runner -run '^TestCommandRuntimePlatform(ShellSmoke|CancelReapsOwnedProcessTree)$' + - name: Verify desktop boundaries without packaging + if: ${{ needs.classify.outputs.packaging != 'true' }} + run: | + go test -count=1 ./internal/desktop ./internal/webui + go test -count=1 -tags desktop ./cmd/cyberagent-desktop ./web - name: Build and verify downloadable macOS preview + if: ${{ needs.classify.outputs.packaging == 'true' }} run: bash scripts/release-desktop-darwin.sh "v1.0.0-ci.$GITHUB_RUN_ID" "$GITHUB_SHA" "$(go env GOARCH)" - name: Upload downloadable macOS preview + if: ${{ needs.classify.outputs.packaging == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: TraverseBoard-macos-preview-arm64 @@ -415,7 +459,7 @@ jobs: desktop: name: Windows Desktop shell needs: classify - if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true') }} + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.native == 'true' }} runs-on: windows-latest env: TRAVERSE_BOARD_LOCAL_SANDBOX_TEST_HOST_PREP: '1' @@ -496,14 +540,23 @@ jobs: run: | $env:CYBERAGENT_ANALYZER_CONFORMANCE_BINARY = Join-Path $env:GITHUB_WORKSPACE "analyzers\target\debug\cyberagent-analyzer-fixture.exe" go test -v -count=1 ./internal/analyzer -run '^Test(SubprocessConformance|AnalyzerResultStaging|AnalyzerSandboxEnforcementConformance|Analyzer(ImmutableHandleHandoff|DedicatedLowPrivilegeIdentity|ReadOnlyFilesystemPrivateStaging)Conformance)' + - name: Verify desktop boundaries without packaging + if: ${{ needs.classify.outputs.packaging != 'true' }} + shell: pwsh + run: | + go test -count=1 ./internal/desktop ./internal/webui + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test -count=1 -tags "desktop,wv2runtime.error" ./cmd/cyberagent-desktop ./web + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and verify portable Desktop shell + if: ${{ needs.classify.outputs.packaging == 'true' }} shell: pwsh run: ./scripts/build-desktop.ps1 -SkipFrontend -VerifyReproducible ui-evidence-windows: name: Real Edge UI evidence (standard user, Windows 2022) needs: classify - if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.classify.result != 'success' || needs.classify.outputs.docs_only != 'true') }} + if: ${{ !cancelled() && needs.classify.result == 'success' && needs.classify.outputs.ui == 'true' }} runs-on: windows-2022 timeout-minutes: 10 steps: @@ -736,3 +789,22 @@ jobs: path: ${{ runner.temp }}/ui-evidence-smoke if-no-files-found: warn retention-days: 5 + + full-ci: + name: Full CI verification + needs: [classify, go, code-intel-lsp, web, rust, desktop-macos, desktop, ui-evidence-windows] + if: ${{ always() && needs.classify.outputs.full == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Require the complete integration and platform matrix + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + python - <<'PY' + import json, os + needs = json.loads(os.environ["NEEDS_JSON"]) + failed = [name for name, job in needs.items() if job["result"] != "success"] + if failed: + raise SystemExit("Full CI did not succeed: " + ", ".join(failed)) + print("Full CI verification succeeded") + PY diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 51134f6d..87a45617 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -18,6 +18,8 @@ on: - finalize pull_request: paths: + # Product/dependency changes are checked by central CI. PR packaging + # validates changes to the release machinery and distribution inputs. - '.github/workflows/release-desktop.yml' - 'cmd/packagede2e/**' - 'cmd/producte2e/**' @@ -27,6 +29,7 @@ on: - 'internal/producte2e/**' - 'internal/releasegate/**' - 'scripts/build-desktop-darwin.sh' + - 'scripts/build-embedded-wasi.sh' - 'scripts/check-macos-compat.sh' - 'scripts/release-desktop-darwin.sh' - 'scripts/macos-release.py' @@ -43,25 +46,12 @@ on: - 'scripts/smoke-desktop-operator-preview.ps1' - 'scripts/standard-code-packaged-e2e.ps1' - 'scripts/standard-code-product-e2e.ps1' + - 'scripts/test-standard-code-packaged-readiness.ps1' - 'scripts/verify-portable-zip.ps1' - 'scripts/verify-msix.ps1' - 'packaging/windows/**' - 'packaging/macos/**' - 'assets/branding/**' - - 'web/public/**' - - 'web/src/assets/fonts/**' - - 'web/src/assets/traverse-board-mark.png' - - 'packaging/windows/RELEASE-NOTES.md' - - 'protocols/registry.json' - - 'go.mod' - - 'go.sum' - - 'web/package.json' - - 'web/package-lock.json' - - 'analyzers/Cargo.toml' - - 'analyzers/Cargo.lock' - - 'analyzers/rust-toolchain.toml' - - 'internal/analyzer/embedded/**' - - 'README.md' push: tags: - 'v*' @@ -150,7 +140,7 @@ jobs: "phase=$phase" >> $env:GITHUB_OUTPUT "signing_prepare=$($signingPrepare.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT - - name: Require successful central CI for the exact release revision + - name: Require successful full central CI for the exact release revision if: ${{ github.event_name != 'pull_request' }} shell: pwsh env: @@ -161,18 +151,44 @@ jobs: throw "Release CI revision is invalid" } $endpoint = "repos/$env:GITHUB_REPOSITORY/actions/workflows/ci.yml/runs" + - "?head_sha=$revision&event=push&status=completed&per_page=100" - $response = gh api $endpoint + "?head_sha=$revision&status=completed&per_page=100" + $response = gh api $endpoint --paginate --slurp if ($LASTEXITCODE -ne 0) { throw "Central CI provenance lookup failed" } - $runs = ($response | Out-String) | ConvertFrom-Json - $successful = @($runs.workflow_runs | Where-Object { + $pages = ($response | Out-String) | ConvertFrom-Json + $successful = @($pages | ForEach-Object { $_.workflow_runs } | Where-Object { [string]$_.head_sha -ceq $revision -and - [string]$_.event -ceq "push" -and + [string]$_.event -cin @("push", "workflow_dispatch", "schedule") -and [string]$_.status -ceq "completed" -and [string]$_.conclusion -ceq "success" }) - if ($successful.Count -lt 1) { - throw "The exact release revision has no successful central CI push run" + $fullVerified = $false + foreach ($run in $successful) { + $runID = [string]$run.id + $attempt = [string]$run.run_attempt + if ($runID -notmatch '^[1-9][0-9]*$' -or $attempt -notmatch '^[1-9][0-9]*$') { + throw "Central CI run or attempt identity is invalid" + } + # Partial runs can succeed. The full-only aggregate is successful + # only after every full check passed in this same run attempt. + $jobsEndpoint = "repos/$env:GITHUB_REPOSITORY/actions/runs/$runID" + + "/attempts/$attempt/jobs?per_page=100" + $jobResponse = gh api $jobsEndpoint --paginate --slurp + if ($LASTEXITCODE -ne 0) { throw "Full central CI job lookup failed" } + $jobPages = ($jobResponse | Out-String) | ConvertFrom-Json + $fullJobs = @($jobPages | ForEach-Object { $_.jobs } | Where-Object { + [string]$_.name -ceq "Full CI verification" + }) + if ($fullJobs.Count -eq 1 -and + [string]$fullJobs[0].run_id -ceq $runID -and + [string]$fullJobs[0].head_sha -ceq $revision -and + [string]$fullJobs[0].status -ceq "completed" -and + [string]$fullJobs[0].conclusion -ceq "success") { + $fullVerified = $true + break + } + } + if (-not $fullVerified) { + throw "The exact release revision has no successful full central CI; run CI manually for this revision before releasing" } - name: Set up pinned Go 1.25.12 diff --git a/docs/ci.md b/docs/ci.md index f6e80e50..91ff236f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,21 +1,79 @@ # CI checks and execution time -The Go result keeps the name **Go control plane**. It aggregates the -general Go checks and all applicable Store shards. A failed classifier, failed -shard, cancellation, or unexpected skip cannot produce a successful Go result. +PRs and main pushes select checks from their changed inputs. **Go control +plane** aggregates the selected Go checks, Store shards, and authority race +checks. **Full CI verification** is a separate result produced only by a complete +run; releases require that result for their exact source commit. ## What runs | Change | Central CI | Desktop release workflow | | --- | --- | --- | -| Push to main | Full checks | Runs for version tags or manual release requests | -| PR containing code, build scripts, workflows, generated contracts, or unknown paths | Full checks | Runs when its existing product-input paths match | -| PR changing only the six explicitly listed documentation files | Module, protocol, Surface and documentation release checks | README.md still triggers archive validation because it is packaged | - -The exact documentation allowlist is in `scripts/ci/classify_changes.py`. -An empty, invalid, or unreadable diff is an error, never permission to skip tests. -Other documentation, including migration history and generated protocol files, -continues through full central CI. Main pushes never use the documentation shortcut. +| Documentation | Module, protocol, Surface and documentation contract checks | No PR package build | +| Frontend | Frontend types, API drift, tests, build, dependency audit, and Go tests loading the actual embedded production bundle | No PR package build | +| OpenAPI snapshot | Frontend checks plus the Go DTO-to-OpenAPI golden check | No PR package build | +| Go source or resources | Direct package tests, affected consumer tests or compilation, and relevant integration tests | No PR package build | +| Store or its dependencies | The applicable Go checks plus all eight Store shards | No PR package build | +| LSP, analyzer, browser or native execution inputs | Their corresponding real runtime / platform checks | No package build unless packaging inputs change | +| Packaging / release tooling | Native boundaries and packaging checks | Existing PR package validation | +| Go module dependencies, CI machinery or unclassified inputs | Full checks | Only if release tooling also changes | +| Nightly or manual CI | Full checks, platform matrix, packaging and Go vulnerability audit | Version tags / manual releases still use the release workflow | + +The selection rules live in `scripts/ci/classify_changes.py`. Main uses the push +before/after commits, so merging a frontend change does not rerun the entire +backend matrix. Nightly CI runs at 19:23 UTC (03:23 Asia/Hong_Kong). The Actions +page's **CI → Run workflow** starts a full run for a selected branch. + +An unreadable or empty change set fails selection. Failed selection, a failed +required job, cancellation, or an unexpected required-job skip cannot become a +successful aggregate result. Unclassified build inputs select full checks. + +## Go impact selection + +`scripts/ci/run_go_checks.py` uses `go list -json ./...` to identify package +ownership, embedded resources and the real import graph. Production imports +propagate impact; test imports add the corresponding test consumer without +turning a test-only dependency into a production dependency. A change confined +to test files or local testdata stays with its package. Shared fixture paths in +`TEST_INPUT_CONSUMERS` explicitly include their other test consumers; the agent-package launch-handoff +fixture therefore runs both the agent-package and MCP suites. Upstream skill +fixtures also select their snapshot, import, CLI or Store consumers. + +Directly changed packages run their complete tests. Indirect consumers also run +their tests except the four large integration packages: `application`, `app`, +`httpapi` and `desktop`. Those compile their tests and run explicitly selected +existing provider, MCP and fixture integration tests where relevant. An affected `httpapi` +package also runs its Go DTO-to-OpenAPI golden check, including when only +`docs/openapi.json` changes. Store runs separately through its existing shards. +Vet covers the entire affected set; subsequent tests disable duplicate automatic vet. + +This deliberately reserves the entire large integration suites for direct +changes and full CI. A direct `application` change can still take significantly +longer than a frontend or leaf-package change. A removed package or an input +with no current Go owner selects full checks, including Store and all platforms. + +The native jobs retain their real platform checks. On a runtime-only change they +run desktop boundary and tagged adapter tests without producing release archives +or running the second reproducibility build. These adapter tests still build the +renderer because they consume the embedded production assets. + +The frontend lane also runs the desktop-tagged `web` package tests on Linux. +These pass the built assets through the same `LoadEmbeddedFS` loader used at +Desktop startup, so invalid production assets fail without a native package +build. Native and packaging checks run that same test alongside the desktop +entry tests. The Rust analyzer lane also runs the Go consumers of the shared +analyzer protocol and archive inventory golden vectors. + +## Release verification + +Ordinary Go, frontend, documentation and dependency changes no longer launch the +separate Desktop release workflow on every PR. Changes to actual release tools, +packaging scripts, packaging assets and branding still do. + +Before a tag or manual release, run full CI for that exact commit (or use a +successful nightly run of the same commit). The release workflow checks a +successful **Full CI verification** job from the same run attempt and commit. +A successful partial run, a PR run, or a skipped full gate is not release evidence. ## Store tests @@ -60,6 +118,5 @@ Central CI and PR-only release validation cancel superseded runs. Heavy jobs use `!cancelled()` so cancellation does not start further work. Tag and manually requested release runs retain their non-cancelling behavior. -Desktop release PR triggers exclude documentation that is not an archive input. -This is not cross-workflow artifact reuse: product-input changes still run the -existing real archive and reproducibility checks. +PR/push, nightly and manual runs use separate concurrency groups, so a new PR +commit does not cancel a full nightly or manually requested run. diff --git a/internal/releasegate/brand_assets_test.go b/internal/releasegate/brand_assets_test.go index 52b6e56f..dfbada04 100644 --- a/internal/releasegate/brand_assets_test.go +++ b/internal/releasegate/brand_assets_test.go @@ -101,8 +101,6 @@ func TestApprovedBrandMasterAndPlatformAssetsStaySynchronized(t *testing.T) { "scripts/windows-visual-assets.ps1", "assets/branding/**", "packaging/macos/**", - "web/public/**", - "web/src/assets/traverse-board-mark.png", } { if !strings.Contains(workflow, requiredPath) { t.Fatalf("Desktop release workflow does not watch %q", requiredPath) diff --git a/internal/releasegate/font_assets_test.go b/internal/releasegate/font_assets_test.go index f4d1d212..f3963a64 100644 --- a/internal/releasegate/font_assets_test.go +++ b/internal/releasegate/font_assets_test.go @@ -83,10 +83,6 @@ func TestApprovedHarmonyOSSansAssetsStayByteIdentical(t *testing.T) { if !strings.Contains(attributes, "web/src/assets/fonts/*.ttf -text") { t.Fatal("font binaries are not explicitly protected from Git text conversion") } - workflow := string(readTestFile(t, filepath.Join(root, ".github", "workflows", "release-desktop.yml"))) - if !strings.Contains(workflow, "web/src/assets/fonts/**") || !strings.Contains(workflow, "web/public/**") { - t.Fatal("Desktop release workflow does not watch fonts and their public notices") - } readme := string(readTestFile(t, filepath.Join(root, "README.md"))) settings := string(readTestFile(t, filepath.Join(root, "web", "src", "v2", "components", "settings.tsx"))) if !strings.Contains(readme, "第三方字体声明") || diff --git a/internal/releasegate/workflow_contract_test.go b/internal/releasegate/workflow_contract_test.go index 4deacfe5..2c1d7359 100644 --- a/internal/releasegate/workflow_contract_test.go +++ b/internal/releasegate/workflow_contract_test.go @@ -27,8 +27,9 @@ func TestDesktopReleaseWorkflowWiresEvidenceAndReverifiesBeforePublish(t *testin "scripts/finalize-windows-release.ps1", "packaging/windows/RELEASE-NOTES.md", "Validate Draft Release evidence intake", - "Require successful central CI for the exact release revision", - "The exact release revision has no successful central CI push run", + "Require successful full central CI for the exact release revision", + "Full CI verification", + "The exact release revision has no successful full central CI", "standard-code-product-e2e.json", "standard-code-security-evidence.json", "standard-code-release-gate.json", diff --git a/scripts/build-desktop-darwin.sh b/scripts/build-desktop-darwin.sh index d520620f..304f71cf 100755 --- a/scripts/build-desktop-darwin.sh +++ b/scripts/build-desktop-darwin.sh @@ -98,7 +98,7 @@ if [ "$SkipFrontend" != true ]; then fi go test ./internal/desktop ./internal/webui -count=1 -go test -tags "desktop" ./cmd/cyberagent-desktop -count=1 +go test -count=1 -tags "desktop" ./cmd/cyberagent-desktop ./web revision="$(git rev-parse HEAD)" if [ -z "$revision" ] || ! printf '%s' "$revision" | grep -Eq '^[0-9a-f]{40}$'; then diff --git a/scripts/build-desktop.ps1 b/scripts/build-desktop.ps1 index 79af4158..0271004b 100644 --- a/scripts/build-desktop.ps1 +++ b/scripts/build-desktop.ps1 @@ -95,7 +95,7 @@ try { go test ./internal/desktop ./internal/webui -count=1 ` -skip '^TestWindowsControlPlaneConsumesValidatedLocalSandboxReadiness$' } "Desktop Go boundary tests failed" - Invoke-Checked { go test -tags "desktop,wv2runtime.error" ./cmd/cyberagent-desktop -count=1 } "Wails adapter tests failed" + Invoke-Checked { go test -count=1 -tags "desktop,wv2runtime.error" ./cmd/cyberagent-desktop ./web } "Wails adapter tests failed" $revision = (& git rev-parse HEAD).Trim() if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^[0-9a-f]{40}$') { diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py index c49294cb..6a34e5a8 100644 --- a/scripts/ci/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -1,80 +1,138 @@ #!/usr/bin/env python3 -"""Classify the small, explicit pull-request documentation fast path.""" +"""Choose PR/push checks from changed inputs and the Go import graph.""" from __future__ import annotations import argparse +import json +import os import re import subprocess import sys from pathlib import Path from typing import Sequence - _COMMIT_RE = re.compile(r"[0-9a-f]{40}") - -# Keep this list deliberately exact. Documentation that is generated, embedded, -# parsed by tests, or used as release input must take the full path unless it is -# reviewed and added here separately. -_DOC_ONLY_PATHS = frozenset( - { - b"CONTRIBUTING.md", - b"README.en.md", - b"README.md", - b"docs/mac-keychain.md", - b"docs/macos-release.md", - b"docs/scheduled-jobs-diagnostics.md", - } +CHECKS = ("full", "backend", "store", "web", "lsp", "rust", "native", + "ui", "race", "audit", "packaging") +_FULL_PATHS = {"go.mod", "go.sum", ".github/workflows/ci.yml"} +_PACKAGING_PREFIXES = ( + "packaging/", "assets/branding/", "scripts/build-desktop", + "scripts/release-desktop", "scripts/package-", "scripts/verify-", + "scripts/stage-direct-exe", "scripts/finalize-windows-release", + "scripts/check-windows-compat", "scripts/check-macos-compat", + "scripts/macos-release", "scripts/test_macos_release", + "scripts/windows-visual-assets", "scripts/generate-brand-assets", + "scripts/smoke-desktop", "scripts/standard-code-packaged", + "scripts/test-standard-code-packaged", "scripts/standard-code-product", +) +_NATIVE_APPLICATION_PREFIXES = ( + "internal/application/command_", "internal/application/local_command", + "internal/application/windows_", "internal/application/runtime_", + "internal/application/execution_", "internal/application/host_", + "internal/application/run_capability_readiness.go", + "internal/app/command_", "internal/app/commands", "internal/app/execution_", + "internal/app/api_", "internal/app/app.go", + "internal/store/command_", "internal/store/execution_", + "internal/toolgateway/command_runtime", +) +_RACE_PREFIXES = ( + "internal/application/command_", "internal/application/approval", + "internal/application/execution_", "internal/application/permission", + "internal/application/workspace_", "internal/application/run_", + "internal/httpapi/execution_", "internal/httpapi/approval", ) class ClassificationError(RuntimeError): - """The change set could not be classified safely.""" + """The change set could not be read.""" -def classify_paths(paths: Sequence[bytes]) -> bool: - """Return whether every changed path is in the exact documentation allowlist.""" +def classify_paths(paths: Sequence[bytes], affected: Sequence[str] = ()) -> dict[str, bool]: if not paths: - raise ClassificationError("the pull request diff is empty") - return all(path in _DOC_ONLY_PATHS for path in paths) + raise ClassificationError("the change set is empty") + checks = dict.fromkeys(CHECKS, False) + for raw_path in paths: + path = raw_path.decode("utf-8", "surrogateescape") + if path in _FULL_PATHS or path.startswith("scripts/ci/"): + return dict.fromkeys(CHECKS, True) + if path.startswith(("internal/", "cmd/")): + checks["backend"] = True + if path.startswith(("internal/desktop/", "cmd/cyberagent-desktop/", "cmd/cyberagent/")): + checks["native"] = True + if path.startswith(("internal/httpapi/", "internal/webui/")): + checks["web"] = True + if path.startswith(_NATIVE_APPLICATION_PREFIXES): + checks["native"] = True + if path.startswith(_RACE_PREFIXES): + checks["race"] = True + if path.startswith(("internal/releasegate/", "internal/packagede2e/", + "cmd/releasegate/", "cmd/releasegen/", "cmd/packagede2e/")): + checks["packaging"] = checks["native"] = True + elif path.startswith("web/"): + checks["web"] = True + # The desktop-tagged embedding adapter is absent from default go list. + if path.endswith(".go"): + checks["native"] = True + elif path == "docs/openapi.json": + checks["backend"] = checks["web"] = True + elif path.startswith("analyzers/") or path == "scripts/build-embedded-wasi.sh": + checks["rust"] = checks["native"] = True + elif path.startswith(_PACKAGING_PREFIXES) or path == ".github/workflows/release-desktop.yml": + checks["packaging"] = checks["native"] = True + elif path.startswith(("scripts/prepare-windows-fixed-go", + "scripts/tests/prepare-windows-fixed-go")): + checks["native"] = True + elif (path.endswith(".md") or path.startswith("docs/convergence/") + or path in {".gitignore", ".gitattributes", "LICENSE", "LICENSE.md"} + or path.startswith((".github/ISSUE_TEMPLATE/", ".github/PULL_REQUEST_TEMPLATE"))): + # Generated registry/inventory and documentation contracts are + # checked in the always-on Go checks job. + pass + else: + # New build inputs and unclassified tooling receive full coverage. + return dict.fromkeys(CHECKS, True) + + def touches(*packages: str) -> bool: + return any(name.endswith("/internal/" + package) + for name in affected for package in packages) + + checks["store"] = touches("store") + checks["rust"] |= touches("analyzer") + # These are the runtime inputs exercised by the real LSP tests. A generic + # toolgateway import in workspace does not make every registry edit an LSP change. + checks["lsp"] = any(path.startswith(( + b"internal/codeintel/", b"internal/workspace/", b"internal/repository/", + b"internal/apperror/", b"internal/redact/", + )) for path in paths) + checks["native"] |= touches("runner", "sandbox", "credential", "commandruntimeadapter") + checks["native"] |= checks["rust"] + checks["ui"] = touches("browserruntime", "uievidence", "sandbox") + checks["race"] |= touches("domain", "executionauth", "policy", "runmutation", "workspace") + return checks def _git(repo: Path, *args: str) -> bytes: try: return subprocess.run( - ["git", "-C", str(repo), *args], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + ["git", "-C", str(repo), *args], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, ).stdout except (OSError, subprocess.CalledProcessError) as exc: - detail = "" - if isinstance(exc, subprocess.CalledProcessError): - detail = exc.stderr.decode("utf-8", "replace").strip() + detail = (exc.stderr.decode("utf-8", "replace").strip() + if isinstance(exc, subprocess.CalledProcessError) else str(exc)) raise ClassificationError(detail or f"git {' '.join(args)} failed") from exc def changed_paths(repo: Path, base: str, head: str) -> list[bytes]: - """Read an exact, NUL-delimited diff while exposing both sides of renames.""" + """Include both sides of renames; preserve spaces and newlines in filenames.""" for label, revision in (("base", base), ("head", head)): if _COMMIT_RE.fullmatch(revision) is None: raise ClassificationError(f"{label} revision is invalid") _git(repo, "cat-file", "-e", f"{revision}^{{commit}}") - - output = _git( - repo, - "diff", - "--no-renames", - "--name-only", - "-z", - base, - head, - "--", - ) - if not output: - raise ClassificationError("the pull request diff is empty") - if not output.endswith(b"\0"): - raise ClassificationError("git returned a malformed path list") + output = _git(repo, "diff", "--no-renames", "--name-only", "-z", base, head, "--") + if not output or not output.endswith(b"\0"): + raise ClassificationError("the change set is empty or malformed") paths = output[:-1].split(b"\0") if any(not path or path.startswith(b"/") for path in paths): raise ClassificationError("git returned an invalid repository path") @@ -88,18 +146,45 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument("--base", default="") parser.add_argument("--head", default="") args = parser.parse_args(argv) - - # Pushes to main and every non-PR event always take the full test path. - if args.event != "pull_request": - print("docs_only=false") - return 0 - try: - docs_only = classify_paths(changed_paths(args.repo, args.base, args.head)) - except ClassificationError as exc: + from run_go_checks import collect_packages, select_plan + + full = args.event in {"schedule", "workflow_dispatch"} + if args.event not in {"pull_request", "push", "schedule", "workflow_dispatch"}: + raise ClassificationError(f"unsupported event: {args.event}") + paths = [] if full else changed_paths(args.repo, args.base, args.head) + checks = dict.fromkeys(CHECKS, True) if full else classify_paths(paths) + go_plan = {} + if checks["backend"]: + packages = collect_packages(args.repo) + go_paths = [path for path in paths if path.startswith((b"internal/", b"cmd/")) + or path == b"docs/openapi.json"] + go_plan = select_plan(packages, go_paths, full=checks["full"]) + if go_plan["full"]: + checks = dict.fromkeys(CHECKS, True) + else: + checks = classify_paths(paths, go_plan["affected"]) + checks["store"] = go_plan["store_affected"] + for key, value in checks.items(): + print(f"{key}={str(value).lower()}") + print("checks=" + json.dumps(checks, separators=(",", ":"))) + print("go_plan=" + json.dumps(go_plan, separators=(",", ":"))) + print("Selected checks: " + ", ".join(key for key, value in checks.items() if value), + file=sys.stderr) + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(summary).open("a", encoding="utf-8") as output: + output.write("### CI selection\n\n") + output.write("Full run\n\n" if checks["full"] else "Affected checks\n\n") + output.write("| Check | Selected |\n| --- | --- |\n") + for key, value in checks.items(): + output.write(f"| {key} | {'yes' if value else 'no'} |\n") + if go_plan: + output.write(f"\nGo: {len(go_plan['test'])} packages with full tests, " + f"{len(go_plan['compile'])} with compilation checks, " + f"{len(go_plan['integrations'])} integration selections.\n") + except (ClassificationError, RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as exc: print(f"change classification failed: {exc}", file=sys.stderr) return 1 - print(f"docs_only={str(docs_only).lower()}") return 0 diff --git a/scripts/ci/run_go_checks.py b/scripts/ci/run_go_checks.py new file mode 100644 index 00000000..c8fc5137 --- /dev/null +++ b/scripts/ci/run_go_checks.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Select Go checks from the actual package graph, excluding sharded Store tests.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path, PurePosixPath +from typing import Sequence + + +VERSION = "go_checks_plan.v1" +HEAVY_INDIRECT = {"internal/application", "internal/app", "internal/httpapi", "internal/desktop"} +OPENAPI_GOLDEN = "TestOpenAPIGoldenDocumentMatchesGoDTOs" +# Tests also read inputs outside their package. Keep these known consumers +# explicit: they affect tests, without propagating production import edges. +# A trailing slash denotes a subtree; None means the consumer's normal suite. +TEST_INPUT_CONSUMERS = { + "docs/openapi.json": (("internal/httpapi", OPENAPI_GOLDEN),), + "internal/agentpackages/testdata/launch-handoff/": (("internal/mcp", None),), + "internal/agentpackages/testdata/upstream/agent-plugins-example/": ( + ("internal/plugins", None), + ("internal/application", "TestPortableSkillUpstreamImportReadResourceAndRestart"), + ("internal/app", "TestPortableSkillCLIImportsThroughExistingCommandAndPluginLifecycle"), + ), + "internal/agentpackages/testdata/upstream/anthropic-skill-creator/skills/skill-creator/": ( + ("internal/plugins", None), + ("internal/application", "TestPortableSkillUpstreamImportReadResourceAndRestart"), + ("internal/store", None), + ), +} +# These are bounded, existing product-service regressions. The graph still +# decides whether their package is affected; a directly changed package is full. +INTEGRATIONS = ( + ("internal/llm", "internal/application", "TestOpenAICompatibleProviderAgentRunnerToolRoundTrip"), + ("internal/toolgateway", "internal/application", "TestThreadTurnStopsAtPendingMCPApprovalAndResumesSameTurn"), + ("internal/toolgateway", "internal/httpapi", "TestMCPApprovalHTTPProductSameTurnAndReplay"), + ("internal/mcp", "internal/app", "TestMCPServeCLIUsesPersistedWorkspaceForReadTools"), + ("internal/toolgateway", "internal/app", "TestMCPServeCLIUsesPersistedWorkspaceForReadTools"), +) + + +class GoCheckError(RuntimeError): + pass + + +def _output(repo: Path, *command: str) -> bytes: + try: + return subprocess.run(command, cwd=repo, check=True, capture_output=True).stdout + except (OSError, subprocess.CalledProcessError) as exc: + detail = exc.stderr.decode("utf-8", "replace").strip() if isinstance(exc, subprocess.CalledProcessError) else str(exc) + raise GoCheckError(detail or f"{' '.join(command)} failed") from exc + + +def collect_packages(repo: Path) -> list[dict]: + """Read native Go package metadata, including imports used only by tests.""" + raw = _output(repo, "go", "list", "-json", "./...").decode("utf-8") + decoder = json.JSONDecoder() + packages = [] + offset = 0 + while offset < len(raw): + if raw[offset].isspace(): + offset += 1 + continue + package, offset = decoder.raw_decode(raw, offset) + if not isinstance(package, dict) or package.get("Error") or package.get("DepsErrors"): + raise GoCheckError("go list returned an invalid or incomplete package graph") + packages.append(package) + if not packages: + raise GoCheckError("go list returned an empty package graph") + return packages + + +def changed_paths(repo: Path, base: str, head: str) -> list[str]: + for revision in (base, head): + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise GoCheckError("base and head must be exact commit identities") + _output(repo, "git", "cat-file", "-e", f"{revision}^{{commit}}") + raw = _output(repo, "git", "diff", "--no-renames", "--name-only", "-z", base, head, "--") + if not raw or not raw.endswith(b"\0"): + raise GoCheckError("the change set is empty or malformed") + return [name.decode("utf-8") for name in raw[:-1].split(b"\0")] + + +def _embedded(package: dict, directory: str, path: str, *, test: bool = False) -> bool: + relative = PurePosixPath(path).relative_to(directory).as_posix() + for field in (("TestEmbedFiles", "XTestEmbedFiles") if test else ("EmbedFiles",)): + if relative in package.get(field, []): + return True + # Deleted embedded files are absent from EmbedFiles. Conservatively retain + # the literal directory before a pattern's wildcard, rather than attempting + # to reimplement Go's embed matching or inspect source text. + for field in (("TestEmbedPatterns", "XTestEmbedPatterns") if test else ("EmbedPatterns",)): + for pattern in package.get(field, []): + pattern = pattern.removeprefix("all:") + wildcard = re.search(r"[*?\[]", pattern) + prefix = pattern[:wildcard.start()].rsplit("/", 1)[0] if wildcard else pattern + if wildcard and "/" not in pattern[:wildcard.start()]: + prefix = "" + if not prefix or relative == prefix or relative.startswith(prefix + "/"): + return True + return False + + +def select_plan(packages: Sequence[dict], paths: Sequence[str | bytes], full: bool = False) -> dict: + """Build a deterministic plan without reading source text or running tests.""" + modules = {package.get("Module", {}).get("Path") for package in packages} + if len(modules) != 1 or None in modules: + raise GoCheckError("expected packages from exactly one Go module") + module = modules.pop() + by_name = {package["ImportPath"]: package for package in packages} + if len(by_name) != len(packages) or any(not name.startswith(module + "/") and name != module for name in by_name): + raise GoCheckError("duplicate or foreign Go package") + directories = { + name: Path(package["Dir"]).relative_to(package["Module"]["Dir"]).as_posix() + for name, package in by_name.items() + } + by_directory = {directory: name for name, directory in directories.items()} + direct = set() + production_direct = set() + test_consumers = set() + input_tests = defaultdict(set) + reasons = [] + for raw_path in paths: + path = raw_path.decode("utf-8") if isinstance(raw_path, bytes) else raw_path + pure_path = PurePosixPath(path) + if pure_path.is_absolute() or ".." in pure_path.parts or "\\" in path: + raise GoCheckError("changed paths must be relative repository paths") + consumers = [consumer for source, targets in TEST_INPUT_CONSUMERS.items() + if path == source or (source.endswith("/") and path.startswith(source)) + for consumer in targets] + for target, test in consumers: + name = module + "/" + target + if name not in by_name: + raise GoCheckError(f"missing declared test input consumer: {name}") + test_consumers.add(name) + if test: + input_tests[name].add(test) + owners = set() + missing_go_package = False + if path.endswith(".go"): + owner = by_directory.get(pure_path.parent.as_posix()) + if owner: + owners.add(owner) + else: + missing_go_package = True + else: + # Runtime/test resources belong to their nearest current package. + # Include other embed owners too (a parent may embed a subtree). + for parent in pure_path.parents: + owner = by_directory.get(parent.as_posix()) + if owner: + owners.add(owner) + break + for name, directory in directories.items(): + if pure_path.is_relative_to(directory) and ( + _embedded(by_name[name], directory, path) or + _embedded(by_name[name], directory, path, test=True)): + owners.add(name) + if (not owners and not consumers) or missing_go_package: + reasons.append(f"no current Go package owns {path}; use full checks") + direct.update(owners) + for name in owners: + directory = directories[name] + relative = pure_path.relative_to(directory) + production_embed = _embedded(by_name[name], directory, path) + if production_embed or (path.endswith(".go") and not path.endswith("_test.go")) or ( + not path.endswith(".go") and "testdata" not in relative.parts and + not _embedded(by_name[name], directory, path, test=True)): + production_direct.add(name) + full = full or bool(reasons) + reverse = defaultdict(set) + for name, package in by_name.items(): + for imported in package.get("Imports", []): + if imported in by_name: + reverse[imported].add(name) + production_affected = set(by_name) if full else set(production_direct) + pending = list(production_affected) + while pending: + for dependent in reverse[pending.pop()] - production_affected: + production_affected.add(dependent) + pending.append(dependent) + affected = production_affected | direct | test_consumers + # A dependency change can affect a consumer's tests without changing its + # production package. Do not propagate that test-only edge to more callers. + for name, package in by_name.items(): + if any(imported in production_affected for field in ("TestImports", "XTestImports") + for imported in package.get(field, [])): + affected.add(name) + store = module + "/internal/store" + heavy = {module + "/" + path for path in HEAVY_INDIRECT} + tests = affected - {store} if full else (affected - heavy | direct) - {store} + compile_only = affected - tests - {store} + selected = defaultdict(set) + if not full: + httpapi = module + "/internal/httpapi" + if httpapi in compile_only: + selected[httpapi].add(OPENAPI_GOLDEN) + for package, names in input_tests.items(): + if package in compile_only: + selected[package].update(names) + for dependency, target, test in INTEGRATIONS: + package = module + "/" + target + if module + "/" + dependency in production_affected and package in compile_only: + selected[package].add(test) + return { + "version": VERSION, "module": module, "packages": sorted(by_name), + "full": full, "changed": sorted(direct), "affected": sorted(affected), + "production_affected": sorted(production_affected), + "test": sorted(tests), "compile": sorted(compile_only), + "store_affected": store in affected, "fallback_reasons": sorted(reasons), + "integrations": [{"package": package, "run": "^(" + "|".join(sorted(names)) + ")$"} + for package, names in sorted(selected.items())], + } + + +def _integration_names(expression: str) -> set[str]: + if not re.fullmatch(r"\^\(Test[A-Za-z0-9_]+(?:\|Test[A-Za-z0-9_]+)*\)\$", expression): + raise GoCheckError("integration filters must be exact test names") + return set(expression[2:-2].split("|")) + + +def validate_plan(plan: dict) -> None: + if plan.get("version") != VERSION or not isinstance(plan.get("module"), str): + raise GoCheckError("unsupported Go check plan") + module = plan["module"] + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.~/-]*", module) is None: + raise GoCheckError("invalid Go module identity") + available = set(plan["packages"]) + if any((not name.startswith(module + "/") and name != module) or + not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.~/-]*", name) for name in available): + raise GoCheckError("plan contains a foreign package") + for field in ("changed", "affected", "production_affected", "test", "compile"): + names = plan[field] + if len(names) != len(set(names)) or not set(names) <= available: + raise GoCheckError(f"invalid plan {field} packages") + if not set(plan["production_affected"]) <= set(plan["affected"]): + raise GoCheckError("production impact must belong to the affected package set") + store = module + "/internal/store" + tested, compiled = set(plan["test"]), set(plan["compile"]) + if tested & compiled or store in tested | compiled or tested | compiled != set(plan["affected"]) - {store}: + raise GoCheckError("plan must cover every affected package and exclude Store tests") + if plan["store_affected"] != (store in plan["affected"]): + raise GoCheckError("Store impact does not match the package graph") + if plan["full"] and (set(plan["affected"]) != available or compiled): + raise GoCheckError("full plan must test every non-Store package") + if not set(plan["changed"]) - {store} <= tested: + raise GoCheckError("directly changed packages require full tests") + for integration in plan["integrations"]: + if integration["package"] not in compiled: + raise GoCheckError("integration must belong to an indirectly compiled package") + _integration_names(integration["run"]) + + +def execute_plan(repo: Path, plan: dict) -> int: + validate_plan(plan) + vet_packages = ["./..."] if plan["full"] else plan["affected"] + if vet_packages: + command = ["go", "vet", *vet_packages] + print(" ".join(command), flush=True) + completed = subprocess.run(command, cwd=repo, check=False) + if completed.returncode: + return completed.returncode + for packages, flags in ((plan["test"], []), (plan["compile"], ["-run", "^$"])): + if packages: + command = ["go", "test", "-vet=off", "-count=1", "-timeout=60m", *flags, *packages] + print(" ".join(command), flush=True) + completed = subprocess.run(command, cwd=repo, check=False) + if completed.returncode: + return completed.returncode + for integration in plan["integrations"]: + command = ["go", "test", "-json", "-vet=off", "-count=1", "-timeout=60m", + "-run", integration["run"], integration["package"]] + print(" ".join(command), flush=True) + completed = set() + with subprocess.Popen(command, cwd=repo, stdout=subprocess.PIPE, + text=True, encoding="utf-8") as process: + assert process.stdout is not None + for line in process.stdout: + sys.stdout.write(line) + event = json.loads(line) + if event.get("Package") == integration["package"] and event.get("Action") in ("pass", "skip") and "/" not in event.get("Test", "/"): + completed.add(event["Test"]) + return_code = process.wait() + if return_code: + return return_code + if completed != _integration_names(integration["run"]): + raise GoCheckError("an integration filter no longer matches every selected test") + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--mode", choices=("affected", "full"), default="affected") + parser.add_argument("--base", default="") + parser.add_argument("--head", default="") + parser.add_argument("--plan", type=Path) + parser.add_argument("--plan-output", type=Path) + parser.add_argument("--plan-only", action="store_true") + args = parser.parse_args(argv) + try: + if args.plan: + plan = json.loads(args.plan.read_text(encoding="utf-8")) + else: + packages = collect_packages(args.repo) + paths = [] if args.mode == "full" else changed_paths(args.repo, args.base, args.head) + plan = select_plan(packages, paths, full=args.mode == "full") + validate_plan(plan) + if args.plan_output: + args.plan_output.parent.mkdir(parents=True, exist_ok=True) + args.plan_output.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8") + if args.plan_only: + print(json.dumps(plan, indent=2)) + return 0 + return execute_plan(args.repo, plan) + except (GoCheckError, OSError, ValueError, KeyError, TypeError) as exc: + print(f"Go checks failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_classify_changes.py b/scripts/ci/test_classify_changes.py index 580a8586..d8c821a7 100644 --- a/scripts/ci/test_classify_changes.py +++ b/scripts/ci/test_classify_changes.py @@ -1,96 +1,181 @@ from __future__ import annotations +import json import os import re import subprocess +import sys import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from io import StringIO from pathlib import Path +from unittest.mock import patch -from classify_changes import ClassificationError, changed_paths, classify_paths, main +from classify_changes import CHECKS, ClassificationError, changed_paths, classify_paths, main +import run_go_checks as runner +@patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": ""}) class ClassifyPathsTests(unittest.TestCase): - def test_exact_documentation_allowlist_is_lightweight(self) -> None: - self.assertTrue( - classify_paths( - [ - b"README.md", - b"README.en.md", - b"CONTRIBUTING.md", - b"docs/mac-keychain.md", - b"docs/macos-release.md", - b"docs/scheduled-jobs-diagnostics.md", - ] - ) - ) + def enabled(self, paths, affected=()): + return {name for name, enabled in classify_paths(paths, affected).items() if enabled} - def test_unknown_generated_and_mixed_paths_are_full(self) -> None: - for paths in ( - [b"docs/development-history.md"], - [b"docs/convergence/protocol-registry.md"], - [b"docs/convergence/surface-registry.json"], - [b"README.md", b"internal/store/sqlite.go"], - [b"README.md\nmalicious=true"], - ): + def test_frontend_and_documentation_do_not_start_backend_or_native_matrix(self): + for paths in ([b"web/src/v2/styles.css"], [b"web/package-lock.json"], + [b"web/public/traverse-board-favicon-32.png", b"docs/architecture.md"]): with self.subTest(paths=paths): - self.assertFalse(classify_paths(paths)) + self.assertEqual(self.enabled(paths), {"web"}) + self.assertEqual(self.enabled([b"README.md", b"docs/ci.md"]), set()) + self.assertEqual(self.enabled([b"docs/openapi.json"]), {"backend", "web"}) + + def test_go_dependencies_select_store_without_unrelated_platforms(self): + selected = self.enabled( + [b"internal/toolgateway/registry.go"], + ["project/internal/toolgateway", "project/internal/store", + "project/internal/application", "project/internal/app", + "project/internal/httpapi", "project/internal/desktop"], + ) + self.assertEqual(selected, {"backend", "store"}) + + def test_native_lsp_and_browser_runtime_inputs_are_selected(self): + self.assertEqual(self.enabled([b"internal/codeintel/runtime.go"]), + {"backend", "lsp"}) + checks = self.enabled([b"internal/runner/process_windows.go"], + ["project/internal/runner", "project/internal/browserruntime"]) + self.assertTrue({"backend", "native", "ui"} <= checks) + self.assertNotIn("packaging", checks) + checks = self.enabled([b"internal/domain/run.go"], [ + "project/internal/domain", "project/internal/store", "project/internal/runner", + ]) + self.assertTrue({"race", "store", "native"} <= checks) + for path in (b"internal/store/command_runtime_jobs.go", + b"internal/store/command_operation_approval.go", + b"internal/application/run_capability_readiness.go", + b"internal/toolgateway/command_runtime.go"): + self.assertIn("native", self.enabled([path]), path) + + def test_mixed_changes_keep_both_lanes_and_tagged_embedding_gets_native_checks(self): + self.assertEqual(self.enabled([b"web/src/App.tsx", b"internal/llm/openai.go"]), + {"backend", "web"}) + self.assertEqual(self.enabled([b"web/assets_desktop.go"]), {"web", "native"}) + self.assertEqual(self.enabled([b"scripts/build-desktop.ps1"]), {"native", "packaging"}) + self.assertEqual(self.enabled([b"analyzers/src/lib.rs"]), {"rust", "native"}) + + def test_global_inputs_and_unknown_paths_get_full_checks(self): + for path in (b"go.mod", b"go.sum", b"scripts/ci/classify_changes.py", + b".github/workflows/ci.yml", b"new-toolchain.toml", + b"protocols/registry.json", b"README.md\nunknown=true"): + with self.subTest(path=path): + self.assertEqual(self.enabled([path]), set(CHECKS)) - def test_empty_diff_fails_closed(self) -> None: + def test_empty_diff_is_an_error(self): with self.assertRaises(ClassificationError): classify_paths([]) - def test_non_pull_request_is_full_without_reading_git(self) -> None: - with tempfile.TemporaryDirectory() as directory: - output = StringIO() - with redirect_stdout(output): - self.assertEqual(main(["--event", "push", "--repo", directory]), 0) - self.assertEqual(output.getvalue(), "docs_only=false\n") + def test_schedule_and_manual_run_full_without_a_diff(self): + with patch("run_go_checks.collect_packages", return_value=[]) as collect, \ + patch("run_go_checks.select_plan", return_value={"full": True}) as select: + for event in ("schedule", "workflow_dispatch"): + output = StringIO() + with redirect_stdout(output), redirect_stderr(StringIO()): + self.assertEqual(main(["--event", event, "--repo", "."]), 0) + values = dict(line.split("=", 1) for line in output.getvalue().splitlines()) + self.assertTrue(all(json.loads(values["checks"]).values())) + self.assertEqual(values["full"], "true") + self.assertEqual(collect.call_count, 2) + self.assertTrue(select.call_args.kwargs["full"]) - def test_classification_failure_emits_no_skip_output(self) -> None: - output = StringIO() - errors = StringIO() - with tempfile.TemporaryDirectory() as directory, redirect_stdout(output), \ - redirect_stderr(errors): - result = main( - [ - "--event", - "pull_request", - "--repo", - directory, - "--base", - "bad", - "--head", - "also-bad", - ] - ) + def test_failed_diff_emits_no_selection_output(self): + output, errors = StringIO(), StringIO() + with redirect_stdout(output), redirect_stderr(errors): + result = main(["--event", "pull_request", "--repo", ".", + "--base", "bad", "--head", "also-bad"]) self.assertEqual(result, 1) self.assertEqual(output.getvalue(), "") - self.assertIn("classification failed", errors.getvalue()) +@patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": ""}) +class GoPlanClassificationTests(unittest.TestCase): + def classify(self, path): + root = Path("fixture-repo").absolute() + module = "cyberagent-workbench" + records = [] + for directory, imports in ( + ("internal/agentpackages", []), ("internal/mcp", []), ("internal/plugins", []), + ("internal/application", []), ("internal/app", ["internal/application"]), + ("internal/httpapi", ["internal/application"]), ("internal/store", [])): + records.append({"ImportPath": module + "/" + directory, + "Dir": str(root / directory), "Module": {"Path": module, "Dir": str(root)}, + "Imports": [module + "/" + imported for imported in imports]}) + output = StringIO() + with patch("classify_changes.changed_paths", return_value=[path]), \ + patch("run_go_checks.collect_packages", return_value=records), \ + redirect_stdout(output), redirect_stderr(StringIO()): + self.assertEqual(main(["--event", "pull_request", "--repo", str(root)]), 0) + values = dict(line.split("=", 1) for line in output.getvalue().splitlines()) + checks, plan = json.loads(values["checks"]), json.loads(values["go_plan"]) + runner.validate_plan(plan) + return {key for key, selected in checks.items() if selected}, plan + + def test_indirect_dto_change_keeps_openapi_golden_without_full_httpapi_suite(self): + checks, plan = self.classify(b"internal/application/github_review_service.go") + self.assertEqual(checks, {"backend"}) + self.assertIn("cyberagent-workbench/internal/httpapi", plan["compile"]) + self.assertNotIn("cyberagent-workbench/internal/httpapi", plan["test"]) + self.assertEqual(plan["integrations"], [{"package": "cyberagent-workbench/internal/httpapi", + "run": "^(TestOpenAPIGoldenDocumentMatchesGoDTOs)$"}]) + + def test_snapshot_only_runs_targeted_go_golden_and_web_api_check(self): + checks, plan = self.classify(b"docs/openapi.json") + self.assertEqual(checks, {"backend", "web"}) + self.assertFalse(plan["full"]) + self.assertEqual(plan["affected"], ["cyberagent-workbench/internal/httpapi"]) + self.assertEqual(plan["test"], []) + self.assertEqual(plan["integrations"], [{"package": "cyberagent-workbench/internal/httpapi", + "run": "^(TestOpenAPIGoldenDocumentMatchesGoDTOs)$"}]) + + def test_shared_launch_fixture_reaches_mcp_but_ordinary_fixture_stays_local(self): + for directory, consumers in (("launch-handoff", ["internal/agentpackages", "internal/mcp"]), + ("ordinary", ["internal/agentpackages"])): + with self.subTest(directory=directory): + checks, plan = self.classify(f"internal/agentpackages/testdata/{directory}/mcp.json".encode()) + self.assertEqual(checks, {"backend"}) + self.assertFalse(plan["full"]) + self.assertEqual(plan["test"], ["cyberagent-workbench/" + p for p in consumers]) + self.assertEqual(plan["production_affected"], []) + + def test_skill_creator_shared_subtree_activates_store_shards_but_pins_remain_local(self): + checks, plan = self.classify( + b"internal/agentpackages/testdata/upstream/anthropic-skill-creator/skills/skill-creator/SKILL.md") + self.assertEqual(checks, {"backend", "store"}) + self.assertTrue(plan["store_affected"]) + self.assertNotIn("cyberagent-workbench/internal/store", plan["test"] + plan["compile"]) + self.assertEqual(plan["production_affected"], []) + self.assertEqual(plan["integrations"], [{"package": "cyberagent-workbench/internal/application", + "run": "^(TestPortableSkillUpstreamImportReadResourceAndRestart)$"}]) + checks, plan = self.classify(b"internal/agentpackages/testdata/upstream/pins.json") + self.assertEqual(checks, {"backend"}) + self.assertEqual(plan["test"], ["cyberagent-workbench/internal/agentpackages"]) + + +@patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": ""}) class GitDiffTests(unittest.TestCase): - def setUp(self) -> None: + def setUp(self): self.temporary_directory = tempfile.TemporaryDirectory() self.repo = Path(self.temporary_directory.name) self.git("init", "-q") self.git("config", "user.name", "CI test") self.git("config", "user.email", "ci@example.invalid") - def tearDown(self) -> None: + def tearDown(self): self.temporary_directory.cleanup() - def git(self, *args: str) -> str: - return subprocess.run( - ["git", "-C", str(self.repo), *args], - check=True, - stdout=subprocess.PIPE, - text=True, - ).stdout.strip() + def git(self, *args): + return subprocess.run(["git", "-C", str(self.repo), *args], check=True, + stdout=subprocess.PIPE, text=True).stdout.strip() - def commit_file(self, name: str, content: str) -> str: + def commit_file(self, name, content): path = self.repo / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") @@ -99,100 +184,84 @@ def commit_file(self, name: str, content: str) -> str: return self.git("rev-parse", "HEAD") @unittest.skipIf(os.name == "nt", "Windows filenames cannot contain newlines") - def test_diff_is_nul_safe_for_newline_path(self) -> None: + def test_diff_is_nul_safe_for_newline_path(self): base = self.commit_file("README.md", "base\n") - self.commit_file("docs/bad\nname.md", "content\n") - head = self.git("rev-parse", "HEAD") - paths = changed_paths(self.repo, base, head) - self.assertEqual(paths, [b"docs/bad\nname.md"]) - self.assertFalse(classify_paths(paths)) - - def test_rename_from_unknown_path_exposes_both_sides(self) -> None: - base = self.commit_file("source.md", "content\n") - self.git("mv", "source.md", "README.en.md") + head = self.commit_file("docs/bad\nname.md", "content\n") + self.assertEqual(changed_paths(self.repo, base, head), [b"docs/bad\nname.md"]) + + def test_rename_and_delete_keep_original_input_paths(self): + base = self.commit_file("internal/old/file.go", "package old\n") + self.git("mv", "internal/old/file.go", "README.en.md") self.git("commit", "-qm", "rename") - head = self.git("rev-parse", "HEAD") - paths = changed_paths(self.repo, base, head) - self.assertEqual(paths, [b"README.en.md", b"source.md"]) - self.assertFalse(classify_paths(paths)) + paths = changed_paths(self.repo, base, self.git("rev-parse", "HEAD")) + self.assertEqual(paths, [b"README.en.md", b"internal/old/file.go"]) + self.assertTrue(classify_paths(paths)["backend"]) - def test_invalid_revision_fails_closed(self) -> None: - head = self.commit_file("README.md", "content\n") - with self.assertRaises(ClassificationError): - changed_paths(self.repo, "not-a-commit", head) + def test_main_push_uses_its_diff_instead_of_forcing_full_ci(self): + base = self.commit_file("README.md", "base\n") + head = self.commit_file("web/src/styles.css", "body {}\n") + output = StringIO() + with redirect_stdout(output), redirect_stderr(StringIO()): + self.assertEqual(main(["--event", "push", "--repo", str(self.repo), + "--base", base, "--head", head]), 0) + values = dict(line.split("=", 1) for line in output.getvalue().splitlines()) + self.assertEqual(values["web"], "true") + self.assertEqual(values["backend"], "false") + self.assertEqual(values["full"], "false") -class WorkflowContractTests(unittest.TestCase): +class WorkflowGateTests(unittest.TestCase): @classmethod - def setUpClass(cls) -> None: + def setUpClass(cls): cls.root = Path(__file__).resolve().parents[2] cls.ci = (cls.root / ".github/workflows/ci.yml").read_text(encoding="utf-8") - cls.release = (cls.root / ".github/workflows/release-desktop.yml").read_text( - encoding="utf-8" - ) + cls.release = (cls.root / ".github/workflows/release-desktop.yml").read_text(encoding="utf-8") - def test_ci_keeps_go_aggregate_and_fails_closed(self) -> None: - self.assertIn(" go:\n name: Go control plane", self.ci) - self.assertIn("CLASSIFY_RESULT: ${{ needs.classify.result }}", self.ci) - self.assertIn('if [[ "$CLASSIFY_RESULT" != success', self.ci) - self.assertIn('elif [[ "$STORE_TESTS_RESULT" != success ]]', self.ci) - # A missing classifier output differs from the sole skip value "true". - self.assertGreaterEqual( - self.ci.count("needs.classify.outputs.docs_only != 'true'"), - 8, - ) - self.assertIn("github.event_name != 'pull_request'", self.ci) - - def test_cancelled_runs_do_not_start_or_continue_heavy_jobs(self) -> None: - job_starts = { - match.group(1): match.start() - for match in re.finditer(r"(?m)^ ([a-z][a-z0-9-]*):\n", self.ci) - } - ordered_starts = sorted(job_starts.values()) - for job in ( - "go-checks", - "store-tests", - "code-intel-lsp", - "web", - "rust", - "desktop-macos", - "desktop", - "ui-evidence-windows", - ): - with self.subTest(job=job): - start = job_starts[job] - end = next( - (candidate for candidate in ordered_starts if candidate > start), - len(self.ci), - ) - header = self.ci[start:end].split(" runs-on:", 1)[0] - self.assertIn("if: ${{ !cancelled()", header) - self.assertNotIn("if: ${{ always()", header) - go_start = job_starts["go"] - go_end = next(candidate for candidate in ordered_starts if candidate > go_start) - aggregate_header = self.ci[go_start:go_end].split(" runs-on:", 1)[0] - self.assertIn("if: ${{ always() }}", aggregate_header) - - def test_release_pr_paths_only_drop_non_inputs(self) -> None: - trigger, _ = self.release.split("permissions:", 1) - self.assertIn(" - 'README.md'", trigger) - for removed in ( - "README.en.md", - "docs/macos-release.md", - "docs/standard-code-packaged-e2e.md", - "docs/standard-code-product-e2e.md", - "docs/standard-code-release-gate.md", - "docs/adr/0138-standard-code-packaged-e2e-foundation.md", - "docs/adr/0144-standard-code-release-gate-aggregation.md", - "docs/convergence/protocol-registry.md", - ): - with self.subTest(removed=removed): - self.assertNotIn(f" - '{removed}'", trigger) - self.assertIn(" - 'protocols/registry.json'", trigger) - self.assertIn( - "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", - self.release, - ) + def run_gate(self, job, needs): + start = self.ci.index(f" {job}:\n") + block = self.ci[start:] + match = re.search(r"python - <<'PY'\n(.*?) PY", block, re.S) + self.assertIsNotNone(match) + code = "\n".join(line[10:] for line in match.group(1).splitlines()) + env = dict(os.environ, NEEDS_JSON=json.dumps(needs)) + return subprocess.run([sys.executable, "-c", code], env=env, + capture_output=True, text=True).returncode + + def test_partial_gate_requires_selected_jobs_and_allows_unselected_skips(self): + needs = {job: {"result": "skipped"} + for job in ("store-tests", "go-race", "go-audit")} + checks = dict.fromkeys(CHECKS, False) + needs["classify"] = {"result": "success", "outputs": {"checks": json.dumps(checks)}} + needs["go-checks"] = {"result": "success"} + self.assertEqual(self.run_gate("go", needs), 0) + checks["store"] = True + needs["classify"]["outputs"]["checks"] = json.dumps(checks) + self.assertNotEqual(self.run_gate("go", needs), 0) + needs["store-tests"]["result"] = "success" + self.assertEqual(self.run_gate("go", needs), 0) + needs["classify"]["result"] = "failure" + self.assertNotEqual(self.run_gate("go", needs), 0) + + def test_full_gate_rejects_failed_cancelled_or_skipped_platform(self): + needs = {job: {"result": "success"} for job in ( + "classify", "go", "code-intel-lsp", "web", "rust", + "desktop-macos", "desktop", "ui-evidence-windows", + )} + self.assertEqual(self.run_gate("full-ci", needs), 0) + for result in ("skipped", "failure", "cancelled"): + needs["desktop"]["result"] = result + self.assertNotEqual(self.run_gate("full-ci", needs), 0) + full_header = self.ci.split(" full-ci:\n", 1)[1].split(" runs-on:", 1)[0] + self.assertIn("needs.classify.outputs.full == 'true'", full_header) + + def test_release_only_packages_on_relevant_inputs_and_requires_full_ci(self): + trigger = self.release.split("permissions:", 1)[0] + for ordinary in ("README.md", "go.mod", "go.sum", "web/package-lock.json", + "protocols/registry.json", "web/public/**"): + self.assertNotIn(f" - '{ordinary}'", trigger) + self.assertIn(" - 'scripts/build-desktop.ps1'", trigger) + self.assertIn('Full CI verification', self.release) + self.assertIn("/attempts/", self.release) if __name__ == "__main__": diff --git a/scripts/ci/test_run_go_checks.py b/scripts/ci/test_run_go_checks.py new file mode 100644 index 00000000..a2a59258 --- /dev/null +++ b/scripts/ci/test_run_go_checks.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import copy +import json +import subprocess +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import run_go_checks as runner + + +MODULE = "cyberagent-workbench" +ROOT = Path("fixture-repo").absolute() + + +def package(path: str, **fields) -> dict: + return {"ImportPath": MODULE + "/" + path, "Dir": str(ROOT / path), + "Module": {"Path": MODULE, "Dir": str(ROOT)}, **fields} + + +def name(path: str) -> str: + return MODULE + "/" + path + + +class GoSelectionTests(unittest.TestCase): + def setUp(self): + self.packages = [ + package("internal/llm"), package("internal/mcp"), package("internal/toolgateway"), + package("internal/application", Imports=[name("internal/llm"), name("internal/toolgateway"), name("internal/mcp")]), + package("internal/store", TestImports=[name("internal/application")]), + package("internal/httpapi", Imports=[name("internal/application")]), + package("internal/app", Imports=[name("internal/application")], XTestImports=[name("internal/httpapi")]), + package("internal/desktop", Imports=[name("internal/app")]), + package("internal/producte2e", Imports=[name("internal/desktop")]), + package("internal/packagede2e", Imports=[name("internal/desktop")]), + package("cmd/cyberagent-desktop", Imports=[name("internal/desktop")]), + package("internal/unrelated"), + ] + + def plan(self, paths, **kwargs): + plan = runner.select_plan(self.packages, paths, **kwargs) + runner.validate_plan(plan) + return plan + + def test_reverse_closure_includes_production_internal_and_external_test_imports(self): + plan = self.plan(["internal/llm/openai.go"]) + self.assertEqual(plan["changed"], [name("internal/llm")]) + self.assertEqual(set(plan["affected"]), {p["ImportPath"] for p in self.packages} - + {name("internal/mcp"), name("internal/toolgateway"), name("internal/unrelated")}) + self.assertTrue(plan["store_affected"]) + self.assertNotIn(name("internal/store"), plan["test"] + plan["compile"]) + self.assertEqual(set(plan["compile"]), {name(p) for p in runner.HEAVY_INDIRECT}) + self.assertIn(name("internal/producte2e"), plan["test"]) + self.assertIn(name("internal/packagede2e"), plan["test"]) + self.assertEqual(plan["integrations"], [ + {"package": name("internal/application"), + "run": "^(TestOpenAICompatibleProviderAgentRunnerToolRoundTrip)$"}, + {"package": name("internal/httpapi"), + "run": "^(TestOpenAPIGoldenDocumentMatchesGoDTOs)$"}, + ]) + + def test_direct_large_package_runs_its_entire_suite(self): + plan = self.plan(["internal/application/thread_turn_test.go"]) + self.assertIn(name("internal/application"), plan["test"]) + self.assertNotIn(name("internal/application"), plan["compile"]) + self.assertNotIn(name("internal/application"), [x["package"] for x in plan["integrations"]]) + + def test_indirect_httpapi_dto_change_keeps_the_openapi_golden(self): + plan = self.plan(["internal/application/github_review_service.go"]) + self.assertFalse(plan["full"]) + self.assertIn(name("internal/application"), plan["test"]) + self.assertIn(name("internal/httpapi"), plan["compile"]) + self.assertNotIn(name("internal/httpapi"), plan["test"]) + self.assertIn({"package": name("internal/httpapi"), + "run": "^(TestOpenAPIGoldenDocumentMatchesGoDTOs)$"}, plan["integrations"]) + + def test_openapi_snapshot_is_only_a_targeted_httpapi_test_input(self): + plan = self.plan(["docs/openapi.json"]) + self.assertFalse(plan["full"]) + self.assertEqual(plan["changed"], []) + self.assertEqual(plan["production_affected"], []) + self.assertEqual(plan["affected"], [name("internal/httpapi")]) + self.assertEqual(plan["test"], []) + self.assertEqual(plan["compile"], [name("internal/httpapi")]) + self.assertFalse(plan["store_affected"]) + self.assertEqual(plan["integrations"], [{"package": name("internal/httpapi"), + "run": "^(TestOpenAPIGoldenDocumentMatchesGoDTOs)$"}]) + direct = self.plan(["docs/openapi.json", "internal/httpapi/openapi.go"]) + self.assertIn(name("internal/httpapi"), direct["test"]) + self.assertNotIn(name("internal/httpapi"), [entry["package"] for entry in direct["integrations"]]) + + def test_shared_launch_handoff_fixture_tests_mcp_without_production_propagation(self): + self.packages.append(package("internal/agentpackages")) + for path in ("internal/agentpackages/testdata/launch-handoff/mcp.json", + "internal/agentpackages/testdata/launch-handoff/deleted-file.json"): + with self.subTest(path=path): + plan = self.plan([path]) + self.assertFalse(plan["full"]) + self.assertEqual(plan["changed"], [name("internal/agentpackages")]) + self.assertEqual(plan["production_affected"], []) + self.assertEqual(set(plan["affected"]), {name("internal/agentpackages"), name("internal/mcp")}) + self.assertEqual(plan["test"], plan["affected"]) + self.assertEqual(plan["integrations"], []) + + def test_shared_upstream_fixtures_select_only_their_actual_consumers(self): + self.packages.extend([package("internal/agentpackages"), package("internal/plugins")]) + for subtree, extra, targeted in ( + ("agent-plugins-example/skills/example/SKILL.md", "internal/app", + [("internal/app", "TestPortableSkillCLIImportsThroughExistingCommandAndPluginLifecycle")]), + ("anthropic-skill-creator/skills/skill-creator/SKILL.md", "internal/store", [])): + with self.subTest(subtree=subtree): + plan = self.plan(["internal/agentpackages/testdata/upstream/" + subtree]) + self.assertFalse(plan["full"]) + self.assertEqual(plan["production_affected"], []) + self.assertEqual(set(plan["affected"]), {name(p) for p in + ("internal/agentpackages", "internal/plugins", "internal/application", extra)}) + self.assertEqual(set(plan["test"]), {name("internal/agentpackages"), name("internal/plugins")}) + self.assertEqual(plan["store_affected"], extra == "internal/store") + expected = targeted + [("internal/application", "TestPortableSkillUpstreamImportReadResourceAndRestart")] + self.assertEqual({(entry["package"], test) for entry in plan["integrations"] + for test in runner._integration_names(entry["run"])}, + {(name(p), test) for p, test in expected}) + + def test_ordinary_or_neighboring_fixtures_remain_local(self): + self.packages.append(package("internal/agentpackages")) + for path in ("internal/agentpackages/testdata/local/mcp.json", + "internal/agentpackages/testdata/launch-handoff-other/mcp.json", + "internal/agentpackages/testdata/upstream/pins.json", + "internal/agentpackages/testdata/upstream/anthropic-skill-creator/README.md"): + with self.subTest(path=path): + plan = self.plan([path]) + self.assertFalse(plan["full"]) + self.assertEqual(plan["affected"], [name("internal/agentpackages")]) + self.assertEqual(plan["test"], plan["affected"]) + self.assertEqual(plan["production_affected"], []) + self.assertFalse(plan["store_affected"]) + + def test_cycles_and_duplicate_imports_are_finite_and_deterministic(self): + self.packages[0]["TestImports"] = [name("internal/application")] * 2 + a = self.plan(["internal/llm/deleted.go"]) + b = runner.select_plan(list(reversed(self.packages)), ["internal/llm/deleted.go"]) + self.assertEqual(a, b) + + def test_test_import_cycle_does_not_propagate_into_unrelated_production_consumers(self): + records = [package("internal/llm"), + package("internal/application", Imports=[name("internal/llm")], + TestImports=[name("internal/store")]), + package("internal/store", XTestImports=[name("internal/application")]), + package("internal/workspace", Imports=[name("internal/store")]), + package("internal/toolgateway", TestImports=[name("internal/store")])] + plan = runner.select_plan(records, ["internal/llm/openai.go"]) + runner.validate_plan(plan) + self.assertEqual(set(plan["production_affected"]), {name("internal/llm"), name("internal/application")}) + self.assertEqual(set(plan["affected"]), {name("internal/llm"), name("internal/application"), name("internal/store")}) + + def test_test_file_and_testdata_changes_do_not_propagate_to_consumers(self): + for path in ("internal/llm/openai_test.go", "internal/llm/testdata/response.json"): + with self.subTest(path=path): + plan = self.plan([path]) + self.assertEqual(plan["production_affected"], []) + self.assertEqual(plan["affected"], [name("internal/llm")]) + self.assertEqual(plan["test"], [name("internal/llm")]) + self.assertFalse(plan["store_affected"]) + self.assertEqual(plan["integrations"], []) + + def test_production_embed_remains_a_production_input_even_inside_testdata(self): + self.packages[0]["EmbedPatterns"] = ["testdata"] + plan = self.plan(["internal/llm/testdata/runtime.json"]) + self.assertIn(name("internal/application"), plan["production_affected"]) + + def test_deleted_file_keeps_current_package_but_deleted_directory_is_full(self): + self.assertFalse(self.plan(["internal/llm/deleted.go"])["full"]) + plan = self.plan(["internal/removed/last.go"]) + self.assertTrue(plan["full"]) + self.assertTrue(plan["store_affected"]) + self.assertEqual(plan["compile"], []) + self.assertTrue(plan["fallback_reasons"]) + + def test_ignored_platform_source_still_marks_its_package_direct(self): + self.packages[0]["IgnoredGoFiles"] = ["platform_windows.go"] + self.assertEqual(self.plan(["internal/llm/platform_windows.go"])["changed"], [name("internal/llm")]) + + def test_embedded_and_testdata_resources_mark_the_owner(self): + self.packages.append(package("internal/skills", EmbedPatterns=["builtins", "all:archives"], + EmbedFiles=["builtins/current.md"], TestEmbedPatterns=["testdata/*.json"])) + for path in ("internal/skills/builtins/new.md", "internal/skills/archives/deleted/data.bin", + "internal/skills/testdata/deleted.json", "internal/skills/testdata/unembedded.txt"): + with self.subTest(path=path): + self.assertEqual(self.plan([path])["changed"], [name("internal/skills")]) + + def test_parent_embed_and_child_go_package_are_both_affected(self): + self.packages.append(package("internal/container", EmbedFiles=["child/data.txt"], + EmbedPatterns=["all:child"])) + self.packages.append(package("internal/container/child")) + for path in ("internal/container/child/data.txt", "internal/container/child/deleted.go"): + with self.subTest(path=path): + self.assertEqual(set(self.plan([path])["changed"]), + {name("internal/container"), name("internal/container/child")}) + + def test_full_mode_runs_every_non_store_package(self): + plan = self.plan([], full=True) + self.assertEqual(set(plan["test"]), {p["ImportPath"] for p in self.packages} - {name("internal/store")}) + self.assertEqual(plan["compile"], []) + self.assertEqual(plan["integrations"], []) + + def test_empty_selection_does_not_manufacture_a_full_run(self): + plan = self.plan([]) + self.assertEqual(plan["affected"], []) + self.assertEqual(plan["test"], []) + self.assertFalse(plan["store_affected"]) + + def test_core_integrations_and_openapi_golden_are_selected_once_for_indirect_packages(self): + plan = self.plan(["internal/llm/openai.go", "internal/toolgateway/gateway.go", "internal/mcp/tools.go"]) + selected = {(entry["package"], test) for entry in plan["integrations"] + for test in runner._integration_names(entry["run"])} + self.assertEqual(len(selected), 5) + self.assertIn((name("internal/httpapi"), "TestOpenAPIGoldenDocumentMatchesGoDTOs"), selected) + direct = self.plan(["internal/llm/openai.go", "internal/application/thread_turn.go"]) + self.assertNotIn(name("internal/application"), [x["package"] for x in direct["integrations"]]) + + def test_paths_cannot_escape_the_repository(self): + for path in ("/absolute.go", "../escape.go", "internal/../escape.go", "internal\\llm\\file.go"): + with self.subTest(path=path), self.assertRaises(runner.GoCheckError): + self.plan([path]) + + def test_invalid_plan_cannot_skip_a_direct_package_or_execute_store_tests(self): + original = self.plan(["internal/llm/openai.go"]) + mutations = [] + bad = copy.deepcopy(original) + bad["test"].remove(name("internal/llm")) + mutations.append(bad) + bad = copy.deepcopy(original) + bad["test"].append(name("internal/store")) + mutations.append(bad) + bad = copy.deepcopy(original) + bad["compile"].append(name("internal/llm")) + mutations.append(bad) + bad = copy.deepcopy(original) + bad["store_affected"] = False + mutations.append(bad) + bad = copy.deepcopy(original) + bad["integrations"][0]["run"] = ".*" + mutations.append(bad) + for plan in mutations: + with self.subTest(plan=plan), self.assertRaises(runner.GoCheckError): + runner.validate_plan(plan) + + def test_execution_vets_the_affected_set_and_disables_duplicate_test_vet(self): + plan = self.plan(["internal/llm/openai.go", "internal/application/thread_turn.go", "internal/httpapi/openapi.go"]) + with patch.object(runner.subprocess, "run", return_value=Mock(returncode=0)) as run, redirect_stdout(StringIO()): + self.assertEqual(runner.execute_plan(ROOT, plan), 0) + commands = [call.args[0] for call in run.call_args_list] + self.assertEqual(commands[0], ["go", "vet", *plan["affected"]]) + self.assertIn(name("internal/store"), commands[0]) + self.assertTrue(all("-vet=off" in command for command in commands[1:])) + self.assertTrue(all(name("internal/store") not in command for command in commands[1:])) + self.assertIn(["-run", "^$"], [command[5:7] for command in commands[1:]]) + + def test_full_execution_vets_the_whole_module_and_never_filters_tests(self): + plan = self.plan([], full=True) + with patch.object(runner.subprocess, "run", return_value=Mock(returncode=0)) as run, redirect_stdout(StringIO()): + self.assertEqual(runner.execute_plan(ROOT, plan), 0) + self.assertEqual(run.call_args_list[0].args[0], ["go", "vet", "./..."]) + self.assertNotIn("-run", run.call_args_list[1].args[0]) + + def test_failed_vet_prevents_test_execution(self): + with patch.object(runner.subprocess, "run", return_value=Mock(returncode=2)) as run, redirect_stdout(StringIO()): + self.assertEqual(runner.execute_plan(ROOT, self.plan(["internal/llm/openai.go"])), 2) + self.assertEqual(run.call_count, 1) + + def test_integration_filter_requires_the_selected_test_in_its_own_package(self): + plan = runner.select_plan([package("internal/llm"), + package("internal/application", Imports=[name("internal/llm")])], + ["internal/llm/openai.go"]) + selected = plan["integrations"][0] + test_name = "TestOpenAICompatibleProviderAgentRunnerToolRoundTrip" + for events, succeeds in (([], False), + ([{"Action": "pass", "Package": name("internal/unrelated"), "Test": test_name}], False), + ([{"Action": "pass", "Package": selected["package"], "Test": test_name}], True)): + with self.subTest(events=events): + process = MagicMock() + process.__enter__.return_value = process + process.stdout = iter(json.dumps(event) + "\n" for event in events) + process.wait.return_value = 0 + with patch.object(runner.subprocess, "run", return_value=Mock(returncode=0)), \ + patch.object(runner.subprocess, "Popen", return_value=process), redirect_stdout(StringIO()): + if succeeds: + self.assertEqual(runner.execute_plan(ROOT, plan), 0) + else: + with self.assertRaises(runner.GoCheckError): + runner.execute_plan(ROOT, plan) + + +class GoMetadataTests(unittest.TestCase): + def test_collect_decodes_the_real_go_json_sequence_without_source_inspection(self): + records = [package("internal/one", TestImports=[name("internal/two")]), package("internal/two")] + output = "\n\n".join(json.dumps(p) for p in records).encode() + with patch.object(runner, "_output", return_value=output) as command: + self.assertEqual(runner.collect_packages(ROOT), records) + command.assert_called_once_with(ROOT, "go", "list", "-json", "./...") + + def test_invalid_metadata_fails_closed(self): + for output in (b"", b"not json", b'{"Error":{"Err":"missing import"}}'): + with self.subTest(output=output), patch.object(runner, "_output", return_value=output), self.assertRaises((runner.GoCheckError, ValueError)): + runner.collect_packages(ROOT) + + def test_plan_only_and_plan_file_execution_do_not_recompute_the_graph(self): + records = [package("internal/llm"), package("internal/store")] + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "plan.json" + with patch.object(runner, "collect_packages", return_value=records), \ + patch.object(runner, "changed_paths", return_value=["internal/llm/openai.go"]), \ + patch.object(runner, "execute_plan") as execute, redirect_stdout(StringIO()): + result = runner.main(["--plan-only", "--plan-output", str(output), "--base", "b", "--head", "h"]) + self.assertEqual(result, 0) + execute.assert_not_called() + with patch.object(runner, "collect_packages") as collect, \ + patch.object(runner, "execute_plan", return_value=0) as execute: + self.assertEqual(runner.main(["--plan", str(output)]), 0) + collect.assert_not_called() + self.assertEqual(execute.call_args.args[1], json.loads(output.read_text())) + + def test_real_git_diff_exposes_renames_and_deleted_packages(self): + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + def git(*args): + return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip() + git("init", "-q") + git("config", "user.name", "CI test") + git("config", "user.email", "ci@example.invalid") + old = repo / "internal/old/last.go" + old.parent.mkdir(parents=True) + old.write_text("package old\n") + git("add", ".") + git("commit", "-qm", "old package") + base = git("rev-parse", "HEAD") + git("mv", "internal/old", "internal/new") + git("commit", "-qam", "rename package") + self.assertEqual(runner.changed_paths(repo, base, git("rev-parse", "HEAD")), + ["internal/new/last.go", "internal/old/last.go"]) + with self.assertRaises(runner.GoCheckError): + runner.changed_paths(repo, "bad", git("rev-parse", "HEAD")) + + +if __name__ == "__main__": + unittest.main() diff --git a/cmd/cyberagent-desktop/embedded_ui_test.go b/web/assets_desktop_test.go similarity index 99% rename from cmd/cyberagent-desktop/embedded_ui_test.go rename to web/assets_desktop_test.go index 612afc00..f7cefe9b 100644 --- a/cmd/cyberagent-desktop/embedded_ui_test.go +++ b/web/assets_desktop_test.go @@ -1,6 +1,6 @@ //go:build desktop -package main +package webassets_test import ( "crypto/sha256"