Skip to content

Drivers

Drivers #27

Workflow file for this run

name: Drivers
# Compile gate for the two native drivers under drivers/ — the only automated
# verification they have. Before this workflow existed, nothing in
# .github/workflows/ mentioned gradle, xcodebuild, Kotlin or Swift: a bad edit
# to the 47 native build-input files (24 Kotlin + 20 Swift + 3 *.gradle.kts)
# was caught by a dead device in someone's manual session, not by a failing
# check.
#
# COMPILE ONLY — deliberately no test execution:
# - drivers/android/app/src/androidTest/ IS the driver (23 of 24 Kotlin
# files). Its single @Test starts a gRPC server and blocks forever, and
# there are zero assertions in the whole tree, so `connectedAndroidTest`
# has nothing to report and would never terminate.
# - drivers/ios has no unit-test target at all.
# The gate therefore proves the native code compiles and links. It does NOT
# prove a tap lands in the right place — that still needs a manual device
# session. Treat "drivers is green" as "it builds", nothing more.
#
# This workflow is ADDITIVE and must stay that way: ci.yml's job graph and its
# `test` job name are a contract with repo ruleset 14531661 (the required
# status check). No job in THIS file may be named `test` — a same-named check
# from this workflow would be indistinguishable from the required one.
#
# Unlike ci.yml, this workflow DOES carry a `concurrency:` block — see the block
# below `on:` for the full reasoning. The short version: ci.yml's
# no-cancellation rule is load-bearing because `test` is the required check, and
# a cancelled run at a PR tip reads exactly like a clean one to the merge gate.
# Nothing in THIS file is required by ruleset 14531661, so no merge gate consults
# it. The PENDING-eviction footgun ci.yml records is still real, which is exactly
# why the group key below is run-id-scoped on push to main.
on:
# Both triggers carry the SAME `paths` list, duplicated verbatim. That is a
# deliberate choice, not a forced one: GitHub Actions HAS supported YAML
# anchors/aliases since 2025-09-18, so a `&anchor`/`*alias` pair would parse.
# Two short adjacent literal lists are kept because they are trivially eyeball-
# and diff-verifiable, and anchor support specifically inside the `on:` trigger
# block is not something this change verified. Keep the two lists byte-identical.
#
# A `paths` filter means the workflow does not run AT ALL when nothing
# matching changed: the check is absent, not green. That is safe here and
# would NOT be on ci.yml — ruleset 14531661 requires exactly one context,
# `test`, which resolves to ci.yml's job, so the two jobs below are additive
# and block no merge by their absence. Never copy this filter to ci.yml
# without also adding an always-runs sentinel job.
#
# Why each entry earns its place:
# - drivers/** — both native trees, their gradle files, the gradle wrapper,
# the Xcode project, and the committed generated Swift under
# drivers/ios/finalrun-ios-test/Generated/.
# - proto/** — load-bearing and easy to miss.
# drivers/android/app/build.gradle.kts:104 adds the repo-root `proto`
# directory as a protobuf source dir, so the Android driver compiles
# proto/finalrun/driver.proto from source on every build. A proto edit can
# break the Kotlin compile with no file under drivers/ changing at all.
# That justification holds for the ANDROID compile only: iOS compiles the
# committed generated Swift under drivers/ios/finalrun-ios-test/Generated/
# and there is no Swift codegen anywhere in this repo, so a proto change
# that desyncs the committed Swift still produces a GREEN ios job. A
# green drivers run is NOT evidence the iOS bindings are in sync with
# the proto.
# - the two build scripts — each job's single `run:` step is one of them, so
# a change to either changes what this gate actually does.
# - this workflow file — so a change to the gate is verified by the gate.
# resources/android/ and resources/ios/ are deliberately ABSENT: they are the
# build scripts' output staging directories, not build inputs.
pull_request:
branches: [main]
paths:
- 'drivers/**'
- 'proto/**'
- 'scripts/build-drivers-android.sh'
- 'scripts/build-drivers-ios.sh'
- '.github/workflows/drivers.yml'
# Filtering `push` too is intentional: a merge whose diff touches no driver
# path cannot break either native build, and a merge that does touch one still
# matches the filter and still runs the post-merge net.
push:
branches: [main]
# Keep byte-identical to the pull_request list above.
paths:
- 'drivers/**'
- 'proto/**'
- 'scripts/build-drivers-android.sh'
- 'scripts/build-drivers-ios.sh'
- '.github/workflows/drivers.yml'
# The two paths-filtered triggers above mean nothing exercises this gate
# between driver changes, while macos-latest and the unpinned Xcode toolchain
# rotate underneath it — a runner-image rotation would surface as a red check
# on whichever unrelated PR next touches a driver path, where it reads as that
# PR's fault. workflow_dispatch gives an on-demand run against any ref. The
# weekly schedule targets ~7 days of maximum staleness at ~4 macOS runs per
# month (a scheduled run can be delayed or dropped under high Actions load, so
# the bound is a target, not a guarantee), so a rotation surfaces as a
# scheduled-run failure attributable to the rotation. `schedule` ignores
# `paths` filters by design and runs against the default-branch tip — exactly
# the coverage the filtered triggers give up.
# Platform caveat: GitHub auto-disables cron after 60 days of repo inactivity
# — acceptable here, since an inactive repo isn't merging PRs that a stale
# green would mislead.
workflow_dispatch:
schedule:
- cron: '17 5 * * 1' # weekly, Mon 05:17 UTC — non-zero minute per GitHub guidance to avoid top-of-hour load spikes
# Supersede in-flight PR runs; never queue or evict a main run.
#
# On a pull_request the group is the PR ref, so a newer push cancels the run
# the previous push started — the measured waste this closes is PR #167's
# three pushes, ~200 billable minutes for one informative verdict. A cancelled
# `drivers` run is safe to trade away in a way a cancelled `ci` run is not:
# ruleset 14531661 requires only ci.yml's `test` context, so no merge gate
# consults this workflow, and the commit a cancellation skipped is by
# definition no longer the tip.
#
# On a push to main the group key is the run id, which is unique per run — so
# every main run is alone in its group and behaves exactly as it does today:
# not cancelled, and never queued behind or evicted by another. A bare
# `group: drivers-${{ github.ref }}` would NOT be equivalent: GitHub cancels an
# existing PENDING run whenever a newer run queues into the same group on the
# default `queue: single` (cancel-in-progress governs only the in-progress
# run), so two rapid merges would silently cost the middle one its verdict.
# See docs/memory/ci/pr-quality-gate.md for the long form of that footgun.
concurrency:
group: drivers-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Debug + androidTest APK pair, both required: the driver lives under
# app/src/androidTest/, so it ships as an instrumentation-test APK and a
# release build produces artifacts that cannot host it.
android:
runs-on: ubuntu-latest
# Job level, not step level, so checkout and toolchain setup are inside the
# bound. Worst `android` run observed across five recorded runs: 2m36s
# (57s, 58s, 2m20s, 2m33s, 2m36s), so 15 leaves ~5.8x headroom for a cold
# gradle cache and runner-image variance. It replaces GitHub's 360-minute
# default: a wedged job now costs 15 Linux-rate minutes, not 360.
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
# AGP 8.1.4 (drivers/android/build.gradle.kts) requires JDK 17.
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: gradle
cache-dependency-path: |
drivers/android/gradle/wrapper/gradle-wrapper.properties
drivers/android/**/*.gradle.kts
# The same script `npm run build:drivers:android` runs. Invoked directly
# so the gate needs no Node toolchain, and NOT reimplemented here: the
# gradle task list and the APK staging paths live in one place.
- name: Build the Android driver (assembleDebug + assembleDebugAndroidTest)
run: ./scripts/build-drivers-android.sh
# Requires a macOS runner (xcodebuild). release.yml already provisions a
# windows-latest runner, so a second runner OS is established precedent.
ios:
runs-on: macos-latest
# Worst `ios` run across all 17 runs recorded as of 2026-07-31: 10m03s
# (run 30572535194, a pull_request run on PR #168's own branch), so 25
# leaves ~2.5x headroom (25/10.05 ≈ 2.49). Stated as a dated observation
# deliberately: the previous version of this comment cited a worst-of-five
# sample that was falsified 14 seconds after it shipped, by its own push's
# run — a fixed sample rots, while an as-of date is superseded by later,
# slower runs rather than contradicted.
# Deliberately the tighter bound RELATIVE to its
# worst run, because macos-latest bills at 10x: a hung xcodebuild now costs
# 250 billable minutes instead of the ~3,600 the 360-minute default allows.
# A spurious timeout is a red job a re-run clears — visible and
# self-correcting — which beats a cap so generous it defeats its purpose.
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
# build-drivers-ios.sh wipes DerivedData every run (rm -rf at line 11), so
# without a cache all 22 packages in Package.resolved re-resolve from
# github.com on every run — the dominant cost at the 10x macOS rate and the
# dominant variance the 25-minute cap has to absorb. This caches SPM's
# GLOBAL clone cache (~/Library/Caches/org.swift.swiftpm), which survives
# the DerivedData wipe: xcodebuild's package resolution copies from it
# instead of cloning over the network. Keyed on Package.resolved so a
# dependency bump repopulates; restore-keys makes a stale cache a partial
# hit (only changed packages re-clone), not a miss.
- uses: actions/cache@v4
with:
path: ~/Library/Caches/org.swift.swiftpm
key: spm-${{ runner.os }}-${{ hashFiles('drivers/ios/finalrun-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: |
spm-${{ runner.os }}-
# The same script `npm run build:drivers:ios` runs. It uses
# `build-for-testing`, not plain `build`: build-for-testing is what emits
# the finalrun-ios-test-Runner.app the script asserts on, and a plain
# build would pass while producing artifacts that cannot host the driver.
- name: Build the iOS driver (build-for-testing)
run: ./scripts/build-drivers-ios.sh