Launch-day fixes: the switch is a switch, the pin keeps moving, one face needs no menu (v0.63.1) #320
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # WHEN A VERSION IS CUT, AND ON EVERY PR TO `main` (Glendon, 2026-08-04; | |
| # PR gating added 2026-08-06). | |
| # | |
| # It used to run on a push to any branch, which meant that cutting a release — | |
| # where the feature branch and `main` are pushed at the same commit — ran the | |
| # whole suite TWICE over identical code. The concurrency group is keyed by ref, | |
| # so two refs at one commit are two groups and neither cancels the other; it | |
| # happened at v0.39.2 (batch-7 + main), v0.40.0 and v0.40.4. So branch pushes | |
| # no longer trigger it — only the tag does. | |
| # | |
| # `pull_request` gates work BEFORE it reaches `main`: a PR runs the whole suite | |
| # against its merge commit. This does NOT bring back the release double-run — | |
| # that was two `push` events at one commit, and a tag push fires no | |
| # `pull_request`. A PR fires no branch-push trigger either (there is none), so | |
| # each PR runs once. | |
| # | |
| # `workflow_dispatch` stays so the suite can still be run by hand against any | |
| # ref without cutting a tag for it. | |
| # | |
| # THE TRADE-OFF, said out loud: this workflow does not gate the Release one — | |
| # both fire on the tag and run alongside each other, so a red CI does not stop | |
| # an APK being published. The maintainer runs the suite locally before tagging, | |
| # and the PR gate above is what catches a regression before it lands; that is | |
| # the actual gate. | |
| on: | |
| push: | |
| tags: ["v*"] | |
| pull_request: | |
| branches: ["main"] | |
| workflow_dispatch: | |
| # One run per ref. A re-pushed tag would otherwise pile up, and a new push to a | |
| # PR branch supersedes the in-flight run for that PR rather than racing it. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| jobs: | |
| # Build + test the crates: the pure domain core, layout, the R&D flags, | |
| # and the C ABI. | |
| test: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Test portable crates | |
| run: cargo test --locked -p plumbline-core -p plumbline-layout -p plumbline-rnd -p plumbline-ffi -p plumbline-hydrate | |
| - name: Test the R&D features | |
| run: cargo test --locked -p plumbline-rnd --features "bridge morphology concept" | |
| # EVERY SHIPPED CORPUS against the file that actually ships. `#[ignore]`d | |
| # in the default run because they read tens of MB and need a hydrated | |
| # checkout; here they are the only thing proving each corpus sits at the | |
| # KJV's verse addresses, comes back in its own language, and is not handed | |
| # evidence keyed to another text's tokens. | |
| # | |
| # NO NAME FILTER, deliberately. This ran `--ignored german_corpus`, which | |
| # is a list of languages by another name: the Spanish test was written, | |
| # passed locally, and would never have run here — and neither, it turns | |
| # out, had `the_deferred_load_still_brings_the_morphology_to_an_english_reader`, | |
| # the half that keeps the German morphology assertion from passing because | |
| # the loader is broken for everybody. The whole ignored set is ~20 s. | |
| # | |
| # --test-threads=1: these tests flip the process-global language and would | |
| # race each other's resets in parallel. | |
| - name: Test every shipped corpus | |
| run: cargo test --locked -p plumbline-ffi --lib -- --ignored --test-threads=1 | |
| # The corpus idxcache must be REPRODUCIBLE. It is the biggest file in the | |
| # web pack and the pack manifest hashes it, so a cache that differs run to | |
| # run re-mints every `?v=` URL and makes every reader re-download the whole | |
| # pack on a release that changed no data. The unit tests assert the | |
| # invariant on a fixture; this asserts it on the real 19 MB corpus, which | |
| # is the artifact that actually ships. | |
| - name: The idxcache builds byte-identically | |
| run: | | |
| cargo run --release --locked -q -p plumbline-hydrate -- \ | |
| web-cache --data data/kjv.jsonl --out /tmp/idxcache.a | |
| cargo run --release --locked -q -p plumbline-hydrate -- \ | |
| web-cache --data data/kjv.jsonl --out /tmp/idxcache.b | |
| cmp /tmp/idxcache.a /tmp/idxcache.b | |
| # Test plumbline-rnd with NO features in isolation: a single -p plumbline-rnd | |
| # invocation in its own job, so feature unification from sibling -p flags | |
| # (or another crate's dev-deps) can't silently switch its features on. | |
| rnd-featureless: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Test plumbline-rnd with default (no) features | |
| run: cargo test --locked -p plumbline-rnd | |
| # Linted here rather than in `lint` for the same isolation reason the job | |
| # exists at all. | |
| - name: Lint plumbline-rnd with default (no) features | |
| run: cargo clippy --locked -p plumbline-rnd --all-targets -- -D warnings | |
| # Formatting + clippy, both HARD gates since 2026-07-30. They were advisory | |
| # (continue-on-error) for as long as the tree was unclean, which is the state | |
| # in which an advisory step buys nothing: nobody reads a green run's warnings. | |
| # The tree was brought to zero and `rustfmt.toml` writes the house line budget | |
| # down, so a warning is now a failure. | |
| lint: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # 224 stock files sat tracked against .gitignore's stated intent until | |
| # 2026-07-29 — a second source of truth for the study set, and the kind of | |
| # thing nobody notices because git keeps honouring an `add` that predates | |
| # the rule. `git add -f` still works on purpose; this only refuses the | |
| # accident. | |
| - name: Nothing is tracked against .gitignore | |
| run: | | |
| tracked="$(git ls-files -i -c --exclude-standard)" | |
| if [ -n "$tracked" ]; then | |
| echo "::error::These files are tracked but .gitignore says they should not be." | |
| echo "Untrack them with: git rm -r --cached <path> (the files stay on disk)" | |
| echo "$tracked" | |
| exit 1 | |
| fi | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt, clippy | |
| # For the wasm clippy pass below. | |
| targets: wasm32-wasip1 | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: rustfmt | |
| run: cargo fmt --all --check | |
| # `--all-targets`, which the advisory version did not have: the old command | |
| # linted the libs and nothing else, so every test module in the tree was | |
| # unlinted. `--all-features` reaches plumbline-rnd's analytics and the | |
| # bindgen bin. | |
| - name: clippy | |
| run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings | |
| # The R&D crate's shipped feature set, linted the way it is tested. | |
| - name: clippy (the R&D features) | |
| run: cargo clippy --locked -p plumbline-rnd --all-targets --features "bridge morphology concept" -- -D warnings | |
| # And the web engine's own target. `crates/ffi` carries `cfg(target_arch = | |
| # "wasm32")` code — the whole `wasm.rs` shim, plus the sliced-warm cluster | |
| # that only it and the tests call — which a host-target clippy never sees. | |
| - name: clippy (wasm32-wasip1, the web engine's target) | |
| run: cargo clippy --locked -p plumbline-ffi --target wasm32-wasip1 --all-targets -- -D warnings | |
| # The workspace declares rust-version = "1.85"; make sure the portable | |
| # crates actually still compile on that toolchain, not just on stable. | |
| msrv: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@1.85 | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Check portable crates on the MSRV toolchain | |
| run: cargo check --locked -p plumbline-core -p plumbline-layout -p plumbline-rnd -p plumbline-ffi -p plumbline-hydrate | |
| # Guard against the bindings drifting from the Rust source: regenerate the C | |
| # header, then fail if anything changed. This is the doc-vs-wire drift class | |
| # the ABI review flagged — a changed #[no_mangle] surface that wasn't | |
| # regenerated shows up here as a diff. The hand-written Kotlin JNA binding is | |
| # covered by the same command: plumbline-bindgen asserts header ↔ Plumbline.kt | |
| # cover the same function set, so a missing `fun` fails the step above. (The | |
| # C# P/Invoke shim was retired with the desktop shells, 2026-07-25.) | |
| bindings: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Regenerate FFI bindings | |
| run: cargo run --locked -p plumbline-ffi --features bindgen --bin plumbline-bindgen | |
| - name: Fail on binding drift | |
| run: | | |
| if ! git diff --quiet -- crates/ffi/include; then | |
| echo "::error::FFI bindings are out of date. Run:" | |
| echo " cargo run -p plumbline-ffi --features bindgen --bin plumbline-bindgen" | |
| echo "and commit the regenerated crates/ffi/include files." | |
| git --no-pager diff -- crates/ffi/include | |
| exit 1 | |
| fi | |
| # Every user-visible string in BOTH shells comes from crates/core/src/i18n — | |
| # see docs/I18N.md. Its own job rather than a step inside the web build, | |
| # because it also reads the Kotlin shell, and because a stray literal should | |
| # fail fast rather than after a wasm compile and a browser download. | |
| i18n: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| - name: No user-visible strings outside the catalogue | |
| run: node scripts/check-i18n.mjs | |
| # Build the web shell end to end and run its Playwright suite — boots the | |
| # real engine (wasm) in a real browser: boot, stock seeding, menu | |
| # responsiveness (freeze regression), word study, search, themes, the | |
| # navigator, and the backup round-trip. | |
| web: | |
| runs-on: ubuntu-latest | |
| # Playwright's own container rather than a bare runner: `npx playwright | |
| # install --with-deps` was 9m20s of every run — a minute of browser | |
| # download and eight minutes of apt-get walking WebKit's dependency | |
| # closure (GStreamer, flite, libwoff…) package by package. The image | |
| # ships chromium + webkit AND those system libraries preinstalled at | |
| # /ms-playwright, so the step is simply gone. Everything inside is FOSS | |
| # (Ubuntu, Node, Playwright under Apache-2.0, the browsers under their | |
| # own licenses); the Dockerfile lives in the playwright repo. | |
| # | |
| # THE TAG MUST MATCH the @playwright/test version package-lock.json pins. | |
| # A newer npm package looks for browser builds the image does not have and | |
| # the config's executablePath() check fails at load — loudly, which is the | |
| # point — so a Playwright bump is a two-file change: the lock and this tag. | |
| # `--ipc=host` is Playwright's own guidance for chromium in containers; | |
| # the Actions default /dev/shm is 64 MB and chromium crashes into it. | |
| container: | |
| image: mcr.microsoft.com/playwright:v1.62.0-noble | |
| options: --ipc=host | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: wasm32-wasip1 | |
| # The image ships browsers, not compilers — and rustc needs a host `cc` | |
| # to link build scripts and proc macros even for a wasm target. | |
| - name: Host linker | |
| run: command -v cc || (apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev) | |
| - uses: Swatinem/rust-cache@v2 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| # `npm ci` was re-downloading the whole tree every run. The lockfile is | |
| # the key, so a run that changes no dependency restores instead of | |
| # resolving — and this is the ONE npm job, so nothing else pays for the | |
| # cache to exist. | |
| cache: npm | |
| cache-dependency-path: apps/web/package-lock.json | |
| - name: Install web dependencies | |
| run: npm ci | |
| working-directory: apps/web | |
| - name: Build the data pack | |
| run: node scripts/build-web-pack.mjs | |
| # The manifest IS the load spec, and the producer lives outside apps/web's | |
| # tsconfig — so `npm run check` validates the loader's view of the shape | |
| # without ever seeing the code that writes it. This closes that gap: entry | |
| # shape, closed stage set, the invariants the loader depends on, and every | |
| # hash re-derived from the shipped bytes. Without it a producer/consumer | |
| # mismatch surfaces only as an e2e boot timeout with no diagnostic. | |
| - name: Check the data pack against the loader's contract | |
| run: node scripts/check-web-pack.mjs | |
| - name: Build the engine (wasm32-wasip1) | |
| run: cargo build --locked -p plumbline-ffi --release --target wasm32-wasip1 | |
| - name: Build the app | |
| run: | | |
| node scripts/copy-wasm.mjs | |
| npm run check | |
| npm run build | |
| working-directory: apps/web | |
| # No browser-install step: the container already carries chromium AND the | |
| # webkit the offline project needs (playwright.config.ts proves the | |
| # offline promise on the engine where the Cache API and eviction actually | |
| # differ, and WebKit is the only engine on iOS). The config still fails | |
| # loudly at load if webkit is missing, so that coverage cannot vanish | |
| # quietly into a chromium-only run. | |
| # Two invocations on purpose (see playwright.config.ts): the bulk of the | |
| # suite runs on 3 workers, then the @perf-tagged tests — the ones that | |
| # assert wall-clock budgets — rerun serialised so CPU contention from a | |
| # neighbouring engine boot cannot fail a timing they were written to | |
| # measure alone. | |
| - name: Playwright e2e | |
| run: npm run test:e2e | |
| working-directory: apps/web | |
| # Build the Android APK (debug) on every push/PR. Like the WinUI job that | |
| # preceded it, the APK was at first only assembled on a v* tag by the release | |
| # workflow, so a Kotlin/JNA break or a .so cross-compile break wouldn't | |
| # surface until release. This cross-builds the engine .so for the shipped | |
| # ABI and assembles the debug-signed APK — the release job's path minus the | |
| # keystore. | |
| android: | |
| runs-on: ubuntu-latest | |
| # ───────────────────────────────────────────────────────────────────── | |
| # TEMPORARILY ON HOLD (2026-08-17). The Android shell is a release behind | |
| # the web — per-pane text language shipped on the web only (docs/PER-PANE- | |
| # LANGUAGE.md), so the APK would advertise a version whose headline feature | |
| # it does not have. | |
| # | |
| # `if: false` rather than deleting the job: everything below is still | |
| # correct and still reviewed, and lifting the hold is removing this one | |
| # line. GitHub reports the job as skipped, so its absence is visible rather | |
| # than silent. | |
| # | |
| # TO LIFT: delete this `if:`, and check the parity note in | |
| # docs/FEATURE-MANIFEST.md still says what is true. | |
| if: false | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: "21" | |
| - uses: android-actions/setup-android@v3 | |
| # Caches the Gradle distribution and dependency jars between runs — | |
| # without it every push re-downloads Gradle, AGP and every dependency | |
| # from zero. | |
| - uses: gradle/actions/setup-gradle@v4 | |
| - name: Install SDK platform + build-tools | |
| run: | | |
| yes | sdkmanager --licenses >/dev/null 2>&1 || true | |
| sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools" >/dev/null | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: aarch64-linux-android | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install cargo-ndk | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-ndk | |
| - name: Cross-compile libplumbline_ffi.so (arm64-v8a, 16 KB-aligned) | |
| run: | | |
| export ANDROID_NDK_HOME="${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_ROOT}" | |
| cargo ndk -t arm64-v8a --platform 26 \ | |
| -o apps/android/app/src/main/jniLibs build -p plumbline-ffi --release | |
| - name: Assemble the debug APK, run the JVM unit tests, and lint | |
| run: | | |
| chmod +x apps/android/gradlew | |
| apps/android/gradlew -p apps/android \ | |
| :app:assembleDebug :app:testDebugUnitTest :app:lintDebug --console=plain | |
| # The RELEASE variant too, added 2026-07-30 with R8 shrinking. Debug does | |
| # not shrink, so a keep-rule that stops keeping a reflected wire type — a | |
| # silent data bug — or a baseline profile that stops compiling would | |
| # otherwise first appear at `git tag`, on a build nobody can iterate on. | |
| # Unsigned here (the keystore secrets belong to the release workflow); an | |
| # unsigned assemble still runs R8, the resource shrinker and the profile | |
| # packaging, which is the part that can break. | |
| - name: Assemble the release variant (R8) — unsigned | |
| run: | | |
| apps/android/gradlew -p apps/android :app:assembleRelease --console=plain | |
| ls -l apps/android/app/build/outputs/apk/release/ | |
| # The release workflow copies the APK by name — a rename would otherwise | |
| # first fail at `git tag`, inside a step a workflow_dispatch dry run skips. | |
| # | |
| # Either name counts, and the difference is the signature: with a keystore | |
| # (the release workflow) AGP writes `app-release.apk`; without one (here, | |
| # and locally) it writes `app-release-unsigned.apk`. Asserting only the | |
| # signed name would fail this job on every push — which is exactly what the | |
| # first version of this step did. | |
| - name: The release workflow's APK filename still exists | |
| run: | | |
| cd apps/android/app/build/outputs/apk/release | |
| ls -l | |
| test -f app-release.apk || test -f app-release-unsigned.apk |