# Straw APK — build on Forgejo Actions, and (gated on a green build) publish to # fdroid.sulkta.com so the in-app updater picks it up. # # Runs in the dedicated git.sulkta.com/sulkta-infra/straw-build image (Android # SDK/NDK + Rust + cargo-ndk, see ci/Dockerfile). The signing key comes from # the STRAW_SIGNING_KEYSTORE_B64 secret (the same androiddebugkey the whole # series is signed with — vaulted as "Sulkta — Straw fdroid signing keystore"). # # Publish is ROOTLESS (#444): the signed APK is handed to a forced-command # script on the Lucy host (key STRAW_FDROID_LUCY_KEY, pinned to that one script # via restrict,command=). The host does the fdroid index re-sign + the Rackham # rsync — the signing keystore never enters this CI container, and this job no # longer needs the host docker socket. The host script re-verifies the APK # signer before publishing. name: build-apk on: push: branches: [main] paths: - 'strawApp/**' - 'rust/**' - 'buildSrc/**' - 'gradle/**' - 'gradlew' - '*.gradle.kts' - 'gradle.properties' - 'ci/Dockerfile' - '.forgejo/workflows/build.yml' workflow_dispatch: jobs: build-and-publish: runs-on: lucy-rust # Run every step with bash, not the runner's default dash — the steps use # `set -euo pipefail` (pipefail is a bashism; dash errors "Illegal option"). # The straw-build image ships /usr/bin/bash. defaults: run: shell: bash container: image: git.sulkta.com/sulkta-infra/straw-build:latest steps: # We clone with plain git instead of actions/checkout@v4: that action is # a Node action, and the straw-build job container ships the Android + # Rust toolchain but NOT node — so checkout@v4 dies with # `exec: "node": not found`. git is in the image, both repos are public, # and a shell clone also sidesteps the runner's flaky data.forgejo.org # action fetch. strawcore must be a SIBLING of straw because # rust/strawcore depends on it via `path = "../../../strawcore"`. - name: Checkout straw + strawcore (sibling, no JS actions) run: | set -euo pipefail git clone https://git.sulkta.com/Sulkta-OSS/straw.git straw git -C straw checkout --detach "${{ github.sha }}" git clone --depth 1 https://git.sulkta.com/Sulkta-OSS/strawcore.git strawcore echo "straw: $(git -C straw rev-parse --short HEAD)" echo "strawcore: $(git -C strawcore rev-parse --short HEAD)" - name: Decode signing keystore env: KS_B64: ${{ secrets.STRAW_SIGNING_KEYSTORE_B64 }} run: echo "$KS_B64" | base64 -d > "$GITHUB_WORKSPACE/straw.keystore" - name: Assemble debug APK working-directory: straw env: STRAW_KEYSTORE_FILE: ${{ github.workspace }}/straw.keystore STRAW_KEYSTORE_PASS: android STRAW_KEY_ALIAS: androiddebugkey STRAW_KEY_PASS: android # Keep the 4-ABI cross-compile off the container rootfs. CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target # ionice idle class + low nice so the build yields disk/CPU to the # live DBs on Lucy — a 5GB build I/O-starved the MAS Postgres # checkpoint and dropped Matrix for ~2min on 2026-06-19. run: ionice -c3 nice -n 19 ./gradlew :strawApp:assembleDebug --no-daemon --stacktrace - name: Verify signer + stage APK working-directory: straw run: | set -euo pipefail VC=$(grep STRAW_VERSION_CODE buildSrc/src/main/kotlin/ProjectConfig.kt | grep -o '[0-9]\+') APK=$(ls strawApp/build/outputs/apk/debug/*.apk | head -1) NAME="com.sulkta.straw.debug_${VC}.apk" cp "$APK" "$GITHUB_WORKSPACE/$NAME" echo "Built vc=$VC -> $NAME" # The whole series is signed with SHA-1 bb9ca96b...; fail loudly if a # build ever produces a different signer (would break in-place updates). # apksigner lives under build-tools//. AGP (compileSdk 36) needs # build-tools 36; older straw-build images pre-installed only 34.0.0 and # AGP auto-fetched 36 at build time, so this sort -V | tail -1 resolves to # 36.0.0's apksigner. (ci/Dockerfile now pre-installs 36 too — see there.) # apksigner is a shell wrapper that needs `java` on PATH; the image # sets JAVA_HOME but doesn't put its bin on PATH for run steps (gradle # uses JAVA_HOME directly, so the build itself is fine). export PATH="$JAVA_HOME/bin:$PATH" APKSIGNER=$(ls "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1) FP=$("$APKSIGNER" verify --print-certs "$APK" | grep -i 'SHA-1' | grep -o '[0-9a-f]\{40\}') echo "signer SHA-1: $FP" if [ "$FP" != "bb9ca96b10ebbc1ac48e037a21f350415d18915f" ]; then echo "::error::APK signer $FP != canonical key — refusing to publish"; exit 1 fi echo "STRAW_VC=$VC" >> "$GITHUB_ENV" echo "STRAW_APK=$NAME" >> "$GITHUB_ENV" # ---- Publish (rootless, no docker socket): hand the signed APK to the # Lucy host forced-command. The host re-verifies the signer, re-signs # the fdroid index (keystore stays on Lucy), and rsyncs to Rackham. ---- - name: Publish to fdroid via Lucy host forced-command if: github.event_name == 'push' && github.ref == 'refs/heads/main' env: LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }} run: | set -euo pipefail install -d -m700 "$HOME/.ssh" printf '%s\n' "$LUCY_KEY" > "$HOME/.ssh/id_lucy" chmod 600 "$HOME/.ssh/id_lucy" # Pin the Lucy host key (no TOFU). printf '%s\n' '192.168.0.5 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIk1uDXaIz6MC3f5IymRHQ9B/azLx4XRrkTNb2F/xRP4' \ > "$HOME/.ssh/known_hosts_lucy" # Hand off the signed APK on stdin. Host exit codes: 0 = published, # 3 = already published (benign no-op on a same-commit re-run), # anything else = real failure. set +e ssh -i "$HOME/.ssh/id_lucy" -o IdentitiesOnly=yes -o BatchMode=yes \ -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$HOME/.ssh/known_hosts_lucy" \ root@192.168.0.5 "publish ${STRAW_APK}" < "$GITHUB_WORKSPACE/${STRAW_APK}" rc=$? set -e case "$rc" in 0) echo "Published vc=${STRAW_VC} to fdroid.sulkta.com (rootless — no docker socket)";; 3) echo "vc=${STRAW_VC} already published — nothing to do";; *) echo "::error::fdroid publish failed (rc=$rc)"; exit "$rc";; esac