tally/.forgejo/workflows/build.yaml
Emil Lerch c22ed56806
All checks were successful
Build / Engine, CLI and TUI (push) Successful in 53s
Build / Android (push) Successful in 4m29s
Build / Sign (push) Successful in 2m18s
Build / Publish (push) Successful in 12s
Build / F-Droid repo (push) Successful in 36s
publish to fdroid through CI
2026-10-04 16:32:56 -07:00

339 lines
15 KiB
YAML

# 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 }}