Skip to content

The status bar takes one answer, and the devotional reaches the reade… #314

The status bar takes one answer, and the devotional reaches the reade…

The status bar takes one answer, and the devotional reaches the reade… #314

Workflow file for this run

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