name: Android Release on: push: tags: - "v*" workflow_dispatch: jobs: build-release-apk: name: Build signed release APK runs-on: ubuntu-latest timeout-minutes: 60 permissions: code: read releases: write env: ANDROID_COMPILE_SDK: "36" ANDROID_BUILD_TOOLS: "36.0.0" ANDROID_NDK_VERSION: "30.0.15729638" ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }} steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "22" cache: npm - name: Set up Java uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" cache: gradle - name: Set up Rust uses: dtolnay/rust-toolchain@1.95.0 with: targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri -> target - name: Set up Android SDK uses: android-actions/setup-android@v3 - name: Install Android SDK packages run: | yes | sdkmanager --licenses >/dev/null || true sdkmanager \ "platforms;android-${ANDROID_COMPILE_SDK}" \ "build-tools;${ANDROID_BUILD_TOOLS}" \ "ndk;${ANDROID_NDK_VERSION}" echo "NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}" >> "${GITEA_ENV}" echo "${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS}" >> "${GITEA_PATH}" - name: Install JavaScript dependencies run: npm ci - name: Apply version from release tag run: | if [ "${GITHUB_REF_TYPE}" != "tag" ]; then echo "Release builds must run on a tag such as v0.1.0." >&2 exit 1 fi RELEASE_VERSION="${GITHUB_REF_NAME#v}" if [[ ! "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Tag ${GITHUB_REF_NAME} must use the format vMAJOR.MINOR.PATCH." >&2 exit 1 fi IFS=. read -r VERSION_MAJOR VERSION_MINOR VERSION_PATCH <<< "${RELEASE_VERSION}" if (( 10#${VERSION_MINOR} > 999 || 10#${VERSION_PATCH} > 999 )); then echo "Minor and patch versions must not exceed 999." >&2 exit 1 fi ANDROID_VERSION_CODE=$((10#${VERSION_MAJOR} * 1000000 + 10#${VERSION_MINOR} * 1000 + 10#${VERSION_PATCH})) if (( ANDROID_VERSION_CODE < 1 || ANDROID_VERSION_CODE > 2100000000 )); then echo "Derived Android versionCode ${ANDROID_VERSION_CODE} is outside the allowed range." >&2 exit 1 fi RELEASE_VERSION="${RELEASE_VERSION}" \ ANDROID_VERSION_CODE="${ANDROID_VERSION_CODE}" \ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \ node -e ' const fs = require("fs"); const version = process.env.RELEASE_VERSION; const versionCode = process.env.ANDROID_VERSION_CODE; function updateJson(path, update) { const value = JSON.parse(fs.readFileSync(path, "utf8")); update(value); fs.writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); } updateJson("package.json", value => { value.version = version; }); updateJson("package-lock.json", value => { value.version = version; value.packages[""].version = version; }); updateJson("src-tauri/tauri.conf.json", value => { value.version = version; }); const cargoTomlPath = "src-tauri/Cargo.toml"; const cargoToml = fs.readFileSync(cargoTomlPath, "utf8").replace( /(\[package\][\s\S]*?\nversion = ")[^"]+("\n)/, `$1${version}$2` ); fs.writeFileSync(cargoTomlPath, cargoToml); const cargoLockPath = "src-tauri/Cargo.lock"; const cargoLock = fs.readFileSync(cargoLockPath, "utf8").replace( /(\[\[package\]\]\nname = "lockscreenwallpaper"\nversion = ")[^"]+("\n)/, `$1${version}$2` ); fs.writeFileSync(cargoLockPath, cargoLock); const fdroidPath = ".fdroid.yml"; let fdroid = fs.readFileSync(fdroidPath, "utf8"); fdroid = fdroid .replace(/^ - versionName: .*$/m, ` - versionName: ${version}`) .replace(/^ versionCode: .*$/m, ` versionCode: ${versionCode}`) .replace(/^ commit: .*$/m, ` commit: ${process.env.GITHUB_REF_NAME}`) .replace(/^CurrentVersion: .*$/m, `CurrentVersion: ${version}`) .replace(/^CurrentVersionCode: .*$/m, `CurrentVersionCode: ${versionCode}`); fs.writeFileSync(fdroidPath, fdroid); ' echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITEA_ENV}" echo "ANDROID_VERSION_CODE=${ANDROID_VERSION_CODE}" >> "${GITEA_ENV}" echo "Building ${GITHUB_REF_NAME} with Android versionCode ${ANDROID_VERSION_CODE}." - name: Validate signing secrets run: | for SECRET_NAME in \ ANDROID_KEYSTORE_BASE64 \ ANDROID_KEYSTORE_PASSWORD \ ANDROID_KEY_ALIAS \ ANDROID_KEY_PASSWORD \ GITEA_RELEASE_TOKEN do if [ -z "${!SECRET_NAME:-}" ]; then echo "Missing Gitea Actions secret: ${SECRET_NAME}" >&2 exit 1 fi done - name: Restore release keystore run: | SIGNING_DIRECTORY="${RUNNER_TEMP}/wallpaperflow-signing" mkdir -p "${SIGNING_DIRECTORY}" printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 --decode > "${SIGNING_DIRECTORY}/release.jks" chmod 600 "${SIGNING_DIRECTORY}/release.jks" echo "ANDROID_SIGNING_KEYSTORE=${SIGNING_DIRECTORY}/release.jks" >> "${GITEA_ENV}" - name: Build unsigned release APK run: npm run tauri -- android build --apk --ci - name: Verify generated Android version run: | TAURI_PROPERTIES="src-tauri/gen/android/app/tauri.properties" grep -Fx "tauri.android.versionName=${RELEASE_VERSION}" "${TAURI_PROPERTIES}" grep -Fx "tauri.android.versionCode=${ANDROID_VERSION_CODE}" "${TAURI_PROPERTIES}" - name: Align and sign APK run: | UNSIGNED_APK="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk" RELEASE_DIRECTORY="release" ALIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release-aligned.apk" SIGNED_APK="${RELEASE_DIRECTORY}/WallpaperFlow-release.apk" test -f "${UNSIGNED_APK}" mkdir -p "${RELEASE_DIRECTORY}" zipalign -p -f 4 "${UNSIGNED_APK}" "${ALIGNED_APK}" apksigner sign \ --ks "${ANDROID_SIGNING_KEYSTORE}" \ --ks-key-alias "${ANDROID_KEY_ALIAS}" \ --ks-pass env:ANDROID_KEYSTORE_PASSWORD \ --key-pass env:ANDROID_KEY_PASSWORD \ --out "${SIGNED_APK}" \ "${ALIGNED_APK}" apksigner verify --verbose --print-certs "${SIGNED_APK}" rm "${ALIGNED_APK}" (cd "${RELEASE_DIRECTORY}" && sha256sum WallpaperFlow-release.apk > WallpaperFlow-release.apk.sha256) - name: Attach APK to Gitea release run: | API_ROOT="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}" RELEASE_JSON="${RUNNER_TEMP}/wallpaperflow-release.json" CREATE_JSON="${RUNNER_TEMP}/wallpaperflow-create-release.json" ASSETS_JSON="${RUNNER_TEMP}/wallpaperflow-release-assets.json" STATUS="$(curl --silent --show-error \ --output "${RELEASE_JSON}" \ --write-out '%{http_code}' \ --header "Authorization: token ${GITEA_RELEASE_TOKEN}" \ "${API_ROOT}/releases/tags/${GITHUB_REF_NAME}")" if [ "${STATUS}" = "404" ]; then node -e ' process.stdout.write(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `WallpaperFlow ${process.env.GITHUB_REF_NAME}`, body: "Automatisch erstelltes Android-Release.", draft: false, prerelease: false })) ' > "${CREATE_JSON}" curl --fail --silent --show-error \ --request POST \ --header "Authorization: token ${GITEA_RELEASE_TOKEN}" \ --header "Content-Type: application/json" \ --data-binary "@${CREATE_JSON}" \ --output "${RELEASE_JSON}" \ "${API_ROOT}/releases" elif [ "${STATUS}" != "200" ]; then echo "Could not read Gitea release for ${GITHUB_REF_NAME} (HTTP ${STATUS})." >&2 exit 1 fi RELEASE_ID="$(node -e ' const release = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if (!release.id) process.exit(1); process.stdout.write(String(release.id)); ' "${RELEASE_JSON}")" curl --fail --silent --show-error \ --header "Authorization: token ${GITEA_RELEASE_TOKEN}" \ --output "${ASSETS_JSON}" \ "${API_ROOT}/releases/${RELEASE_ID}/assets" for FILE in \ release/WallpaperFlow-release.apk \ release/WallpaperFlow-release.apk.sha256 do ASSET_NAME="$(basename "${FILE}")" ASSET_ID="$(ASSET_NAME="${ASSET_NAME}" node -e ' const assets = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); const asset = assets.find(item => item.name === process.env.ASSET_NAME); if (asset) process.stdout.write(String(asset.id)); ' "${ASSETS_JSON}")" if [ -n "${ASSET_ID}" ]; then curl --fail --silent --show-error \ --request DELETE \ --header "Authorization: token ${GITEA_RELEASE_TOKEN}" \ "${API_ROOT}/releases/${RELEASE_ID}/assets/${ASSET_ID}" fi curl --fail --silent --show-error \ --request POST \ --header "Authorization: token ${GITEA_RELEASE_TOKEN}" \ --form "attachment=@${FILE}" \ "${API_ROOT}/releases/${RELEASE_ID}/assets" >/dev/null done