diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26fe5a904..e0ad561ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,184 @@ env: CARGO_TERM_COLOR: always jobs: + # Decides once which jobs a change needs and where they run. "CI result" at + # the end always runs and reports it, so a check required by branch + # protection never waits on a skipped workflow (RFC-0043 §9.2). + # + # What runs, on a pull request: + # - Documentation only (top-level Markdown, docs/, rfc/, assets/): nothing. + # docs/api/providers.md is generated and checked, so it counts as code. + # - Only inside bindings//: that binding's jobs. The Rust workspace + # does not read the bindings, so nothing else can break. + # - aimux-ffi/: the Rust jobs and every binding that links the C ABI. Node + # and Python link the Rust crates directly, not the C ABI. + # - Anything else (the Rust crates, tools, scripts, contract fixtures, + # Cargo manifests, workflows): every job. + # Contract tests run for any code change: they check generated files that + # live in every binding. + # + # Where it runs, on a pull request: the Rust code is platform-neutral + # (rustls, no platform cfg outside tools/), so jobs run on Linux, and the + # macOS and Windows legs are added only when the change touches what differs + # per platform: the C ABI, the Node, Go, Flutter or Swift bindings, a Cargo + # manifest (dependencies), a build script, line-ending rules + # (.gitattributes), the toolchain or the workflows. + # Shared Rust changes still get one Windows leg (Node x64 tests); Swift + # contract tests are macOS-only and run whenever they are needed: they + # drive the C ABI from async Swift, which caught a stack overflow no Linux + # job hit. + # + # Pushes to master, and runs with no base to compare with, run everything. + changes: + name: Detect code changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.diff.outputs.code }} + run: ${{ steps.diff.outputs.run }} + node-legs: ${{ steps.legs.outputs.node }} + ffi-legs: ${{ steps.legs.outputs.ffi }} + go-legs: ${{ steps.legs.outputs.go }} + example-legs: ${{ steps.legs.outputs.example }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - id: diff + env: + BEFORE: ${{ github.event.before }} + run: | + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + # The test merge commit's first parent is the base branch head, + # so this diff is exactly what the pull request changes. + base=HEAD^1 + everything=false + elif [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ] \ + && git fetch --no-tags --depth=1 origin "$BEFORE"; then + base=$BEFORE + everything=true + else + echo "No base to compare with: running everything." + base= + everything=true + fi + if [ -n "$base" ]; then + git diff --name-only "$base" HEAD + fi + # changed PATHSPEC...: prints true when the change touches any of them. + changed() { + if [ -n "$base" ] && git diff --quiet "$base" HEAD -- "$@"; then + echo false + else + echo true + fi + } + not_docs=(. ':(exclude,glob)*.md' ':(exclude)docs' ':(exclude)rfc' ':(exclude)assets') + generated_doc=$(changed docs/api/providers.md) + code=$(changed "${not_docs[@]}") + shared=$(changed "${not_docs[@]}" ':(exclude)bindings' ':(exclude)aimux-ffi') + # Line-ending rules apply to files of every job, wherever the + # .gitattributes lives. + if [ "$generated_doc" = true ] \ + || [ "$(changed ':(glob)**/.gitattributes')" = true ]; then + code=true + shared=true + fi + if [ "$everything" = true ]; then + shared=$code + fi + platforms=$everything + if [ "$(changed .github rust-toolchain.toml .cargo aimux-ffi bindings/node \ + bindings/go bindings/flutter bindings/swift \ + scripts/build-ios-xcframework.sh ':(glob)**/.gitattributes' \ + ':(glob)**/Cargo.toml' ':(glob)**/build.rs')" = true ]; then + platforms=true + fi + # job NAME DIRECTORY...: the job runs when shared code changed, or + # any of the directories it builds and tests. + job() { + local name=$1 run=$shared + shift + if [ "$(changed "$@")" = true ]; then + run=true + fi + printf '"%s": %s,' "$name" "$run" + } + run="{ + $(job rust-test aimux-ffi) + $(job node-binding bindings/node) + $(job python-binding bindings/python) + $(job ffi-build aimux-ffi) + $(job java-binding aimux-ffi bindings/java) + $(job kotlin-binding aimux-ffi bindings/kotlin) + $(job go-binding aimux-ffi bindings/go) + $(job flutter-binding aimux-ffi bindings/flutter) + $(job flutter-example-build aimux-ffi bindings/flutter) + $(job swift-contract-tests aimux-ffi bindings/swift) + \"contract-tests\": $code}" + run=$(jq -c . <<< "$run") + printf 'code=%s\nshared=%s\nplatforms=%s\nrun=%s\n' \ + "$code" "$shared" "$platforms" "$run" \ + | tee -a "$GITHUB_OUTPUT" + # The matrices of the jobs that have macOS or Windows legs. A leg on + # another host than ubuntu-latest runs only when "platforms" is true, + # except the Windows x64 Node test leg, which also runs whenever the + # shared Rust code changed: it is the only job that runs that code on + # Windows, where I/O errors and refused connections behave differently + # (Swift contract tests cover macOS). + - id: legs + env: + PLATFORMS: ${{ steps.diff.outputs.platforms }} + SHARED: ${{ steps.diff.outputs.shared }} + run: | + legs() { + jq -c --arg platforms "$PLATFORMS" --arg shared "$SHARED" \ + 'map(select(.host == "ubuntu-latest" or $platforms == "true" + or (.shared == true and $shared == "true")) | del(.shared))' + } + node=$(legs <<'EOF' + [ + {"host": "windows-latest", "target": "x86_64-pc-windows-msvc", "binary": "aimux.win32-x64-msvc.node", "test": true, "shared": true}, + {"host": "windows-latest", "target": "aarch64-pc-windows-msvc", "binary": "aimux.win32-arm64-msvc.node", "test": false}, + {"host": "macos-15-intel", "target": "x86_64-apple-darwin", "binary": "aimux.darwin-x64.node", "test": true}, + {"host": "macos-latest", "target": "aarch64-apple-darwin", "binary": "aimux.darwin-arm64.node", "test": true}, + {"host": "ubuntu-latest", "target": "x86_64-unknown-linux-gnu", "binary": "aimux.linux-x64-gnu.node", "test": true}, + {"host": "ubuntu-latest", "target": "aarch64-unknown-linux-gnu", "binary": "aimux.linux-arm64-gnu.node", "test": false} + ] + EOF + ) + ffi=$(legs <<'EOF' + [ + {"host": "ubuntu-latest", "target": "x86_64-unknown-linux-gnu", "artifact": "libaimux_ffi.so"}, + {"host": "macos-latest", "target": "aarch64-apple-darwin", "artifact": "libaimux_ffi.dylib"}, + {"host": "windows-latest", "target": "x86_64-pc-windows-msvc", "artifact": "aimux_ffi.dll"} + ] + EOF + ) + # macOS included because the cgo LDFLAGS are platform-specific + # (ld64 rejects GNU -Bstatic/-Bdynamic; see bindings/go/aimux.go). + go=$(legs <<'EOF' + [ + {"host": "ubuntu-latest"}, + {"host": "macos-latest"} + ] + EOF + ) + example=$(legs <<'EOF' + [ + {"platform": "ios", "host": "macos-latest", "targets": "aarch64-apple-ios aarch64-apple-ios-sim"}, + {"platform": "android", "host": "ubuntu-latest", "targets": "aarch64-linux-android armv7-linux-androideabi x86_64-linux-android"} + ] + EOF + ) + printf 'node=%s\nffi=%s\ngo=%s\nexample=%s\n' "$node" "$ffi" "$go" "$example" \ + | tee -a "$GITHUB_OUTPUT" + # ── Rust core: fmt + clippy + tests (single OS — cassettes are platform-agnostic) ── rust-test: name: Rust core (fmt + clippy + test) runs-on: ubuntu-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['rust-test'] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -33,36 +207,14 @@ jobs: node-binding: name: Node binding (${{ matrix.settings.target }}) runs-on: ${{ matrix.settings.host }} + needs: changes + if: fromJSON(needs.changes.outputs.run)['node-binding'] env: MACOSX_DEPLOYMENT_TARGET: '10.13' strategy: fail-fast: false matrix: - settings: - - host: windows-latest - target: x86_64-pc-windows-msvc - binary: aimux.win32-x64-msvc.node - test: true - - host: windows-latest - target: aarch64-pc-windows-msvc - binary: aimux.win32-arm64-msvc.node - test: false - - host: macos-15-intel - target: x86_64-apple-darwin - binary: aimux.darwin-x64.node - test: true - - host: macos-latest - target: aarch64-apple-darwin - binary: aimux.darwin-arm64.node - test: true - - host: ubuntu-latest - target: x86_64-unknown-linux-gnu - binary: aimux.linux-x64-gnu.node - test: true - - host: ubuntu-latest - target: aarch64-unknown-linux-gnu - binary: aimux.linux-arm64-gnu.node - test: false + settings: ${{ fromJSON(needs.changes.outputs.node-legs) }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -145,6 +297,8 @@ jobs: python-binding: name: Python binding runs-on: ubuntu-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['python-binding'] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -170,19 +324,12 @@ jobs: ffi-build: name: aimux-ffi (${{ matrix.target }}) runs-on: ${{ matrix.host }} + needs: changes + if: fromJSON(needs.changes.outputs.run)['ffi-build'] strategy: fail-fast: false matrix: - include: - - host: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact: libaimux_ffi.so - - host: macos-latest - target: aarch64-apple-darwin - artifact: libaimux_ffi.dylib - - host: windows-latest - target: x86_64-pc-windows-msvc - artifact: aimux_ffi.dll + include: ${{ fromJSON(needs.changes.outputs.ffi-legs) }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -205,6 +352,8 @@ jobs: java-binding: name: Java binding (JDK ${{ matrix.java }}) runs-on: ubuntu-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['java-binding'] strategy: fail-fast: false matrix: @@ -235,6 +384,8 @@ jobs: kotlin-binding: name: Kotlin binding (JDK ${{ matrix.java }}) runs-on: ubuntu-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['kotlin-binding'] strategy: fail-fast: false matrix: @@ -260,14 +411,14 @@ jobs: # ── Go binding: build FFI static archive + vet/test ─────────────────────── go-binding: - name: Go binding (${{ matrix.os }}) - runs-on: ${{ matrix.os }} + name: Go binding (${{ matrix.host }}) + runs-on: ${{ matrix.host }} + needs: changes + if: fromJSON(needs.changes.outputs.run)['go-binding'] strategy: fail-fast: false matrix: - # macOS included because the cgo LDFLAGS are platform-specific - # (ld64 rejects GNU -Bstatic/-Bdynamic; see bindings/go/aimux.go). - os: [ubuntu-latest, macos-latest] + include: ${{ fromJSON(needs.changes.outputs.go-legs) }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -296,6 +447,8 @@ jobs: flutter-binding: name: Flutter binding runs-on: ubuntu-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['flutter-binding'] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -324,16 +477,12 @@ jobs: flutter-example-build: name: Flutter example (${{ matrix.platform }}) runs-on: ${{ matrix.host }} + needs: changes + if: fromJSON(needs.changes.outputs.run)['flutter-example-build'] strategy: fail-fast: false matrix: - include: - - platform: ios - host: macos-latest - targets: aarch64-apple-ios aarch64-apple-ios-sim - - platform: android - host: ubuntu-latest - targets: aarch64-linux-android armv7-linux-androideabi x86_64-linux-android + include: ${{ fromJSON(needs.changes.outputs.example-legs) }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -394,6 +543,8 @@ jobs: contract-tests: name: Contract tests runs-on: ubuntu-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['contract-tests'] steps: - uses: actions/checkout@v4 - name: ProviderName generator drift (checked-in files must match generator output) @@ -426,6 +577,8 @@ jobs: swift-contract-tests: name: Swift contract tests runs-on: macos-latest + needs: changes + if: fromJSON(needs.changes.outputs.run)['swift-contract-tests'] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -438,3 +591,40 @@ jobs: DYLD_LIBRARY_PATH: ${{ github.workspace }}/target/release run: | swift test -Xlinker -L${{ github.workspace }}/target/release + + # The one check to require in branch protection. It runs whatever happened + # above: a job may be skipped only because "changes" decided it is not + # needed; any other skip, failure or cancellation fails it. A new job goes + # into the map in "changes" (a job missing there turns this red) and into + # the list below (a job missing here is not checked at all). + ci-result: + name: CI result + if: always() + needs: + - changes + - rust-test + - node-binding + - python-binding + - ffi-build + - java-binding + - kotlin-binding + - go-binding + - flutter-binding + - flutter-example-build + - contract-tests + - swift-contract-tests + runs-on: ubuntu-latest + steps: + - env: + NEEDS: ${{ toJSON(needs) }} + RUN: ${{ needs.changes.outputs.run }} + CODE: ${{ needs.changes.outputs.code }} + run: | + echo "$NEEDS" | jq -e --argjson run "${RUN:-null}" ' + .changes.result == "success" and + (del(.changes) | to_entries | all(.value.result == "success" + or (.value.result == "skipped" and $run[.key] == false)))' + if [ "$CODE" = false ]; then + echo "Documentation-only change: build and test jobs not applicable." \ + | tee -a "$GITHUB_STEP_SUMMARY" + fi