# Build, test, sign and publish Tally. # # Every push, any branch: the engine and both terminal frontends are built and tested, # the JNI layer is proved against a real JVM, the Android libraries are audited, and the # app is built and unit tested. Artifacts are attached to the run. # # Pushes to master also sign and publish, signing with action-hsm-sign: # - the APK, signed (schemes v2 and v3) by the HSM # - every release file, with a detached signature logged to sigstore's public # transparency log # - all of it to the Forgejo generic package registry as `tally`, under the short SHA # and under `latest` # # Secrets: HSM_USER_PIN (signing), PACKAGE_PUSH (registry), NTFY_* (notifications). # Runners: ubuntu-latest, and ubuntu-latest-with-hsm, which has the HSM on a smart USB hub # (per-port power switching) and runs one job at a time. name: Build on: workflow_dispatch: push: branches: - '*' env: RELEASE_OPTIMIZATION: ReleaseSafe # Pinned and checked, because CI should not take whatever was published today. The # same versions as .mise.toml, which is what a developer machine uses. ZLINT_VERSION: v0.9.0 ZLINT_SHA256: 2485f4f744345e4b7c23a6023e96f0a22306d832f64ba3aeba08d35c90f81a71 GRADLE_VERSION: 8.14.5 GRADLE_SHA256: 6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854 jobs: engine: name: Engine, CLI and TUI runs-on: ubuntu-latest steps: - name: Check out repository code uses: actions/checkout@v4 - name: Setup Zig # No version given on purpose: setup-zig resolves it from minimum_zig_version # in build.zig.zon, so the toolchain cannot drift from what the package declares. uses: https://github.com/mlugg/setup-zig@v2.2.1 - name: Check formatting # Before the build, which fetches dependencies into zig-pkg/ - not ours to format. run: zig fmt --check build.zig build.zig.zon build engine src - name: Lint run: | curl -fsSLo "$RUNNER_TEMP/zlint" \ "https://github.com/DonIsaac/zlint/releases/download/${ZLINT_VERSION}/zlint-linux-x86_64" echo "${ZLINT_SHA256} $RUNNER_TEMP/zlint" | sha256sum -c - chmod 755 "$RUNNER_TEMP/zlint" "$RUNNER_TEMP/zlint" --deny-warnings - name: Build project run: zig build --summary all - name: Run tests # Engine, CLI, TUI, the C ABI from Zig, the JNI layer against a synthetic # function table, and the C ABI from C through the installed header. run: zig build test --summary all - name: Run tests in each release mode # What ships is not Debug: the CLI and TUI are ReleaseSafe, the Android library # ReleaseSmall. Debug fills undefined memory with 0xaa, so code that reads # something it never wrote can pass there by luck and fail in a release build: # the JNI tests did exactly that. ReleaseFast has no safety checks at all, which # is where such a read shows up most readily, so it runs too. run: | for mode in ReleaseSafe ReleaseSmall ReleaseFast; do echo "== $mode" zig build test -Doptimize="$mode" --summary all done - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - name: Prove the JNI table against a real JVM # Debug, and ReleaseSmall: the optimization the Android library ships with. run: | zig build jvm-test --summary all zig build jvm-test -Doptimize=ReleaseSmall --summary all - name: Package the CLI and TUI # One binary holds both. Static musl on Linux, so it runs on any distribution. run: | mkdir -p dist for target in x86_64-linux-musl aarch64-linux-musl aarch64-macos; do zig build -Dtarget="$target" -Doptimize="$RELEASE_OPTIMIZATION" --prefix "out/$target" name="tally-${target%-musl}" cp "out/$target/bin/tally" "dist/$name" done ls -la dist - name: Upload uses: actions/upload-artifact@v3 with: name: tally-cli path: dist/ - name: Notify uses: https://git.lerch.org/lobo/action-notify-ntfy@v2 if: always() && env.GITEA_ACTIONS == 'true' with: host: ${{ secrets.NTFY_HOST }} topic: ${{ secrets.NTFY_TOPIC }} status: ${{ job.status }} user: ${{ secrets.NTFY_USER }} password: ${{ secrets.NTFY_PASSWORD }} android: name: Android runs-on: ubuntu-latest steps: - name: Check out repository code uses: actions/checkout@v4 - name: Setup Zig uses: https://github.com/mlugg/setup-zig@v2.2.1 - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - name: Setup Android SDK # The packages `mise run android-sdk` installs. No NDK: Zig builds the native # library without one. uses: https://github.com/android-actions/setup-android@v3 with: packages: 'platform-tools platforms;android-35 build-tools;35.0.0' log-accepted-android-sdk-licenses: 'false' - name: Setup Gradle # No wrapper is committed (android/README.md), so the version comes from here, # as it comes from .mise.toml locally. run: | curl -fsSLo "$RUNNER_TEMP/gradle.zip" \ "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" echo "${GRADLE_SHA256} $RUNNER_TEMP/gradle.zip" | sha256sum -c - unzip -q "$RUNNER_TEMP/gradle.zip" -d "$RUNNER_TEMP" echo "$RUNNER_TEMP/gradle-${GRADLE_VERSION}/bin" >> "$GITHUB_PATH" - name: Build the native libraries # Gradle never invokes Zig, so this comes first or the APK packages nothing. run: zig build android --summary all - name: Audit the native libraries # No undefined symbols, no TLS, no DT_NEEDED: each one loads on a desktop and # fails in dlopen on a phone. run: | command -v readelf >/dev/null || { sudo apt-get update && sudo apt-get install -y binutils; } android/audit-libs.sh zig-out/android - name: Unit test and build the app working-directory: android # The release APK is unsigned here; signing happens on the HSM runner. Lint's # release checks run as part of assembleRelease. The run number is the # versionCode, so each published build is an update to the one before. run: gradle --no-daemon -PtallyVersionCode=${{ github.run_number }} testDebugUnitTest assembleDebug assembleRelease - name: Upload the unsigned release APK uses: actions/upload-artifact@v3 with: name: tally-apk-unsigned path: android/app/build/outputs/apk/release/app-release-unsigned.apk - name: Upload the debug APK # Signed with this runner's throwaway debug key, so it installs only where no # other build of the app is installed. For trying a branch, not for keeping. uses: actions/upload-artifact@v3 with: name: tally-apk-debug path: android/app/build/outputs/apk/debug/app-debug.apk - name: Upload test results if: always() uses: actions/upload-artifact@v3 with: name: android-test-results path: android/app/build/test-results/ - name: Notify uses: https://git.lerch.org/lobo/action-notify-ntfy@v2 if: always() && env.GITEA_ACTIONS == 'true' with: host: ${{ secrets.NTFY_HOST }} topic: ${{ secrets.NTFY_TOPIC }} status: ${{ job.status }} user: ${{ secrets.NTFY_USER }} password: ${{ secrets.NTFY_PASSWORD }} sign: name: Sign # Only what is published is signed: the HSM is hardware on a hub, not something to # cycle for every branch push. if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest-with-hsm needs: [engine, android] steps: - name: Download the binaries uses: actions/download-artifact@v3 with: name: tally-cli path: dist - name: Download the unsigned APK uses: actions/download-artifact@v3 with: name: tally-apk-unsigned path: unsigned - name: Sign the APK and every release file # The APK first, then a detached signature of every file in dist/, the signed APK # included, each logged to sigstore. One power cycle of the HSM for all of it. # The APK's signature is the app's identity on every device that installs it, for # good: the key is the HSM's, and its certificate is the one `make-cert` wrote to # the token (android/signing-cert.pem; README, "Signing"). apk_cert_sha256 pins # it: an APK signed with any other certificate fails here instead of shipping. id: sign uses: https://git.lerch.org/lobo/action-hsm-sign@v3.1 with: pin: ${{ secrets.HSM_USER_PIN }} apk: unsigned/app-release-unsigned.apk apk_output: dist/tally.apk apk_cert_sha256: '4b41d0b1b3a184342fdc348e67563c8ca6cbc3765fa7341dee8a4a8fbb5d4aad' files: dist/* public_key: 'https://emil.lerch.org/serverpublic.pem' uhub_control: 'true' - name: Transparency log entries run: | echo "${{ steps.sign.outputs.SOURCE_0 }}: ${{ steps.sign.outputs.URL_0 }}" echo "${{ steps.sign.outputs.SOURCE_1 }}: ${{ steps.sign.outputs.URL_1 }}" echo "${{ steps.sign.outputs.SOURCE_2 }}: ${{ steps.sign.outputs.URL_2 }}" echo "${{ steps.sign.outputs.SOURCE_3 }}: ${{ steps.sign.outputs.URL_3 }}" - name: Upload uses: actions/upload-artifact@v3 with: name: tally-release path: dist/ - name: Notify uses: https://git.lerch.org/lobo/action-notify-ntfy@v2 if: always() with: host: ${{ secrets.NTFY_HOST }} topic: ${{ secrets.NTFY_TOPIC }} status: ${{ job.status }} user: ${{ secrets.NTFY_USER }} password: ${{ secrets.NTFY_PASSWORD }} publish: name: Publish if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest needs: sign steps: - name: Download the signed release uses: actions/download-artifact@v3 with: name: tally-release path: dist - name: Publish to the Forgejo generic package registry env: PACKAGE_PUSH: ${{ secrets.PACKAGE_PUSH }} ACTOR: ${{ github.actor }} OWNER: ${{ github.repository_owner }} SHA: ${{ github.sha }} run: | set -eu shortsha="$(printf '%s' "$SHA" | cut -c1-7)" base="https://git.lerch.org/api/packages/${OWNER}/generic/tally" cd dist for version in "$shortsha" latest; do for file in *; do url="${base}/${version}/${file}" # The registry refuses a second PUT of the same file (409), so `latest` # is replaced by delete-then-put. A 404 for a new short SHA is harmless. curl -sS -o /dev/null -w "DELETE %{http_code} ${url}\n" \ -u "${ACTOR}:${PACKAGE_PUSH}" -X DELETE "${url}" || true curl -sSf -u "${ACTOR}:${PACKAGE_PUSH}" --upload-file "${file}" "${url}" echo "uploaded ${url}" done done - name: Notify uses: https://git.lerch.org/lobo/action-notify-ntfy@v2 if: always() with: host: ${{ secrets.NTFY_HOST }} topic: ${{ secrets.NTFY_TOPIC }} status: ${{ job.status }} user: ${{ secrets.NTFY_USER }} password: ${{ secrets.NTFY_PASSWORD }} fdroid: name: F-Droid repo # The signed APK into https://fdroid.lerch.org/repo, which is /data/fdroid on the HSM # runner's host: this label is what puts the job on that host, as it does the # signing. The repo itself (config.yml, metadata, the index signing keystore) lives # there, not here; README, "Install with F-Droid". if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest-with-hsm needs: sign env: # fdroidserver, pinned: `fdroid update` regenerates and signs the repo's index. FDROID_IMAGE: registry.gitlab.com/fdroid/docker-executable-fdroidserver@sha256:75f6b88ef13a63fc5c49aa4c4dde35607c4a364d22cf57d534aa75c54effa56d FDROID_REPO: /data/fdroid # How many tally builds the repo offers; older ones are deleted. FDROID_KEEP: 3 steps: - name: Download the signed release uses: actions/download-artifact@v3 with: name: tally-release path: dist - name: Publish to the F-Droid repo # In fdroidserver's container on the host's daemon, as the repo's owner (uid # 1000). The repo is a bind mount of a host path, so the APK cannot be copied in # from this job's workspace by path: it goes over stdin. `--mount` rather than # `-v`, so that on a host without the repo the run fails instead of creating an # empty one. The versionCode is the run number (the android job), and so is the # file name, which is what orders the builds for pruning. # # The index signing keystore's password (the store's and the key's are the same) # is a secret, and only that: it is not on the host beside the keystore, and a # pull request from a fork gets no secrets. It reaches fdroid as environment # variables, passed to docker by name so the value is never on a command line; # the repo's config.yml reads them with {env: ...}. env: APK: dev.lerch.tally_${{ github.run_number }}.apk FDROID_KEY_STORE_PASS: ${{ secrets.FDROID_KEYSTORE_PASS }} FDROID_KEY_PASS: ${{ secrets.FDROID_KEYSTORE_PASS }} run: | set -eu [ -n "$FDROID_KEY_STORE_PASS" ] || { echo "the FDROID_KEYSTORE_PASS secret is not set" >&2; exit 1; } docker run --rm -i -u 1000:1000 -e HOME=/tmp -e APK -e FDROID_KEEP \ -e FDROID_KEY_STORE_PASS -e FDROID_KEY_PASS \ -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory \ -e GIT_CONFIG_VALUE_0=/home/vagrant/fdroidserver \ --mount "type=bind,source=${FDROID_REPO},target=/repo" \ --entrypoint sh "$FDROID_IMAGE" -euc ' [ -f config.yml ] || { echo "no F-Droid repo at /repo (no config.yml)" >&2; exit 1; } cat > "repo/${APK}.part" mv "repo/${APK}.part" "repo/${APK}" ls repo/dev.lerch.tally_*.apk | sort -t _ -k 2 -n | head -n "-${FDROID_KEEP}" | while read -r old; do echo "removing ${old}"; rm -f "${old}"; done . /etc/profile.d/bsenv.sh "${fdroidserver}/fdroid" update ls repo/dev.lerch.tally_*.apk ' < dist/tally.apk - name: Notify uses: https://git.lerch.org/lobo/action-notify-ntfy@v2 if: always() with: host: ${{ secrets.NTFY_HOST }} topic: ${{ secrets.NTFY_TOPIC }} status: ${{ job.status }} user: ${{ secrets.NTFY_USER }} password: ${{ secrets.NTFY_PASSWORD }}