From b80400dfa4fc7d0128420be320e354758b64307d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 28 Sep 2026 09:48:20 -0700 Subject: [PATCH 1/4] ci: skip platform validation for unrelated pull request paths A repository-owned classifier gates the Windows, macOS shared Swift, and Linux jobs on pull requests so documentation-only changes such as the parity ledger skip them. Required check names stay present: plain jobs skip (reported as success), and the required deterministic hardening matrix legs run step-gated on a Linux runner. Non-PR events and any classifier failure run full validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .github/workflows/linux.yml | 25 +++ .github/workflows/macos-shared-regression.yml | 22 +++ .github/workflows/windows-hardening.yml | 34 +++- .github/workflows/windows-port-validation.yml | 22 +++ .github/workflows/windows-shell.yml | 22 +++ Tools/ci/classify-changes.sh | 149 ++++++++++++++++++ Tools/ci/tests/classify-changes.test.sh | 146 +++++++++++++++++ 7 files changed, 419 insertions(+), 1 deletion(-) create mode 100755 Tools/ci/classify-changes.sh create mode 100755 Tools/ci/tests/classify-changes.test.sh 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..fbc71df6 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.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-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/Tools/ci/classify-changes.sh b/Tools/ci/classify-changes.sh new file mode 100755 index 00000000..9e6ebaea --- /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..5f5e8ce6 --- /dev/null +++ b/Tools/ci/tests/classify-changes.test.sh @@ -0,0 +1,146 @@ +#!/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" + +echo "classify-changes: $passed passed, $failed failed" +[[ $failed -eq 0 && $passed -gt 0 ]] From 315607a0bae3eaa9245b9bcc2b11affa9b8b4265 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 28 Sep 2026 09:49:22 -0700 Subject: [PATCH 2/4] docs: describe path-gated pull request CI Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- AGENTS.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d38e114..03b1570a 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 and TDD +evidence 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`). --- From 444aa92e1b2e7134bab482d70a86c063a3ab05d9 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 28 Sep 2026 09:52:27 -0700 Subject: [PATCH 3/4] ci: always run the investigation privacy scan on pull requests Path gating lets docs-only investigation/ changes skip windows-spikes, which was the only job running validate.ps1 -Task privacy. Run that toolchain-free task in its own ungated job. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .github/workflows/windows-port-validation.yml | 12 ++++++++++++ AGENTS.md | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml index fbc71df6..deb914b0 100644 --- a/.github/workflows/windows-port-validation.yml +++ b/.github/workflows/windows-port-validation.yml @@ -28,6 +28,18 @@ jobs: 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') }} diff --git a/AGENTS.md b/AGENTS.md index 03b1570a..462f646f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,8 +199,8 @@ then `tuist install`, `make install-zmx`, `make test`, and `make check`. 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 and TDD -evidence always run. Pushes, merge queue, schedules, manual dispatches, unknown +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`). From c550d3528fb2d38f3ed4cd92050278a104a7a86b Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 28 Sep 2026 09:58:29 -0700 Subject: [PATCH 4/4] ci: keep shell scripts LF so Linux runners can parse them The classifier was committed with CRLF endings, so every 'Classify changed paths' job on #497 failed with \\$'\r': command not found\ (downstream jobs then ran in full, as designed). Force *.sh to LF and assert it in the classifier tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .gitattributes | 1 + Tools/ci/classify-changes.sh | 298 +++++++++++------------ Tools/ci/tests/classify-changes.test.sh | 301 ++++++++++++------------ 3 files changed, 305 insertions(+), 295 deletions(-) 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/Tools/ci/classify-changes.sh b/Tools/ci/classify-changes.sh index 9e6ebaea..8646679b 100755 --- a/Tools/ci/classify-changes.sh +++ b/Tools/ci/classify-changes.sh @@ -1,149 +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 +#!/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 index 5f5e8ce6..e2181907 100755 --- a/Tools/ci/tests/classify-changes.test.sh +++ b/Tools/ci/tests/classify-changes.test.sh @@ -1,146 +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" - -echo "classify-changes: $passed passed, $failed failed" -[[ $failed -eq 0 && $passed -gt 0 ]] +#!/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 ]]