A tap answers with the Word itself, and threads get an edit mode (v0.… #110
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: Release | |
| # Tag a version (v0.2.0) and this turns the repo into a download page: a | |
| # signed Android APK (arm64-v8a) attached to a GitHub Release, and | |
| # the PWA (apps/web) deployed to GitHub Pages — the path for everyone else | |
| # (hosting decided 2026-07-25). The desktop shells were retired 2026-07-25. | |
| # | |
| # The version a reader SEES is derived once, in the `version` job below, and | |
| # both shells read that one output. The tag keeps its `v` (it is a git name); | |
| # About does not. This used to be computed per job, and the two disagreed — | |
| # the web said "v1.0.0" while the APK said "1.0.0" for the same release. | |
| on: | |
| push: | |
| tags: ["v*"] | |
| # A manual run is a DRY RUN, and only a dry run. It does everything a tag | |
| # does — the version gate, the data pack contract, the type check, the wasm | |
| # engine, the signed APK, the Pages bundle — and publishes NOTHING: no | |
| # release is created or touched, no APK is uploaded, nothing reaches | |
| # plumblinebible.org. Every publishing step carries the same guard, | |
| # `github.event_name != 'workflow_dispatch'`. There are no inputs on purpose; | |
| # a manual run cannot be pointed at a version, so it cannot be talked into | |
| # overwriting one. Exercising this workflow should not cost a tag you then | |
| # have to delete. | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| jobs: | |
| # ONE release, ONE version number. Every consumer of a displayed version reads | |
| # this job's outputs, so the two shells cannot label the same release | |
| # differently again: | |
| # name = the tag without its leading 'v' (1.0.0) — Android's versionName and | |
| # the web's PLUMBLINE_VERSION, both rendered bare under About next to | |
| # `engine <CARGO_PKG_VERSION>`, which is bare too. | |
| # code = major*10000 + minor*100 + patch (0.3.1 -> 301), monotonic across | |
| # semver so each release installs in place over the last. Any | |
| # pre-release suffix (-rc1) is dropped before the arithmetic. | |
| # It also refuses a tag whose version disagrees with the manifests, because | |
| # About reads the engine version from Cargo.toml, not from the tag: v1.1.0 off | |
| # an unbumped tree ships "Plumbline 1.1.0 · engine 1.0.0". Everything else | |
| # needs this job on purpose — a pre-flight gate is not the artifact coupling | |
| # v0.3.0 taught us to avoid; artifacts labelled with the wrong version are | |
| # worse than a release that stops and says why. | |
| version: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| name: ${{ steps.v.outputs.name }} | |
| code: ${{ steps.v.outputs.code }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Derive the displayed version, and check the manifests agree | |
| id: v | |
| run: | | |
| crate=$(grep -m1 -E '^version = "' Cargo.toml | cut -d'"' -f2) | |
| web=$(grep -m1 -E '^ "version": "' apps/web/package.json | cut -d'"' -f4) | |
| # A dry run has no tag. It labels itself from Cargo.toml — the number a | |
| # tag would have to match anyway — so the build is stamped like the real | |
| # thing while the manifests are still checked against each other. Nothing | |
| # publishes on this path, so the string only ever reaches a build. | |
| tag="${{ github.ref_name }}" | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| tag="v$crate" | |
| fi | |
| name="${tag#v}" | |
| core="${name%%-*}" | |
| IFS=. read -r maj min pat <<< "$core" | |
| if [ "$core" != "$crate" ] || [ "$core" != "$web" ]; then | |
| echo "::error::Tag $tag means version $core, but Cargo.toml says '$crate' and apps/web/package.json says '$web'. Bump both manifests (About shows the Cargo version as the engine version), commit, and move the tag." | |
| exit 1 | |
| fi | |
| echo "name=$name" >> "$GITHUB_OUTPUT" | |
| echo "code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))" >> "$GITHUB_OUTPUT" | |
| # Create the GitHub Release up front so the platform jobs attach their own | |
| # artifacts independently (the lesson of v0.3.0, where one platform's break | |
| # blocked every other artifact from shipping). | |
| create-release: | |
| runs-on: ubuntu-latest | |
| needs: version | |
| steps: | |
| # Guarded on the step, not the job: a skipped job skips everything that | |
| # needs it, and a dry run is supposed to keep building. | |
| - name: Create the release if it does not exist yet | |
| if: github.event_name != 'workflow_dispatch' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh release view "${{ github.ref_name }}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ | |
| || gh release create "${{ github.ref_name }}" --repo "$GITHUB_REPOSITORY" \ | |
| --title "Plumbline ${{ github.ref_name }}" --generate-notes | |
| android: | |
| runs-on: ubuntu-latest | |
| needs: [version, create-release] | |
| # ───────────────────────────────────────────────────────────────────── | |
| # 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 | |
| - name: Signing secrets present? | |
| id: sign | |
| env: | |
| KS: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| run: | | |
| if [ -n "$KS" ]; then | |
| echo "ready=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "ready=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::ANDROID_KEYSTORE_* secrets not set — skipping the Android APK. Add them to attach a signed APK to releases." | |
| fi | |
| - uses: actions/setup-java@v4 | |
| if: steps.sign.outputs.ready == 'true' | |
| with: | |
| distribution: temurin | |
| java-version: "21" | |
| - uses: android-actions/setup-android@v3 | |
| if: steps.sign.outputs.ready == 'true' | |
| # Caches the Gradle distribution and dependency jars between runs — | |
| # without it every tag re-downloads Gradle, AGP and every dependency | |
| # from zero. | |
| - uses: gradle/actions/setup-gradle@v4 | |
| if: steps.sign.outputs.ready == 'true' | |
| - name: Install SDK platform + build-tools | |
| if: steps.sign.outputs.ready == 'true' | |
| 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 | |
| if: steps.sign.outputs.ready == 'true' | |
| with: | |
| targets: aarch64-linux-android | |
| - uses: Swatinem/rust-cache@v2 | |
| if: steps.sign.outputs.ready == 'true' | |
| # Same pinned install CI uses, so the toolchain that builds the shipped .so | |
| # is the one the pushes were tested with (and it comes prebuilt, not from a | |
| # cold `cargo install`). | |
| - name: Install cargo-ndk | |
| if: steps.sign.outputs.ready == 'true' | |
| uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-ndk | |
| - name: Cross-compile libplumbline_ffi.so (arm64-v8a, 16 KB-aligned) | |
| if: steps.sign.outputs.ready == 'true' | |
| 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: Build the signed release APK | |
| if: steps.sign.outputs.ready == 'true' | |
| env: | |
| ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/release.jks | |
| ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} | |
| ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} | |
| KS: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| run: | | |
| echo "$KS" | base64 -d > "$ANDROID_KEYSTORE_FILE" | |
| chmod +x apps/android/gradlew | |
| apps/android/gradlew -p apps/android :app:assembleRelease --console=plain \ | |
| -PplumblineVersionName="${{ needs.version.outputs.name }}" \ | |
| -PplumblineVersionCode="${{ needs.version.outputs.code }}" | |
| - name: Attach the APK to the release | |
| if: steps.sign.outputs.ready == 'true' && github.event_name != 'workflow_dispatch' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Named explicitly rather than globbed: a glob would silently attach | |
| # whichever file sorted first if a second APK ever appeared, and `cp` | |
| # failing loudly at tag time is the better outcome. | |
| OUT="plumbline-${{ github.ref_name }}-android.apk" | |
| cp apps/android/app/build/outputs/apk/release/app-release.apk "$OUT" | |
| gh release upload "${{ github.ref_name }}" "$OUT" --clobber | |
| # Deploy the PWA to GitHub Pages — the install path for everyone without an | |
| # APK (hosting decided 2026-07-25). Same pipeline CI verifies: data pack → | |
| # wasm engine → Vite bundle; the bundle is base "./" so the /plumbline/ | |
| # subpath needs no rebuild. Independent of the APK job on purpose (the | |
| # lesson of v0.3.0 — one artifact's break must not block the others). | |
| pages: | |
| runs-on: ubuntu-latest | |
| needs: version | |
| permissions: | |
| contents: read | |
| pages: write | |
| id-token: write | |
| concurrency: | |
| group: github-pages | |
| cancel-in-progress: false | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deploy.outputs.page_url }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: wasm32-wasip1 | |
| - uses: Swatinem/rust-cache@v2 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| - name: Install web dependencies | |
| run: npm ci | |
| working-directory: apps/web | |
| - name: Build the data pack | |
| run: node scripts/build-web-pack.mjs | |
| # The pack is generated fresh in THIS job and then deployed to readers, so | |
| # this is the last place it can be checked and the only place it really | |
| # matters. CI validates the same thing on every push, but a release can be | |
| # tagged from any commit — and a pack that violates the loader's contract | |
| # surfaces as a boot that hangs with no diagnostic, on everyone's device at | |
| # once. Entry shape, closed stage set, and every hash re-derived from the | |
| # bytes about to ship. | |
| - 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 | |
| # `npm run check` for the same reason the pack check is here: CI runs | |
| # svelte-check on every push, but a tag can be cut from any commit, and | |
| # this job goes straight to plumblinebible.org. It runs before the bundle | |
| # so a type error stops the release instead of shipping in it. | |
| - name: Build the app | |
| run: | | |
| node scripts/copy-wasm.mjs | |
| npm run check | |
| npm run build | |
| working-directory: apps/web | |
| env: | |
| # Stamped into About, so a bug report can name its build. Without it | |
| # a deployed PWA cannot tell you which release it is (2026-07-27). | |
| # The same string the APK carries — bare, no leading 'v'. | |
| PLUMBLINE_VERSION: ${{ needs.version.outputs.name }} | |
| # Everything above builds and checks; everything below publishes. A dry run | |
| # stops here — it has proved the bundle can be built, which is the whole | |
| # question a manual run is asking. | |
| - uses: actions/configure-pages@v5 | |
| if: github.event_name != 'workflow_dispatch' | |
| with: | |
| enablement: true | |
| - uses: actions/upload-pages-artifact@v3 | |
| if: github.event_name != 'workflow_dispatch' | |
| with: | |
| path: apps/web/dist | |
| - name: Deploy to GitHub Pages | |
| id: deploy | |
| if: github.event_name != 'workflow_dispatch' | |
| uses: actions/deploy-pages@v4 |