From 66c344e28931b2997397ab36ac02fa32cd1b549e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:31:27 +0800 Subject: [PATCH] ci: scope provider and native verification by changed inputs (#1660) --- .github/actionlint.yaml | 6 + .github/workflows/ci.yml | 195 ++++++--- .github/workflows/nativeaot-fixtures.yml | 7 +- .github/workflows/real-binary-layout.yml | 31 +- .github/workflows/real-browser.yml | 35 +- .github/workflows/real-evm-interface.yml | 28 +- .../workflows/real-javascript-recovery.yml | 27 +- .github/workflows/real-recorded-crash.yml | 29 +- .../workflows/real-web-network-captures.yml | 33 +- .github/workflows/real-web-runtime.yml | 30 +- .github/workflows/real-web-source-map.yml | 23 +- .github/workflows/website-check.yml | 7 +- docs/testing.md | 37 +- scripts/ci/plan.mjs | 174 ++++++++ scripts/ci/required.mjs | 25 ++ scripts/ci/scopes.mjs | 227 +++++++++++ .../boundary/filesystem/ciChangeScope.test.ts | 378 +++++++++++++++--- .../filesystem/releaseWorkflow.test.ts | 29 +- 18 files changed, 967 insertions(+), 354 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 scripts/ci/plan.mjs create mode 100644 scripts/ci/required.mjs create mode 100644 scripts/ci/scopes.mjs diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..465de2b613 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +self-hosted-runner: + labels: + - hopper + - ghidra-12-1-2 + # Hosted label introduced after the pinned actionlint release. + - macos-15-intel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 503d44f161..b55d8dcaf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: branches: [main, "release/*"] + workflow_dispatch: + +permissions: + contents: read concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} @@ -36,37 +40,39 @@ jobs: name: Classify changes runs-on: ubuntu-latest outputs: - code: ${{ steps.package.outputs.required }} - package: ${{ steps.package.outputs.required }} + code: ${{ steps.plan.outputs.code }} + runtime: ${{ steps.plan.outputs.runtime }} + package: ${{ steps.plan.outputs.package }} + apple: ${{ steps.plan.outputs.apple }} + fixtures: ${{ steps.plan.outputs.fixtures }} + readiness: ${{ steps.plan.outputs.readiness }} + docs: ${{ steps.plan.outputs.docs }} + workflows: ${{ steps.plan.outputs.workflows }} + browser: ${{ steps.plan.outputs.browser }} + binary_layout: ${{ steps.plan.outputs.binary_layout }} + evm: ${{ steps.plan.outputs.evm }} + javascript_recovery: ${{ steps.plan.outputs.javascript_recovery }} + recorded_crash: ${{ steps.plan.outputs.recorded_crash }} + web_network: ${{ steps.plan.outputs.web_network }} + web_runtime: ${{ steps.plan.outputs.web_runtime }} + web_source_map: ${{ steps.plan.outputs.web_source_map }} + nativeaot: ${{ steps.plan.outputs.nativeaot }} + website: ${{ steps.plan.outputs.website }} + matrix: ${{ steps.plan.outputs.matrix }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Select package and Windows lanes - id: package - shell: bash + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Select verification lanes + id: plan env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then - echo "required=true" >> "${GITHUB_OUTPUT}" - # Compare the PR's changes, excluding unrelated updates on its base. - elif git diff --quiet "${BASE_SHA}...${HEAD_SHA}" -- \ - . \ - ':(exclude)docs/**' \ - ':(top,exclude,glob)README*.md' \ - ':(exclude)AGENTS.md' \ - ':(exclude)CONTRIBUTING.md'; then - echo "required=false" >> "${GITHUB_OUTPUT}" - else - diff_status=$? - # A failed comparison must fail classification, not report a scope. - if [[ "${diff_status}" -ne 1 ]]; then - exit "${diff_status}" - fi - echo "required=true" >> "${GITHUB_OUTPUT}" - fi + CI_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }} + run: node scripts/ci/plan.mjs static: name: Static checks @@ -84,17 +90,17 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci - - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + - if: needs.changes.outputs.code == 'true' run: npm run check:ci - - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + - if: needs.changes.outputs.code == 'true' run: npm run verify:test-discovery - - if: github.event_name == 'pull_request' && needs.changes.outputs.code != 'true' + - if: needs.changes.outputs.code != 'true' run: npm run format:check test-shard: name: Test shard (${{ matrix.shard }}) needs: [changes, build] - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -131,7 +137,7 @@ jobs: build: name: Build package needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -224,7 +230,7 @@ jobs: conformance-fixtures: name: Source-built conformance fixtures needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + if: needs.changes.outputs.fixtures == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -235,7 +241,7 @@ jobs: windows-native-build: name: Build bundled Windows x64 native controls needs: changes - if: github.event_name == 'push' || needs.changes.outputs.package == 'true' + if: needs.changes.outputs.runtime == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -265,24 +271,11 @@ jobs: package-e2e: name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }}) needs: [changes, windows-native-build] - if: github.event_name == 'push' || needs.changes.outputs.package == 'true' + if: needs.changes.outputs.runtime == 'true' strategy: fail-fast: false max-parallel: 2 - matrix: - include: - - os: ubuntu-latest - platform: linux - architecture: x64 - - os: ubuntu-24.04-arm - platform: linux - architecture: arm64 - - os: macos-15 - platform: darwin - architecture: arm64 - - os: macos-15-intel - platform: darwin - architecture: x64 + matrix: ${{ fromJSON(needs.changes.outputs.matrix) }} runs-on: ${{ matrix.os }} timeout-minutes: 20 env: @@ -317,7 +310,7 @@ jobs: apple-artifacts: name: Real Apple artifact CLI/MCP E2E needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + if: needs.changes.outputs.apple == 'true' runs-on: macos-15 timeout-minutes: 15 steps: @@ -350,7 +343,7 @@ jobs: reconstruction-readiness: name: Reconstruction readiness needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + if: needs.changes.outputs.readiness == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -379,7 +372,7 @@ jobs: windows-curated: name: Windows capability and package lane needs: [changes, windows-native-build] - if: github.event_name == 'push' || needs.changes.outputs.package == 'true' + if: needs.changes.outputs.package == 'true' runs-on: windows-latest timeout-minutes: 25 steps: @@ -476,7 +469,8 @@ jobs: docs: name: Docs Generation - if: github.event_name == 'pull_request' + needs: changes + if: needs.changes.outputs.docs == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -496,3 +490,104 @@ jobs: docs/verification/managed-conformance-*.json skills/reverse-engineer-anything if-no-files-found: error + + workflows: + name: Validate workflows + needs: changes + if: needs.changes.outputs.workflows == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate workflow syntax and expressions + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes= + + browser: + needs: changes + if: needs.changes.outputs.browser == 'true' + uses: ./.github/workflows/real-browser.yml + + binary_layout: + needs: changes + if: needs.changes.outputs.binary_layout == 'true' + uses: ./.github/workflows/real-binary-layout.yml + + evm: + needs: changes + if: needs.changes.outputs.evm == 'true' + uses: ./.github/workflows/real-evm-interface.yml + + javascript_recovery: + needs: changes + if: needs.changes.outputs.javascript_recovery == 'true' + uses: ./.github/workflows/real-javascript-recovery.yml + + recorded_crash: + needs: changes + if: needs.changes.outputs.recorded_crash == 'true' + uses: ./.github/workflows/real-recorded-crash.yml + + web_network: + needs: changes + if: needs.changes.outputs.web_network == 'true' + uses: ./.github/workflows/real-web-network-captures.yml + + web_runtime: + needs: changes + if: needs.changes.outputs.web_runtime == 'true' + uses: ./.github/workflows/real-web-runtime.yml + + web_source_map: + needs: changes + if: needs.changes.outputs.web_source_map == 'true' + uses: ./.github/workflows/real-web-source-map.yml + + nativeaot: + needs: changes + if: needs.changes.outputs.nativeaot == 'true' + uses: ./.github/workflows/nativeaot-fixtures.yml + + website: + needs: changes + if: needs.changes.outputs.website == 'true' + uses: ./.github/workflows/website-check.yml + + required: + name: CI required + if: ${{ always() }} + needs: + [ + changes, + pr-title, + static, + build, + test-shard, + test, + conformance-fixtures, + windows-native-build, + package-e2e, + apple-artifacts, + reconstruction-readiness, + windows-curated, + docs, + workflows, + browser, + binary_layout, + evm, + javascript_recovery, + recorded_crash, + web_network, + web_runtime, + web_source_map, + nativeaot, + website, + ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Require every selected lane to pass + env: + CI_JOBS: ${{ toJSON(needs) }} + run: node scripts/ci/required.mjs diff --git a/.github/workflows/nativeaot-fixtures.yml b/.github/workflows/nativeaot-fixtures.yml index 92c6abf4f7..e819ca5fbc 100644 --- a/.github/workflows/nativeaot-fixtures.yml +++ b/.github/workflows/nativeaot-fixtures.yml @@ -2,12 +2,7 @@ name: Source-built NativeAOT fixtures on: workflow_dispatch: - pull_request: - paths: - - "bridge/ghidra/extensions/nativeaot/**" - - "scripts/generate-nativeaot-fixtures.mjs" - - "tests/fixtures/nativeaot/**" - - ".github/workflows/nativeaot-fixtures.yml" + workflow_call: permissions: contents: read diff --git a/.github/workflows/real-binary-layout.yml b/.github/workflows/real-binary-layout.yml index 95a03e165d..687f7a4b59 100644 --- a/.github/workflows/real-binary-layout.yml +++ b/.github/workflows/real-binary-layout.yml @@ -7,36 +7,7 @@ on: description: "Also verify the complete 65,281-section result over MCP (slow)" type: boolean default: false - pull_request: - paths: - - ".github/workflows/real-binary-layout.yml" - - "bridge/pwntools/**" - - "src/native/pwntools/**" - - "src/domain/native/binaryLayout*" - - "src/application/binaryDiagnostics/**" - - "src/contracts/native/binaryDiagnosticsToolContracts.ts" - - "src/composition/binaryDiagnostics.ts" - - "src/server/registerBinaryDiagnosticsTools.ts" - - "src/server/createServer.ts" - - "src/cli/binaryDiagnosticsCommands.ts" - - "src/cli.ts" - - "src/cliCommandNames.ts" - - "src/contracts/toolContracts.ts" - - "src/contracts/toolEffects.ts" - - "src/process/OwnedCommand.ts" - - "src/process/ProviderProcess.ts" - - "src/process/ProviderDeadline.ts" - - "src/process/ProcessOwnership*.ts" - - "src/process/PrivateRuntimeRoot.ts" - - "src/artifacts/readStableArtifact*.ts" - - "src/artifacts/ArtifactReader.ts" - - "src/domain/analysisError*.ts" - - "scripts/verify-binary-layout.mjs" - - "scripts/lib/elf-section-name-fixtures.mjs" - - "scripts/lib/large-result-mcp.mjs" - - "scripts/fixtures/binary-layout*" - - "package.json" - - "package-lock.json" + workflow_call: permissions: contents: read diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml index 39d5b288b9..0f380c0d2b 100644 --- a/.github/workflows/real-browser.yml +++ b/.github/workflows/real-browser.yml @@ -1,40 +1,7 @@ name: Real browser verification on: - pull_request: - paths: - - ".github/workflows/real-browser.yml" - - "scripts/verify-real-browser.mjs" - - "scripts/verify-browser-network.mjs" - - "scripts/verify-browser-scripts.mjs" - - "scripts/verify-browser-modules.mjs" - - "scripts/verify-browser-dom-destinations.mjs" - - "scripts/lib/browser-*.mjs" - - "scripts/fixtures/browser-network-site.mjs" - - "scripts/fixtures/browser-script-site.mjs" - - "scripts/fixtures/browser-module-site.mjs" - - "scripts/fixtures/browser-verifier-site.mjs" - - "scripts/fixtures/page-cdp-proxy.mjs" - - "src/browser/**" - - "src/domain/browser*.ts" - - "src/domain/webScript*.ts" - - "src/domain/webModule*.ts" - - "src/application/WebModule*.ts" - - "src/composition/webModules.ts" - - "src/artifacts/readStableArtifact.ts" - - "src/artifacts/ArtifactPaths.ts" - - "src/application/WebScriptExportService.ts" - - "src/contracts/webScriptToolContracts.ts" - - "src/server/registerWebScriptTool.ts" - - "src/server/registerWebModuleTool.ts" - - "src/server/createServer.ts" - - "src/cli/webModuleCommands.ts" - - "src/cli.ts" - - "src/cliCommandNames.ts" - - "src/contracts/toolEffects.ts" - - "src/cli/webScriptCommands.ts" - - "package.json" - - "package-lock.json" + workflow_call: workflow_dispatch: concurrency: diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml index a493481812..6ea22c20c0 100644 --- a/.github/workflows/real-evm-interface.yml +++ b/.github/workflows/real-evm-interface.yml @@ -2,33 +2,7 @@ name: Real offline EVM interface on: workflow_dispatch: - pull_request: - paths: - - ".github/workflows/real-evm-interface.yml" - - "src/evm/**" - - "src/domain/evm/**" - - "src/application/evm/**" - - "src/contracts/evm/**" - - "src/composition/evm.ts" - - "src/cli/evmCommands.ts" - - "src/server/registerEvmTools.ts" - - "src/server/createServer.ts" - - "src/cli.ts" - - "src/cliCommandNames.ts" - - "src/contracts/toolContracts.ts" - - "src/contracts/toolEffects.ts" - - "src/process/OwnedCommand.ts" - - "src/process/ProviderProcess.ts" - - "src/process/ProcessOwnership*.ts" - - "src/process/ProviderDeadline.ts" - - "src/process/PrivateRuntimeRoot.ts" - - "src/artifacts/readStableArtifact*.ts" - - "src/artifacts/ArtifactReader.ts" - - "scripts/verify-evm-interface.mjs" - - "scripts/fixtures/evm-interface.sol" - - "third_party/evmole/**" - - "package.json" - - "package-lock.json" + workflow_call: permissions: contents: read diff --git a/.github/workflows/real-javascript-recovery.yml b/.github/workflows/real-javascript-recovery.yml index 14101ff559..52488eb539 100644 --- a/.github/workflows/real-javascript-recovery.yml +++ b/.github/workflows/real-javascript-recovery.yml @@ -1,32 +1,7 @@ name: Real JavaScript recovery on: - pull_request: - paths: - - ".github/workflows/real-javascript-recovery.yml" - - "src/javascript/recovery/**" - - "src/domain/javascript/**" - - "src/application/javascript/JavaScriptRecovery*.ts" - - "src/composition/javascriptRecovery.ts" - - "src/contracts/javascript/javascriptRecoveryToolContracts.ts" - - "src/cli/javascriptRecoveryCommands.ts" - - "src/cli/javascriptApplicationAnalysis.ts" - - "src/server/registerJavaScriptRecoveryTool.ts" - - "src/server/createServer.ts" - - "src/server/sessionAvailabilityPolicy.ts" - - "src/application/CapabilityInventory.ts" - - "src/cli.ts" - - "src/cliCommandNames.ts" - - "src/contracts/toolContracts.ts" - - "src/contracts/toolEffects.ts" - - "scripts/rea.mjs" - - "src/process/**" - - "src/artifacts/SafeOutputTree.ts" - - "scripts/verify/javascript/recovery.mjs" - - "scripts/fixtures/javascript-recovery/**" - - "third_party/wakaru" - - "package.json" - - "package-lock.json" + workflow_call: workflow_dispatch: permissions: diff --git a/.github/workflows/real-recorded-crash.yml b/.github/workflows/real-recorded-crash.yml index 5138df212f..ef6696c466 100644 --- a/.github/workflows/real-recorded-crash.yml +++ b/.github/workflows/real-recorded-crash.yml @@ -2,34 +2,7 @@ name: Real recorded crash evidence on: workflow_dispatch: - pull_request: - paths: - - ".github/workflows/real-recorded-crash.yml" - - "bridge/pwntools/**" - - "bridge/pwndbg/**" - - "src/native/pwntools/**" - - "src/native/pwndbg/**" - - "src/domain/native/recordedCrash*" - - "src/application/binaryDiagnostics/**" - - "src/contracts/native/binaryDiagnosticsToolContracts.ts" - - "src/contracts/toolEffects.ts" - - "src/contracts/toolOutputSchemaGroups.ts" - - "src/composition/binaryDiagnostics.ts" - - "src/server/registerRecordedCrashTools.ts" - - "src/server/createServer.ts" - - "src/server/sessionAvailabilityPolicy.ts" - - "src/cli/binaryDiagnosticsCommands.ts" - - "src/cliCommandNames.ts" - - "src/process/**" - - "src/artifacts/readStableArtifact*.ts" - - "src/domain/analysisError*.ts" - - "scripts/verify-recorded-crash.mjs" - - "scripts/verify/recorded-crash/**" - - "scripts/lib/recorded-core-fixtures.py" - - "scripts/fixtures/recorded-crash.c" - - "third_party/pwndbg/**" - - "package.json" - - "package-lock.json" + workflow_call: permissions: contents: read diff --git a/.github/workflows/real-web-network-captures.yml b/.github/workflows/real-web-network-captures.yml index 91681043ac..004677d8fb 100644 --- a/.github/workflows/real-web-network-captures.yml +++ b/.github/workflows/real-web-network-captures.yml @@ -2,38 +2,7 @@ name: Real historical web network captures on: workflow_dispatch: - pull_request: - paths: - - ".github/workflows/real-web-network-captures.yml" - - "bridge/mitmproxy/**" - - "src/browser/history/**" - - "src/domain/webNetworkCapture*.ts" - - "src/domain/analysisError*.ts" - - "src/contracts/errorSchemas.ts" - - "src/domain/explicitSensitive*.ts" - - "src/application/WebNetworkCapture*" - - "src/composition/webNetworkCaptures.ts" - - "src/cli/webNetworkCaptureCommands.ts" - - "src/server/registerWebNetworkCaptureTool.ts" - - "src/server/createServer.ts" - - "src/server/toolRegistrationOptions.ts" - - "src/cli.ts" - - "src/cliCommandNames.ts" - - "src/contracts/toolContracts.ts" - - "src/contracts/toolEffects.ts" - - "src/process/OwnedCommand.ts" - - "src/process/ProviderProcess.ts" - - "src/process/ProviderDeadline.ts" - - "src/process/ProcessOwnership*.ts" - - "src/process/PrivateRuntimeRoot.ts" - - "src/process/capture/ProcessCaptureLifecycle.ts" - - "src/artifacts/readStableArtifact*.ts" - - "src/artifacts/ArtifactReader.ts" - - "src/contracts/webNetworkCaptureToolContracts.ts" - - "scripts/verify-web-network-captures.mjs" - - "scripts/fixtures/generate-mitmproxy-capture.py" - - "package.json" - - "package-lock.json" + workflow_call: permissions: contents: read diff --git a/.github/workflows/real-web-runtime.yml b/.github/workflows/real-web-runtime.yml index 8972ad251f..8a307d524b 100644 --- a/.github/workflows/real-web-runtime.yml +++ b/.github/workflows/real-web-runtime.yml @@ -1,34 +1,6 @@ name: Real website runtime attribution on: - pull_request: - paths: - - ".github/workflows/real-web-runtime.yml" - - "scripts/verify-browser-runtime.mjs" - - "scripts/lib/browser-runtime-*.mjs" - - "src/process/ProviderProcess.ts" - - "src/process/ProcessOwnership*.ts" - - "scripts/fixtures/browser-runtime-site.mjs" - - "src/browser/execution/**" - - "src/browser/CdpConnection.ts" - - "src/browser/CdpEndpoint.ts" - - "src/browser/CdpTarget*.ts" - - "src/browser/CdpCaptureValues.ts" - - "src/browser/CdpAuthorized*.ts" - - "src/domain/webRuntime.ts" - - "src/domain/webExecution.ts" - - "src/domain/webEventListeners.ts" - - "src/domain/browserObservationError*.ts" - - "src/application/WebRuntime*.ts" - - "src/composition/webRuntime.ts" - - "src/contracts/webRuntimeToolContracts.ts" - - "src/contracts/toolEffects.ts" - - "src/server/registerWebRuntimeTools.ts" - - "src/server/createServer.ts" - - "src/cli/webRuntimeCommands.ts" - - "src/cli/commandCancellation.ts" - - "src/cli.ts" - - "package.json" - - "package-lock.json" + workflow_call: workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/real-web-source-map.yml b/.github/workflows/real-web-source-map.yml index e58764a36c..dff9780247 100644 --- a/.github/workflows/real-web-source-map.yml +++ b/.github/workflows/real-web-source-map.yml @@ -1,27 +1,6 @@ name: Real website source-map verification on: - pull_request: - paths: - - ".github/workflows/real-web-source-map.yml" - - "scripts/verify-browser-source-maps.mjs" - - "scripts/lib/browser-source-map-e2e.mjs" - - "scripts/fixtures/browser-source-map-site.mjs" - - "src/javascript/sourceMaps/**" - - "src/browser/assets/**" - - "src/domain/webSource*.ts" - - "src/domain/webScript*.ts" - - "src/application/WebSourceLocation*.ts" - - "src/application/ValidateSelectedWebScript.ts" - - "src/composition/webSourceLocations.ts" - - "src/server/registerWebSourceLocationTool.ts" - - "src/server/createServer.ts" - - "src/contracts/webScriptToolContracts.ts" - - "src/process/**" - - "src/cli/webSourceLocationCommands.ts" - - "src/cli.ts" - - "src/artifacts/readStableArtifact.ts" - - "package.json" - - "package-lock.json" + workflow_call: workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml index 676a9c83a6..8931ba418d 100644 --- a/.github/workflows/website-check.yml +++ b/.github/workflows/website-check.yml @@ -1,12 +1,7 @@ name: Website checks on: - pull_request: - branches: [main, "release/*"] - paths: - - "website/**" - - "scripts/*website*.py" - - ".github/workflows/**" + workflow_call: workflow_dispatch: permissions: diff --git a/docs/testing.md b/docs/testing.md index 2dc5b40acf..bd32a6ea0e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -771,12 +771,37 @@ Pull requests that change only root `README*.md` files, `docs/`, `AGENTS.md`, or `CONTRIBUTING.md` run formatting and generated-document validation without the source-test shards or native package lanes. Classification compares the PR head with its merge base, so later base-branch changes do not expand that scope. -The required static and coverage aggregate jobs remain present and fail if -classification fails. Mixed changes, unknown paths and CI configuration changes -retain full checks. Main pushes retain the full baseline, including after -documentation merges; newer commits cancel superseded CI for the same PR or -main branch. Release publication has its own concurrency group and retains its -package verification and public-registry canary. +Static and coverage aggregate jobs remain present and fail if classification +fails. Every code change retains the complete four-shard Linux suite; individual +test files are not selected by imports or filenames. + +`scripts/ci/plan.mjs` selects lanes using the ownership table in +`scripts/ci/scopes.mjs`. Provider implementation changes run their real-provider +verifiers and the Linux x64 installed-package/Inspector baseline. Test-only +changes run the Linux suite without native package verification. Website changes +run website checks. Provider workflow changes validate the workflow and run that provider's verifier. +Manual-provider/Android workflow changes run workflow validation only. +Shared process/contract/CLI boundaries, dependencies, packaging inputs, unknown +paths, and changes to the planner select the full baseline. Package script-only +edits select their owning verifier when its entrypoint is identifiable; unknown +or shared script changes select everything. Android build-script edits retain +the Linux suite and workflow validation; real Android verification stays manual. +Renames account for both paths. + +Main pushes and manual dispatch retain the full baseline, including all portable +provider lanes and the native matrix below. A `ci:full` PR label selects the full +baseline on the next PR run; manual dispatch can request it immediately. Each +classification job records its selection and broad-fallback reasons in the run +summary. Representative Git-diff cases live in +`tests/boundary/filesystem/ciChangeScope.test.ts`. + +`CI required` checks classification and every selected lane, rejecting failed, +cancelled, or unexpectedly skipped work. Existing required check names are +preserved; changing branch protection to require the new aggregate is a separate +repository-settings operation. Newer commits cancel superseded CI for the same +PR or main branch. Release publication has its own concurrency group and retains +its package verification and public-registry canary. Licensed/self-hosted +provider workflows and the Android smoke test retain their explicit triggers. CI exercises the pinned Node.js runtime on native hosted runners: diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs new file mode 100644 index 0000000000..40ec03a6b3 --- /dev/null +++ b/scripts/ci/plan.mjs @@ -0,0 +1,174 @@ +import { execFileSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { matchesGlob } from "node:path"; +import { SCOPES } from "./scopes.mjs"; + +const flags = [ + "code", + "runtime", + "package", + "apple", + "fixtures", + "readiness", + "docs", + "workflows", + ...Object.keys(SCOPES), +]; +const selected = Object.fromEntries(flags.map((flag) => [flag, false])); +const reasons = []; +const full = (reason) => { + for (const flag of flags) selected[flag] = true; + reasons.push(reason); +}; +const git = (...args) => + execFileSync("git", args, { encoding: "utf8" }).trimEnd(); +function classifyPackage(base, head) { + const before = JSON.parse(git("show", `${base}:package.json`)); + const after = JSON.parse(git("show", `${head}:package.json`)); + const { scripts: beforeScripts, ...beforeRest } = before; + const { scripts: afterScripts, ...afterRest } = after; + if (JSON.stringify(beforeRest) !== JSON.stringify(afterRest)) { + full("Package metadata or dependencies changed"); + return; + } + selected.code = true; + const changed = [ + ...new Set([ + ...Object.keys(beforeScripts ?? {}), + ...Object.keys(afterScripts ?? {}), + ]), + ].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]); + for (const script of changed) { + if ( + /^(test(?::|$)|check(?::|$)|lint(?::|$)|format(?::|$)|knip$|jscpd$)/.test( + script, + ) + ) + continue; + if ( + ["compile:termux", "build:termux", "build:termux:run"].includes(script) + ) { + selected.workflows = true; + reasons.push( + `Android build script changed; real Android verification remains manual: ${script}`, + ); + continue; + } + // Verify scripts own provider setup as well as execution. Reuse their + // existing source-file ownership rather than maintaining a second map. + const entrypoint = (command) => + /^(?:npm run build:cached && )?node (scripts\/[\w/-]+\.mjs)(?: [\w./=-]+)*$/.exec( + command ?? "", + )?.[1]; + const beforeEntry = entrypoint(beforeScripts?.[script]); + const afterEntry = entrypoint(afterScripts?.[script]); + const entries = [beforeEntry, afterEntry].filter(Boolean); + const simple = + (!beforeScripts?.[script] || beforeEntry) && + (!afterScripts?.[script] || afterEntry); + const scriptOwners = + simple && + entries.every((entry) => + Object.values(SCOPES).some((patterns) => patterns.includes(entry)), + ) + ? Object.entries(SCOPES) + .filter(([, patterns]) => + entries.some((entry) => patterns.includes(entry)), + ) + .map(([owner]) => owner) + : []; + if (scriptOwners.length) + for (const owner of scriptOwners) selected[owner] = true; + else full(`Shared or unknown package script: ${script}`); + } +} + +const event = process.env.GITHUB_EVENT_NAME; +let files = []; +if ( + event === "push" || + event === "schedule" || + event === "workflow_dispatch" || + process.env.CI_FULL === "true" +) { + full("Full baseline requested by event or ci:full label"); +} else if (event === "pull_request") { + const base = git("merge-base", process.env.BASE_SHA, process.env.HEAD_SHA); + const head = process.env.HEAD_SHA; + // Disable rename detection so both the removed and added paths participate. + files = git("diff", "--name-only", "--no-renames", "-z", base, head) + .split("\0") + .filter(Boolean); + for (const file of files) { + if ( + /^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md)$/.test(file) || + file.startsWith("docs/") + ) { + selected.docs = true; + continue; + } + if (/^(src|tests)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)) { + selected.code = true; + continue; + } + const owners = Object.entries(SCOPES) + .filter(([, patterns]) => + patterns.some((pattern) => matchesGlob(file, pattern)), + ) + .map(([owner]) => owner); + for (const owner of owners) selected[owner] = true; + if ( + file.startsWith(".github/workflows/") || + file === ".github/actionlint.yaml" + ) { + selected.workflows = true; + if (file !== ".github/workflows/ci.yml") continue; + } + if (owners.length === 1 && owners[0] === "website") continue; + if (/^src\/(contracts|domain)\//.test(file)) selected.docs = true; + if (file === "package.json") { + classifyPackage(base, head); + continue; + } + selected.code = true; + if (file.startsWith("tests/")) continue; + if ( + /^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test( + file, + ) + ) { + full(`Shared boundary: ${file}`); + } else if (owners.length) { + if (file.startsWith("src/") || file.startsWith("bridge/")) + selected.runtime = true; + } else { + full(`Unclassified input: ${file}`); + } + } +} else throw new Error(`Unsupported CI event: ${event}`); +selected.runtime ||= selected.package; +const linux = { os: "ubuntu-latest", platform: "linux", architecture: "x64" }; +const matrix = { + include: selected.package + ? [ + linux, + { os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" }, + { os: "macos-15", platform: "darwin", architecture: "arm64" }, + { os: "macos-15-intel", platform: "darwin", architecture: "x64" }, + ] + : [linux], +}; +const plan = { ...selected, matrix, files, reasons }; +if (process.env.GITHUB_OUTPUT) + appendFileSync( + process.env.GITHUB_OUTPUT, + Object.entries({ ...selected, matrix: JSON.stringify(matrix) }) + .map(([key, value]) => `${key}=${value}\n`) + .join(""), + ); +if (process.env.GITHUB_STEP_SUMMARY) + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `### Selected CI lanes\n\n${flags.filter((flag) => selected[flag]).join(", ") || "Formatting only"}\n\n${reasons.join("\n\n")}\n`, + ); +console.log(JSON.stringify(plan)); diff --git a/scripts/ci/required.mjs b/scripts/ci/required.mjs new file mode 100644 index 0000000000..fb5115b8ee --- /dev/null +++ b/scripts/ci/required.mjs @@ -0,0 +1,25 @@ +const jobs = JSON.parse(process.env.CI_JOBS); +const outputs = jobs.changes.outputs; +const required = ["changes", "static", "test"]; +const lanes = { + code: ["build", "test-shard"], + runtime: ["windows-native-build", "package-e2e"], + package: ["windows-curated"], + apple: ["apple-artifacts"], + fixtures: ["conformance-fixtures"], + readiness: ["reconstruction-readiness"], + docs: ["docs"], +}; +for (const [scope, value] of Object.entries(outputs)) { + if (value === "true") required.push(...(lanes[scope] ?? [scope])); +} +const failed = [...new Set(required)].filter( + (name) => jobs[name]?.result !== "success", +); +for (const [name, job] of Object.entries(jobs)) { + if (["failure", "cancelled"].includes(job.result) && !failed.includes(name)) + failed.push(name); +} +if (failed.length) + throw new Error(`CI lanes did not pass: ${failed.join(", ")}`); +console.log("Every selected CI lane passed"); diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs new file mode 100644 index 0000000000..eac78049b0 --- /dev/null +++ b/scripts/ci/scopes.mjs @@ -0,0 +1,227 @@ +// Ownership of the real-provider and website verification lanes. Shared build +// inputs are classified once in plan.mjs rather than repeated in every workflow. +export const SCOPES = { + browser: [ + ".github/workflows/real-browser.yml", + "scripts/verify-real-browser.mjs", + "scripts/verify-browser-network.mjs", + "scripts/verify-browser-scripts.mjs", + "scripts/verify-browser-modules.mjs", + "scripts/verify-browser-dom-destinations.mjs", + "scripts/lib/browser-*.mjs", + "scripts/fixtures/browser-network-site.mjs", + "scripts/fixtures/browser-script-site.mjs", + "scripts/fixtures/browser-module-site.mjs", + "scripts/fixtures/browser-verifier-site.mjs", + "scripts/fixtures/page-cdp-proxy.mjs", + "src/browser/**", + "src/domain/browser*.ts", + "src/domain/webScript*.ts", + "src/domain/webModule*.ts", + "src/application/WebModule*.ts", + "src/composition/webModules.ts", + "src/artifacts/readStableArtifact.ts", + "src/artifacts/ArtifactPaths.ts", + "src/application/WebScriptExportService.ts", + "src/contracts/webScriptToolContracts.ts", + "src/server/registerWebScriptTool.ts", + "src/server/registerWebModuleTool.ts", + "src/server/createServer.ts", + "src/cli/webModuleCommands.ts", + "src/cli.ts", + "src/cliCommandNames.ts", + "src/contracts/toolEffects.ts", + "src/cli/webScriptCommands.ts", + ], + binary_layout: [ + ".github/workflows/real-binary-layout.yml", + "bridge/pwntools/**", + "src/native/pwntools/**", + "src/domain/native/binaryLayout*", + "src/application/binaryDiagnostics/**", + "src/contracts/native/binaryDiagnosticsToolContracts.ts", + "src/composition/binaryDiagnostics.ts", + "src/server/registerBinaryDiagnosticsTools.ts", + "src/server/createServer.ts", + "src/cli/binaryDiagnosticsCommands.ts", + "src/cli.ts", + "src/cliCommandNames.ts", + "src/contracts/toolContracts.ts", + "src/contracts/toolEffects.ts", + "src/process/OwnedCommand.ts", + "src/process/ProviderProcess.ts", + "src/process/ProviderDeadline.ts", + "src/process/ProcessOwnership*.ts", + "src/process/PrivateRuntimeRoot.ts", + "src/artifacts/readStableArtifact*.ts", + "src/artifacts/ArtifactReader.ts", + "src/domain/analysisError*.ts", + "scripts/verify-binary-layout.mjs", + "scripts/lib/elf-section-name-fixtures.mjs", + "scripts/lib/large-result-mcp.mjs", + "scripts/fixtures/binary-layout*", + ], + evm: [ + ".github/workflows/real-evm-interface.yml", + "src/evm/**", + "src/domain/evm/**", + "src/application/evm/**", + "src/contracts/evm/**", + "src/composition/evm.ts", + "src/cli/evmCommands.ts", + "src/server/registerEvmTools.ts", + "src/server/createServer.ts", + "src/cli.ts", + "src/cliCommandNames.ts", + "src/contracts/toolContracts.ts", + "src/contracts/toolEffects.ts", + "src/process/OwnedCommand.ts", + "src/process/ProviderProcess.ts", + "src/process/ProcessOwnership*.ts", + "src/process/ProviderDeadline.ts", + "src/process/PrivateRuntimeRoot.ts", + "src/artifacts/readStableArtifact*.ts", + "src/artifacts/ArtifactReader.ts", + "scripts/verify-evm-interface.mjs", + "scripts/fixtures/evm-interface.sol", + "third_party/evmole/**", + ], + javascript_recovery: [ + ".github/workflows/real-javascript-recovery.yml", + "src/javascript/recovery/**", + "src/domain/javascript/**", + "src/application/javascript/JavaScriptRecovery*.ts", + "src/composition/javascriptRecovery.ts", + "src/contracts/javascript/javascriptRecoveryToolContracts.ts", + "src/cli/javascriptRecoveryCommands.ts", + "src/cli/javascriptApplicationAnalysis.ts", + "src/server/registerJavaScriptRecoveryTool.ts", + "src/server/createServer.ts", + "src/server/sessionAvailabilityPolicy.ts", + "src/application/CapabilityInventory.ts", + "src/cli.ts", + "src/cliCommandNames.ts", + "src/contracts/toolContracts.ts", + "src/contracts/toolEffects.ts", + "scripts/rea.mjs", + "src/process/**", + "src/artifacts/SafeOutputTree.ts", + "scripts/verify/javascript/recovery.mjs", + "scripts/fixtures/javascript-recovery/**", + "third_party/wakaru", + ], + recorded_crash: [ + ".github/workflows/real-recorded-crash.yml", + "bridge/pwntools/**", + "bridge/pwndbg/**", + "src/native/pwntools/**", + "src/native/pwndbg/**", + "src/domain/native/recordedCrash*", + "src/application/binaryDiagnostics/**", + "src/contracts/native/binaryDiagnosticsToolContracts.ts", + "src/contracts/toolEffects.ts", + "src/contracts/toolOutputSchemaGroups.ts", + "src/composition/binaryDiagnostics.ts", + "src/server/registerRecordedCrashTools.ts", + "src/server/createServer.ts", + "src/server/sessionAvailabilityPolicy.ts", + "src/cli/binaryDiagnosticsCommands.ts", + "src/cliCommandNames.ts", + "src/process/**", + "src/artifacts/readStableArtifact*.ts", + "src/domain/analysisError*.ts", + "scripts/verify-recorded-crash.mjs", + "scripts/verify/recorded-crash/**", + "scripts/lib/recorded-core-fixtures.py", + "scripts/fixtures/recorded-crash.c", + "third_party/pwndbg/**", + ], + web_network: [ + ".github/workflows/real-web-network-captures.yml", + "bridge/mitmproxy/**", + "src/browser/history/**", + "src/domain/webNetworkCapture*.ts", + "src/domain/analysisError*.ts", + "src/contracts/errorSchemas.ts", + "src/domain/explicitSensitive*.ts", + "src/application/WebNetworkCapture*", + "src/composition/webNetworkCaptures.ts", + "src/cli/webNetworkCaptureCommands.ts", + "src/server/registerWebNetworkCaptureTool.ts", + "src/server/createServer.ts", + "src/server/toolRegistrationOptions.ts", + "src/cli.ts", + "src/cliCommandNames.ts", + "src/contracts/toolContracts.ts", + "src/contracts/toolEffects.ts", + "src/process/OwnedCommand.ts", + "src/process/ProviderProcess.ts", + "src/process/ProviderDeadline.ts", + "src/process/ProcessOwnership*.ts", + "src/process/PrivateRuntimeRoot.ts", + "src/process/capture/ProcessCaptureLifecycle.ts", + "src/artifacts/readStableArtifact*.ts", + "src/artifacts/ArtifactReader.ts", + "src/contracts/webNetworkCaptureToolContracts.ts", + "scripts/verify-web-network-captures.mjs", + "scripts/fixtures/generate-mitmproxy-capture.py", + ], + web_runtime: [ + ".github/workflows/real-web-runtime.yml", + "scripts/verify-browser-runtime.mjs", + "scripts/lib/browser-runtime-*.mjs", + "src/process/ProviderProcess.ts", + "src/process/ProcessOwnership*.ts", + "scripts/fixtures/browser-runtime-site.mjs", + "src/browser/execution/**", + "src/browser/CdpConnection.ts", + "src/browser/CdpEndpoint.ts", + "src/browser/CdpTarget*.ts", + "src/browser/CdpCaptureValues.ts", + "src/browser/CdpAuthorized*.ts", + "src/domain/webRuntime.ts", + "src/domain/webExecution.ts", + "src/domain/webEventListeners.ts", + "src/domain/browserObservationError*.ts", + "src/application/WebRuntime*.ts", + "src/composition/webRuntime.ts", + "src/contracts/webRuntimeToolContracts.ts", + "src/contracts/toolEffects.ts", + "src/server/registerWebRuntimeTools.ts", + "src/server/createServer.ts", + "src/cli/webRuntimeCommands.ts", + "src/cli/commandCancellation.ts", + "src/cli.ts", + ], + web_source_map: [ + ".github/workflows/real-web-source-map.yml", + "scripts/verify-browser-source-maps.mjs", + "scripts/lib/browser-source-map-e2e.mjs", + "scripts/fixtures/browser-source-map-site.mjs", + "src/javascript/sourceMaps/**", + "src/browser/assets/**", + "src/domain/webSource*.ts", + "src/domain/webScript*.ts", + "src/application/WebSourceLocation*.ts", + "src/application/ValidateSelectedWebScript.ts", + "src/composition/webSourceLocations.ts", + "src/server/registerWebSourceLocationTool.ts", + "src/server/createServer.ts", + "src/contracts/webScriptToolContracts.ts", + "src/process/**", + "src/cli/webSourceLocationCommands.ts", + "src/cli.ts", + "src/artifacts/readStableArtifact.ts", + ], + nativeaot: [ + "bridge/ghidra/extensions/nativeaot/**", + "scripts/generate-nativeaot-fixtures.mjs", + "tests/fixtures/nativeaot/**", + ".github/workflows/nativeaot-fixtures.yml", + ], + website: [ + "website/**", + "scripts/*website*.py", + ".github/workflows/website-check.yml", + ], +}; diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 107ba8809c..b8924d34c0 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -11,16 +11,6 @@ import { workflowGit as git, } from "../../support/workflowGit.js"; -const workflowSchema = z.object({ - jobs: z.object({ - changes: z.object({ - steps: z.array( - z.object({ id: z.string().optional(), run: z.string().optional() }), - ), - }), - }), -}); - async function commit(directory: string) { await git(directory, ["add", "."]); await git(directory, ["commit", "-m", "fix: fixture change"]); @@ -56,33 +46,26 @@ async function classify( base: string, head: string, event = "pull_request", + full = false, ) { - const workflow = workflowSchema.parse( - parse( - await readFile( - new URL("../../../.github/workflows/ci.yml", import.meta.url), - "utf8", - ), - ), - ); - const command = z - .string() - .parse( - workflow.jobs.changes.steps.find((step) => step.id === "package")?.run, - ); const output = join(directory, "scope-output"); await writeFile(output, ""); - await execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], { - cwd: directory, - env: { - ...process.env, - BASE_SHA: base, - HEAD_SHA: head, - GITHUB_EVENT_NAME: event, - GITHUB_OUTPUT: output, + const result = await execFileAsync( + process.execPath, + [new URL("../../../scripts/ci/plan.mjs", import.meta.url).pathname], + { + cwd: directory, + env: { + ...process.env, + BASE_SHA: base, + HEAD_SHA: head, + GITHUB_EVENT_NAME: event, + CI_FULL: String(full), + GITHUB_OUTPUT: output, + }, }, - }); - return readFile(output, "utf8"); + ); + return z.record(z.string(), z.unknown()).parse(JSON.parse(result.stdout)); } it("keeps every translated root README and authored guide on the documentation lane", async () => { @@ -97,7 +80,11 @@ it("keeps every translated root README and authored guide on the documentation l "AGENTS.md", "CONTRIBUTING.md", ]); - expect(await classify(directory, base, head)).toBe("required=false\n"); + expect(await classify(directory, base, head)).toMatchObject({ + code: false, + package: false, + docs: true, + }); }); it("does not charge a documentation PR for unrelated implementation updates on main", async () => { @@ -107,15 +94,15 @@ it("does not charge a documentation PR for unrelated implementation updates on m await git(directory, ["switch", "main"]); const advancedBase = await change(directory, ["src/anotherProvider.ts"]); expect(advancedBase).not.toBe(base); - expect(await classify(directory, advancedBase, head)).toBe( - "required=false\n", - ); + expect(await classify(directory, advancedBase, head)).toMatchObject({ + code: false, + package: false, + }); }); it.each([ "src/README.md", "README.md.ts", - "package.json", "bridge/ghidra/provider.py", "tests/fixture.test.ts", ".github/workflows/ci.yml", @@ -125,7 +112,7 @@ it.each([ async (path) => { const { directory, base } = await fixture(); const head = await change(directory, ["README.md", path]); - expect(await classify(directory, base, head)).toBe("required=true\n"); + expect(await classify(directory, base, head)).toMatchObject({ code: true }); }, ); @@ -137,19 +124,25 @@ it("retains complete checks when implementation is renamed into documentation", join(directory, "docs/provider.md"), ); const head = await commit(directory); - expect(await classify(directory, base, head)).toBe("required=true\n"); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + package: true, + }); }); it("retains the complete main-push baseline even for documentation", async () => { const { directory, base } = await fixture(); const head = await change(directory, ["README_ja.md"]); - expect(await classify(directory, base, head, "push")).toBe("required=true\n"); + expect(await classify(directory, base, head, "push")).toMatchObject({ + code: true, + package: true, + }); }); it("fails classification rather than treating an invalid Git comparison as a scope", async () => { const { directory, base } = await fixture(); await expect(classify(directory, "missing-ref", base)).rejects.toMatchObject({ - code: 128, + code: 1, }); expect(await readFile(join(directory, "scope-output"), "utf8")).toBe(""); }); @@ -164,13 +157,6 @@ it("retains native Inspector verification on every documented package host", asy .object({ jobs: z.object({ "package-e2e": z.object({ - strategy: z.object({ - matrix: z.object({ - include: z.array( - z.object({ platform: z.string(), architecture: z.string() }), - ), - }), - }), steps, }), "windows-curated": z.object({ steps }), @@ -190,10 +176,15 @@ it("retains native Inspector verification on every documented package host", asy ); expect(packageInspector.if).toBeUndefined(); expect( - packageLane.strategy.matrix.include - .map(({ platform, architecture }) => `${platform}/${architecture}`) - .sort(), - ).toEqual(["darwin/arm64", "darwin/x64", "linux/arm64", "linux/x64"]); + (await classify((await fixture()).directory, "", "", "push")).matrix, + ).toEqual({ + include: [ + { os: "ubuntu-latest", platform: "linux", architecture: "x64" }, + { os: "ubuntu-24.04-arm", platform: "linux", architecture: "arm64" }, + { os: "macos-15", platform: "darwin", architecture: "arm64" }, + { os: "macos-15-intel", platform: "darwin", architecture: "x64" }, + ], + }); const windowsInspector = stepSchema.parse( workflow.jobs["windows-curated"].steps.find( (step) => step.run === "npm run verify:inspector", @@ -201,3 +192,284 @@ it("retains native Inspector verification on every documented package host", asy ); expect(windowsInspector.if).toBeUndefined(); }); + +it.each([ + [ + "src/evm/client.ts", + { code: true, runtime: true, evm: true, browser: false, package: false }, + ], + [ + "src/browser/execution/evaluate.ts", + { + code: true, + runtime: true, + browser: true, + web_runtime: true, + evm: false, + package: false, + }, + ], + [ + "tests/module/example.test.ts", + { code: true, runtime: false, browser: false, package: false }, + ], + ["website/src/index.ts", { code: false, website: true, package: false }], + [ + "src/process/ProviderProcess.ts", + { code: true, package: true, evm: true, browser: true }, + ], + [ + "package-lock.json", + { code: true, package: true, evm: true, browser: true }, + ], + [ + ".github/workflows/real-evm-interface.yml", + { code: false, evm: true, browser: false, package: false }, + ], + ["unknown-input", { code: true, package: true, evm: true, browser: true }], +])("selects the appropriate lanes for %s", async (path, expected) => { + const { directory, base } = await fixture(); + const head = await change(directory, [path]); + expect(await classify(directory, base, head)).toMatchObject(expected); +}); + +it("scopes package script edits while retaining broad dependency checks", async () => { + const { directory } = await fixture(); + const before = { + name: "fixture", + scripts: { "verify:evm": "node scripts/verify-evm-interface.mjs" }, + }; + const base = await change( + directory, + ["package.json"], + JSON.stringify(before), + ); + const after = { + ...before, + scripts: { + "verify:evm": "node scripts/verify-evm-interface.mjs --verbose", + }, + }; + const head = await change(directory, ["package.json"], JSON.stringify(after)); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + evm: true, + browser: false, + package: false, + }); + const dependency = await change( + directory, + ["package.json"], + JSON.stringify({ ...after, dependencies: { example: "1.0.0" } }), + ); + expect(await classify(directory, head, dependency)).toMatchObject({ + code: true, + browser: true, + package: true, + }); +}); + +it.each([ + ["browser", "real-browser"], + ["binary_layout", "real-binary-layout"], + ["evm", "real-evm-interface"], + ["javascript_recovery", "real-javascript-recovery"], + ["recorded_crash", "real-recorded-crash"], + ["web_network", "real-web-network-captures"], + ["web_runtime", "real-web-runtime"], + ["web_source_map", "real-web-source-map"], + ["nativeaot", "nativeaot-fixtures"], + ["website", "website-check"], +])( + "routes %s through the selector without a duplicate PR trigger", + async (scope, name) => { + const workflow = z + .object({ + on: z.object({ workflow_call: z.null() }).passthrough(), + }) + .parse( + parse( + await readFile( + new URL(`../../../.github/workflows/${name}.yml`, import.meta.url), + "utf8", + ), + ), + ); + expect(workflow.on).not.toHaveProperty("pull_request"); + const ci = z + .object({ + jobs: z.record( + z.string(), + z.object({ + uses: z.string().optional(), + if: z.string().optional(), + }), + ), + }) + .parse( + parse( + await readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ), + ), + ); + expect(ci.jobs[scope]).toMatchObject({ + uses: `./.github/workflows/${name}.yml`, + if: `needs.changes.outputs.${scope} == 'true'`, + }); + }, +); + +it("rejects skipped selected work while allowing unselected lanes to skip", async () => { + const command = new URL("../../../scripts/ci/required.mjs", import.meta.url) + .pathname; + const jobs = { + changes: { result: "success", outputs: { evm: "true" } }, + static: { result: "success" }, + test: { result: "success" }, + evm: { result: "success" }, + browser: { result: "skipped" }, + }; + const run = (result: string) => + execFileAsync(process.execPath, [command], { + env: { + ...process.env, + CI_JOBS: JSON.stringify({ ...jobs, evm: { result } }), + }, + }); + await expect(run("success")).resolves.toMatchObject({ stderr: "" }); + for (const result of ["failure", "cancelled", "skipped"]) { + await expect(run(result)).rejects.toMatchObject({ code: 1 }); + } +}); + +it.each([ + [ + ".github/workflows/real-termux.yml", + { workflows: true, code: false, package: false, website: false }, + ], + [ + "src/domain/javascript/example.test.ts", + { code: true, runtime: false, javascript_recovery: false, package: false }, + ], + [ + "src/process/capture/example.test.ts", + { code: true, runtime: false, browser: false, package: false }, + ], + [".nvmrc", { code: true, package: true, browser: true, evm: true }], + [ + "tsconfig.build.json", + { code: true, package: true, browser: true, evm: true }, + ], + ["docs/.vitepress/config.ts", { code: false, docs: true, package: false }], + [ + "src/contracts/evm/tools.ts", + { code: true, docs: true, evm: true, package: false }, + ], + ["scripts/unknown-website-helper.mjs", { code: true, package: true }], +])("preserves scope boundaries for %s", async (path, expected) => { + const { directory, base } = await fixture(); + const head = await change(directory, [path]); + expect(await classify(directory, base, head)).toMatchObject(expected); +}); + +it("unions provider scopes across mixed changes and deletions", async () => { + const { directory } = await fixture(); + const base = await change(directory, ["src/evm/client.ts"]); + await git(directory, ["rm", "src/evm/client.ts"]); + const head = await change(directory, ["src/browser/execution/evaluate.ts"]); + expect(await classify(directory, base, head)).toMatchObject({ + evm: true, + browser: true, + runtime: true, + package: false, + }); +}); + +it("fails without emitting a plan when package metadata is malformed", async () => { + const { directory } = await fixture(); + const base = await change(directory, ["package.json"], '{"scripts":{}}'); + const head = await change(directory, ["package.json"], "{broken"); + await expect(classify(directory, base, head)).rejects.toMatchObject({ + code: 1, + }); + expect(await readFile(join(directory, "scope-output"), "utf8")).toBe(""); +}); + +it("retains a broad fallback when a provider script adds shared shell work", async () => { + const { directory } = await fixture(); + const before = { + scripts: { "verify:evm": "node scripts/verify-evm-interface.mjs" }, + }; + const base = await change( + directory, + ["package.json"], + JSON.stringify(before), + ); + const after = { + scripts: { + "verify:evm": "node scripts/verify-evm-interface.mjs && npm run compile", + }, + }; + const head = await change(directory, ["package.json"], JSON.stringify(after)); + expect(await classify(directory, base, head)).toMatchObject({ + evm: true, + browser: true, + package: true, + }); +}); + +it.each(["workflow_dispatch", "schedule"])( + "retains full selection for %s", + async (event) => { + const { directory, base } = await fixture(); + expect(await classify(directory, base, base, event)).toMatchObject({ + code: true, + package: true, + evm: true, + browser: true, + }); + }, +); + +it("honors ci:full for successive PR revisions", async () => { + const { directory, base } = await fixture(); + const first = await change(directory, ["README.md"]); + const second = await change(directory, ["README_ja.md"]); + for (const head of [first, second]) { + expect( + await classify(directory, base, head, "pull_request", true), + ).toMatchObject({ code: true, package: true, evm: true, browser: true }); + } +}); + +it.each(["compile:termux", "build:termux", "build:termux:run"])( + "does not expand %s edits into unrelated providers", + async (script) => { + const { directory } = await fixture(); + const base = await change( + directory, + ["package.json"], + JSON.stringify({ scripts: { [script]: "npm run compile:termux" } }), + ); + const head = await change( + directory, + ["package.json"], + JSON.stringify({ + scripts: { + [script]: + "node scripts/run-exclusive.mjs artifacts npm run compile:termux", + }, + }), + ); + expect(await classify(directory, base, head)).toMatchObject({ + code: true, + workflows: true, + runtime: false, + package: false, + browser: false, + evm: false, + }); + }, +); diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index faf65ff35a..ceadf525f5 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -582,12 +582,12 @@ it("runs release PR CI without enabling implementation pushes on release branche ); }); -it("checks website changes on release-branch pull requests", async () => { +it("routes website verification through the central PR policy", async () => { const workflow = z .object({ - on: z.object({ - pull_request: z.object({ branches: z.array(z.string()) }), - }), + on: z + .object({ workflow_call: z.null(), workflow_dispatch: z.null() }) + .passthrough(), }) .parse( parse( @@ -600,7 +600,26 @@ it("checks website changes on release-branch pull requests", async () => { ), ), ); - expect(workflow.on.pull_request.branches).toEqual(["main", "release/*"]); + expect(workflow.on).not.toHaveProperty("pull_request"); + const ci = z + .object({ + jobs: z.object({ + website: z.object({ + uses: z.string(), + if: z.string(), + }), + }), + }) + .parse( + parse( + await readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ), + ), + ); + expect(ci.jobs.website.uses).toBe("./.github/workflows/website-check.yml"); + expect(ci.jobs.website.if).toBe("needs.changes.outputs.website == 'true'"); }); // Publishing is irreversible. Keep the release authority invariant as a static