diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1eaecafc..71709534 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,14 +14,96 @@ concurrency: env: CARGO_TERM_COLOR: always - # Full native CI is mandatory for main, external contributors, and explicit - # owner opt-in. Owner PRs without run-ci stay on the required Linux fast path. - FULL_NATIVE_REQUESTED: ${{ github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner || contains(github.event.pull_request.labels.*.name, 'run-ci') }} jobs: + changes: + # Classify only trusted event metadata plus base...head Git data. For PRs the + # classifier is loaded from the trusted base commit, so a PR cannot weaken its + # own native gates. If the trusted base predates the classifier, fail safe to + # the full native matrix instead of executing classifier code from the PR head. + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + needs_windows: ${{ steps.classify.outputs.needs_windows }} + needs_windows_core: ${{ steps.classify.outputs.needs_windows_core }} + needs_windows_runner: ${{ steps.classify.outputs.needs_windows_runner }} + needs_windows_package: ${{ steps.classify.outputs.needs_windows_package }} + needs_windows_desktop: ${{ steps.classify.outputs.needs_windows_desktop }} + needs_windows_arm64: ${{ steps.classify.outputs.needs_windows_arm64 }} + needs_macos: ${{ steps.classify.outputs.needs_macos }} + needs_macos_desktop: ${{ steps.classify.outputs.needs_macos_desktop }} + needs_linux_arm64: ${{ steps.classify.outputs.needs_linux_arm64 }} + needs_desktop_package: ${{ steps.classify.outputs.needs_desktop_package }} + needs_full_native: ${{ steps.classify.outputs.needs_full_native }} + categories: ${{ steps.classify.outputs.categories }} + reason: ${{ steps.classify.outputs.reason }} + steps: + - name: Checkout trusted classifier source + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Classify native CI risk + id: classify + env: + EVENT_NAME: ${{ github.event_name }} + EXTERNAL_CONTRIBUTOR: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner }} + RUN_CI: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-ci') }} + BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + run: | + set -euo pipefail + classifier="scripts/ci_path_risk.py" + + if [ ! -f "$classifier" ]; then + { + for key in \ + needs_windows \ + needs_windows_core \ + needs_windows_runner \ + needs_windows_package \ + needs_windows_desktop \ + needs_windows_arm64 \ + needs_macos \ + needs_macos_desktop \ + needs_linux_arm64 \ + needs_desktop_package \ + needs_full_native; do + echo "$key=true" + done + echo "categories=bootstrap-full-native" + echo "reason=full-native:bootstrap-base-missing" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$EVENT_NAME" = pull_request ] && \ + [ "$EXTERNAL_CONTRIBUTOR" != true ] && \ + [ "$RUN_CI" != true ] && \ + ! git cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then + git fetch --no-tags origin "refs/pull/$PR_NUMBER/head" + fetched_head="$(git rev-parse FETCH_HEAD)" + if [ "$fetched_head" != "$HEAD_SHA" ]; then + echo "fetched PR head does not match event head SHA" >&2 + exit 1 + fi + fi + + python3 "$classifier" \ + --event-name "$EVENT_NAME" \ + --external-contributor "$EXTERNAL_CONTRIBUTOR" \ + --run-ci "$RUN_CI" \ + --base "$BASE_SHA" \ + --head "$HEAD_SHA" \ + --github-output "$GITHUB_OUTPUT" + contract: - # Mandatory first lane for every workflow invocation: pull requests and - # pushes to main. Owner-authored PRs do not need `run-ci` for this cheap gate. + # Mandatory contract lane for every workflow invocation: pull requests and + # pushes to main. It runs independently of the cheap changes classifier. # Keep it limited to deterministic, in-process/static checks so feedback stays fast. runs-on: ubuntu-latest timeout-minutes: 15 @@ -141,11 +223,8 @@ jobs: # Linux arm64 cfg/target coverage here so release-readiness does not need a # duplicate release-profile build merely to prove the native architecture. test-linux-arm64: - needs: contract - if: >- - github.event_name == 'push' || - github.event.pull_request.user.login != github.repository_owner || - contains(github.event.pull_request.labels.*.name, 'run-ci') + needs: [contract, changes] + if: needs.changes.outputs.needs_linux_arm64 == 'true' runs-on: ubuntu-24.04-arm timeout-minutes: 30 steps: @@ -183,14 +262,49 @@ jobs: exit 1 fi - test-macos: - needs: contract - if: >- - github.event_name == 'push' || - github.event.pull_request.user.login != github.repository_owner || - contains(github.event.pull_request.labels.*.name, 'run-ci') - # Native macOS gates for both published architectures plus the Runner and - # Computer platform suites. Keep these ahead of immutable tagging. + test-macos-core: + needs: [contract, changes] + if: needs.changes.outputs.needs_macos == 'true' + # Native macOS core gates cover both published architectures plus the Runner + # and Computer platform suites without mechanically building Desktop packages. + strategy: + fail-fast: false + matrix: + include: + - platform: darwin-arm64 + runner: macos-15 + host_arch: arm64 + rust_host: aarch64-apple-darwin + - platform: darwin-x64 + runner: macos-15-intel + host_arch: x86_64 + rust_host: x86_64-apple-darwin + runs-on: ${{ matrix.runner }} + timeout-minutes: 75 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: macos-core-ci-${{ matrix.platform }} + - name: Verify native macOS host + env: + EXPECTED_HOST_ARCH: ${{ matrix.host_arch }} + EXPECTED_RUST_HOST: ${{ matrix.rust_host }} + run: | + test "$(uname -m)" = "$EXPECTED_HOST_ARCH" + rust_host="$(rustc -vV | sed -n 's/^host: //p')" + test "$rust_host" = "$EXPECTED_RUST_HOST" + - name: Check macOS workspace production surfaces + run: cargo check --locked --workspace + - name: Run native macOS Runner and Computer tests + run: cargo test --locked -p webcodex-runner -p webcodex-computer + + test-macos-desktop: + needs: [contract, changes] + if: needs.changes.outputs.needs_macos_desktop == 'true' + # Keep Desktop package validation separate so process/plugin changes receive + # native macOS coverage without paying the DMG build cost unless needed. strategy: fail-fast: false matrix: @@ -212,7 +326,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: - shared-key: macos-ci-${{ matrix.platform }} + shared-key: macos-desktop-ci-${{ matrix.platform }} - uses: actions/setup-node@v4 with: node-version: 22 @@ -226,10 +340,6 @@ jobs: test "$(uname -m)" = "$EXPECTED_HOST_ARCH" rust_host="$(rustc -vV | sed -n 's/^host: //p')" test "$rust_host" = "$EXPECTED_RUST_HOST" - - name: Check macOS release production surfaces - run: cargo check --locked -p webcodex -p webcodex-cli -p webcodex-runner -p webcodex-computer - - name: Run native macOS Runner and Computer tests - run: cargo test --locked -p webcodex-runner -p webcodex-computer - name: Install Desktop dependencies run: npm ci --prefix apps/desktop - name: Check Desktop frontend @@ -315,6 +425,43 @@ jobs: --stage-metadata "${{ steps.desktop_bundle.outputs.metadata }}" \ --signing-mode adhoc + test-macos: + needs: [contract, changes, test-macos-core, test-macos-desktop] + # Stable aggregate context: every configured macOS sublane must either succeed + # when required by policy or be intentionally skipped when not required. + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate macOS lane policy + env: + CONTRACT_RESULT: ${{ needs.contract.result }} + CHANGES_RESULT: ${{ needs.changes.result }} + NEEDS_CORE: ${{ needs.changes.outputs.needs_macos }} + NEEDS_DESKTOP: ${{ needs.changes.outputs.needs_macos_desktop }} + CORE_RESULT: ${{ needs.test-macos-core.result }} + DESKTOP_RESULT: ${{ needs.test-macos-desktop.result }} + run: | + set -euo pipefail + echo "changes=$CHANGES_RESULT contract=$CONTRACT_RESULT core=$CORE_RESULT desktop=$DESKTOP_RESULT needs_core=$NEEDS_CORE needs_desktop=$NEEDS_DESKTOP" + if [ "$CHANGES_RESULT" != success ] || [ "$CONTRACT_RESULT" != success ]; then + echo "CI policy or contract classification did not succeed" >&2 + exit 1 + fi + require_lane() { + label="$1" + required="$2" + actual="$3" + expected=skipped + if [ "$required" = true ]; then expected=success; fi + if [ "$actual" != "$expected" ]; then + echo "$label macOS lane expected $expected, got $actual" >&2 + exit 1 + fi + } + require_lane core "$NEEDS_CORE" "$CORE_RESULT" + require_lane desktop "$NEEDS_DESKTOP" "$DESKTOP_RESULT" + # Keep Windows process-heavy suites isolated on separate hosted VMs. The # webcodex-runner lane caps libtest at two concurrent test functions: default # parallelism can starve real PowerShell/process-tree startup on hosted Windows, @@ -323,11 +470,8 @@ jobs: # running after the Computer runtime was split out of the Runner crate. # Core/library and packaging coverage can run independently in parallel. test-windows-core: - needs: contract - if: >- - github.event_name == 'push' || - github.event.pull_request.user.login != github.repository_owner || - contains(github.event.pull_request.labels.*.name, 'run-ci') + needs: [contract, changes] + if: needs.changes.outputs.needs_windows_core == 'true' runs-on: windows-latest timeout-minutes: 45 steps: @@ -343,15 +487,14 @@ jobs: "WEBCODEX_TEST_PWSH=$pwsh" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Run Windows persistent-shell tests (PowerShell 5.1 + PowerShell 7) run: cargo test --locked -p webcodex-persistent-shell + - name: Check Windows workspace production surfaces + run: cargo check --locked --workspace - name: Run Windows library and CLI tests run: cargo test --locked -p webcodex-runner-config -p webcodex-process -p webcodex-cli test-windows-runner: - needs: contract - if: >- - github.event_name == 'push' || - github.event.pull_request.user.login != github.repository_owner || - contains(github.event.pull_request.labels.*.name, 'run-ci') + needs: [contract, changes] + if: needs.changes.outputs.needs_windows_runner == 'true' runs-on: windows-latest timeout-minutes: 45 steps: @@ -366,11 +509,8 @@ jobs: run: cargo test --locked -p webcodex-runner -p webcodex-computer -- --test-threads=2 test-windows-package: - needs: contract - if: >- - github.event_name == 'push' || - github.event.pull_request.user.login != github.repository_owner || - contains(github.event.pull_request.labels.*.name, 'run-ci') + needs: [contract, changes] + if: needs.changes.outputs.needs_windows_package == 'true' runs-on: windows-latest timeout-minutes: 45 steps: @@ -394,11 +534,8 @@ jobs: run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/npm_install_windows_smoke.ps1 test-windows-desktop: - needs: contract - if: >- - github.event_name == 'push' || - github.event.pull_request.user.login != github.repository_owner || - contains(github.event.pull_request.labels.*.name, 'run-ci') + needs: [contract, changes] + if: needs.changes.outputs.needs_windows_desktop == 'true' runs-on: windows-latest timeout-minutes: 60 steps: @@ -493,15 +630,11 @@ jobs: -SourceSha "${{ steps.runtime.outputs.source }}" ` -BuiltAt ([Int64]"${{ steps.runtime.outputs.built_at }}") - # The x64 Windows lanes above exercise full process/package behavior. Keep one - # native ARM64 compile gate on exact main so the release workflow is not the - # first place target-specific Windows code is compiled. + # Keep native ARM64 compile coverage path-aware as well; full-native overrides + # and explicit aarch64 cfg/target changes still require it before merge. test-windows-arm64: - needs: contract - if: >- - github.event_name == 'push' || - github.event.pull_request.user.login != github.repository_owner || - contains(github.event.pull_request.labels.*.name, 'run-ci') + needs: [contract, changes] + if: needs.changes.outputs.needs_windows_arm64 == 'true' runs-on: windows-11-arm timeout-minutes: 30 steps: @@ -520,10 +653,9 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "cargo check failed with exit code $LASTEXITCODE" } test-windows: - needs: [contract, test-windows-core, test-windows-runner, test-windows-package, test-windows-desktop, test-windows-arm64] - # Preserve the historical required Windows context in both modes. Full-native - # runs require every eligible lane to succeed; fast owner PRs instead prove - # that every heavy Windows lane was intentionally skipped by policy. + needs: [contract, changes, test-windows-core, test-windows-runner, test-windows-package, test-windows-desktop, test-windows-arm64] + # Preserve the stable Windows aggregate while allowing each heavy lane to be + # selected independently by the deterministic path-risk classifier. if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -531,42 +663,45 @@ jobs: - name: Validate Windows lane policy env: CONTRACT_RESULT: ${{ needs.contract.result }} + CHANGES_RESULT: ${{ needs.changes.result }} + NEEDS_CORE: ${{ needs.changes.outputs.needs_windows_core }} + NEEDS_RUNNER: ${{ needs.changes.outputs.needs_windows_runner }} + NEEDS_PACKAGE: ${{ needs.changes.outputs.needs_windows_package }} + NEEDS_DESKTOP: ${{ needs.changes.outputs.needs_windows_desktop }} + NEEDS_ARM64: ${{ needs.changes.outputs.needs_windows_arm64 }} CORE_RESULT: ${{ needs.test-windows-core.result }} RUNNER_RESULT: ${{ needs.test-windows-runner.result }} PACKAGE_RESULT: ${{ needs.test-windows-package.result }} DESKTOP_RESULT: ${{ needs.test-windows-desktop.result }} ARM64_RESULT: ${{ needs.test-windows-arm64.result }} run: | - echo "full_native=$FULL_NATIVE_REQUESTED contract=$CONTRACT_RESULT core=$CORE_RESULT runner=$RUNNER_RESULT package=$PACKAGE_RESULT desktop=$DESKTOP_RESULT arm64=$ARM64_RESULT" - if [ "$CONTRACT_RESULT" != success ]; then - echo "contract CI did not succeed" >&2 + set -euo pipefail + echo "changes=$CHANGES_RESULT contract=$CONTRACT_RESULT core=$CORE_RESULT runner=$RUNNER_RESULT package=$PACKAGE_RESULT desktop=$DESKTOP_RESULT arm64=$ARM64_RESULT" + if [ "$CHANGES_RESULT" != success ] || [ "$CONTRACT_RESULT" != success ]; then + echo "CI policy or contract classification did not succeed" >&2 exit 1 fi - if [ "$FULL_NATIVE_REQUESTED" = true ]; then - if [ "$CORE_RESULT" != success ] || \ - [ "$RUNNER_RESULT" != success ] || \ - [ "$PACKAGE_RESULT" != success ] || \ - [ "$DESKTOP_RESULT" != success ] || \ - [ "$ARM64_RESULT" != success ]; then - echo "requested Windows CI lane failed or was unexpectedly skipped" >&2 - exit 1 - fi - else - if [ "$CORE_RESULT" != skipped ] || \ - [ "$RUNNER_RESULT" != skipped ] || \ - [ "$PACKAGE_RESULT" != skipped ] || \ - [ "$DESKTOP_RESULT" != skipped ] || \ - [ "$ARM64_RESULT" != skipped ]; then - echo "fast owner PR expected every heavy Windows lane to be intentionally skipped" >&2 + require_lane() { + label="$1" + required="$2" + actual="$3" + expected=skipped + if [ "$required" = true ]; then expected=success; fi + if [ "$actual" != "$expected" ]; then + echo "$label Windows lane expected $expected, got $actual" >&2 exit 1 fi - fi + } + require_lane core "$NEEDS_CORE" "$CORE_RESULT" + require_lane runner "$NEEDS_RUNNER" "$RUNNER_RESULT" + require_lane package "$NEEDS_PACKAGE" "$PACKAGE_RESULT" + require_lane desktop "$NEEDS_DESKTOP" "$DESKTOP_RESULT" + require_lane arm64 "$NEEDS_ARM64" "$ARM64_RESULT" test-native: - needs: [contract, test-linux-arm64, test-macos, test-windows] - # Keep a stable required native aggregate. Fast owner PRs accept only the - # exact intentional-skip shape; full-native invocations require every heavy - # aggregate to succeed, so an eligible lane that is unexpectedly skipped is red. + needs: [contract, changes, test-linux-arm64, test-macos, test-windows] + # Stable branch-protection context: platform aggregates always resolve, while + # Linux arm64 must match the classifier's required-or-skipped decision. if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -574,27 +709,25 @@ jobs: - name: Validate native lane policy env: CONTRACT_RESULT: ${{ needs.contract.result }} + CHANGES_RESULT: ${{ needs.changes.result }} + NEEDS_LINUX_ARM64: ${{ needs.changes.outputs.needs_linux_arm64 }} LINUX_ARM64_RESULT: ${{ needs.test-linux-arm64.result }} MACOS_RESULT: ${{ needs.test-macos.result }} WINDOWS_RESULT: ${{ needs.test-windows.result }} run: | - echo "full_native=$FULL_NATIVE_REQUESTED contract=$CONTRACT_RESULT linux_arm64=$LINUX_ARM64_RESULT macos=$MACOS_RESULT windows=$WINDOWS_RESULT" - if [ "$CONTRACT_RESULT" != success ]; then - echo "contract CI did not succeed" >&2 + set -euo pipefail + echo "changes=$CHANGES_RESULT contract=$CONTRACT_RESULT linux_arm64=$LINUX_ARM64_RESULT macos=$MACOS_RESULT windows=$WINDOWS_RESULT needs_linux_arm64=$NEEDS_LINUX_ARM64" + if [ "$CHANGES_RESULT" != success ] || [ "$CONTRACT_RESULT" != success ]; then + echo "CI policy or contract classification did not succeed" >&2 exit 1 fi - if [ "$FULL_NATIVE_REQUESTED" = true ]; then - if [ "$LINUX_ARM64_RESULT" != success ] || \ - [ "$MACOS_RESULT" != success ] || \ - [ "$WINDOWS_RESULT" != success ]; then - echo "requested native CI lane failed or was unexpectedly skipped" >&2 - exit 1 - fi - else - if [ "$LINUX_ARM64_RESULT" != skipped ] || \ - [ "$MACOS_RESULT" != skipped ] || \ - [ "$WINDOWS_RESULT" != success ]; then - echo "fast owner PR native lane shape was not the intentional policy skip" >&2 - exit 1 - fi + if [ "$MACOS_RESULT" != success ] || [ "$WINDOWS_RESULT" != success ]; then + echo "native platform aggregate failed" >&2 + exit 1 + fi + expected_linux_arm64=skipped + if [ "$NEEDS_LINUX_ARM64" = true ]; then expected_linux_arm64=success; fi + if [ "$LINUX_ARM64_RESULT" != "$expected_linux_arm64" ]; then + echo "Linux arm64 lane expected $expected_linux_arm64, got $LINUX_ARM64_RESULT" >&2 + exit 1 fi diff --git a/docs/TESTING.md b/docs/TESTING.md index 6311d2d1..bb7affe0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,26 +29,36 @@ tests with different cost profiles sharing the same default lane. The lanes above define test semantics; workflows decide when to run them. -- `.github/workflows/ci.yml` is the ordinary repository gate. Its lightweight - `contract` job is the mandatory first lane for every configured pull request - and every push to `main`; it never requires the owner-only `run-ci` label. The - lane owns frontend install/type/test/dist validation, workspace-boundary - self-test/checks, formatting, the heuristic test-inventory self-test/report - (without count thresholds), and focused registry/OpenAPI/MCP schema and metadata parity. +- `.github/workflows/ci.yml` is the ordinary repository gate. Its cheap `changes` + job classifies the exact PR base...head path set before native scheduling, while + the lightweight `contract` job remains mandatory for every configured pull + request and every push to `main`. The classifier is deterministic and local to + Git: it does not use commit messages or PR titles, and it emits per-platform and + per-package-lane requirements. The contract lane owns frontend + install/type/test/dist validation, workspace-boundary self-test/checks, + formatting, the heuristic test-inventory self-test/report (without count + thresholds), and focused registry/OpenAPI/MCP schema and metadata parity. - The heavy Linux Rust matrix `test-linux-rust` and Linux tooling lane `test-linux-tooling` both depend on a successful `contract` job and now run for every pull request as well as every push to `main`, including owner-authored PRs. Release readiness must not be the first place complete Linux package suites or release-tooling tests execute. The historical `test` job id remains the aggregate Linux status check and always evaluates `contract` plus both Linux lanes, failing - unless every required result is `success`. Native macOS and Windows lanes plus - Linux arm64 retain the existing main/external-PR/owner-`run-ci` policy so routine - owner PRs do not automatically consume the full cross-platform matrix. The - always-evaluated `test-native` aggregate fails when those native lanes are skipped - instead of treating missing platform evidence as success; it provides one stable - check for branch protection once an owner PR opts into `run-ci`. This is CI - orchestration, not a claim that the repository now has perfectly pure - fast/integration/platform suites. + unless every required result is `success`. Pushes to `main`, external-contributor + PRs, and owner PRs carrying `run-ci` still force the complete native matrix. Other + owner PRs are upgraded automatically according to changed-path risk: native + process-owning Runner surfaces, shell, Plugin, Computer, platform-specific, and + Desktop Rust ownership selects Windows and/or macOS lanes; `npm/webcodex/**` + selects the native Windows package lane; packaging/signing/release and workflow + policy surfaces select the corresponding package lanes or the full matrix. + For changed Rust files and Cargo manifests, classification inspects bounded full + diff context so a body-only edit inside an existing platform `cfg`/target section + cannot silently look platform-neutral; an over-bound context falls back to full + native. Ordinary Rust domain/control changes remain on the mandatory Linux gates. + The stable `test-macos`, + `test-windows`, and `test-native` aggregates always resolve and verify each child + lane is `success` when required or `skipped` when not required, avoiding a skipped + required-check context that could leave branch protection pending. - Linux Rust execution is package-sharded without test-name filters: the server package `webcodex`, the integration-rich Runner package `webcodex-runner`, and the remaining workspace crates run as three complete package groups in @@ -63,8 +73,12 @@ The lanes above define test semantics; workflows decide when to run them. - Linux tooling runs in parallel with the Rust shards and retains release-verification tooling, Markdown-link validation, and npm package-smoke tooling; within the Linux heavy split, only this tooling lane installs Node - because those smoke scripts invoke Node/npm directly. macOS still owns release-surface compilation and the native - Runner suite on both published architectures, including detached ownership/restart recovery. The local-`sshd` + because those smoke scripts invoke Node/npm directly. macOS native coverage is + split into a core two-architecture Runner/Computer lane and a separately selected + two-architecture Desktop DMG/package lane, so process/Plugin changes do not + mechanically rebuild Desktop packages. macOS still owns release-surface + compilation and the native Runner suite on both published architectures, + including detached ownership/restart recovery. The local-`sshd` SSH integration fixture remains Linux-only because it depends on Linux daemon account/auth configuration; Windows still owns its native library, CLI, Runner, npm, and artifact-to-install coverage. The Windows Runner + Computer lane caps diff --git a/scripts/ci_path_risk.py b/scripts/ci_path_risk.py new file mode 100644 index 00000000..83b9441c --- /dev/null +++ b/scripts/ci_path_risk.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Deterministic CI native-risk classification for a Git base...head range.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import PurePosixPath + + +MAX_CHANGED_PATHS = 2048 +MAX_PATH_BYTES = 4096 +MAX_NAME_STATUS_BYTES = 1024 * 1024 +MAX_PLATFORM_DIFF_BYTES = 2 * 1024 * 1024 +MAX_GIT_STDERR_BYTES = 64 * 1024 + +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +WINDOWS_CFG_RE = re.compile( + r"(?:\bcfg!?\s*\([^\n)]*\bwindows\b|\btarget_(?:os|family)\s*=\s*\"windows\")" +) +MACOS_CFG_RE = re.compile( + r"(?:\bcfg!?\s*\([^\n)]*\bmacos\b|\btarget_os\s*=\s*\"macos\")" +) +AARCH64_CFG_RE = re.compile(r"\btarget_arch\s*=\s*\"aarch64\"") + + +class GitDiffError(RuntimeError): + pass + + +class DiffLimitExceeded(RuntimeError): + pass + + +@dataclass(frozen=True) +class Change: + status: str + path: str + + +@dataclass +class Risk: + needs_windows_core: bool = False + needs_windows_runner: bool = False + needs_windows_package: bool = False + needs_windows_desktop: bool = False + needs_windows_arm64: bool = False + needs_macos: bool = False + needs_macos_desktop: bool = False + needs_linux_arm64: bool = False + needs_full_native: bool = False + categories: set[str] = field(default_factory=set) + changed_count: int = 0 + + @classmethod + def full(cls, category: str, *, changed_count: int = 0) -> "Risk": + risk = cls(needs_full_native=True, changed_count=changed_count) + risk.categories.add(category) + return risk.finalize() + + def finalize(self) -> "Risk": + if self.needs_full_native: + self.needs_windows_core = True + self.needs_windows_runner = True + self.needs_windows_package = True + self.needs_windows_desktop = True + self.needs_windows_arm64 = True + self.needs_macos = True + self.needs_macos_desktop = True + self.needs_linux_arm64 = True + return self + + def outputs(self) -> dict[str, str]: + self.finalize() + needs_windows = any( + ( + self.needs_windows_core, + self.needs_windows_runner, + self.needs_windows_package, + self.needs_windows_desktop, + self.needs_windows_arm64, + ) + ) + needs_desktop_package = self.needs_windows_desktop or self.needs_macos_desktop + categories = ",".join(sorted(self.categories)) or "no-changes" + reason_prefix = "full-native" if self.needs_full_native else "path-risk" + return { + "needs_windows": _bool(needs_windows), + "needs_windows_core": _bool(self.needs_windows_core), + "needs_windows_runner": _bool(self.needs_windows_runner), + "needs_windows_package": _bool(self.needs_windows_package), + "needs_windows_desktop": _bool(self.needs_windows_desktop), + "needs_windows_arm64": _bool(self.needs_windows_arm64), + "needs_macos": _bool(self.needs_macos), + "needs_macos_desktop": _bool(self.needs_macos_desktop), + "needs_linux_arm64": _bool(self.needs_linux_arm64), + "needs_desktop_package": _bool(needs_desktop_package), + "needs_full_native": _bool(self.needs_full_native), + "categories": categories, + "reason": f"{reason_prefix}:{categories}", + "changed_count": str(self.changed_count), + } + + +def _bool(value: bool) -> str: + return "true" if value else "false" + + +def _mark_windows_core(risk: Risk, category: str) -> None: + risk.needs_windows_core = True + risk.categories.add(category) + + +def _mark_windows_runner(risk: Risk, category: str) -> None: + risk.needs_windows_runner = True + risk.categories.add(category) + + +def _mark_windows_package(risk: Risk, category: str) -> None: + risk.needs_windows_package = True + risk.categories.add(category) + + +def _mark_windows_desktop(risk: Risk, category: str) -> None: + risk.needs_windows_desktop = True + risk.categories.add(category) + + +def _mark_macos(risk: Risk, category: str, *, desktop: bool = False) -> None: + risk.needs_macos = True + if desktop: + risk.needs_macos_desktop = True + risk.categories.add(category) + + +def _is_docs_or_text(path: str) -> bool: + return path.startswith("docs/") or path in {"README.md", "CHANGELOG.md", "LICENSE"} + + +def _is_frontend_only(path: str) -> bool: + return path.startswith("frontend/") or path.startswith("apps/desktop/src/") or path in { + "apps/desktop/index.html", + "apps/desktop/tsconfig.json", + "apps/desktop/vite.config.ts", + } + + +def _release_tooling(path: str) -> bool: + if path.startswith(".github/workflows/release-"): + return True + if not path.startswith("scripts/"): + return False + name = PurePosixPath(path).name + return ( + name.startswith("release_") + or name == "release_check.sh" + or name.startswith("package_release_artifact.") + or name + in { + "collect_release_bundle.py", + "prepare_release_metadata.py", + "stage_npm_release.sh", + "verify_public_release.py", + "macos_sign_local_runner.sh", + } + ) + + +def _classify_path(risk: Risk, path: str) -> None: + lower = path.lower() + name = PurePosixPath(lower).name + tokens = {token for token in re.split(r"[/_.-]+", lower) if token} + + if _is_docs_or_text(path): + risk.categories.add("docs") + return + if _is_frontend_only(path): + risk.categories.add("frontend") + return + + if path.startswith(".github/workflows/") or path == "scripts/ci_path_risk.py": + risk.needs_full_native = True + risk.categories.add("ci-policy") + return + if _release_tooling(path) or (path.startswith("scripts/") and "sign" in name): + risk.needs_full_native = True + risk.categories.add("release-signing") + return + if path in {"Cargo.toml", "Cargo.lock"} or path.startswith(".cargo/"): + risk.needs_full_native = True + risk.categories.add("workspace-dependencies") + return + if name == "build.rs": + risk.needs_full_native = True + risk.categories.add("native-build-script") + return + + if path.startswith("apps/desktop/src-tauri/"): + _mark_windows_desktop(risk, "desktop-native") + _mark_macos(risk, "desktop-native", desktop=True) + return + if path in {"apps/desktop/package.json", "apps/desktop/package-lock.json"}: + _mark_windows_desktop(risk, "desktop-package") + _mark_macos(risk, "desktop-package", desktop=True) + return + + # The npm installer/wrapper contains real Windows-specific process/path + # behavior. Linux tooling exercises its portable contract, but production + # package changes also need the native Windows package lane that runs the + # artifact-to-install smoke. + if path.startswith("npm/webcodex/"): + _mark_windows_package(risk, "npm-package") + return + + if path in { + "scripts/prepare_desktop_bundle.ps1", + "scripts/desktop_install_windows_smoke.ps1", + }: + _mark_windows_desktop(risk, "windows-desktop-package") + if path == "scripts/desktop_install_windows_smoke.ps1": + _mark_windows_package(risk, "windows-package") + return + if path == "scripts/npm_install_windows_smoke.ps1": + _mark_windows_package(risk, "windows-package") + return + if path in { + "scripts/prepare_desktop_bundle_macos.py", + "scripts/desktop_install_macos_smoke.sh", + }: + _mark_macos(risk, "macos-desktop-package", desktop=True) + return + + signing_tokens = {"sign", "signing", "codesign", "notarize", "notarization"} + windows_package_tokens = {"windows", "win32", "win64", "msi", "nsis", "wix", "wxs"} + macos_package_tokens = {"macos", "darwin", "osx", "dmg"} + packaging_tokens = {"installer", "packaging", "bundle", "bundles", "msi", "nsis", "wix", "wxs", "dmg"} + suffix = PurePosixPath(lower).suffix + if signing_tokens & tokens: + risk.needs_full_native = True + risk.categories.add("release-signing") + return + if suffix in {".msi", ".wxs", ".wixproj"} or ( + packaging_tokens & tokens and windows_package_tokens & tokens + ): + _mark_windows_package(risk, "windows-package") + _mark_windows_desktop(risk, "windows-desktop-package") + return + if suffix in {".dmg", ".pkg"} or ( + packaging_tokens & tokens and macos_package_tokens & tokens + ): + _mark_macos(risk, "macos-desktop-package", desktop=True) + return + if packaging_tokens & tokens: + risk.needs_full_native = True + risk.categories.add("release-packaging") + return + + if path.startswith("crates/webcodex-process/"): + _mark_windows_core(risk, "native-process") + _mark_macos(risk, "native-process") + return + if path.startswith("crates/webcodex-persistent-shell/"): + _mark_windows_core(risk, "persistent-shell") + _mark_macos(risk, "persistent-shell") + return + if path.startswith("crates/webcodex-computer/"): + _mark_windows_runner(risk, "computer-runtime") + _mark_macos(risk, "computer-runtime") + return + if path.startswith("crates/webcodex-runner/"): + runner_native_tokens = ( + "plugin", + "shell", + "process", + "transport", + "ssh", + "computer", + "shutdown", + "detached_job", + "supervisor", + "coding_agent", + "external_tools", + "mcp_gateway", + "projects", + "validation", + ) + if any(token in lower for token in runner_native_tokens): + _mark_windows_runner(risk, "runner-native") + _mark_macos(risk, "runner-native") + return + + # Platform-specific source outside the known ownership crates is still native + # risk. Rust platform files gate both major desktop OSes so counterpart cfg + # drift is caught rather than only compiling the named platform. + if path.endswith(".rs") and ({"windows", "macos", "darwin"} & tokens): + _mark_windows_core(risk, "platform-source") + _mark_macos(risk, "platform-source") + return + if path.startswith("scripts/") and "windows" in tokens: + _mark_windows_runner(risk, "windows-runner-script") + return + if path.startswith("scripts/") and ({"macos", "darwin"} & tokens): + _mark_macos(risk, "macos-script") + return + if {"arm64", "aarch64"} & tokens: + risk.needs_linux_arm64 = True + risk.categories.add("arm64-target") + return + + risk.categories.add("normal-linux") + + +def classify_changes(changes: list[Change], platform_diff: str = "") -> Risk: + risk = Risk(changed_count=len(changes)) + for change in changes: + _validate_path(change.path) + _classify_path(risk, change.path) + + # `platform_diff` deliberately contains the complete bounded context of every + # changed Rust/Cargo manifest file, not only +/- lines. A body-only edit inside + # an existing `#[cfg(windows)]` block or target-specific Cargo dependency is + # still platform risk even when the cfg declaration itself is unchanged. + # Oversized context fails safe to full native in `classify_git_range`. + if WINDOWS_CFG_RE.search(platform_diff) or MACOS_CFG_RE.search(platform_diff): + _mark_windows_core(risk, "platform-cfg") + _mark_macos(risk, "platform-cfg") + if AARCH64_CFG_RE.search(platform_diff): + risk.needs_linux_arm64 = True + risk.needs_windows_arm64 = True + _mark_macos(risk, "aarch64-cfg") + risk.categories.add("aarch64-cfg") + + return risk.finalize() + + +def invocation_override_reason( + event_name: str, *, external_contributor: bool, run_ci: bool +) -> str | None: + if event_name == "push": + return "override-push-main" + if external_contributor: + return "override-external-contributor" + if run_ci: + return "override-run-ci" + return None + + +def forced_risk_for_invocation( + event_name: str, *, external_contributor: bool, run_ci: bool +) -> Risk | None: + reason = invocation_override_reason( + event_name, external_contributor=external_contributor, run_ci=run_ci + ) + return Risk.full(reason) if reason else None + + +def _validate_path(path: str) -> None: + encoded = path.encode("utf-8") + if not path or len(encoded) > MAX_PATH_BYTES: + raise DiffLimitExceeded("changed path exceeds classifier bounds") + pure = PurePosixPath(path) + if pure.is_absolute() or ".." in pure.parts or "\x00" in path: + raise DiffLimitExceeded("changed path is not a bounded repository-relative path") + + +def _run_git_bounded(args: list[str], *, max_stdout_bytes: int) -> bytes: + with subprocess.Popen( + ["git", *args], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) as process: + assert process.stdout is not None + stdout = process.stdout.read(max_stdout_bytes + 1) + if len(stdout) > max_stdout_bytes: + process.kill() + process.wait() + raise DiffLimitExceeded("git diff output exceeds classifier bounds") + return_code = process.wait() + if return_code != 0: + message = stdout[:MAX_GIT_STDERR_BYTES].decode("utf-8", errors="replace").strip() + raise GitDiffError(message or f"git exited with status {return_code}") + return stdout + + +def _git_changes(base: str, head: str) -> list[Change]: + raw = _run_git_bounded( + [ + "diff", + "--name-status", + "-z", + "--no-renames", + "--no-ext-diff", + "--no-textconv", + f"{base}...{head}", + ], + max_stdout_bytes=MAX_NAME_STATUS_BYTES, + ) + fields = raw.split(b"\x00") + if fields and fields[-1] == b"": + fields.pop() + if len(fields) % 2 != 0: + raise DiffLimitExceeded("git returned malformed zero-delimited name-status data") + changes: list[Change] = [] + for status_raw, path_raw in zip(fields[0::2], fields[1::2]): + try: + status = status_raw.decode("ascii", errors="strict") + path = path_raw.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise DiffLimitExceeded("git returned an unsupported changed path") from exc + if not status: + raise DiffLimitExceeded("git returned an empty change status") + _validate_path(path) + changes.append(Change(status=status[:1], path=path)) + if len(changes) > MAX_CHANGED_PATHS: + raise DiffLimitExceeded("changed path count exceeds classifier bounds") + return changes + + +def _git_platform_diff(base: str, head: str) -> str: + raw = _run_git_bounded( + [ + "diff", + "--unified=1000000", + "--no-renames", + "--no-ext-diff", + "--no-textconv", + "--no-color", + f"{base}...{head}", + "--", + "*.rs", + ":(glob)**/Cargo.toml", + ":(exclude)docs/**", + ":(exclude)frontend/**", + ":(exclude)apps/desktop/src/**", + ], + max_stdout_bytes=MAX_PLATFORM_DIFF_BYTES, + ) + return raw.decode("utf-8", errors="replace") + + +def classify_git_range(base: str, head: str) -> Risk: + if not SHA_RE.fullmatch(base) or not SHA_RE.fullmatch(head): + raise GitDiffError("base and head must be exact 40-hex Git commit ids") + try: + changes = _git_changes(base, head) + needs_platform_context = any( + change.path.endswith(".rs") or PurePosixPath(change.path).name == "Cargo.toml" + for change in changes + ) + platform_diff = _git_platform_diff(base, head) if needs_platform_context else "" + return classify_changes(changes, platform_diff) + except DiffLimitExceeded: + return Risk.full("bounded-fallback") + + +def _parse_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized == "true": + return True + if normalized == "false": + return False + raise argparse.ArgumentTypeError("expected true or false") + + +def _write_github_output(path: str, outputs: dict[str, str]) -> None: + with open(path, "a", encoding="utf-8") as handle: + for key, value in outputs.items(): + handle.write(f"{key}={value}\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--event-name", required=True, choices=("pull_request", "push")) + parser.add_argument("--external-contributor", type=_parse_bool, required=True) + parser.add_argument("--run-ci", type=_parse_bool, required=True) + parser.add_argument("--base") + parser.add_argument("--head") + parser.add_argument("--github-output") + args = parser.parse_args(argv) + + risk = forced_risk_for_invocation( + args.event_name, + external_contributor=args.external_contributor, + run_ci=args.run_ci, + ) + if risk is None: + if not args.base or not args.head: + parser.error("--base and --head are required for path-aware pull requests") + try: + risk = classify_git_range(args.base, args.head) + except GitDiffError as exc: + print(f"ci path risk classification failed: {exc}", file=sys.stderr) + return 2 + + outputs = risk.outputs() + if args.github_output: + _write_github_output(args.github_output, outputs) + print(json.dumps(outputs, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_ci_path_risk.py b/scripts/tests/test_ci_path_risk.py new file mode 100644 index 00000000..b7942f8d --- /dev/null +++ b/scripts/tests/test_ci_path_risk.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts import ci_path_risk as risk + + +def classify( + *paths: str, + statuses: tuple[str, ...] | None = None, + platform_diff: str = "", +) -> dict[str, str]: + if statuses is None: + statuses = ("M",) * len(paths) + changes = [risk.Change(status=status, path=path) for status, path in zip(statuses, paths)] + return risk.classify_changes(changes, platform_diff).outputs() + + +class PathRiskFixtureTests(unittest.TestCase): + def test_docs_only_does_not_upgrade_native(self) -> None: + result = classify("docs/TESTING.md") + self.assertEqual(result["needs_full_native"], "false") + self.assertEqual(result["needs_windows"], "false") + self.assertEqual(result["needs_macos"], "false") + self.assertEqual(result["needs_linux_arm64"], "false") + + def test_desktop_rust_requires_windows_macos_and_desktop_packages(self) -> None: + result = classify("apps/desktop/src-tauri/src/process/supervisor.rs") + self.assertEqual(result["needs_windows_desktop"], "true") + self.assertEqual(result["needs_macos"], "true") + self.assertEqual(result["needs_macos_desktop"], "true") + self.assertEqual(result["needs_desktop_package"], "true") + + def test_process_requires_windows_core_and_macos_without_desktop_package(self) -> None: + result = classify("crates/webcodex-process/src/lib.rs") + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + self.assertEqual(result["needs_desktop_package"], "false") + + def test_runner_plugin_requires_windows_runner_and_macos(self) -> None: + result = classify("crates/webcodex-runner/src/webcodex_runner/plugin.rs") + self.assertEqual(result["needs_windows_runner"], "true") + self.assertEqual(result["needs_macos"], "true") + + def test_npm_installer_change_requires_native_windows_package_lane(self) -> None: + result = classify("npm/webcodex/install.js") + self.assertEqual(result["needs_windows_package"], "true") + self.assertEqual(result["needs_windows_desktop"], "false") + self.assertEqual(result["needs_macos"], "false") + + def test_runner_shell_and_persistent_shell_require_windows_and_macos(self) -> None: + for path in ( + "crates/webcodex-runner/src/webcodex_runner/shell.rs", + "crates/webcodex-persistent-shell/src/lib.rs", + ): + with self.subTest(path=path): + result = classify(path) + self.assertEqual(result["needs_windows"], "true") + self.assertEqual(result["needs_macos"], "true") + + def test_runner_process_owners_require_native_runner_lane(self) -> None: + for path in ( + "crates/webcodex-runner/src/webcodex_runner/coding_agent.rs", + "crates/webcodex-runner/src/webcodex_runner/external_tools.rs", + "crates/webcodex-runner/src/webcodex_runner/mcp_gateway.rs", + "crates/webcodex-runner/src/webcodex_runner/projects.rs", + "crates/webcodex-runner/src/webcodex_runner/validation/execute.rs", + ): + with self.subTest(path=path): + result = classify(path) + self.assertEqual(result["needs_windows_runner"], "true") + self.assertEqual(result["needs_macos"], "true") + + def test_windows_installer_and_npm_package_choose_windows_package_lanes(self) -> None: + desktop = classify("scripts/desktop_install_windows_smoke.ps1") + self.assertEqual(desktop["needs_windows_desktop"], "true") + self.assertEqual(desktop["needs_windows_package"], "true") + self.assertEqual(desktop["needs_desktop_package"], "true") + self.assertEqual(desktop["needs_macos"], "false") + + npm = classify("scripts/npm_install_windows_smoke.ps1") + self.assertEqual(npm["needs_windows_package"], "true") + self.assertEqual(npm["needs_windows_desktop"], "false") + + msi = classify("packaging/windows/webcodex-desktop.msi") + self.assertEqual(msi["needs_windows_package"], "true") + self.assertEqual(msi["needs_windows_desktop"], "true") + self.assertEqual(msi["needs_macos"], "false") + + def test_macos_packaging_is_macos_only(self) -> None: + result = classify("scripts/prepare_desktop_bundle_macos.py") + self.assertEqual(result["needs_macos"], "true") + self.assertEqual(result["needs_macos_desktop"], "true") + self.assertEqual(result["needs_windows"], "false") + + dmg = classify("packaging/macos/WebCodex.dmg") + self.assertEqual(dmg["needs_macos"], "true") + self.assertEqual(dmg["needs_macos_desktop"], "true") + self.assertEqual(dmg["needs_windows"], "false") + + def test_release_or_signing_is_full_native(self) -> None: + for path in ( + ".github/workflows/release-build.yml", + "scripts/macos_sign_local_runner.sh", + ): + with self.subTest(path=path): + result = classify(path) + self.assertEqual(result["needs_full_native"], "true") + self.assertEqual(result["needs_windows_arm64"], "true") + self.assertEqual(result["needs_linux_arm64"], "true") + self.assertEqual(result["needs_macos_desktop"], "true") + + def test_any_workflow_policy_change_is_full_native(self) -> None: + result = classify(".github/workflows/future-native-policy.yml") + self.assertEqual(result["needs_full_native"], "true") + self.assertIn("ci-policy", result["categories"]) + + def test_mixed_docs_and_process_uses_highest_risk(self) -> None: + result = classify("docs/README.md", "crates/webcodex-process/src/windows.rs") + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + + def test_rename_into_risky_path_classifies_destination(self) -> None: + result = classify( + "docs/old.rs", + "crates/webcodex-process/src/renamed.rs", + statuses=("D", "A"), + ) + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + + def test_deleted_risky_file_still_requires_native(self) -> None: + result = classify("crates/webcodex-process/src/windows.rs", statuses=("D",)) + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + + def test_platform_cfg_change_upgrades_native_even_from_normal_rust_path(self) -> None: + result = classify( + "src/runtime.rs", + platform_diff='+#[cfg(target_os = "windows")]\n-fn old() {}', + ) + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + self.assertIn("platform-cfg", result["categories"]) + + def test_aarch64_cfg_requests_all_architecture_native_lanes(self) -> None: + result = classify( + "src/runtime.rs", + platform_diff='+#[cfg(target_arch = "aarch64")]\n+fn arm_only() {}', + ) + self.assertEqual(result["needs_linux_arm64"], "true") + self.assertEqual(result["needs_windows_arm64"], "true") + self.assertEqual(result["needs_macos"], "true") + self.assertIn("aarch64-cfg", result["categories"]) + + def test_changed_paths_are_repository_relative_and_bounded(self) -> None: + with self.assertRaises(risk.DiffLimitExceeded): + classify("../escape.rs") + with self.assertRaises(risk.DiffLimitExceeded): + classify("x" * (risk.MAX_PATH_BYTES + 1)) + + +class InvocationOverrideFixtureTests(unittest.TestCase): + def test_run_ci_override_forces_full_native(self) -> None: + forced = risk.forced_risk_for_invocation( + "pull_request", external_contributor=False, run_ci=True + ) + self.assertIsNotNone(forced) + assert forced is not None + result = forced.outputs() + self.assertEqual(result["needs_full_native"], "true") + self.assertIn("override-run-ci", result["reason"]) + + def test_push_main_forces_full_native(self) -> None: + forced = risk.forced_risk_for_invocation( + "push", external_contributor=False, run_ci=False + ) + self.assertIsNotNone(forced) + assert forced is not None + self.assertEqual(forced.outputs()["needs_full_native"], "true") + + def test_external_contributor_preserves_full_native_policy(self) -> None: + forced = risk.forced_risk_for_invocation( + "pull_request", external_contributor=True, run_ci=False + ) + self.assertIsNotNone(forced) + assert forced is not None + self.assertEqual(forced.outputs()["needs_full_native"], "true") + + def test_owner_pr_without_override_uses_path_classifier(self) -> None: + self.assertIsNone( + risk.forced_risk_for_invocation( + "pull_request", external_contributor=False, run_ci=False + ) + ) + + +class GitRangeIntegrationTests(unittest.TestCase): + def init_repo(self, root: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run( + ["git", "config", "user.email", "ci-risk@example.invalid"], + cwd=root, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "CI Risk Fixture"], + cwd=root, + check=True, + ) + + def commit(self, root: Path, message: str) -> str: + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run(["git", "commit", "-q", "-m", message], cwd=root, check=True) + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=root, text=True + ).strip() + + def test_platform_diff_bound_falls_back_to_full_native(self) -> None: + change = risk.Change(status="M", path="src/runtime.rs") + with ( + mock.patch.object(risk, "_git_changes", return_value=[change]), + mock.patch.object( + risk, + "_git_platform_diff", + side_effect=risk.DiffLimitExceeded("fixture bound"), + ), + ): + result = risk.classify_git_range("0" * 40, "1" * 40).outputs() + self.assertEqual(result["needs_full_native"], "true") + self.assertIn("bounded-fallback", result["categories"]) + + def test_real_git_rename_is_observed_as_delete_plus_add_and_upgrades_risk(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.init_repo(root) + old = root / "docs" / "old.rs" + old.parent.mkdir(parents=True) + old.write_text("fn fixture() {}\n", encoding="utf-8") + base = self.commit(root, "base") + + new = root / "crates" / "webcodex-process" / "src" / "renamed.rs" + new.parent.mkdir(parents=True) + subprocess.run(["git", "mv", str(old.relative_to(root)), str(new.relative_to(root))], cwd=root, check=True) + head = self.commit(root, "rename") + + previous = Path.cwd() + try: + os.chdir(root) + changes = risk._git_changes(base, head) + result = risk.classify_git_range(base, head).outputs() + finally: + os.chdir(previous) + + self.assertEqual( + sorted((change.status, change.path) for change in changes), + sorted( + [ + ("D", "docs/old.rs"), + ("A", "crates/webcodex-process/src/renamed.rs"), + ] + ), + ) + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + + def test_body_only_edit_inside_existing_windows_cfg_upgrades_native(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.init_repo(root) + source = root / "src" / "runtime.rs" + source.parent.mkdir(parents=True) + source.write_text( + '#[cfg(windows)]\nfn platform_value() -> u32 {\n 1\n}\n', + encoding="utf-8", + ) + base = self.commit(root, "base") + source.write_text( + '#[cfg(windows)]\nfn platform_value() -> u32 {\n 2\n}\n', + encoding="utf-8", + ) + head = self.commit(root, "body-only windows change") + + previous = Path.cwd() + try: + os.chdir(root) + result = risk.classify_git_range(base, head).outputs() + finally: + os.chdir(previous) + + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + self.assertIn("platform-cfg", result["categories"]) + + def test_body_only_edit_inside_existing_target_cargo_section_upgrades_native(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.init_repo(root) + manifest = root / "crates" / "example" / "Cargo.toml" + manifest.parent.mkdir(parents=True) + manifest.write_text( + "[package]\nname = \"example\"\nversion = \"0.1.0\"\n\n" + "[target.'cfg(windows)'.dependencies]\nwindows-sys = \"0.60\"\n", + encoding="utf-8", + ) + base = self.commit(root, "base") + manifest.write_text( + "[package]\nname = \"example\"\nversion = \"0.1.0\"\n\n" + "[target.'cfg(windows)'.dependencies]\nwindows-sys = \"0.61\"\n", + encoding="utf-8", + ) + head = self.commit(root, "target dependency change") + + previous = Path.cwd() + try: + os.chdir(root) + result = risk.classify_git_range(base, head).outputs() + finally: + os.chdir(previous) + + self.assertEqual(result["needs_windows_core"], "true") + self.assertEqual(result["needs_macos"], "true") + self.assertIn("platform-cfg", result["categories"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_release_readiness.py b/scripts/tests/test_release_readiness.py index 8c595079..4773de84 100644 --- a/scripts/tests/test_release_readiness.py +++ b/scripts/tests/test_release_readiness.py @@ -248,88 +248,110 @@ def job_block(name: str, next_name: str) -> str: end = workflow.index(f" {next_name}:\n", start) return workflow[start:end] + changes = job_block("changes", "contract") linux_rust = job_block("test-linux-rust", "test-linux-tooling") linux_tooling = job_block("test-linux-tooling", "test-linux-arm64") linux_arm64 = job_block("test-linux-arm64", "test") - aggregate = job_block("test", "test-macos") + aggregate = job_block("test", "test-macos-core") self.assertNotIn("pull_request.user.login", linux_rust) self.assertNotIn("contains(github.event.pull_request.labels.*.name, 'run-ci')", linux_rust) self.assertNotIn("pull_request.user.login", linux_tooling) self.assertNotIn("contains(github.event.pull_request.labels.*.name, 'run-ci')", linux_tooling) self.assertIn("runs-on: ubuntu-24.04-arm", linux_arm64) self.assertIn("cargo check --locked -p webcodex -p webcodex-cli -p webcodex-runner", linux_arm64) - self.assertIn("contains(github.event.pull_request.labels.*.name, 'run-ci')", linux_arm64) + self.assertIn("needs.changes.outputs.needs_linux_arm64 == 'true'", linux_arm64) self.assertIn("cargo check --locked --workspace --all-targets", linux_tooling) self.assertIn("bash scripts/release_check.sh --static-only", linux_tooling) - macos = job_block("test-macos", "test-windows-core") - self.assertIn("platform: darwin-x64", macos) - self.assertIn("runner: macos-15-intel", macos) - self.assertIn("rust_host: x86_64-apple-darwin", macos) - self.assertIn("contains(github.event.pull_request.labels.*.name, 'run-ci')", macos) - self.assertIn("cargo build --locked --profile dogfood -p webcodex -p webcodex-cli -p webcodex-runner", macos) - self.assertIn("--bin-dir target/dogfood", macos) + + self.assertIn("ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}", changes) + self.assertIn("persist-credentials: false", changes) + self.assertIn('classifier="scripts/ci_path_risk.py"', changes) + self.assertIn("reason=full-native:bootstrap-base-missing", changes) + self.assertNotIn('git show "$HEAD_SHA:scripts/ci_path_risk.py"', changes) + self.assertIn('git fetch --no-tags origin "refs/pull/$PR_NUMBER/head"', changes) + self.assertIn('if [ "$fetched_head" != "$HEAD_SHA" ]', changes) + self.assertIn("--external-contributor", changes) + self.assertIn("--run-ci", changes) + self.assertNotIn("pull_request_target", workflow) + + macos_core = job_block("test-macos-core", "test-macos-desktop") + macos_desktop = job_block("test-macos-desktop", "test-macos") + macos_aggregate = job_block("test-macos", "test-windows-core") + self.assertIn("needs.changes.outputs.needs_macos == 'true'", macos_core) + self.assertIn("platform: darwin-x64", macos_core) + self.assertIn("runner: macos-15-intel", macos_core) + self.assertIn("rust_host: x86_64-apple-darwin", macos_core) + self.assertIn("cargo check --locked --workspace", macos_core) + self.assertIn("cargo test --locked -p webcodex-runner -p webcodex-computer", macos_core) + self.assertNotIn("--bundles dmg", macos_core) + self.assertIn("needs.changes.outputs.needs_macos_desktop == 'true'", macos_desktop) + self.assertIn("cargo build --locked --profile dogfood -p webcodex -p webcodex-cli -p webcodex-runner", macos_desktop) + self.assertIn("--bin-dir target/dogfood", macos_desktop) + self.assertIn("--bundles dmg", macos_desktop) + + windows_core = job_block("test-windows-core", "test-windows-runner") + windows_runner = job_block("test-windows-runner", "test-windows-package") + windows_package = job_block("test-windows-package", "test-windows-desktop") windows_desktop = job_block("test-windows-desktop", "test-windows-arm64") + self.assertIn("needs.changes.outputs.needs_windows_core == 'true'", windows_core) + self.assertIn("cargo check --locked --workspace", windows_core) + self.assertIn("needs.changes.outputs.needs_windows_runner == 'true'", windows_runner) + self.assertIn("needs.changes.outputs.needs_windows_package == 'true'", windows_package) + self.assertIn("needs.changes.outputs.needs_windows_desktop == 'true'", windows_desktop) self.assertIn("cargo build --locked --profile dogfood -p webcodex -p webcodex-cli -p webcodex-runner", windows_desktop) self.assertIn('Join-Path "target\\dogfood"', windows_desktop) windows_arm64 = job_block("test-windows-arm64", "test-windows") self.assertIn("runs-on: windows-11-arm", windows_arm64) self.assertIn("aarch64-pc-windows-msvc", windows_arm64) + self.assertIn("needs.changes.outputs.needs_windows_arm64 == 'true'", windows_arm64) self.assertIn("cargo check --locked -p webcodex -p webcodex-cli -p webcodex-runner", windows_arm64) windows_aggregate = job_block("test-windows", "test-native") native_aggregate = workflow[workflow.index(" test-native:\n"):] - for required_aggregate in (aggregate, windows_aggregate, native_aggregate): + for required_aggregate in (aggregate, macos_aggregate, windows_aggregate, native_aggregate): self.assertIn("if: always()", required_aggregate) - self.assertIn("FULL_NATIVE_REQUESTED", windows_aggregate) - self.assertIn("requested Windows CI lane failed or was unexpectedly skipped", windows_aggregate) - self.assertIn("fast owner PR expected every heavy Windows lane to be intentionally skipped", windows_aggregate) - self.assertIn("FULL_NATIVE_REQUESTED", native_aggregate) - self.assertIn("requested native CI lane failed or was unexpectedly skipped", native_aggregate) - self.assertIn("fast owner PR native lane shape was not the intentional policy skip", native_aggregate) + self.assertIn("NEEDS_CORE: ${{ needs.changes.outputs.needs_macos }}", macos_aggregate) + self.assertIn("NEEDS_CORE: ${{ needs.changes.outputs.needs_windows_core }}", windows_aggregate) + self.assertIn("NEEDS_LINUX_ARM64: ${{ needs.changes.outputs.needs_linux_arm64 }}", native_aggregate) + self.assertIn("expected=skipped", windows_aggregate) + self.assertIn("expected_linux_arm64=skipped", native_aggregate) + self.assertNotIn("FULL_NATIVE_REQUESTED", workflow) self.assertIn("cargo build --locked --release -p webcodex -p webcodex-cli -p webcodex-runner", release_build) - def test_ci_native_policy_truth_table(self) -> None: + def test_ci_native_lane_conditions_are_classifier_driven(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") - expression = "FULL_NATIVE_REQUESTED: ${{ github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner || contains(github.event.pull_request.labels.*.name, 'run-ci') }}" - self.assertIn(expression, workflow) - - def full_native_requested(event_name: str, actor: str, owner: str, labels: set[str]) -> bool: - return event_name == "push" or actor != owner or "run-ci" in labels - - scenarios = ( - ("owner PR without run-ci", "pull_request", "owner", "owner", set(), False), - ("owner PR with run-ci", "pull_request", "owner", "owner", {"run-ci"}, True), - ("external PR", "pull_request", "contributor", "owner", set(), True), - ("main push", "push", "owner", "owner", set(), True), - ) - for name, event_name, actor, owner, labels, expected in scenarios: - with self.subTest(name=name): - self.assertEqual(full_native_requested(event_name, actor, owner, labels), expected) - - def windows_aggregate_ok(full_native: bool, contract: str, lanes: tuple[str, ...]) -> bool: - expected = "success" if full_native else "skipped" - return contract == "success" and all(result == expected for result in lanes) - - def native_aggregate_ok( - full_native: bool, - contract: str, - linux_arm64: str, - macos: str, - windows: str, - ) -> bool: - if contract != "success": - return False - if full_native: - return (linux_arm64, macos, windows) == ("success", "success", "success") - return (linux_arm64, macos, windows) == ("skipped", "skipped", "success") - - skipped_windows = ("skipped",) * 5 - successful_windows = ("success",) * 5 - self.assertTrue(windows_aggregate_ok(False, "success", skipped_windows)) - self.assertTrue(native_aggregate_ok(False, "success", "skipped", "skipped", "success")) - self.assertTrue(windows_aggregate_ok(True, "success", successful_windows)) - self.assertTrue(native_aggregate_ok(True, "success", "success", "success", "success")) - self.assertFalse(windows_aggregate_ok(True, "success", ("skipped",) + successful_windows[1:])) - self.assertFalse(native_aggregate_ok(True, "success", "success", "skipped", "success")) + pairs = { + "test-linux-arm64": "needs_linux_arm64", + "test-macos-core": "needs_macos", + "test-macos-desktop": "needs_macos_desktop", + "test-windows-core": "needs_windows_core", + "test-windows-runner": "needs_windows_runner", + "test-windows-package": "needs_windows_package", + "test-windows-desktop": "needs_windows_desktop", + "test-windows-arm64": "needs_windows_arm64", + } + job_names = list(pairs) + job_names.extend(("test-macos", "test-windows", "test-native")) + for index, (job_name, output_name) in enumerate(pairs.items()): + start = workflow.index(f" {job_name}:\n") + following = [workflow.find(f" {name}:\n", start + 1) for name in job_names] + following = [position for position in following if position > start] + end = min(following) if following else len(workflow) + block = workflow[start:end] + with self.subTest(job=job_name): + self.assertIn("needs: [contract, changes]", block) + self.assertIn(f"if: needs.changes.outputs.{output_name} == 'true'", block) + + changes = workflow[workflow.index(" changes:\n"):workflow.index(" contract:\n")] + for output in ( + "needs_windows", + "needs_macos", + "needs_linux_arm64", + "needs_desktop_package", + "needs_full_native", + "categories", + "reason", + ): + self.assertIn(f"{output}: ${{{{ steps.classify.outputs.{output} }}}}", changes) def test_macos_ci_host_check_does_not_use_quiet_grep_under_pipefail(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8")