Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
*.swift text eol=lf
*.sh text eol=lf
25 changes: 25 additions & 0 deletions .github/workflows/linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/macos-shared-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion .github/workflows/windows-hardening.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/windows-port-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/windows-shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

---

Expand Down
149 changes: 149 additions & 0 deletions Tools/ci/classify-changes.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading