diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70f7f20 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,120 @@ +name: CI + +# Until this existed, nothing ran on a pull request except CodeQL's default +# scan: unit tests and the submodule pointer were only exercised by the tag +# build, so a regression was reviewable but not detectable. Runs on pushes to +# main too, so a direct commit is covered by the same gate as a PR. +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: android-actions/setup-android@v3 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + # setup.sh fetches the ONNX Runtime and LiteRT AARs the native build + # links against. It is idempotent — it compares the installed version.txt + # against the versions pinned in the script — so a cache hit makes it a + # no-op rather than something to skip. + - name: Cache native dependencies + uses: actions/cache@v4 + with: + path: | + ort + litert + key: native-deps-${{ runner.os }}-${{ hashFiles('setup.sh') }} + + - name: Run setup + run: ./setup.sh + + # The SDK suite covers the download manifest, the app suite covers the + # demo's own logic including text insertion, and control-demo matches + # what the release build runs. None of them ran on a PR before. + - name: Unit tests + run: ./gradlew :sdk:test :app:testDebugUnitTest :control-demo:testDebugUnitTest + + # Compiles the JNI bridge against the pinned speech-core, so a submodule + # that does not build with the current bridge fails here rather than at + # release time. + - name: Assemble + run: ./gradlew :sdk:assembleRelease :app:assembleDebug + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + sdk/build/reports/tests/ + app/build/reports/tests/ + retention-days: 7 + + submodule-ancestry: + # A pull request that carries an older speech-core pointer than main + # regresses the engine silently: it is one line in the diff and reads as + # noise. This makes it a failing check instead of something a reviewer has + # to notice. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: speech-core must not move backwards + run: | + set -euo pipefail + git fetch --quiet origin "${{ github.base_ref }}" + base=$(git rev-parse "origin/${{ github.base_ref }}:speech-core") + head=$(git rev-parse "HEAD:speech-core") + echo "base ${{ github.base_ref }}: $base" + echo "head: $head" + + if [ "$base" = "$head" ]; then + echo "unchanged" + exit 0 + fi + + # The pointer may legitimately advance. It may not retreat, and the + # commit has to exist upstream — a pointer only reachable from a fork + # or a local branch breaks every later checkout of this repository. + git clone --quiet --filter=blob:none --no-checkout \ + https://github.com/soniqo/speech-core.git /tmp/speech-core + cd /tmp/speech-core + + if ! git cat-file -e "$head^{commit}" 2>/dev/null; then + echo "::error::speech-core $head does not exist upstream" + exit 1 + fi + + if git merge-base --is-ancestor "$head" "$base"; then + echo "::error::speech-core moves backwards: $head is an ancestor of $base on ${{ github.base_ref }}" + exit 1 + fi + + echo "speech-core advances or diverges forward"