From 418a6e5f5fc2a1286d5e57ede01c52bb11bc98c1 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:38:52 +0200 Subject: [PATCH] Add reproducible release pipeline --- .github/workflows/release-build.yml | 119 ++++++++++++++++ .gitignore | 1 + README.md | 2 + docs/RELEASING.md | 98 +++++++++++++ scripts/build_and_sign.sh | 211 +++++++++++++++++++++++----- 5 files changed, 399 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/release-build.yml create mode 100644 docs/RELEASING.md diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 000000000..29b85f433 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,119 @@ +name: Build unsigned GitHub release APK + +on: + push: + tags: + - '[0-9]*' + workflow_dispatch: + inputs: + ref: + description: Tag or commit to build + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-build-${{ inputs.ref || github.ref }} + cancel-in-progress: false + +jobs: + build-unsigned: + name: Build unsigned APK on Linux + runs-on: ubuntu-22.04 + timeout-minutes: 60 + + steps: + - name: Check out release source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.ref || github.ref }} + submodules: recursive + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + with: + java-version: '21' + distribution: temurin + + - name: Install pinned Android build tools + run: | + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ + "platforms;android-37" \ + "build-tools;36.1.0" \ + "cmake;3.22.1" \ + "ndk;27.2.12479018" + + - name: Set up pinned Rust toolchain + run: ./scripts/setup_rust_android.sh + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + with: + cache-read-only: true + + - name: Prefetch Gradle dependencies + run: | + ./gradlew :app:dependencies :app:prefetchAndroidLintDependencies \ + --no-daemon --no-configuration-cache + + - name: Build unsigned GitHub release APK offline + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/tc-wgbridge-target + run: | + ./gradlew :app:assembleGithubRelease --offline \ + --no-build-cache --no-configuration-cache --no-daemon + + - name: Verify and collect unsigned APK + id: artifact + env: + RELEASE_REF: ${{ inputs.ref || github.ref_name }} + run: | + BUILD_TOOLS="$ANDROID_HOME/build-tools/36.1.0" + mapfile -t apks < <(find app/build/outputs/apk/github/release \ + -type f -name '*.apk' -print) + if (( ${#apks[@]} != 1 )); then + printf 'Expected exactly one release APK, found %d\n' "${#apks[@]}" >&2 + printf '%s\n' "${apks[@]}" >&2 + exit 1 + fi + + apk=${apks[0]} + if "$BUILD_TOOLS/apksigner" verify "$apk" >/dev/null 2>&1; then + echo 'Release build was unexpectedly signed.' >&2 + exit 1 + fi + "$BUILD_TOOLS/zipalign" -c -P 16 -v 4 "$apk" + + commit=$(git rev-parse HEAD) + mkdir -p release-artifact + cp "$apk" release-artifact/TrackerControl-githubRelease-unsigned.apk + ( + cd release-artifact + sha256sum TrackerControl-githubRelease-unsigned.apk \ + > SHA256SUMS-unsigned.txt + ) + + { + printf 'commit=%s\n' "$commit" + printf 'tag_or_ref=%s\n' "$RELEASE_REF" + printf 'runner_image=%s\n' "$ImageOS-$ImageVersion" + printf 'android_build_tools=36.1.0\n' + printf 'android_ndk=27.2.12479018\n' + printf 'cargo_ndk='; cargo ndk --version + rustc -vV + java -version 2>&1 + } > release-artifact/BUILD-INFO.txt + + printf 'commit=%s\n' "$commit" >> "$GITHUB_OUTPUT" + + - name: Upload unsigned release artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: trackercontrol-github-release-${{ steps.artifact.outputs.commit }} + path: release-artifact/ + if-no-files-found: error + compression-level: 0 + retention-days: 30 diff --git a/.gitignore b/.gitignore index fd38c2d64..68026a3d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +/output/ .claude/ fdroidserver/ diff --git a/README.md b/README.md index debb7f084..dd773d66c 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ You need: Build from within Android Studio, or use the provided gradle wrapper — see the [Android developer documentation](https://developer.android.com/studio/build/building-cmdline). If you find any problems with these instructions, please file an issue. +Maintainers publishing the GitHub APK should follow the [release build and local signing procedure](docs/RELEASING.md). + ## Contributing TrackerControl is community-driven and welcomes contributions of all kinds — programming skills are not required. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 000000000..ab5870f29 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,98 @@ +# Publishing a GitHub APK release + +TrackerControl builds release APK payloads on Linux so that external rebuilders +can reproduce the native Rust libraries. The long-lived Android signing key +stays on the release maintainer's Mac and is never uploaded to GitHub Actions. + +The release has two stages: + +1. A numeric tag triggers `.github/workflows/release-build.yml`. It performs a + clean, unsigned `githubRelease` build on Ubuntu 22.04 with pinned Android, + Rust, cargo-ndk, and Java toolchains. +2. `scripts/build_and_sign.sh` downloads that exact workflow artifact, checks + that its commit matches the tag, signs it locally, verifies the certificate, + and creates a draft GitHub release. + +## One-time local setup + +Install and authenticate the GitHub CLI, and ensure Android SDK Build Tools are +installed: + +```bash +gh auth login +"$HOME/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager" \ + 'build-tools;36.1.0' +``` + +Place the release keystore at `trackercontrol.jks` in the repository root. The +path is ignored by Git. A different path can be supplied with +`RELEASE_STORE_FILE`. + +The script expects the alias `trackercontrol`. Override it with +`RELEASE_KEY_ALIAS` if necessary. + +Store both passwords in macOS Keychain under the service names used by the +existing release process: + +```bash +security add-generic-password -U -a "$USER" \ + -s android_keystore_password -w +security add-generic-password -U -a "$USER" \ + -s android_key_password -w +``` + +Keep an offline backup of the keystore and its passwords. The expected public +certificate SHA-256 is hard-coded in the script so that the wrong keystore +cannot accidentally sign a release. + +## Publish a release + +First update and commit `versionCode` and `versionName` in `app/build.gradle`. +Create and push the numeric release tag: + +```bash +git tag 2026071301 +git push origin 2026071301 +``` + +Wait for **Build unsigned GitHub release APK** to finish successfully. Then run: + +```bash +./scripts/build_and_sign.sh 2026071301 +``` + +The script writes the signed APK and verification metadata to +`output/release-2026071301/` and creates a draft GitHub release containing: + +- `TrackerControl-githubRelease-latest.apk` +- `SHA256SUMS.txt` +- `BUILD-INFO.txt` + +Install and test the APK from the draft, then publish the release in GitHub. + +If more than one successful build exists for the same commit, pass the desired +Actions run ID explicitly: + +```bash +./scripts/build_and_sign.sh 2026071301 1234567890 +``` + +## Manually rebuild a tag or commit + +The workflow can also be started from the Actions UI with **Run workflow** and +a tag or commit in the `ref` input. It still produces only an unsigned artifact; +the signing key remains local. + +## Overrides + +The local script supports these environment variables: + +- `GH_REPO` +- `RELEASE_WORKFLOW` +- `RELEASE_STORE_FILE` +- `RELEASE_STORE_PASSWORD` +- `RELEASE_KEY_ALIAS` +- `RELEASE_KEY_PASSWORD` +- `RELEASE_CERT_SHA256` +- `RELEASE_BUILD_TOOLS_VERSION` +- `RELEASE_OUTPUT_DIR` diff --git a/scripts/build_and_sign.sh b/scripts/build_and_sign.sh index b517a760d..eee5fb0f2 100755 --- a/scripts/build_and_sign.sh +++ b/scripts/build_and_sign.sh @@ -1,34 +1,181 @@ #!/bin/bash +set -euo pipefail -# first run docker build -t fdroid-buildserver fdroidserver/buildserver - -export RELEASE_STORE_PASSWORD=$(security find-generic-password - a $USER -s "android_keystore_password" -w) -export RELEASE_KEY_PASSWORD=$(security find-generic-password -a $USER -s "android_key_password" -w) - -docker run --rm \ - -v "$(pwd)/output:/workspace/output" \ - -w /workspace \ - -e RELEASE_STORE_FILE="/workspace/trackercontrol.jks" \ - -e RELEASE_STORE_PASSWORD="$RELEASE_STORE_PASSWORD" \ - -e RELEASE_KEY_ALIAS="trackercontrol" \ - -e RELEASE_KEY_PASSWORD="$RELEASE_KEY_PASSWORD" \ - fdroid-buildserver \ - bash -c "git clone https://github.com/TrackerControl/tracker-control-android.git && \ - cd tracker-control-android && \ - export ANDROID_HOME=/opt/android-sdk && \ - ./gradlew clean assembleGithubRelease --no-build-cache --no-configuration-cache --no-daemon && \ - cp -r app/build/outputs/apk/github/release/ /workspace/output/" - -#docker run --rm \ -# -v "$(pwd):/workspace" \ -# -v "$(pwd)/output:/workspace/output" \ -# -w /workspace \ -# -e RELEASE_STORE_FILE="/workspace/trackercontrol.jks" \ -# -e RELEASE_STORE_PASSWORD="$RELEASE_STORE_PASSWORD" \ -# -e RELEASE_KEY_ALIAS="trackercontrol" \ -# -e RELEASE_KEY_PASSWORD="$RELEASE_KEY_PASSWORD" \ -# fdroid-buildserver \ -# bash -c "export ANDROID_HOME=/opt/android-sdk && \ -# ./gradlew clean assembleGithubRelease --no-build-cache --no-configuration-cache --no-daemon && \ -# mkdir -p /workspace/output && \ -# cp -r app/build/outputs/apk/github/release/ /workspace/output/" \ No newline at end of file +readonly REPO=${GH_REPO:-TrackerControl/tracker-control-android} +readonly WORKFLOW=${RELEASE_WORKFLOW:-release-build.yml} +readonly ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +readonly KEYSTORE=${RELEASE_STORE_FILE:-"$ROOT/trackercontrol.jks"} +readonly KEY_ALIAS=${RELEASE_KEY_ALIAS:-trackercontrol} +readonly EXPECTED_CERT_SHA256=${RELEASE_CERT_SHA256:-7f48f74781632067cae334734ff2bdf54eb2a977c061a327499ca9109202d680} + +usage() { + cat >&2 < [workflow-run-id] + +Downloads the successful unsigned Linux build for the tag, signs it with the +TrackerControl release key, verifies the signing certificate, and creates a +draft GitHub release. + +Defaults: + repository: $REPO + keystore: $KEYSTORE + key alias: $KEY_ALIAS + +See docs/RELEASING.md for setup and the complete release procedure. +EOF + exit 2 +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +[[ $# -ge 1 && $# -le 2 ]] || usage +readonly TAG=$1 +readonly REQUESTED_RUN_ID=${2:-} +[[ $TAG =~ ^[0-9]+$ ]] || fail "release tag must be numeric: $TAG" + +require_command gh +require_command git +require_command security +require_command shasum +gh auth status >/dev/null + +[[ -f $KEYSTORE ]] || fail "keystore not found: $KEYSTORE" + +ANDROID_SDK=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-"$HOME/Library/Android/sdk"}} +readonly ANDROID_SDK +readonly BUILD_TOOLS_VERSION=${RELEASE_BUILD_TOOLS_VERSION:-36.1.0} +readonly BUILD_TOOLS_DIR="$ANDROID_SDK/build-tools/$BUILD_TOOLS_VERSION" +[[ -d $BUILD_TOOLS_DIR ]] || \ + fail "Android SDK Build Tools $BUILD_TOOLS_VERSION not found under $ANDROID_SDK" +readonly APKSIGNER="$BUILD_TOOLS_DIR/apksigner" +readonly ZIPALIGN="$BUILD_TOOLS_DIR/zipalign" +[[ -x $APKSIGNER ]] || fail "apksigner not executable: $APKSIGNER" +[[ -x $ZIPALIGN ]] || fail "zipalign not executable: $ZIPALIGN" + +printf 'Resolving release tag %s in %s...\n' "$TAG" "$REPO" +readonly TAG_SHA=$(gh api "repos/$REPO/commits/$TAG" --jq .sha) +[[ $TAG_SHA =~ ^[0-9a-f]{40}$ ]] || fail "could not resolve tag $TAG to a commit" + +if [[ -n $REQUESTED_RUN_ID ]]; then + RUN_ID=$REQUESTED_RUN_ID +else + RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" \ + --commit "$TAG_SHA" --status success --limit 10 \ + --json databaseId --jq '.[0].databaseId') +fi +readonly RUN_ID +[[ -n $RUN_ID && $RUN_ID =~ ^[0-9]+$ ]] || \ + fail "no successful $WORKFLOW run found for $TAG_SHA" + +readonly RUN_SHA=$(gh run view "$RUN_ID" --repo "$REPO" --json headSha --jq .headSha) +readonly RUN_EVENT=$(gh run view "$RUN_ID" --repo "$REPO" --json event --jq .event) +readonly RUN_STATUS=$(gh run view "$RUN_ID" --repo "$REPO" \ + --json status,conclusion --jq '.status + "/" + .conclusion') +readonly RUN_URL=$(gh run view "$RUN_ID" --repo "$REPO" --json url --jq .url) +[[ $RUN_EVENT == workflow_dispatch || $RUN_SHA == "$TAG_SHA" ]] || \ + fail "workflow run built $RUN_SHA, but tag $TAG resolves to $TAG_SHA" +[[ $RUN_STATUS == completed/success ]] || \ + fail "workflow run is not successful: $RUN_STATUS ($RUN_URL)" + +readonly TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/trackercontrol-release.XXXXXX") +cleanup() { + rm -rf -- "$TEMP_DIR" + unset RELEASE_STORE_PASSWORD RELEASE_KEY_PASSWORD +} +trap cleanup EXIT + +readonly ARTIFACT_DIR="$TEMP_DIR/artifact" +mkdir -p "$ARTIFACT_DIR" +printf 'Downloading unsigned build from %s...\n' "$RUN_URL" +gh run download "$RUN_ID" --repo "$REPO" \ + --name "trackercontrol-github-release-$TAG_SHA" \ + --dir "$ARTIFACT_DIR" + +readonly UNSIGNED_APK="$ARTIFACT_DIR/TrackerControl-githubRelease-unsigned.apk" +readonly UNSIGNED_SUMS="$ARTIFACT_DIR/SHA256SUMS-unsigned.txt" +readonly BUILD_INFO="$ARTIFACT_DIR/BUILD-INFO.txt" +[[ -f $UNSIGNED_APK ]] || fail "unsigned APK missing from downloaded artifact" +[[ -f $UNSIGNED_SUMS ]] || fail "unsigned checksum missing from downloaded artifact" +[[ -f $BUILD_INFO ]] || fail "build information missing from downloaded artifact" +grep -qx "commit=$TAG_SHA" "$BUILD_INFO" || \ + fail "build information does not identify the expected commit" +( + cd "$ARTIFACT_DIR" + shasum -a 256 -c "$(basename "$UNSIGNED_SUMS")" +) + +if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + fail "GitHub release $TAG already exists; refusing to overwrite release assets" +fi + +if [[ -z ${RELEASE_STORE_PASSWORD:-} ]]; then + RELEASE_STORE_PASSWORD=$(security find-generic-password \ + -a "$USER" -s android_keystore_password -w) +fi +if [[ -z ${RELEASE_KEY_PASSWORD:-} ]]; then + RELEASE_KEY_PASSWORD=$(security find-generic-password \ + -a "$USER" -s android_key_password -w) +fi +export RELEASE_STORE_PASSWORD RELEASE_KEY_PASSWORD + +readonly OUTPUT_DIR=${RELEASE_OUTPUT_DIR:-"$ROOT/output/release-$TAG"} +mkdir -p "$OUTPUT_DIR" +readonly ALIGNED_APK="$TEMP_DIR/TrackerControl-githubRelease-aligned.apk" +readonly TEMP_SIGNED_APK="$TEMP_DIR/TrackerControl-githubRelease-signed.apk" +readonly SIGNED_APK="$OUTPUT_DIR/TrackerControl-githubRelease-latest.apk" +readonly SIGNED_SUMS="$OUTPUT_DIR/SHA256SUMS.txt" +readonly OUTPUT_BUILD_INFO="$OUTPUT_DIR/BUILD-INFO.txt" +[[ ! -e $SIGNED_APK ]] || fail "signed output already exists: $SIGNED_APK" + +printf 'Aligning and signing the APK...\n' +"$ZIPALIGN" -P 16 -f 4 "$UNSIGNED_APK" "$ALIGNED_APK" +"$APKSIGNER" sign \ + --ks "$KEYSTORE" \ + --ks-key-alias "$KEY_ALIAS" \ + --ks-pass env:RELEASE_STORE_PASSWORD \ + --key-pass env:RELEASE_KEY_PASSWORD \ + --v1-signing-enabled true \ + --v2-signing-enabled true \ + --v3-signing-enabled false \ + --v4-signing-enabled false \ + --out "$TEMP_SIGNED_APK" \ + "$ALIGNED_APK" + +readonly VERIFY_OUTPUT=$("$APKSIGNER" verify --verbose --print-certs "$TEMP_SIGNED_APK") +printf '%s\n' "$VERIFY_OUTPUT" +readonly ACTUAL_CERT_SHA256=$(printf '%s\n' "$VERIFY_OUTPUT" | awk -F': ' \ + '/Signer #1 certificate SHA-256 digest:/ { print $2; exit }') +[[ $ACTUAL_CERT_SHA256 == "$EXPECTED_CERT_SHA256" ]] || \ + fail "unexpected signing certificate: $ACTUAL_CERT_SHA256" +"$ZIPALIGN" -c -P 16 -v 4 "$TEMP_SIGNED_APK" >/dev/null +cp "$TEMP_SIGNED_APK" "$SIGNED_APK" + +( + cd "$OUTPUT_DIR" + shasum -a 256 "$(basename "$SIGNED_APK")" > "$(basename "$SIGNED_SUMS")" +) +cp "$BUILD_INFO" "$OUTPUT_BUILD_INFO" +printf 'signing_certificate_sha256=%s\n' "$ACTUAL_CERT_SHA256" \ + >> "$OUTPUT_BUILD_INFO" +printf 'workflow_run=%s\n' "$RUN_URL" >> "$OUTPUT_BUILD_INFO" + +printf 'Creating draft GitHub release %s...\n' "$TAG" +gh release create "$TAG" \ + "$SIGNED_APK" \ + "$SIGNED_SUMS" \ + "$OUTPUT_BUILD_INFO" \ + --repo "$REPO" \ + --verify-tag \ + --draft \ + --generate-notes \ + --title "$TAG" + +printf '\nSigned APK: %s\n' "$SIGNED_APK" +printf 'A draft release was created. Test the APK, then publish it in GitHub.\n'