diff --git a/.gitattributes b/.gitattributes index db548ae2..00256f8e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.swift text eol=lf +*.sh text eol=lf diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index c08b398c..55cc4d14 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -11,7 +11,32 @@ permissions: contents: read jobs: + changes: + name: Classify changed paths + runs-on: ubuntu-latest + outputs: + windows: ${{ steps.classify.outputs.windows }} + macos: ${{ steps.classify.outputs.macos }} + linux: ${{ steps.classify.outputs.linux }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + - name: Test the changed-path classifier + shell: bash + run: bash Tools/ci/tests/classify-changes.test.sh + - name: Classify changed paths + id: classify + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash Tools/ci/classify-changes.sh --event "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" + build: + needs: changes + if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.linux == 'true') }} name: Linux build runs-on: ubuntu-latest container: swift:6.2 diff --git a/.github/workflows/macos-shared-regression.yml b/.github/workflows/macos-shared-regression.yml index 71efd771..090a5e7f 100644 --- a/.github/workflows/macos-shared-regression.yml +++ b/.github/workflows/macos-shared-regression.yml @@ -8,7 +8,29 @@ permissions: contents: read jobs: + changes: + name: Classify changed paths + runs-on: ubuntu-latest + outputs: + windows: ${{ steps.classify.outputs.windows }} + macos: ${{ steps.classify.outputs.macos }} + linux: ${{ steps.classify.outputs.linux }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + - name: Classify changed paths + id: classify + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash Tools/ci/classify-changes.sh --event "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" + macos: + needs: changes + if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.macos == 'true') }} runs-on: macos-26 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml index 3f4b9eb4..66432c7d 100644 --- a/.github/workflows/windows-hardening.yml +++ b/.github/workflows/windows-hardening.yml @@ -15,20 +15,52 @@ permissions: contents: read jobs: + changes: + name: Classify changed paths + runs-on: ubuntu-latest + outputs: + windows: ${{ steps.classify.outputs.windows }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + - name: Classify changed paths + id: classify + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash Tools/ci/classify-changes.sh --event "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" + + # The two matrix legs are required checks by their expanded names. A job-level `if` + # that skips a matrix job reports one unexpanded check name instead, leaving the + # required ones pending, so the legs always run and gate their steps. A leg with + # nothing to validate lands on a cheap Linux runner and passes without steps. deterministic: name: "Deterministic hardening (${{ matrix.os }}, ${{ matrix.powershell }})" + needs: changes + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: os: [windows-2022, windows-2025] powershell: [pwsh] - runs-on: ${{ matrix.os }} + env: + HARDENING_REQUIRED: ${{ github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true' }} + runs-on: ${{ (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') && matrix.os || 'ubuntu-latest' }} steps: + - name: No Windows-relevant changes + if: env.HARDENING_REQUIRED != 'true' + run: echo "No Windows-relevant paths changed; deterministic hardening is not required." - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + if: env.HARDENING_REQUIRED == 'true' - name: Run hardening contract + if: env.HARDENING_REQUIRED == 'true' shell: pwsh run: ./Tools/windows/validate.ps1 -Task hardening - name: Verify runner contract + if: env.HARDENING_REQUIRED == 'true' shell: pwsh run: ./Tools/windows/Tests/ValidationRunner.Tests.ps1 diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index ff581b61..deb914b0 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -8,7 +8,41 @@ permissions: contents: read jobs: + changes: + name: Classify changed paths + runs-on: ubuntu-latest + outputs: + windows: ${{ steps.classify.outputs.windows }} + macos: ${{ steps.classify.outputs.macos }} + linux: ${{ steps.classify.outputs.linux }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + - name: Classify changed paths + id: classify + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash Tools/ci/classify-changes.sh --event "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" + + # Deliberately ungated: documentation-only investigation/ changes skip + # windows-spikes, but still must not leak local paths or generated artifacts. + # The task needs no toolchain or providers and takes about a second. + investigation-privacy: + name: Investigation privacy scan + runs-on: windows-2022 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Run investigation privacy checks + shell: pwsh + run: ./Tools/windows/validate.ps1 -Task privacy + windows-spikes: + needs: changes + if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') }} runs-on: windows-2022 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 diff --git a/.github/workflows/windows-shell.yml b/.github/workflows/windows-shell.yml index a85c4ccb..b86e21e7 100644 --- a/.github/workflows/windows-shell.yml +++ b/.github/workflows/windows-shell.yml @@ -8,7 +8,29 @@ permissions: contents: read jobs: + changes: + name: Classify changed paths + runs-on: ubuntu-latest + outputs: + windows: ${{ steps.classify.outputs.windows }} + macos: ${{ steps.classify.outputs.macos }} + linux: ${{ steps.classify.outputs.linux }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + - name: Classify changed paths + id: classify + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash Tools/ci/classify-changes.sh --event "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA" + windows-shell: + needs: changes + if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.result != 'success' || needs.changes.outputs.windows == 'true') }} runs-on: windows-2022 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 diff --git a/AGENTS.md b/AGENTS.md index 5d38e114..462f646f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,10 +189,21 @@ both. Both files are gitignored; `.env.example` is tracked and documents the ### macOS CI `.github/workflows/macos-shared-regression.yml` runs on `macos-26` for every -pull request. It checks out submodules recursively, runs -`python3 Tools/portable-prepare.py`, installs mise and the pinned tools, runs -`swift test --package-path investigation/spikes/swift-portable`, then -`tuist install`, `make install-zmx`, `make test`, and `make check`. +pull request that touches macOS-relevant paths. It checks out submodules +recursively, runs `python3 Tools/portable-prepare.py`, installs mise and the +pinned tools, runs `swift test --package-path investigation/spikes/swift-portable`, +then `tuist install`, `make install-zmx`, `make test`, and `make check`. + +### Path-gated CI + +On pull requests, the Windows, macOS shared Swift, and Linux build jobs run only +when `Tools/ci/classify-changes.sh` finds relevant changed paths, so a +documentation-only change (for example `investigation/ui-parity-matrix.md`) +skips them; skipped jobs still satisfy their required checks. DCO, TDD +evidence, and the investigation privacy scan always run. Pushes, merge queue, schedules, manual dispatches, unknown +paths, and classifier failures all get full validation. When you add a directory +a suite builds or reads, add it to the classifier and its tests +(`bash Tools/ci/tests/classify-changes.test.sh`). --- diff --git a/Tools/ci/classify-changes.sh b/Tools/ci/classify-changes.sh new file mode 100755 index 00000000..8646679b --- /dev/null +++ b/Tools/ci/classify-changes.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Decide which expensive CI suites a change needs. +# +# Prints (and appends to $GITHUB_OUTPUT when set) one line per suite: +# windows=true|false macos=true|false linux=true|false +# +# Only pull_request events are narrowed. Every other event (push, merge_group, +# workflow_dispatch, schedule) gets full validation. Anything the classifier cannot +# account for — an unknown path, an empty change list, a diff it cannot compute — +# also gets full validation: it fails safe, never quiet. +# +# Usage: +# classify-changes.sh --event NAME --base SHA --head SHA # diff base...head +# classify-changes.sh --event NAME --stdin # newline-separated paths +# +# Tests: bash Tools/ci/tests/classify-changes.test.sh +set -uo pipefail + +event="" +base="" +head="" +from_stdin=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --event) event="${2:-}"; shift 2 ;; + --base) base="${2:-}"; shift 2 ;; + --head) head="${2:-}"; shift 2 ;; + --stdin) from_stdin=1; shift ;; + *) echo "classify-changes: unknown argument: $1" >&2; exit 2 ;; + esac +done + +windows=false +macos=false +linux=false + +emit() { + local line + for line in "windows=$windows" "macos=$macos" "linux=$linux"; do + echo "$line" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "$line" >>"$GITHUB_OUTPUT" + fi + done +} + +all() { + windows=true + macos=true + linux=true +} + +if [[ "$event" != "pull_request" ]]; then + echo "classify-changes: event '${event:-unset}' is not pull_request; running every suite." >&2 + all + emit + exit 0 +fi + +paths=() +if [[ $from_stdin -eq 1 ]]; then + while IFS= read -r path || [[ -n "$path" ]]; do + path="${path%$'\r'}" + [[ -n "$path" ]] && paths+=("$path") + done +else + if [[ -z "$base" || -z "$head" ]]; then + echo "classify-changes: --base and --head are required without --stdin; running every suite." >&2 + all + emit + exit 0 + fi + if ! diff_output="$(git diff --no-renames --name-only "$base...$head" 2>&1)"; then + echo "classify-changes: could not diff $base...$head; running every suite." >&2 + echo "$diff_output" >&2 + all + emit + exit 0 + fi + while IFS= read -r path; do + [[ -n "$path" ]] && paths+=("$path") + done <<<"$diff_output" +fi + +if [[ ${#paths[@]} -eq 0 ]]; then + echo "classify-changes: no changed paths found; running every suite." >&2 + all + emit + exit 0 +fi + +for path in "${paths[@]}"; do + matched=0 + + # The classifier governs every gated suite, so a change to it re-runs them all. + case "$path" in + Tools/ci/*) all; matched=1 ;; + esac + + # Windows: the Zig shell, its validation/bootstrap/packaging tooling, the Windows + # Swift tests, the investigation spikes validate.ps1 builds, and the shared Swift + # products and pins the Windows build consumes. + case "$path" in + graphcode-windows/* | Tools/windows/* | Tools/tdd/* | windows-tests/* | \ + investigation/spikes/* | investigation/visual-baseline/* | \ + GraphcodeKit/* | MailroomKit/* | graphcoded/* | graphcode-cli/* | \ + Package.swift | Package.resolved | mise.toml | .gitattributes | \ + .github/workflows/windows-*.yml) + windows=true; matched=1 ;; + esac + + # macOS shared Swift regression: the app, kit, daemon, CLI, the portable Swift + # package, Tuist/SwiftPM/lint configuration, submodules, and the scripts that + # make and the portable setup run. + case "$path" in + graphcode/* | GraphcodeKit/* | MailroomKit/* | graphcoded/* | graphcode-cli/* | \ + investigation/spikes/swift-portable/* | \ + Package.swift | Package.resolved | Project.swift | Tuist.swift | Tuist/* | \ + Makefile | mise.toml | .swiftlint.yml | .swift-format | .gitattributes | \ + .gitmodules | ThirdParty/* | scripts/* | Tools/portable-prepare.py | \ + Tools/zig-sdk-shim/* | .github/workflows/macos-shared-regression.yml) + macos=true; matched=1 ;; + esac + + # Linux: everything `swift format` lints and `swift build` compiles, plus the CLI + # smoke script and the workflow itself. + case "$path" in + graphcode/* | GraphcodeKit/* | MailroomKit/* | graphcoded/* | graphcode-cli/* | \ + Package.swift | Package.resolved | .swift-format | .gitattributes | \ + scripts/* | .github/workflows/linux.yml) + linux=true; matched=1 ;; + esac + + [[ $matched -eq 1 ]] && continue + + # Paths no gated suite reads. DCO and TDD-evidence still run on every PR. + case "$path" in + *.md | docs/* | screenshots/* | investigation/contracts/* | \ + LICENSE | DCO | .env.example | .github/PULL_REQUEST_TEMPLATE.md | \ + .github/ISSUE_TEMPLATE/* | .github/workflows/dco.yml | \ + .github/workflows/tdd-evidence.yml) + continue ;; + esac + + echo "classify-changes: unclassified path '$path'; running every suite." >&2 + all +done + +emit diff --git a/Tools/ci/tests/classify-changes.test.sh b/Tools/ci/tests/classify-changes.test.sh new file mode 100755 index 00000000..e2181907 --- /dev/null +++ b/Tools/ci/tests/classify-changes.test.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Path-mapping tests for Tools/ci/classify-changes.sh. +# Run: bash Tools/ci/tests/classify-changes.test.sh +set -u + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +classifier="$here/../classify-changes.sh" +passed=0 +failed=0 + +check() { + local name="$1" want="$2" got="$3" + if [[ "$got" == "$want" ]]; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + echo "FAIL: $name" + echo " want: $want" + echo " got: $got" + fi +} + +flatten() { tr -d '\r' | tr '\n' ' ' | sed 's/ $//'; } + +# expect NAME WINDOWS MACOS LINUX PATH... +expect() { + local name="$1" want="windows=$2 macos=$3 linux=$4" + shift 4 + local got + if [[ $# -eq 0 ]]; then + got="$(: | GITHUB_OUTPUT='' bash "$classifier" --event pull_request --stdin 2>/dev/null | flatten)" + else + got="$(printf '%s\n' "$@" | GITHUB_OUTPUT='' bash "$classifier" --event pull_request --stdin 2>/dev/null | flatten)" + fi + check "$name" "$want" "$got" +} + +expect "parity ledger only" false false false \ + investigation/ui-parity-matrix.md +expect "macOS evidence prompt only" false false false \ + investigation/macos-parity-evidence-agent-prompt.md +expect "prompt and ledger together" false false false \ + investigation/ui-parity-matrix.md investigation/macos-parity-evidence-agent-prompt.md +expect "general documentation only" false false false \ + README.md AGENTS.md CONTRIBUTING.md docs/guide.md investigation/contracts/remote-bridge.md \ + .github/PULL_REQUEST_TEMPLATE.md screenshots/canvas.png +expect "DCO and TDD workflows only" false false false \ + .github/workflows/dco.yml .github/workflows/tdd-evidence.yml + +expect "Windows Zig source" true false false \ + graphcode-windows/src/App.zig +expect "Windows validation tooling" true false false \ + Tools/windows/validate.ps1 +expect "Windows production tests" true false false \ + windows-tests/WindowsDaemonTests.swift +expect "Windows spike package" true false false \ + investigation/spikes/swift-contracts/Package.swift +expect "TDD evidence tooling validated by Windows suite" true false false \ + Tools/tdd/Test-TddEvidence.ps1 +expect "visual baseline manifest" true false false \ + investigation/visual-baseline/manifest.json +expect "Windows release workflow" true false false \ + .github/workflows/windows-release.yml + +expect "macOS Swift app" false true true \ + graphcode/Sources/App.swift +expect "Tuist project" false true false \ + Project.swift +expect "Tuist dependencies" false true false \ + Tuist/Package.swift +expect "Makefile" false true false \ + Makefile +expect "submodule bump" false true false \ + ThirdParty/ghostty +expect "portable prepare script" false true false \ + Tools/portable-prepare.py +expect "Zig SDK shim" false true false \ + Tools/zig-sdk-shim/xcrun +expect "portable Swift spike" true true false \ + investigation/spikes/swift-portable/Package.swift + +expect "shared GraphcodeKit" true true true \ + GraphcodeKit/Sources/GraphStore.swift +expect "shared MailroomKit" true true true \ + MailroomKit/Sources/Mailroom.swift +expect "daemon" true true true \ + graphcoded/Sources/main.swift +expect "CLI" true true true \ + graphcode-cli/Sources/main.swift +expect "SwiftPM manifest" true true true \ + Package.swift +expect "SwiftPM pins" true true true \ + Package.resolved +expect "swift-format configuration" false true true \ + .swift-format +expect "SwiftLint configuration" false true false \ + .swiftlint.yml +expect "CLI smoke script" false true true \ + scripts/cli-smoke.sh +expect "mise pins" true true false \ + mise.toml + +expect "mixed ledger and Windows source" true false false \ + investigation/ui-parity-matrix.md graphcode-windows/src/GraphModel.zig +expect "mixed prompt and GraphcodeKit" true true true \ + investigation/macos-parity-evidence-agent-prompt.md GraphcodeKit/Sources/Workspace.swift + +expect "Windows shell workflow" true false false \ + .github/workflows/windows-shell.yml +expect "Windows port workflow" true false false \ + .github/workflows/windows-port-validation.yml +expect "Windows hardening workflow" true false false \ + .github/workflows/windows-hardening.yml +expect "macOS workflow" false true false \ + .github/workflows/macos-shared-regression.yml +expect "Linux workflow" false false true \ + .github/workflows/linux.yml +expect "classifier script" true true true \ + Tools/ci/classify-changes.sh +expect "classifier tests" true true true \ + Tools/ci/tests/classify-changes.test.sh + +expect "unknown path fails safe" true true true \ + some-new-directory/build.sh +expect "empty change list fails safe" true true true + +# Non-PR events always get full validation, whatever changed. +for event in push merge_group workflow_dispatch schedule; do + got="$(printf 'README.md\n' | GITHUB_OUTPUT='' bash "$classifier" --event "$event" --stdin 2>/dev/null | flatten)" + check "$event forces full validation" "windows=true macos=true linux=true" "$got" +done + +# A diff that cannot be computed fails safe to full validation. +got="$(GITHUB_OUTPUT='' bash "$classifier" --event pull_request \ + --base 0000000000000000000000000000000000000000 \ + --head 1111111111111111111111111111111111111111 2>/dev/null | flatten)" +check "unresolvable diff fails safe" "windows=true macos=true linux=true" "$got" + +# GITHUB_OUTPUT receives the same key=value lines. +out="$(mktemp)" +printf 'investigation/ui-parity-matrix.md\n' | GITHUB_OUTPUT="$out" bash "$classifier" --event pull_request --stdin >/dev/null 2>&1 +check "writes GITHUB_OUTPUT" "windows=false macos=false linux=false" "$(flatten <"$out")" +rm -f "$out" + +# Linux runners' bash rejects CRLF scripts; .gitattributes must keep these LF. +for script in "$classifier" "${BASH_SOURCE[0]}"; do + if grep -q $'\r' "$script"; then + check "$(basename "$script") has LF line endings" "no CR" "CR found" + else + check "$(basename "$script") has LF line endings" "no CR" "no CR" + fi +done + +echo "classify-changes: $passed passed, $failed failed" +[[ $failed -eq 0 && $passed -gt 0 ]]