diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml new file mode 100644 index 0000000..3bdb6b2 --- /dev/null +++ b/.forgejo/workflows/build.yaml @@ -0,0 +1,256 @@ +# 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: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: Prove the JNI table against a real JVM + run: zig build jvm-test --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. + run: gradle --no-daemon 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 (README, "Signing"). + id: sign + uses: https://git.lerch.org/lobo/action-hsm-sign@v3 + with: + pin: ${{ secrets.HSM_USER_PIN }} + apk: unsigned/app-release-unsigned.apk + apk_output: dist/tally.apk + 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 }}