Skip to content
Merged
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
284 changes: 237 additions & 47 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/: 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading