Skip to content

A tap answers with the Word itself, and threads get an edit mode (v0.… #110

A tap answers with the Word itself, and threads get an edit mode (v0.…

A tap answers with the Word itself, and threads get an edit mode (v0.… #110

Workflow file for this run

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