The build-apk job ran only ./gradlew :strawApp:assembleDebug, so no unit test ran before an APK was signed and published. With the Shorts check planted to never match, that command still built successfully. Two steps are added after "Auto versionCode from commit count" and before "Assemble debug APK": - Rust unit tests (strawcore): cargo test -p strawcore --lib, in straw/rust. - JVM unit tests: ./gradlew :strawApp:testDebugUnitTest, in straw. Both run under the same ionice/nice as the build step and with the same CARGO_TARGET_DIR; neither has a secret in its environment. Nothing else in the workflow changed: same triggers, same build, signer check and publish steps and conditions. Lint is not added. Run by hand in the straw-build image, in the workflow's order: cargo test 11 passed, rc 0; testDebugUnitTest 31 passed, rc 0; assembleDebug rc 0. With the Shorts check planted to never match, the JVM step exited 1 (6 of 31 failed); with the Rust parser planted to never mark a Short, the Rust step exited 101 (5 of 11 failed). Not run on Forgejo: the workflow only runs on main.
183 lines
9.4 KiB
YAML
183 lines
9.4 KiB
YAML
# 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"
|
|
|
|
# Auto-derive versionCode from the main commit count so a release can
|
|
# NEVER silently reuse a code — the 2026-07-29 trap where a commit that
|
|
# didn't hand-bump ProjectConfig rebuilt the same debug_<vc>.apk, the
|
|
# publish receiver's anti-downgrade guard refused the duplicate (exit 3 =
|
|
# "nothing to do"), and the CI went green having shipped nothing. The
|
|
# OFFSET keeps the number continuous with the manual era (last hand-set
|
|
# code was 91). Full clone above ⇒ rev-list --count is the true count.
|
|
- name: Auto versionCode from commit count
|
|
working-directory: straw
|
|
run: |
|
|
set -euo pipefail
|
|
COUNT=$(git rev-list --count HEAD)
|
|
VC=$(( COUNT - 12208 ))
|
|
sed -i "s/^const val STRAW_VERSION_CODE = .*/const val STRAW_VERSION_CODE = $VC/" \
|
|
buildSrc/src/main/kotlin/ProjectConfig.kt
|
|
grep -q "STRAW_VERSION_CODE = $VC\$" buildSrc/src/main/kotlin/ProjectConfig.kt \
|
|
|| { echo "::error::versionCode auto-patch failed (sed matched nothing)"; exit 1; }
|
|
echo "auto versionCode = $VC (commit count $COUNT)"
|
|
|
|
# The unit tests run before the APK is built, so that a failing test
|
|
# fails the job before anything is built, signed or published. Neither
|
|
# step has a secret in its environment. Both use the build step's
|
|
# target dir (off the container rootfs) and its low CPU and I/O priority.
|
|
- name: Rust unit tests (strawcore)
|
|
working-directory: straw/rust
|
|
env:
|
|
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
|
|
run: ionice -c3 nice -n 19 cargo test -p strawcore --lib
|
|
|
|
- name: JVM unit tests
|
|
working-directory: straw
|
|
env:
|
|
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
|
|
run: ionice -c3 nice -n 19 ./gradlew :strawApp:testDebugUnitTest --no-daemon --stacktrace
|
|
|
|
- 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
|
|
# Dogfood log-shipping: the app is compile-DISABLED unless this is set
|
|
# (empty ⇒ no crash handler, no network, Settings row hidden). Repo
|
|
# secret; absent on contributor forks so their builds don't phone home.
|
|
STRAW_LOGS_INGEST_TOKEN: ${{ secrets.STRAW_LOGS_INGEST_TOKEN }}
|
|
# 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/<ver>/. 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
|
|
# Publish on a push to main OR a manual dispatch. A workflow_dispatch is
|
|
# how we ship a strawcore-only change (strawcore is cloned fresh at build
|
|
# time) into an APK without any straw code change — without the dispatch
|
|
# arm a dispatch builds + verifies the APK but silently never publishes.
|
|
if: >-
|
|
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
|
|
github.event_name == 'workflow_dispatch'
|
|
env:
|
|
LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }}
|
|
# Publish target + its host-key are Forgejo secrets, NOT literals, so
|
|
# this public workflow carries no infra topology — only references.
|
|
PUBLISH_TARGET: ${{ secrets.STRAW_PUBLISH_TARGET }}
|
|
LUCY_HOSTKEY: ${{ secrets.STRAW_LUCY_HOSTKEY }}
|
|
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 publish host key (no TOFU) — from a secret, not a literal.
|
|
printf '%s\n' "$LUCY_HOSTKEY" > "$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" \
|
|
"$PUBLISH_TARGET" "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
|